Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions docs/adr/0061-per-dimension-partial-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
status: accepted
date: 2026-08-12
deciders: Allen D. Householder
consulted: Vultron protocol maintainers
informed: Vultron contributors
---

# Adjudicate Received `ParticipantStatus` Per Dimension, Not as a Unit

## Context and Problem Statement

A `ParticipantStatus` is not a single state value. It is a snapshot of several
independent state machines: `rm` (Report Management), `vfd` (vendor fix path),
`em` (Embargo Management), `pxa` (public state) and `consent` (Participant
Embargo Consent). When one arrives over the wire in
`Add(ParticipantStatus, CaseParticipant)`, the receiving CaseActor must decide
what to believe.

Before this decision it decided all-or-nothing. `ValidateRMTransitionNode`
refused any backwards `rm` step and any status for a participant already at
terminal `RM.CLOSED`, and its FAILURE inside the `AppendParticipantStatusBT`
Sequence discarded the entire snapshot — including dimensions the receiver had
no grounds to refuse. Worse, the FAILURE aborted the enclosing Sequence before
`StatusUpdateGuard` and `EmitAddCaseStatusToSelfNode`, so the Seam 1 → Seam 2
emit never happened and embargo teardown silently did not run (ADR-0046,
RSH-01-003, RSH-01-004). A vendor that had closed its report management
workflow could not report deploying its fix at all.

Reported as ISSUE-2235, under the liberal-accept epic ISSUE-2229 (Postel's
maxim: be conservative in what you send, liberal in what you accept).

## Decision Drivers

- The dimensions are genuinely independent state machines; a refusal in one
carries no information about the others.
- Liberal accept (ISSUE-2229): refuse the narrowest thing that must be refused.
- Refusals must be *visible*. The pre-existing behaviour was a silent drop.
- CLP-10-006 receive-side ordering: precondition guards run before
`GuardedCommit`; guards MUST NOT write to the DataLayer.
- The canonical `CaseLedgerEntry` is hash-chained and replicated to every
participant. Whatever it snapshots becomes every replica's view.
- Monotonic visibility: a replica must never un-see progress it has observed.

## Considered Options

- **Keep all-or-nothing, but return SUCCESS on refusal.** Fixes the aborted
Seam 2 emit only.
- **Per-dimension adjudication with a filtered snapshot.** Refuse each
dimension independently; carry the participant's current value forward for
the refused ones; record the resulting filtered `ParticipantStatus`.
- **Per-dimension adjudication plus an outbound refusal message.** As above,
plus a new wire message telling the sender what was refused.

## Decision Outcome

Chosen option: **per-dimension adjudication with a filtered snapshot**.

`FilterParticipantStatusDimensionsNode`
(`vultron/core/behaviors/status/nodes/dimension_filter.py`) adjudicates `rm`,
`vfd` and `pxa` separately, then publishes a filtered `ParticipantStatus` in
which each refused dimension carries the participant's current value forward.
That filtered object is what gets persisted, appended to the participant, and
snapshotted in the canonical ledger entry. It runs as a read-only precondition
guard of `add_participant_status_tree`, replacing
`CheckParticipantRMNotClosedNode`.

Per-dimension rules:

- `rm` — accepted when it confirms the current value, is a valid adjacent
transition, or is a monotone forward jump. `RM.CLOSED` is terminal: once a
participant has closed, no further `rm` value is accepted, *including*
`CLOSED` again.
- `vfd` and `pxa` — each is a triple of independent one-way latches
(`v→V`, `f→F`, `d→D`; `p→P`, `x→X`, `a→A`). Accepted when no component
regresses from uppercase back to lowercase.
- `em` — not adjudicated here. Embargo state is Seam 2's (`add_case_status_tree`,
RSH-02-001); Seam 1 adjudicating it would duplicate and could contradict
Seam 2's decision. Tracked in ISSUE-2256.

