Skip to content

Owner-Close Is a Hard Write Boundary; RM.CLOSED Is Terminal and Rejoin Is Unsupported

Context and Problem Statement

Owner closure is specified as global and terminal — a "permanent Leave" that closes the case for all participants (CM-04-007, ADR-0050). But the protocol never states what that terminal state means for inbound activities, and the RM state machine has no exit from RM.CLOSED. Two gaps result:

  1. The post-owner-close message boundary is unspecified (CONCERN-1894). The only post-close rules (VP-03-013, RMB-14-002) are permissive MAY-ignore rules scoped to R* (RM) messages. Add(Note) is a General-Inquiry (GI) message, not R*, and nothing addresses it — the reference note handler has no closed-case guard and persists post-close notes today. Independent implementations can each drop, accept, or selectively accept post-close traffic and all claim conformance.

  2. RM.CLOSED is terminal with no rejoin path (CONCERN-1918). A participant that sends Leave(VulnerabilityCase) advances to RM.CLOSED. There is no defined transition out, so a departed participant that later wants back in has no protocol path.

A related concern, CONCERN-1902, observes that SvcCloseCaseUseCase (the report-phase closure path) is never reached because the reference process auto-creates the case before report validity is evaluated.

Decision Drivers

  • Owner-close is defined as terminal and global; the canonical case history must not keep growing from external writers after the owner has closed the case.
  • A single, stated rule is required so independent implementations converge rather than each inventing a post-close policy.
  • Rejoin raises unresolved design questions (multiple lifecycle arcs per actor, actor_participant_index is one slot per actor, whether RM.CLOSED should become non-terminal) that are larger than this session can settle.
  • A workable interim path for "I might come back" already exists in the state machine.

Considered Options

Post-close message boundary

  • A — Hard kill switch. After owner-close, the Case Actor accepts no new external ledger writes at all.
  • B — Selective gate. Some activity classes (e.g. notes / GI) remain permitted while R* is refused.
  • C — Generalize MAY-ignore. Extend the permissive "MAY ignore" rule from R* to all message types, making current behavior explicit.

Rejoin

  • A — Reactivate the same participant record (make RM.CLOSED non-terminal).
  • B — New participant record via re-invitation.
  • C — RM.CLOSED stays terminal; rejoin unsupported, with a defer-not-close workaround.

Decision Outcome

Post-close boundary: Option A — hard kill switch. Rejoin: Option C — RM.CLOSED terminal, rejoin unsupported.

Post-close write boundary (the locked front door)

