Authority Is the CASE_MANAGER Role; "Case Actor" Names the Prototype Actor That Enacts It, Not the Authority
Context and Problem Statement
On nearly every case-scoped message, Vultron code must answer one question: is
the executing actor the case's single-writer authority — the actor enacting
the CASE_MANAGER role — or an ordinary participant whose replica only mirrors
what the authority commits? The authority commits a CaseLedgerEntry and
broadcasts; the participant asserts and waits for the broadcast.
The distinction is correct and load-bearing. The problem is twofold.
First, the question is answered in many places, in different ways. There is
no single shared way to determine authority, so the check is re-derived per site
and the discipline is held together only by convention. Specs CLP-09-002 and
CLP-09-003 mandate commit-authorization ratchets — an enumeration that fails
when a site commits unguarded, and a coverage test that every use case reaches a
commit — but those enforcement tests are not yet implemented; only CLP-09-001's
per-node role check has unit tests today. A ratchet, specified or in place, only
fires when a site forgets to check — the signature of a convention, not a
structural guarantee (notes/case-ledger-authority.md, "Authorization Was a
Convention, Not a Gate").
Second — and this is the deeper error — some of those ways determine
authority from the wrong thing. They key off where a thing is hosted or the
shape of a URL rather than off the role. "Case Actor" is a placeholder name
— the readable label a demo gives to whichever actor is enacting CASE_MANAGER,
exactly like "vendor" or "finder". Nobody adopts a "Case Actor" identity. The
identity is defined entirely by "the entity enacting CASE_MANAGER must do
these things." Treating the name or URL as a protocol signal elevates a demo
convention to protocol truth.
This ADR settles one decision: authority, recognition, and routing key off the
CASE_MANAGER role, and nothing else. The scope is authority determination.
The per-CVD-role procedures (Vendor vs. Coordinator vs. Reporter) legitimately
differ and stay in their own use cases and subtrees; consolidating those would
recreate a god dispatcher and is out of scope.
What is duplicated, and how it answers the question (verified)
Four resolvers answer variants of "is this actor the case authority", three of them by the wrong signal:
- Role membership (correct).
_resolve_case_manager_id/resolve_case_manager_idreadCaseParticipant.case_roles. This is the twin to keep — but it is a twin: one copy inuse_cases/_helpers.py, one inbehaviors/case/nodes/participant/roles.py, the second existing "to avoid a behaviors→use_cases import (BTND-04-003)". YetCheckIsCaseManagerNode(a behaviors node incase/nodes/conditions.py) performs exactly that import, anduse_cases/_helpers._case_actor_by_rolereaches back into behaviors foris_case_actor_identity. The twin dodges an import two nodes make anyway. - Service-hosting (wrong signal).
_find_case_actor(sync/nodes/conditions.py) scans for aServiceobject withcontext == case_idattributed to the executing actor. Used byCheckIsOwnCaseActorNodeto pick the CaseActor arm of the replication router. - URL shape (wrong signal).
is_case_actor_identity(case/case_actor_identity.py) tests whether an id ends in/actors/case-actor. Used to decide whether a role-holder "is really a CaseActor". - Multi-path address lookup.
_find_case_actor_idcombines ReportCaseLink bootstrap paths, the role, the URL-shape gate, and aServicescan. This one answers a different question — "what address do I route to?" — not "am I the authority."
The hosting/shape signals are not merely duplicative; they are fragile.
_find_case_actor_id's own docstring records a bootstrap window in which the
Service object has no context yet. In that window the real CaseActor fails
its own hosting test and would take the participant arm on its own ledger.
Role membership is stable from replica-seed onward and has no such window.
Authority is not the same question as "which store may append"
DeclineForeignLedgerCommitNode (sync/nodes/ledger_authority.py) /
store_for_actor (store_scope.py) look like a third authority check but are
not one. They are a downstream anti-fork
guard: once role-authority says "you may commit," they stop a delegated-emit
fall-through from minting a canonical index in a store that is not the log's
home (which would fork the hash chain, #2626). Authority (role) and "where the
write may land" (store) are different jobs; conflating them was a mistake in an
earlier draft of this ADR.
The one genuine two-arm site
A full "authority does X / participant does Y, both non-trivial" split exists in
exactly one place: AnnounceLogEntryReceivedBT (sync/announce_tree.py), where
the authority confirms its own broadcast and the participant validates the hash
chain and applies the entry to its replica. A scan of the other received trees
found only authorization gates ("role-holder acts, everyone else skips" —
reject_tree, update_tree, the status commit/adoption/teardown gates) and
participant-side effects that already live inside the announce arm. Those gates
are already served by the sanctioned create_case_manager_gated_tree
(BTND-07-005).
Decision Drivers
- DRY / no parallel duplicate implementations. Determining authority must have one implementation, composed at every site.
- Correct signal. Authority must derive from the role, never from a hosting location or a URL name — both are incidental and one is provably fragile.
- Composability without over-abstraction. Reuse the pieces that exist; do not invent a router for a single caller.
- Discoverability. One module and one glossary entry should answer "how does an actor know it is the case authority."
- Respect the layering. No
behaviors → use_casesimport (BTND-04-003); remove the ones that already leak.
Considered Options
- Status quo — twin role resolvers, hosting/shape checks, ratchet tests.
- Role is the sole authority signal; "Case Actor" names the prototype actor that enacts it, not the authority — one neutral role resolver, one role-based predicate, retire the hosting and URL-shape signals from all protocol logic, fold address resolution into "the role-holder's address", fix the one two-arm site in place. (chosen)
- Consolidate resolvers but keep hosting/shape as legitimate signals — dedupe the plumbing without changing what authority is determined from.
- Build a reusable authority/participant router across received trees.
Decision Outcome
Chosen: "Role is the sole authority signal; 'Case Actor' names the prototype
actor that enacts it, not the authority." It removes the duplication and the wrong/fragile signals, keeps
the pieces composable, and matches the actual protocol meaning: authority is
whoever enacts CASE_MANAGER.
Concretely:
- One role resolver, in a neutral layer. A single home (e.g.
vultron/core/participants/authority.py) below bothbehaviors/anduse_cases/, depending only onmodels,ports, andenums. It answers "which actor enactsCASE_MANAGERon this case?" by readingCaseParticipant.case_roles. Both current twins are deleted and delegate to it. It needs nobehaviorsimport, because the URL-shape concern that forced the old cross-layer reach is being removed. - One role-based authority predicate.
CheckIsCaseManagerNodeandCheckIsOwnCaseActorNode/CheckIsNotOwnCaseActorNodecollapse into one condition node (plus a negation decorator) that calls the single resolver. - Retire the wrong signals from protocol logic.
is_case_actor_identityand theService-hosting resolution (_find_case_actor) are removed from authority, recognition, and routing._find_case_actorloses its only callers when the router goes role-based and is deleted. - Fix the one two-arm site in place; no router abstraction. The announce
router's two arms switch to the role predicate. This also fixes the
bootstrap-window latent bug; a regression test covers it. Every other
received tree keeps
create_case_manager_gated_tree(the authorization-gate shape). No general router is introduced. - Address resolution folds in. The authority's address is simply the
role-holder's address.
_find_case_actor_idkeeps whatever bootstrap paths it genuinely needs for delivery, but drops the URL-shape gate as an authority/recognition test; it is documented as an address lookup, a different concern from authority. - The store guard is reclassified, not removed.
DeclineForeignLedgerCommitNode/store_for_actorstay exactly as they are, relabeled as store-consistency / anti-fork — explicitly not authority. There is no role/store "coincidence" to assert: authority is purely the role. - "Case Actor" is the prototype identity, not the authority. A spawned
software actor may still be provisioned at a stable inbox URL via a case
actor service (ADR-0041's fix for the unhostable per-case slug stands) —
but the string
case-actorcarries no protocol meaning and no code may branch on it. "CaseActor" names that concrete prototype/demo actor; it is not shorthand for the authority. Protocol-normative prose names the authority the CASE_MANAGER (the role holder), reserving "CaseActor" for the prototype actor and "case actor service" for the provisioning endpoint.
Consequences
- Good: one implementation of authority, composed everywhere; the twins and the leaking cross-layer imports are gone.
- Good: the fragile hosting/shape signals are gone, including the bootstrap window where a CaseActor failed to recognize its own ledger.
- Good: one module plus one glossary entry answer "what is the authority."
- Good: no god object and no one-user router — resolver, predicate, gate, and the single two-arm site stay separate and composable.
- Bad / cost: a migration touching the resolver twins, the condition-node pair,
and every reader of
is_case_actor_identity, guarded by the existing ratchets. - Neutral: the CVD-role procedure dispersion is untouched by design.
Validation
- A ratchet test asserting exactly one role-authority resolver module, imported
by both
behaviors/anduse_cases/(no twin definition, nobehaviors → use_cases._helpersrole-resolution import). - A test asserting one authority condition node is the only thing resolving "am I
the authority";
CheckIsOwnCaseActorNode/CheckIsNotOwnCaseActorNodeare removed or aliased. - A ratchet asserting no protocol-logic module branches on
is_case_actor_identityor on thecase-actorURL substring. - A regression test for the bootstrap window: the actor enacting
CASE_MANAGERtakes the authority arm on its own ledger entry even before anyServiceobject carriescontext. - The existing CLP-09 commit-coverage ratchets keep passing throughout.
Pros and Cons of the Options
Status quo
- Good, because the ratchets catch the most dangerous omission (unguarded commit).
- Bad, because authority is determined four ways, three of them by the wrong signal, one of them fragile in a real bootstrap window.
Role is the sole authority signal (chosen)
- Good, because it fixes the concept (role, not name/host) and the duplication at once, and removes a latent bug.
- Good, because the neutral resolver becomes import-clean once the URL-shape concern leaves authority.
- Neutral, because it adds one module and one glossary edit but deletes more.
- Bad, because it is a cross-cutting migration and touches ADR-0041's identity narrative.
Consolidate resolvers but keep hosting/shape signals
- Good, because it is a smaller change.
- Bad, because it preserves the wrong signal and the bootstrap-window fragility; it dedupes the plumbing while leaving the concept wrong.
Reusable authority/participant router
- Good, because every received tree would look identical.
- Bad, because a scan found exactly one genuine two-arm site; the abstraction would have a single user, and authorization gates are already unified.
More Information
Relationship to prior decisions: this refines ADR-0041 / #1872. That work
gave the enacting actor a stable inbox URL, fixing the unhostable per-case slug
that 404'd; that fix stands. This ADR narrows the earlier identity narrative:
the stable URL is a provisioning convenience, and no protocol logic may treat the
case-actor name or URL shape as evidence of authority — the role is the sole
signal. Related: ADR-0021 (CaseActor inbox routing to canonical entries),
ADR-0073 (per-actor storage — origin of the store-consistency guard this ADR
reclassifies). Sibling consolidation: ADR-0087 applies the same
"one shared implementation, chosen by role, never re-decided per call site"
pattern to a different question (a BT node's disposition when a case is
absent); this ADR is the analogous consolidation for who is the authority
when the case is present. Related notes: notes/case-communication-model.md,
notes/case-ledger-authority.md.
A companion glossary change discriminates the three referents that
"CaseActor" had been conflating: the CASE_MANAGER role (the authority,
defined by the role and nothing else), the Case Actor (the concrete
prototype/demo actor that enacts it — a cosmetic identity, never matched on),
and the case actor service (the provisioning endpoint that spawns
case-actor identities). Authority is defined by the role, not by name or
identity; "CaseActor" is not treated as shorthand for the authority —
protocol-normative prose names the authority the CASE_MANAGER.
This decision generates recurring testable requirements, authored alongside it:
the single-resolver / single-predicate invariants and the no-name-branching
ratchet in specs/architecture.yaml (ARCH-24-001 through ARCH-24-005), and
the role-is-authority / bootstrap-window / name-is-cosmetic requirements in
specs/case-management.yaml (CM-02-011 through CM-02-013). The verification:
field on each names the ratchet or regression test that enforces it; those tests
land with the implementation. Implementation is tracked under Epic #2685
(Authority model & conservative gate defaults). Re-visit if per-actor storage
authority (ADR-0073) or the CASE_MANAGER role model changes.
Generated spec requirements: architecture.yaml ARCH-24-001 through
ARCH-24-005; case-management.yaml CM-02-011 through CM-02-013.