The refusal is made visible through the canonical ledger rather than a new wire
message: the committed entry snapshots the accepted portion, so it differs from
what the sender asserted and every participant sees the receiver's actual view.
No new message type is introduced (an outbound refusal is deferred; see
ISSUE-2255 for the separate problem that the HTTP response is `202 Accepted`
regardless of outcome).

A status update whose accepted portion is indistinguishable from the
participant's current state is refused *in full* — nothing appended, no ledger
entry committed. Such an assertion carries no acceptable information, and
recording it would grow both the status history and the hash chain with no
state change.

Symmetrically on the replica side,
`ApplyParticipantStatusFromLedgerNode` now ratchets `rm`: an
`Announce(CaseLedgerEntry)` that would move the local `rm` backwards on the
progress scale has that dimension carried forward at the local value, while
every other dimension is applied as the entry describes it. Lateral moves at
the same rank (`VALID` ↔ `INVALID`, `DEFERRED` ↔ `ACCEPTED`) are the Case
Actor re-adjudicating, not a regression, and are applied unchanged.

### Consequences

- Good, because a refused dimension no longer destroys accepted state, and no
longer kills the Seam 1 → Seam 2 emit or embargo teardown.
- Good, because the canonical ledger — the thing that actually replicates — now
records what the receiver believes rather than what the sender claimed.
- Good, because the guard is read-only with respect to the DataLayer, so it
fits CLP-10-006 ordering and can run before the commit.
- Good, because a `RM.CLOSED` participant can still report VFD/PXA progress.
- Bad, because the sender still gets no explicit signal that a dimension was
refused; it must observe the canonical ledger. The HTTP-status half of that
gap is ISSUE-2255.
- Bad, because the guard-to-append handoff uses the py_trees blackboard, which
is process-global and not cleared between executions. Both keys are therefore
written on every tick (with `None` when inapplicable) and matched by object ID
on read. This is a real hazard, not a hypothetical one.
- Neutral, because `CheckParticipantRMNotClosedNode` remains in the codebase,
marked deprecated with a pointer to its replacement, rather than being
removed.

## Validation

`test/core/behaviors/status/test_partial_accept_participant_status.py` covers
each rule: a refused `rm` with accepted `vfd`/`pxa`, survival of the Seam 2
emit, the ledger snapshot carrying the accepted `rm`, a `RM.CLOSED` participant
advancing `vfd`, whole-update refusal committing no entry, and the replica-side
RM ratchet.

## Pros and Cons of the Options

### Keep all-or-nothing, but return SUCCESS on refusal

- Good, because it is a one-line change that unblocks embargo teardown.
- Bad, because accepted dimensions are still silently discarded — the
liberal-accept violation remains.
- Bad, because the canonical entry would still snapshot the raw assertion.

### Per-dimension adjudication with a filtered snapshot

- Good, because it matches the actual structure of the data: independent state
machines adjudicated independently.
- Good, because it makes the refusal visible in the one artifact that is
replicated and hash-chained.
- Neutral, because it needs a guard→append handoff channel; the blackboard is
the available mechanism and carries the leakage hazard noted above.

### Per-dimension adjudication plus an outbound refusal message

- Good, because the sender learns immediately and precisely what was refused.
- Bad, because it introduces a new wire message type and its authorization
semantics — a much larger surface than the bug requires.
- Bad, because a refusal message invites refusal loops between peers that
disagree; that needs its own design.

## More Information