When the Case Owner sends Leave(VulnerabilityCase), the Case Actor commits and broadcasts the closure — Announce(CaseLedgerEntry(Leave(Case, actor=Owner))) — and thereafter accepts no new ledger entries from external participants. The metaphor is a store at closing time: when the owner leaves, the front door locks, even if staff are still tidying up inside.

  • Leave(VulnerabilityCase) from the owner is the owner saying "I'm done here." The Announce(CaseLedgerEntry(...)) from the Case Actor is the closure announcement to participants. The Case Owner MAY wait for that announcement to land before exiting its own process.
  • After emitting the closure announcement, the Case Actor MAY continue to service resync / replay requests (implementer's choice) but MUST NOT append anything new to the case ledger on behalf of an external participant.
  • The note handler (AddNoteToCase) and analogous handlers MUST gain a closed-case guard; a post-close inbound that would append to the ledger is refused.
  • An inbound message arriving on a closed case MAY prompt the Case Owner with an option to reopen the case. Reopen is Case-Owner-only. Its mechanics are not decided here (see Deferred).

Option B was rejected: there is no principled line between note-like and other GI traffic, and it lets canonical history grow after close. Option C was rejected: "MAY reject" is not "MUST reject," so implementations would still diverge — the ambiguity CONCERN-1894 filed.

Closure is a case-level boundary, not a participant RM cascade

Owner-close being "global and terminal" describes the case-level write boundary — the Case Actor accepts no further external ledger entries — not the RM dimension of every participant. Owner-close advances exactly two participants to RM.CLOSED: the Case Owner (the leaver) and the Case Actor (CM-23-002). It advances no others. A Leave advances only the leaving actor's own RM state, regardless of the rung it was on, because a Leave is that actor's own self-declaratory closure act (ADR-0121). Every remaining ("bystander") participant retains its last RM state when the case closes around it; closure never force-advances a participant that did not itself leave. Same locked-door store: the front door locks, but a book a patron never returned stays wherever it was left. This is stated as CM-23-012.

Rejoin: closed is terminal, defer-don't-close is the workaround

RM.CLOSED remains terminal. There is no rejoin transition. A participant that anticipates it might return SHOULD move to RM.DEFERRED (the parking lot) rather than closing: DEFERRED is resumable (DEFER: ACCEPTED → DEFERRED and ACCEPT: DEFERRED → ACCEPTED both exist), whereas CLOSED is not. DEFERRED is a shallow sleep; CLOSED is a deep sleep from which there is no reawakening in this design.

Options A and B were rejected: A requires RM state-machine surgery and breaks the single-flow-per-participant-record property; B creates a second participant record for one actor and collides with actor_participant_index, which holds one slot per actor.

CONCERN-1902 — no code change

Auto-create-case is an implementation choice made by the reference/demo process, not a protocol requirement. SvcCloseCaseUseCase is the closure path for implementations that do not auto-create; it is already unit-tested (test/core/use_cases/triggers/test_close_case_trigger.py, test/core/behaviors/report/test_trigger_report_trees.py). CONCERN-1902 resolves to a spec clarification that auto-create-case is an implementation choice; no new code is required.

Consequences

  • Good: post-close behavior is one stated rule; implementations converge, and the canonical history stops growing after owner-close.
  • Good: the rejoin design tension is not forced prematurely; a real interim path (defer-don't-close) exists today.
  • Neutral: reopen is acknowledged as possible but its mechanics are deferred, so a closed case cannot yet be reopened in the reference implementation.
  • Bad: the note handler and analogous handlers need a new closed-case guard, and VP-03-013 / the RM-behavior satisfiers must be broadened beyond R*.
  • Bad: a bystander Leave(VulnerabilityCase) is an external append too, so a participant that wants its departure recorded must leave before the Case Owner, and one arriving after close is declined with as:Reject and commits nothing (CM-23-013).
  • Neutral: the boundary is not chain finality, because the Case Actor's own housekeeping is not an external append, so case_fully_closed is not a log-complete marker; owner-close settles pending Invites first so that nothing follows it in practice (CM-23-002, CM-23-014).

Validation

  • A test asserts a post-close Add(Note, target=Case) is refused (no ledger append) rather than persisted.
  • A test asserts the Case Actor emits the closure Announce(CaseLedgerEntry) before ceasing external writes.
  • Existing SvcCloseCaseUseCase trigger tests continue to pass; a spec entry records that auto-create-case is an implementation choice.
  • Spec amendments in specs/case-management.yaml, specs/rm-behavior.yaml, and specs/vultron-protocol-spec.yaml broaden the post-close rule beyond R* and state the kill-switch boundary.

More Information

  • CONCERN-2833 (planning group G05) — session that produced this decision
  • CONCERN-1894 — post-owner-close message boundary is unspecified (source)
  • CONCERN-1918 — rejoin semantics for departed participants (source)
  • CONCERN-1902 — SvcCloseCaseUseCase unreachable under auto-create-case (source)
  • ADR-0050 — Leave(VulnerabilityCase) is the canonical RM closure mechanism
  • ADR-0121 — participant status authority, carried forward from ADR-0084 (companion decision)
  • CONCERN-3106 — whether case closure should force participant RM state (resolved: only the leaver advances; bystanders retain their rung — CM-23-012)
  • CONCERN-3400 — case_fully_closed was called the last ledger entry while bystander Leaves still landed after it (resolved: CM-23-013, CM-23-014)
  • Deferred: post-join role change (Update(CaseParticipant), #3065) and case reopen mechanics (#3066) are tracked as Ideas under Epic #2567.

Annotation — 2026-10-09

CM-23-013 later made the reply to a post-close message optional: the CASE_MANAGER MAY send an as:Reject or MAY send nothing. ADR-0131 sets the reference implementation's choice. By default it sends nothing. A Reject is sent only for a late Leave(VulnerabilityCase), and only when the CASE_MANAGER is configured to send one. So the Consequences bullet above that says a late bystander Leave "is declined with as:Reject" now describes the optional setting, not the default. The closed-case guard is one guard placed in every received tree, not a separate guard in each handler.