Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/0060-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-0060 Adjudicate Received `ParticipantStatus` Per Dimension, Not as a Unit](0060-per-dimension-partial-accept.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/0060-per-dimension-partial-accept.md'
- About:
- Contributing: 'about/contributing.md'
- FAQ: 'about/faq.md'
Expand Down
43 changes: 41 additions & 2 deletions notes/received-status-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,55 @@ 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-0060)

`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", "object"}`): any receive tree may
substitute the `object` entry of the ledger payload snapshot, and the other
receive trees are unaffected because the override is opt-in and ID-matched.

`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.

### CASE_OWNER gospel-bypass rationale

CASE_OWNER is the human decision-maker for the case. Their reported status
Expand Down
10 changes: 9 additions & 1 deletion notes/sync-ledger-replication.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,15 @@ 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-0060).
5. **Reject-on-divergence**: Entries that do not extend the current hash
chain MUST be rejected and MUST trigger resynchronization.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: "py_trees blackboard is process-global and survives BT runs; every guard→effect handoff must be written per tick and ID-matched"
type: learning
timestamp: "2026-08-12T00:00:00Z"
source: ISSUE-2235
signal: concern
---

The ISSUE-2235 fix needed a read-only precondition guard
(`FilterParticipantStatusDimensionsNode`) to hand a filtered `ParticipantStatus`
to two downstream consumers in the same tree. The only available channel is the
py_trees blackboard, and it is a **process-global singleton**. `BTBridge.execute_with_setup`
restores exactly two keys between runs — `datalayer` and `trigger_activity_factory`
— so every other key written by a previous BT execution is still visible to the
next one, in the same process.

Consequences observed while implementing:

- A guard that writes its key *only when it has something to say* leaks that
value into the next activity's tree, where a consumer reads a stale payload
that describes a different object entirely. The fix is to write the key on
**every** tick, with `None` when there is nothing to publish.
- Writing per tick is not sufficient on its own: two activities in the same
process can carry different object IDs, so the payload must also record which
object it applies to and consumers must **ID-match** before honouring it.
Both new keys (`append_status_dimension_filter`,
`ledger_payload_object_override`) carry the ID and are matched on read.

This is not specific to this fix. Any future guard→effect blackboard handoff has
the same hazard, and the failure mode is silent cross-activity contamination
that unit tests running one tree per process will not reproduce. Candidates for
a systemic fix: have `BTBridge` clear (or namespace per-execution) all keys it
did not explicitly seed, rather than restoring a hardcoded pair.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: "Comments on the DataLayer read-back claim it returns wire-format objects; ADR-0034 says core. The read-back may be vestigial"
type: learning
timestamp: "2026-08-12T00:00:00Z"
source: ISSUE-2235
signal: concern
---

Two places on the participant-status paths save a `ParticipantStatus` and then
immediately read it back from the DataLayer before appending it to
`participant.participant_statuses`, with a comment explaining that the read-back
is needed "to obtain the vocabulary-typed (wire-format) version" — the claim
being that appending a core-model instance to a list declared as
`list[WireParticipantStatus]` makes Pydantic serialize defaults instead of actual
values. The comment now lives in
`vultron/core/behaviors/sync/nodes/participant_status_effect.py`; an equivalent
one was in `status/nodes/append.py`.

ADR-0034 says the DataLayer port returns **core** domain objects, and that is
what `SqliteDataLayer.read()` was observed to do while diagnosing ISSUE-2235: a
read-back `ParticipantStatus` has `.rm` / `.vfd` dimension objects, not the flat
`.rm_state` / `.vfd_state` of the wire model. So the stated reason for the
read-back cannot be right as written.

What is not established is whether the read-back is therefore *unnecessary*. It
may still be doing something real (normalizing through the vocabulary registry,
or catching a save failure), or it may be a leftover from before ADR-0034 that
now costs an extra query per apply. I did not change it — ISSUE-2235 had no
reason to touch it and the round-trip is load-bearing for the tests as they
stand.

Cost paid this session: the misleading comments sent the initial diagnosis toward
a wire/core serialization theory before the actual all-or-nothing control-flow
defect was found. Someone should determine which of the two — the comment or the
read-back — is the thing that is wrong, and delete it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
title: "Introduced a generic ledger payload_snapshot override seam rather than a status-specific one"
type: learning
timestamp: "2026-08-12T00:00:00Z"
source: ISSUE-2235
signal: design-question
---

ISSUE-2235 required the canonical `CaseLedgerEntry` to snapshot the *accepted*
portion of an inbound `ParticipantStatus`, not the raw assertion. `CommitCaseLedgerEntryNode`
builds `payload_snapshot` from the activity, so something had to let a preceding
guard substitute the `object` entry. Two shapes were available:

1. A status-specific hook on `CommitCaseLedgerEntryNode` (e.g. read
`append_status_dimension_filter` directly).
2. A **generic** override key, `ledger_payload_object_override`, carrying
`{"object_id", "object"}`, which any receive tree may publish.

Option 2 was chosen and the key is defined in
`vultron/core/behaviors/case/nodes/lifecycle.py` next to its consumer, not next
to its (currently sole) producer — so the import direction stays status → case,
matching the pre-existing edge, and no cycle is introduced.

This was a decision beyond what the issue asked for. Rationale: the same need
recurs for every seam that adjudicates before committing (Seam 2 `em`,
ISSUE-2256, is the immediate next case), and a per-concern hook on the commit
node would accumulate one branch per concern inside the single writer of the
hash chain.

The risk it accepts: the commit node — the one place that must be trustworthy,
since its output is hash-chained and replicated to every participant — now has an
opt-in path by which an upstream node can rewrite what gets committed. It is
mitigated by being ID-matched (the override is ignored unless it names the object
being committed) and by logging the substitution at INFO. Anything wired to
produce this key is effectively asserting canonical content and should be
reviewed with that in mind.
Loading
Loading