- ISSUE-2235 — the bug this decision resolves.
- ISSUE-2229 — liberal-accept epic (Postel's maxim).
- ISSUE-2255 — receive path returns `202 Accepted` regardless of BT outcome.
- ISSUE-2256 — Seam 2 `em` adjudication.
- ADR-0046 — two-seam authorization model.
- `notes/sync-ledger-replication.md` — monotonic visibility and the
reject-on-divergence invariants.

Generated spec requirements: `received-status-handling.yaml` RSH-05-001 through
RSH-05-008.
1 change: 1 addition & 0 deletions docs/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ General information about architectural decision records is available at <https:
- [ADR-0057 Rename `CVDRole.OTHER` to `CVDRole.OBSERVER` and Define Observer Participant Semantics](0057-observer-participant-role.md)
- [ADR-0058 Gate Demo Scenario Steps on Causal Preconditions, Not Temporal Order](0058-causal-gating-in-demo-scenarios.md) *(provisional)*
- [ADR-0059 Buffer Pre-Genesis `Announce(CaseLedgerEntry)` and Drain on Case Seed](0059-buffer-pre-genesis-ledger-entries.md)
- [ADR-0061 Adjudicate Received `ParticipantStatus` Per Dimension, Not as a Unit](0061-per-dimension-partial-accept.md)
- [ADR-0062 Normalise Wire → Core at Ingress, and Enforce It Again at the Persistence Boundary](0062-normalise-wire-to-core-at-both-ingress-and-persistence.md)

## Proposed ADRs
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ nav:
- Rename CVDRole.OTHER to CVDRole.OBSERVER and Define Observer Participant Semantics: 'adr/0057-observer-participant-role.md'
- Gate Demo Scenario Steps on Causal Preconditions, Not Temporal Order: 'adr/0058-causal-gating-in-demo-scenarios.md'
- Buffer Pre-Genesis Announce(CaseLedgerEntry) and Drain on Case Seed: 'adr/0059-buffer-pre-genesis-ledger-entries.md'
- Adjudicate Received ParticipantStatus Per Dimension, Not as a Unit: 'adr/0061-per-dimension-partial-accept.md'
- Normalise Wire to Core at Ingress, and Enforce It Again at the Persistence Boundary: 'adr/0062-normalise-wire-to-core-at-both-ingress-and-persistence.md'
- About:
- Contributing: 'about/contributing.md'
Expand Down
34 changes: 23 additions & 11 deletions notes/flaky-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,29 @@ and fall through to Level 2 (GitHub label search).
|---|---|---|
| `fvcv-extension` | — | 2026-07-31 |
| `fccv-extension` | — | 2026-07-31 |
| `fcvcv Demo Integration` | #2216 | 2026-08-12 |
| `fvcv-handoff Demo Integration` | #2216 | 2026-08-12 |
| `fvcv-handoff Invariant Harness` | #2216 | 2026-08-12 |
| `fcvcv Invariant Harness` | — | 2026-08-10 |
| `fcv-reject Invariant Harness` | #2121 | 2026-08-10 |

> These jobs fail intermittently due to inter-container HTTP delivery timeouts
> (async race windows). Root cause documented in `plan/incoming/learnings/`
> entry `20260731-async-race-windows-in-fv-demo.md`. When a new occurrence is
> confirmed, `pr-execute` will open or comment on a `flaky-test` + `bug` issue
> and record it here.
| `fcvcv Demo Integration` | #2233 (was #2216, closed) | 2026-08-13 |
| `fvcv-handoff Demo Integration` | #2233 (was #2216, closed) | 2026-08-13 |
| `fvcv-handoff Invariant Harness` | #2233 (was #2216, closed) | 2026-08-13 |
| `fcvcv Invariant Harness` | #2233 | 2026-08-13 |
| `fcv-reject Invariant Harness` | #2233 (was #2121, closed) | 2026-08-13 |
| `fv Invariant Harness` | #2233 | 2026-08-13 |

> Some of these jobs fail intermittently due to inter-container HTTP delivery
> timeouts (async race windows). Root cause documented in
> `plan/incoming/learnings/` entry `20260731-async-race-windows-in-fv-demo.md`.
> When a new occurrence is confirmed, `pr-execute` will open or comment on a
> `flaky-test` + `bug` issue and record it here.
>
> **The six rows pointing at #2233 are not flaky** — they fail on *every* run
> until the engage-case 422 lands, and they are listed here only because
> `pr-execute`'s dedup procedure looks here first. The Demo Integration pair
> fails on `SvcEngageCaseUseCase failed: TransitionParticipantRMtoAccepted`; the
> four Invariant Harness rows fail downstream of it on
> `test_invariant_5_expected_event_types_present[engage_case]`, because the 422
> aborts the trigger before `GuardedCommitCaseLedgerEntryBT` can record the
> entry that #2266 made universally required. Keep them in one row set: routing
> them to separate issues is what left #2216 and #2121 as stale pointers here
> after they were closed.

---

Expand Down
82 changes: 80 additions & 2 deletions notes/received-status-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,94 @@ not wired into the CaseActor's received-side pipeline.
```text
AddParticipantStatusBT (Sequence)
├─ VerifySenderIsParticipantNode ← unchanged
├─ CheckParticipantRMNotClosedNode ← unchanged
├─ FilterParticipantStatusDimensionsNode ← per-dimension adjudication (RSH-05)
├─ GuardedCommitOrSkip ← unchanged (CLP-10-006)
├─ AppendParticipantStatusNode ← records "X said FOO" (unchanged)
├─ AppendParticipantStatusNode ← records the accepted portion
├─ StatusUpdateGuard (Fallback) ← NEW
│ ├─ CheckIsCaseOwnerNode ← hard bypass: CASE_OWNER = gospel
│ └─ CaseOwnerApprovesStatusUpdate ← Evaluator call-out (AlwaysSucceed)
├─ EmitAddCaseStatusToSelfNode ← NEW: triggers canonicalization
└─ AutoCloseIfCaseManager ← unchanged
```

### Per-dimension partial accept (RSH-05, ADR-0061)

`FilterParticipantStatusDimensionsNode` replaced the former
`CheckParticipantRMNotClosedNode` guard. The old guard — and
`ValidateRMTransitionNode` inside the append subtree — refused a whole
`ParticipantStatus` snapshot when its `rm` dimension was unacceptable, which
discarded the accepted `vfd`/`pxa` values *and* aborted this Sequence before
the Seam 1 emit, silently skipping embargo teardown (ISSUE-2235).

The guard now adjudicates `rm`, `vfd` and `pxa` independently and publishes a
*filtered* `ParticipantStatus` in which each refused dimension carries the
participant's current value forward. It is read-only with respect to the
DataLayer (CLP-10-006), so it can run before `GuardedCommitOrSkip` and the
canonical entry snapshots the accepted portion rather than the raw claim.

`em` is deliberately **not** adjudicated here — embargo state belongs to Seam 2
(ISSUE-2256).

Two blackboard keys carry the handoff. Both are written on *every* tick (with
`None` when nothing was filtered) and matched by object ID on read, because the
py_trees blackboard is process-global and `BTBridge.execute_with_setup` restores
only `datalayer` and `trigger_activity_factory` between runs:

| Key | Producer | Consumers |
|---|---|---|
| `append_status_dimension_filter` | `FilterParticipantStatusDimensionsNode` | `ResolveAndPersistStatusObjectNode`, `ValidateRMTransitionNode` |
| `ledger_payload_object_override` | `FilterParticipantStatusDimensionsNode` | `CommitCaseLedgerEntryNode` |

`ledger_payload_object_override` is defined in
`vultron/core/behaviors/case/nodes/lifecycle.py` next to its consumer and is
deliberately generic (`{"object_id", "fields"}`): any receive tree may patch the
`object` entry of the ledger payload snapshot, and the other receive trees are
unaffected because the override is opt-in and ID-matched.

It carries a **field patch, not a replacement object** (RSH-05-009). The
snapshot's `object` is the sender's wire-shaped `ParticipantStatus` — flat
`rmState`/`vfdState`, nested `caseStatus`, plus `@context`, `emConsentState` and
`cvdRole` — and every replica plus the case-ledger invariant harness read it in
that shape. A guard in `vultron.core` cannot rebuild that object: core has zero
`from vultron.wire` imports (ADR-0009, ADR-0017), so dumping the core model
would emit nested `rm`/`vfd` dimension objects and drop every field the guard
never adjudicated. Naming only the adjudicated fields, keyed by wire alias, and
merging them onto the existing snapshot makes shape preservation structural
rather than something the guard has to remember:

```python
{"object_id": status_id, "fields": {"rmState": "VALID", "vfdState": "VFd",
"caseStatus": {"pxaState": "Pxa", ...}}}
```

`CommitCaseLedgerEntryNode._resolve_payload_object_override` merges one level
deep, so patching `caseStatus.pxaState` keeps that nested object's own `id`; it
leaves a `caseStatus` that is still a bare reference string alone, and it drops
the stale snake_case twin of any patched alias so a consumer preferring
`rm_state` cannot read the value the receiver just refused.

`ValidateRMTransitionNode` keeps its all-or-nothing RM semantics when the
append subtree is used standalone. It only relaxes when the blackboard says
`rm` was refused upstream and carried forward — a narrower change than
reordering its terminal-`CLOSED` and equality checks would have been.

Two things the guard tracks that are easy to conflate:

- **An omitted `caseStatus` is not a refusal.** A status that says nothing about
`pxa`/`em` has the receiver's own `case_status` carried forward; persisting the
assertion verbatim would blank both dimensions, which is silent data loss, not
adjudication. So the guard returns two different sets: `refused` names the
dimensions whose asserted value was rejected, while the `model_copy` update
also covers dimensions nobody asserted. If carrying `case_status` forward is
the *only* thing the update does, nothing new was learned and the status is
refused in full (RSH-05-005).
- **A blocked dimension is not always a rewritten one.** `RM.CLOSED` restated by
a participant already at `RM.CLOSED` is refused by the terminal-state rule, but
the recorded value matches the assertion, so nothing was discarded. The
operator-facing WARNING distinguishes `rewrote dimension(s) …` from `blocked
dimension(s) … with no change to the asserted value`; calling the latter a
refusal would misdescribe the audit trail.

### CASE_OWNER gospel-bypass rationale

CASE_OWNER is the human decision-maker for the case. Their reported status
Expand Down
21 changes: 20 additions & 1 deletion notes/sync-ledger-replication.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,26 @@ invariants under normal operation and partial failure:
3. **Idempotent replay**: Reprocessing any log prefix (including duplicates)
MUST NOT change the resulting state.
4. **Monotonic visibility**: Participants MUST NOT regress their acknowledged
log position.
log position. This extends to projected protocol state:
`ApplyParticipantStatusFromLedgerNode` will not let an entry move a
replica's RM state backwards on the progress scale, even though the Case
Actor is authoritative for *which* transition happened. A replayed,
reordered, or divergent entry would otherwise un-see progress the replica
has already observed. The local value is carried forward for `rm` only;
every other dimension is applied as the entry describes it, and lateral
moves at the same rank (`VALID` ↔ `INVALID`) are re-adjudication rather than
regression (RSH-05-007, ADR-0061).

The ratcheted status is saved to the DataLayer **unconditionally**. The node
appends the object it reads *back* from the DataLayer — a wire-typed instance
is required, because appending the core model to a
`list[WireParticipantStatus]` makes Pydantic serialize it with the declared
element type's defaults. So the ratchet only takes effect if the ratcheted
copy is what got written. A status object can already be stored locally
without being on the participant (an out-of-order `Announce` of the object
itself, or a replayed entry), and skipping the save in that case appends the
un-ratcheted status while the ratchet's own warning claims the local value was
carried forward.
5. **Reject-on-divergence**: Entries that do not extend the current hash
chain MUST be rejected and MUST trigger resynchronization.

Expand Down
Loading
Loading