Joining a Case: The Invite Creates an Inert Participant, the Stub Is Its Own Type, and RM Closes from Received
Context and Problem Statement
CONCERN-4006 began as a narrow question: an on-behalf d→D for a deployer that
had never joined a case was refused, because the on-behalf tree minted a
participant at RM.START and the RM↔D entailment refuses D there. Working
through it showed that the narrow question sat on top of a misunderstanding of
how an actor joins a case:
- The participant record came too late. A
CaseParticipantwas created only when the CASE_MANAGER processedAccept(Invite). Before the invitee answered, the case held no record that it had been invited, so the CASE_MANAGER could not track the one thing it most needs to: who has been told about the case and how they responded. The embargo-consent model already has anINVITEDstate, so the design expected the record to exist before the reply; the roster did not. - Membership stood in for consent. Almost every recipient list for
case-scoped traffic is the whole roster (
case_addressees). Withholding case content from an actor that has not accepted the embargo (CM-10-004, VP-08-006) held only because a non-accepted actor was absent from the roster, not because any send site checked. - On-behalf status assertions created participants. The original ADR-0084 scoped the
on-behalf
v→Vto a vendor "not yet — or never — a participant", so the on-behalf tree minted a participant for an absent target. A status update is not a way to join a case. - The stub and the full case were indistinguishable on the wire.
as_VulnerabilityCaseStubemitstype: "VulnerabilityCase"and carries the case's own ID, so no message can be about "the stub" as distinct from "the case". - The RM model could not express a hard no from Received. The published
transition function has no
R → C; Closed is reachable only from Invalid, Accepted and Deferred. Two existing paths already close from other rungs by bypassing the table:Leave(Case)through theforce_rm_stateoverride (CM-23-012), and the report hard-reject, which writesCLOSED"regardless of current status". TheLeaveoverride also closes from Valid, which VP-02-004 forbids outright.
The question: how does an actor go from "invited" to "participating" in a case, what does the case record at each step, and what may it receive?
How the joined participant then judges the case — the full-case Invite and its three replies — is ADR-0121 (which replaces ADR-0070). The two decisions are one flow, split so that each ADR holds one decision.
Decision Drivers
- The CASE_MANAGER must be able to track an invitee from the moment it is invited: whether it has been told, whether it has answered, and how.
- Case content reaches only actors entitled to it. Entitlement is having accepted the invitation and, when an embargo is active, being party to it. Many cases have no active embargo — not yet established, or already exited — so entitlement cannot be "is SIGNATORY" alone.
- A participant's status is self-declaratory (ADR-0121). No other party writes a judgement the participant has not made.
- A receiver classifies an activity by its shape. Two messages that mean different things must differ in shape, not only in an ID a receiver would have to look up.
- We accept offers and invitations, not bare objects.
Offermeans "take this" (ownership transfer isOffer(VulnerabilityCase));Invitemeans "take part in this". ActivityStreams definesInviteas a specialization ofOfferfor exactly that sense.
Considered Options
- Keep the record at
Accept(Invite); add the missing filters only. - Create the record at
Invite; the participant is inert until it is entitled to case content; give the stub its own type and ID; addR → Cto the RM model. - As option 2, but delete the record when the invitee declines.
Decision Outcome
Chosen option: Option 2, because it lets the CASE_MANAGER track every invitee from the moment it is invited, makes entitlement to case content an explicit check instead of a side effect of roster membership, and records only judgements the participant actually made.
The participant record is created by the Invite
When the CASE_MANAGER sends Invite(Actor, VulnerabilityCaseStub) it creates
the invitee's CaseParticipant in the same step, carrying the roles the Invite
names:
| Dimension | Initial value |
|---|---|
| RM | RECEIVED |
| VF (VENDOR role only) | v |
| Embargo consent | INVITED when an embargo is active; otherwise unbound |
This birth is a CASE_MANAGER write through the single participant-status
writer (ADR-0089). It is the one place a vendor participant carries v.
The ledger holds the wire messages; every state change has a message
Every state change the CASE_MANAGER makes in a case is carried by a message in the replicated ledger: if it is not in the ledger, it did not happen. The ledger holds the wire messages exchanged, so a change that already travels as a message needs no entry of its own, and one trigger can still produce several entries, one for each message or act it involves. A replica applies the effects of each entry through the same functions the CASE_MANAGER uses, and stores what the entry carries, as received (ADR-0103, CLP-15-007). It infers nothing from another entry: it does not build a participant record because it saw an Invite, and it computes no id or time (ADR-0124).
For the stub Invite:
| What happens | The entry | A replica |
|---|---|---|
| The CASE_MANAGER sends the stub Invite | invite_actor_to_case: the message to the invitee |
applies nothing from it |
The CASE_MANAGER creates the inert record (roles, birth status with its id and times, joined=False, the consent rows, INVITED when an embargo is active) and puts it on the roster |
create_case_participant, Create(CaseParticipant): the CASE_MANAGER's own act, the one entry the stub path adds; the whole record as stored |
stores the record |
The invitee sends Accept(Invite(stub)): consent signed to the embargo in force, record marked joined |
accept_invite_actor_to_case: the invitee's Accept message itself (its actor, the stub Invite, its published) |
applies the same consent and joined writes the CASE_MANAGER applies, with updated = the Accept's published; fails if it holds no record |
For a participant with the VENDOR role only: VF advances to Vf |
add_participant_status_to_participant: the status the CASE_MANAGER wrote, with its id and times (VF is a vendor-only status) |
stores the status |
The invitee sends Reject(Invite(stub)): consent DECLINED when an embargo is active |
reject_invite_actor_to_case: the invitee's Reject message itself |
applies the same consent write |
The record closes (RM CLOSED, and for a vendor VF Vf) |
add_participant_status_to_participant: the closing status |
stores the status |
| The stub Invite expires | none: expiry changes no state (CM-11-014) | nothing |
The Accept's VF status is committed after the case is announced to the invitee
and the prior ledger is backfilled, so it reaches the invitee in chain order and
not ahead of its case seed (#2898). A replica seeded with the case replays the
ledger from genesis over a seed that may already be ahead, so each effect moves
forward only: joined is only set and a consent row changes only by a legal
move (CM-18-003).
Inert and active
A participant is active when both hold:
- it was seated by the case initialization sequence (CM-14-001) — the Case Owner, the CASE_MANAGER and the reporter, none of whom is sent a stub — or it has accepted the stub Invite, and
- no embargo is active, or its embargo consent is
SIGNATORY.
Every other participant is inert. An inert participant exists in the case for tracking, and receives no case content: no ledger entries, no case announcements, no status broadcasts, no embargo announcements. The check lives in the shared recipient selection that every case-content send uses, not at each send site.
Two kinds of message are addressed to a participant because it is not yet active, and are not case content: the Invites that ask it to join (the stub Invite) or to consent (an embargo Invite, EP-09). They reach an inert participant.
A participant can become inert again. An active participant that has not
accepted a newly activated embargo — one whose consent lapses under ADR-0093 —
is no longer entitled to case content until it consents. A participant whose RM
is CLOSED has declared it has stopped paying attention, and receives nothing
further either way (CM-23-004). The exception is the ledger entries that record its own closure (the close_case
entry and the status entries that follow it, CM-23-001), which are the last things it
receives: it waits for them rather than writing CLOSED itself, and its replica never
learns of later closures, case_fully_closed included. The exception never widens
entitlement: a participant that is inert or removed when it leaves is not sent them.
The CM-31-009 embargo notice to a closed SIGNATORY is the only other message it
can receive, and it carries no case content. Every participant's closure is verified in the
CASE_MANAGER's store.
Replies to the stub Invite
The stub Invite has exactly two replies.
| Reply | Meaning | Record afterwards |
|---|---|---|
Accept(Invite(stub)) |
Join the case, and consent to the active embargo if there is one. | RM stays RECEIVED; VF V; consent SIGNATORY if an embargo is active. Active. |
Reject(Invite(stub)) |
A hard no: decline to join. | RM CLOSED; VF V; consent DECLINED if an embargo is active. Kept, inert permanently. |
TentativeReject is not a reply to a stub Invite. Accepting the stub is not a
judgement of the case: the invitee has seen only the stub. The judgement comes
after, in reply to the full-case Invite (ADR-0121).
Any reply at all is evidence the vendor is aware, so either reply sets VF V.
An unanswered stub Invite
A stub Invite carries a reply deadline, as an embargo Invite does (CM-28). When
the deadline passes unanswered, the Invite expires as an unanswered ask; the
participant's state does not change. The record stays inert at RECEIVED,
which records the truth: told, never answered. The CASE_MANAGER cannot close the
invitee's RM for it — RM is the participant's own judgement (ADR-0121) — so a
timeout never stands in for one. That includes embargo consent: it stays
INVITED. An expired embargo Invite moves the participant's consent to
EXPIRED, not DECLINED (CM-28-004, ADR-0118), because silence is not a
refusal; an unanswered stub is not a refusal to join either, and the invitee
may be re-invited on the same record.
Case-wide closure checks ("have all participants closed?") count only participants that joined. An invitee that never answered does not block closure.
Re-inviting
The CASE_MANAGER may re-invite an invitee that has not answered, or whose stub
Invite expired, by sending a fresh stub Invite on the same participant
record with a new deadline. An invitee that rejected is at CLOSED, which is
terminal with no rejoin (ADR-0085); a re-invite to it is refused.
The embargo changes while a stub Invite is outstanding
Accepting a stub Invite consents to the embargo terms that stub carried. If the
active embargo changes — activated, revised or terminated — while a stub Invite
is outstanding, an Accept of the old stub would consent to terms that no
longer apply: under longer revised terms the participant would join already
LAPSED, and under a newly activated embargo it would join without consent,
and in both cases it would have to answer again at once.
So when the active embargo changes while a stub Invite is outstanding, the
CASE_MANAGER re-issues the stub Invite with the current terms, on the same
record, with a new deadline. The replacement names the Invite it
supersedes, so the invitee knows the old one is dead whichever arrives first.
An Accept of a superseded stub Invite is refused, and the refusal names the
replacement. A Reject of a superseded stub Invite is still honoured: declining
to join does not depend on the terms. The invitee's single Accept therefore
always answers a stub that shows the current embargo state, and nobody joins at
LAPSED.
A revision proposal is not such a change: it is relayed to the invitee as an
embargo Invite (EP-09-002) like any other non-closed participant. Answering that
Invite records the invitee's consent to the proposed terms and does not join the
case — only an Accept of the stub does. If the revision is then activated, the
stub is re-issued as above.
The superseded Invite is not retracted with an Undo. Correctness does not
depend on a retraction — the refusal already prevents joining on stale terms —
and naming the superseded Invite in its replacement gives the invitee the same
knowledge without a new activity type. Nothing else in the protocol retracts an
ask with Undo: proposals cleared by an embargo termination simply stop being
answerable (EP-08-004). See "Pros and Cons" below.
Joining starts the ledger replay
When the CASE_MANAGER processes Accept(Invite(stub)) and the participant is
active, it sends the case and replays the case ledger to the participant at
once. No readiness signal from the participant is needed: the Accept is the
signal, out-of-order arrival is buffered (SYNC-14, SYNC-15), and a real gap is
recovered by Reject(CaseLedgerEntry) and replay. The case object carries the
current state and the per-case genesis hash, not the ledger entries, so the
replay is what gives the participant the history it then judges.
The stub is its own type and its own object
VulnerabilityCaseStub gets its own wire type value ("VulnerabilityCaseStub") and its own ID, the case ID with /stub appended.
It names the case it stands for explicitly, in its caseId field, so no receiver derives one ID from the other.
What else it carries is CM-17-010: only the embargo terms, and only while an embargo is active.
A stub Invite and a full-case Invite therefore differ in shape and are classified without a lookup.
On-behalf status targets existing participants only
A status update never creates a participant. The on-behalf assertions of
ADR-0121 target a participant already in the case; an absent target is refused.
For v→V that means an inert invitee the CASE_MANAGER or Case Owner has
evidence is aware: the bump changes VF only, never RM.
RM closes from Received
The RM transition function gains one edge, R → cC:
The reply semantics need it: Reject means close, and is sent from Received
(the stub's hard no, and the hard no to the full-case Invite in ADR-0121).
Valid still has no close edge. VP-02-004 forbids closing from Valid: a
participant that has found a report valid decides to engage or defer before it
leaves. A Leave from VALID is therefore recorded as two ordinary
transitions, V → D → C, not as an override. With R → C in the table and
Leave from VALID routed through Deferred, a Leave from any rung and the
report hard-reject are ordinary transitions, and no closure path needs the
force_rm_state override. The Case Actor commits each of those transitions as
its own participant-status ledger entry, so a replica applies the steps the
ledger records and never derives the path itself (CM-23-001, #4091).
Consequences
- Good, because the CASE_MANAGER sees every invitee from the moment it is invited, including the ones that decline.
- Good, because entitlement to case content is one explicit check, so adding a participant to the roster can no longer leak content by accident.
- Good, because the on-behalf
v→Vhas a real job again — marking an inert invitee aware — without minting participants. - Good, because every closure is an ordinary RM transition and two override paths go away.
- Bad, because roster membership no longer means "accepted": every roster consumer that read it that way — accept-invite idempotency, the join backfill guards, embargo-consent cascades, "all participants closed", demo roster assertions — must be reviewed.
- Bad, because the RM model no longer matches the RM model of the original
CERT MPCVD protocol paper (it gains
R → C); the published formal model, its diagrams and every test pinning the transition table change. - Neutral, because the original report receiver's path (
Offer(Report)and its replies) is unchanged apart from its hard-reject now being a legal transition.
Validation
- A test sends a stub Invite and finds the invitee's participant at
RECEIVED,v(vendor) andINVITED(active embargo), and finds that no ledger entry or case announcement is addressed to it. - Tests drive each stub reply and check the record against the table above,
including that
TentativeReject(Invite(stub))is refused. - A test asserts every case-content send selects recipients through the shared active-participant check (an architecture ratchet).
- A test asserts an on-behalf status update for an absent target is refused and leaves the roster unchanged.
- A test lets a stub Invite expire and finds the Invite closed and the record
unchanged at
RECEIVED, and finds that an all-participants-closed check ignores it. - A test changes the active embargo while a stub Invite is outstanding and
finds a replacement stub Invite naming the superseded one, an
Acceptof the superseded Invite refused with the replacement named, and aRejectof it honoured. - The RM transition table test includes
R → Cand still excludesV → C(VP-02-004); aLeavefromVALIDrecordsV → D → C; no closure path callsforce_rm_state.
Pros and Cons of the Options
Option 1 — Keep the record at Accept(Invite); add the missing filters only
- Good, because the roster keeps its current meaning and no consumer changes.
- Bad, because the case still has no record of an invitee before it answers, which is the tracking gap that started this.
- Bad, because the on-behalf
v→Vstill has nothing to target except by minting a participant.
Option 2 — Record at Invite, inert until entitled, stub type, R → C
- Good, because every invitee is tracked from the Invite onward.
- Good, because entitlement becomes an explicit, shared check.
- Bad, because it changes what roster membership means, which many consumers assumed.
Option 3 — As option 2, but delete the record on decline
- Good, because the roster after a decline matches today's.
- Bad, because it discards the record that the actor was told and said no, which is the history the early record exists to keep.
Retracting a superseded stub Invite with Undo
Considered for the case where the embargo changes while a stub Invite is outstanding, and rejected.
- Good, because the invitee learns the old Invite is withdrawn from an explicit wire message.
- Bad, because it would be the protocol's first retraction message — a new pattern to specify, extract and test — for a rare event.
- Bad, because it adds nothing correctness needs: an
Acceptof the superseded Invite is refused either way, and the replacement naming the superseded Invite tells the invitee the same thing in whichever order the two arrive.
More Information
- CONCERN-4006 — source; the on-behalf
d→Drefusal that exposed the join model. - VP-02-004 — no close from Valid; why
V → Cwas not added. - ADR-0121 — how an active participant judges the case (the full-case Invite, its three replies, and the ledger positions they carry) and participant assertion authority (on-behalf status scoped to existing participants). It supersedes ADR-0070 and ADR-0084, whose 2026-10-01 rewrites it carries.
- ADR-0089 — the single participant-status writer, through which the birth at Invite is written.
- ADR-0093 — embargo consent and lapse.
- ADR-0116 — refines this decision's definition of an active participant: a participant the Case Owner removed is inert.
notes/case-joining.md— what was misunderstood and why, for readers of the old model.
Amendment — 2026-10-09
Corrected in place by instruction of Allen D. Householder (PR #4396, #4384); the decision is unchanged, one assumption in how it was written is not.
Replaced: "This birth is a CASE_MANAGER write through the single participant-status writer (ADR-0089) and is committed to the case ledger like any other roster change."
The sentence, and the way ADR-0116 relied on it ("replicas already learn of the new member from the Accept(Invite) ledger entry"), assumed that one trigger produces one ledger entry: the stub Invite's entry, and later the Accept's, would tell a replica everything.
It does not.
Inviting an actor and creating the participant record that tracks it are two state changes, and the creation has no wire message of its own.
A replica that infers the record from the Invite builds an object the CASE_MANAGER never declared, with ids and times of its own, and so differs from the CASE_MANAGER at the same ledger position.
The corrected rule is in "The ledger holds the wire messages; every state change has a message" above: the one new entry is the CASE_MANAGER's own create_case_participant; the invitee's Accept and Reject are the entries for their own effects.
Ratification — 2026-10-02
Ratified by Allen D. Householder on 2026-10-02, in the audit of unsupervised agent decisions (#4195).
Confirmed: the participant record is created at the stub Invite and is inert until active; Accept(stub) is join plus consent and leaves RM at RECEIVED; and the RM table gains R → C, with Leave from Valid recorded as V → D → C and from Start as S → R → C.
Also recorded from the audit: a replica is case(t0) plus the ledger entries, and applies recorded transitions rather than re-deriving them by walking the RM table (#4091; CM-23-001, SYNC).
The closure semantics (CM-23-002, CM-23-013, CM-23-014; #4065, #4066) are a separate decision the maintainer has not yet made and are not ratified here.
The stub's contents (CM-17-010) stand; the open Idea #4184 is linked from that requirement.
The 2026-10-01 text of ADR-0070 and ADR-0084 that this ADR cites is now ADR-0121.
Annotation — 2026-10-12
Allen D. Householder decided the closure semantics on 2026-10-12 (CM-23-002, CM-23-013, CM-23-014). The Ratification above left them open; this note records that they are now decided. ADR-0085 sets the owner-close write boundary. ADR-0131 sets the CM-23-013 reply to late traffic: by default the CASE_MANAGER drops it, and it may answer only a late Leave with a Reject (#4065). CM-23-014, which expires every pending embargo Invite before case_fully_closed, is implemented by #4066 (PR #4489).