ADR-0026: CaseActor-Routed Actor Suggestion and Invitation Flow
Context and Problem Statement
When a case participant wants to suggest that another actor be invited to the case, how should the suggestion be communicated so that (1) the CaseActor can record the event in the canonical ledger, (2) the Case Owner receives the suggestion and can approve or reject it, and (3) the approved invitation follows the existing invite/accept/bootstrap chain?
The current prototype implementation (suggest_actor_tree.py,
suggest_actor_demo.py) routes the suggestion Offer(Actor, Case) directly to the
Case Owner's inbox. This predates the CaseActor routing model established in
ADR-0021 and PCR-08: all case-scoped protocol activity MUST route through the
CaseActor's inbox so it can record the event in the canonical ledger before
broadcasting to participants.
Decision Drivers
- All case-scoped messages MUST route through the CaseActor inbox (ADR-0021, PCR-08-001, PCR-08-002).
- The Case Owner is the sole decision-maker for case participant membership; no participant can invite directly without Case Owner approval (CM-02-001).
- The CaseActor MUST commit a ledger entry for every received and every sent protocol activity (CLP-07, CLP-10-006 ordering rule).
- The invited actor MUST have enough information to give informed consent to the active embargo before accepting the invitation (issue #1289, MV-10-006).
- Invited participants MUST carry the roles the Case Owner intends for them (issue #1288).
Considered Options
Option A: Direct-to-owner routing (current prototype behavior)
The recommending participant sends Offer(Actor, Case) directly to the Case
Owner's inbox. The Case Owner's BT checks case.attributed_to == actor_id and
either emits an AcceptActorRecommendation + RmInviteToCase or a
RejectActorRecommendation.
- Good: simple, fewer protocol hops.
- Bad: bypasses the CaseActor; recommendation is never recorded in the canonical ledger.
- Bad: the Case Owner's BT must self-identify as the owner, coupling the handler to the authorization model in a way that other received-side use cases do not.
- Bad: there is no mechanism for the CaseActor to broadcast the recommendation to other participants so they have consistent ledger state.
- Bad: roles for the new participant must come from the recommender or be invented at accept time; neither path is specified.
Option B: CaseActor-routed with transform (chosen)
The recommending participant sends Offer(Actor X, Case) to the CaseActor's
inbox. The CaseActor:
- Records the received Offer in the ledger.
- Broadcasts the ledger entry to all participants.
- Runs an Evaluator BT node to assign default roles (
[VENDOR]). - Transforms the Offer into
Offer(CaseParticipant{actor=X, roles=[VENDOR]}, Case)addressed to the Case Owner, withorigin = original-offer-id.
The Case Owner responds with Accept(Offer) or Reject(Offer) to the CaseActor.
The CaseActor records the response, fans it out, and either sends
Invite(CaseStub+embargo) to Actor X or sends RejectActorRecommendation to
the original recommender (with in_reply_to = original-offer-id).
- Good: all protocol events pass through the CaseActor and are recorded in the ledger.
- Good: the Case Owner can override default roles in their Accept response.
- Good: the recommender's original offer and the Case Owner's decision are both auditable.
- Good: the
originfield on the CaseActor's Offer to the Case Owner preserves the causal chain without requiring a separate lookup table. - Neutral: adds two extra protocol hops (recommender→CaseActor, CaseActor→CaseOwner) compared to Option A.
Option C: Any-participant-can-invite
Any existing participant can directly invite another actor without Case Owner approval.
- Good: simpler for cases with low-trust or egalitarian coordination styles.
- Bad: removes the ownership gate; violates CM-02-001 (Case Owner administers the case).
- Bad: in multi-vendor or multi-coordinator scenarios, uncontrolled invites create race conditions on participant list state.
- Rejected.
Decision Outcome
Chosen option: Option B — CaseActor-routed with transform, because it is the only option that satisfies all routing invariants (ADR-0021, PCR-08) and the ledger recording requirement (CLP-07) while preserving the Case Owner as sole decision-maker for participant membership.
Consequences
- Good: the suggest-actor flow becomes consistent with all other case-scoped protocol messages.
- Good: all three roles in the flow — recommender, CaseActor, Case Owner — have a clearly defined inbox and single responsibility.
- Good: the
originfield canonicalizes how AS2 activities carry causal chain context without a side-channel data store. - Bad: the existing
suggest_actor_tree.pyBT andsuggest_actor_demo.pymust be redesigned; they cannot be incrementally adapted. - Bad: the Case Owner now receives a transformed Offer (CaseParticipant) rather than the recommender's original Offer; implementations must handle the indirection.
Ledger Commit Ordering
Per the invariant established in this design session: a ledger entry MUST be committed only for events that have already occurred — receipt of an inbound message or emission of an outbound message. Pre-committing a decision before communicating it is not permitted. The sequence for every step in this flow is: (1) send/receive the protocol message, (2) record it in the ledger, (3) fan-out the ledger entry to all participants.
Duplicate Recommendation Handling
Three distinct states must be distinguished when a second Offer(Actor X) arrives:
| State | CaseActor behavior |
|---|---|
| Pending decision (first Offer not yet resolved) | Record in ledger, fan-out, send Note to Case Owner noting the reinforcing demand signal |
| Invite issued to Actor X (awaiting Accept/Reject) | Record in ledger, fan-out, auto-send AcceptActorRecommendation to second recommender, no duplicate Invite |
| Actor X is already a participant | Record in ledger, fan-out, auto-send AcceptActorRecommendation to second recommender |
Validation
suggest_actor_tree.pyand its test coverage must be rewritten to exercise the new CaseActor-side BT (receiveOffer(Actor), transform, forwardOffer(CaseParticipant)to Case Owner).suggest_actor_demo.pymust be updated to route the recommendation to the CaseActor inbox rather than the Case Owner inbox.- A new received-side BT must handle
Accept(Offer(CaseParticipant))from the Case Owner at the CaseActor and triggerInvite(CaseStub+embargo)to the invitee. - Spec requirements CM-16 and CM-17 (in
specs/case-management.yaml) capture the normative requirements derived from this decision.
Trust Rule: Roles Come From the Offer, Not the Accept
Generalized by ADR-0080 (2026-08-31). This rule is not specific to participant roles. ASK-01-004 states it for every ask: the ask is authoritative and the reply is only a yes. Whatever action a reply authorizes MUST be read from the stored ask, never from the reply's content — otherwise the answerer can alter what it grants itself, in any exchange, not just this one. ADR-0080 also adds the time dimension: an ask carries a deadline, and for ask kinds whose expiry is void, a reply arriving after it does not authorize the action (ASK-03-006). Trusting when a reply arrives is the same class of mistake as trusting what it claims.
The routing tree and stored-ask record described below are the reference implementations the ADR-0080 primitive generalizes; see
notes/protocol-asks.md.
When the CaseActor processes Accept(Offer(CaseParticipant)) from the Case
Owner, the roles assigned to the new participant MUST be taken from the
stored outgoing Offer(CaseParticipant) that the CaseActor sent, not
from the embedded CaseParticipant object inside the received Accept.
Why this is a security boundary, not just an implementation detail:
- The accepting actor (Case Owner) may modify the embedded
CaseParticipantin theAcceptpayload, or may send only a bare ID reference. - Reading roles from the received
Acceptallows the accepting actor to unilaterally escalate or modify the roles the invitee would receive. - The stored
Offeris the CaseActor's own record of what was proposed; it is the authoritative source of truth for the intended participant roles.
Failure pattern that violates this rule:
- Reading the
CaseParticipantrole list fromevent.object_.roles(the object embedded in the receivedAccept) instead of from the storedOfferactivity. - Reading roles from the BT blackboard across ticks: the blackboard is cleared between BT executions, so a role written in the offer-received tick is gone by the accept-received tick.
Implementation: the CaseActor stores the outgoing Offer(CaseParticipant)
in its DataLayer at offer-emit time. At accept-received time, it reads the
stored offer to recover the intended roles, then creates the participant record.
See vultron/core/behaviors/case/nodes/suggest_actor/ for the canonical
implementation pattern (PR #1745).
Source: ISSUE-1745, plan/incoming/learnings/20260728-offer-trust-boundary-implicit.md.
More Information
- ADR-0021: CaseActor Inbox Routing as the Sole Path to Canonical Ledger Entries
- ADR-0022: Single BT Execution Per Inbox Delivery for Received-Side CaseActor Routing
notes/case-communication-model.md— canonical PCR-08 routing rulesnotes/case-bootstrap-trust.md— late-joiner trust establishmentspecs/participant-case-replica.yamlPCR-08 — invite/accept/bootstrap chain- Issue #1292 — invite/embargo consent completeness epic
- Issue #1295 — tracking issue for this ADR + spec additions PR
Generated spec requirements: case-management.yaml CM-16, CM-17.