Single BT Execution Per Inbox Delivery for Received-Side CaseActor Routing
Context and Problem Statement
ADR-0021 established that the CaseActor's own inbox delivery is the sole
path to a canonical ledger commit, and introduced a pre-flight guard
(CLP-10-002, CLP-10-003): a received-side use case compares
receiving_actor_id to the resolved case_actor_id in Python before
deciding whether to execute the guarded-commit BT
(create_guarded_commit_case_ledger_entry_tree).
Issue #1036 found that implementing that guard produced a second
anti-pattern: every adopting use case now runs two separate
BTBridge.execute_with_setup() calls per inbox delivery — one for the
use case's main operation (often under the message's own actor identity,
e.g. the invitee or accepting actor), and a second, conditionally
dispatched call for the guarded commit (under receiving_actor_id). The
condition that decides whether the second call happens at all is plain
Python control flow (if receiving_actor_id != case_actor_id: return),
not a node inside either tree.
An audit (issue #1036, broadened during planning to also resolve a
follow-on Concern, issue #1047, which independently named the same
anti-pattern in status.py and asked for it to be codified as a
spec-level invariant) found the underlying violation in 9 use cases
across 6 modules, but
not all in the same precise shape. There are three distinct ways the
"execute() does nothing but build one tree, run it once, handle the
result" intent was violated:
(1) Genuine actor-identity switching — a real second
execute_with_setup() call under a different actor than the main
operation's. This is the literal anti-pattern #1036 and #1047 describe:
vultron/core/use_cases/received/embargo.py:RemoveEmbargoEventFromCaseReceivedUseCase,InviteToEmbargoOnCaseReceivedUseCase,AcceptInviteToEmbargoOnCaseReceivedUseCase— main tree runs underrequest.actor_id/invitee_id/accepting_actor_id; a second, separately constructedcreate_guarded_commit_case_ledger_entry_treeruns underreceiving_actor_id. Notably, the main tree (built byvultron/core/behaviors/embargo/announce_teardown_tree.py's factory functions) already composes the identical guarded-commit branch as its own last child — but because the main tree executes under the invitee's/accepting actor's identity,CheckIsCaseManagerNodeinside it is a guaranteed no-op (that actor is essentially never the CaseActor). The second, separately-dispatched call is the one that actually fires, on the rare second attempt under the correct identity. The fix is not "add a guarded-commit branch" (it already exists) — it is "stop calling the tree under the wrong actor a second time, and run the one tree that already has the branch underreceiving_actor_idinstead."vultron/core/use_cases/received/report.py:ValidateReportReceivedUseCase(main operation is a separateValidateCaseUseCasecall underactor_id, not even a tree of its own),AckReportReceivedUseCase(main tree underrequest.actor_id) — both followed by a second, separately-gated guarded-commit call underreceiving_actor_id/case_actor_id.vultron/core/use_cases/received/status.py:AddParticipantStatusToParticipantReceivedUseCase— main tree (add_participant_status_tree) underrequest.actor_id, then a second guarded-commit call undercase_actor_idvia_commit_log_cascade_bt(). This is the exact site #1047 cited as its primary evidence.
(2) Single BT call, but non-BT procedural code precedes it — only one
execute_with_setup() call exists (the commit), but execute() still
does domain-significant work outside any tree before reaching it, so it
is not "build one tree, run it, handle the result" either:
vultron/core/use_cases/received/note.py:AddNoteToCaseReceivedUseCase— appends tocase.notes, callsdl.save(), and broadcasts to peers, all in plain Python, before the single guarded-commitexecute_with_setup()call.vultron/core/use_cases/received/actor/case_manager_role.py:OfferCaseManagerRoleReceivedUseCase— idempotent-creates the activity and calls a trigger-activity port directly in Python, before the single guarded-commit call. This is a pre-existing BT-06-001 gap (the main operation was never expressed as a BT at all), not actor-switching.
(3) Single BT call under the correct actor, but assembled ad hoc —
no actor-switching and no procedural code outside the tree, but the tree
itself is built inline in execute() rather than via a
vultron/core/behaviors/ factory function:
vultron/core/use_cases/received/case/lifecycle.py:CloseCaseReceivedUseCase— constructs a one-offpy_trees.composites.Sequencedirectly insideexecute(), combiningStoreActivityNodeand the guarded-commit subtree, and runs it once underreceiving_actor_id. This already satisfies the actor-identity requirement; the fix here is purely "move this tree into a named factory function inbehaviors/case/," not an actor-switching repair.
All nine sites import create_guarded_commit_case_ledger_entry_tree
directly into vultron/core/use_cases/, which is what the structural test
(below) detects — but the underlying defect, and the fix required, differs
by category. This is the same class of problem the "Post-BT Procedural
Cascade Anti-Pattern" (notes/bt-integration.md) already names for the
validate→prioritize cascade: a cascade that is a child of the triggering
operation in the canonical model must be expressed as a BT subtree, not as
a procedural call (or a second, conditionally-dispatched tree execution,
or any other domain-significant code) sitting outside the tree in
execute(). The fact that the in-tree guard (CheckIsCaseManagerNode) is
itself correctly role-gated does not fix category (1): the decision of
whether to even ask the question is still made in Python, once per
inbox delivery, instead of being one branch of one BT run.
A related architectural question raised during planning — whether the
inbox dispatch pipeline itself needs to be restructured so a use case
never has to ask "which actor am I running as" — turned out not to apply.
Investigation found receiving_actor_id is already resolved before
dispatch (FastAPIDispatchAdapter.dispatch(), which sets it from the
canonical actor ID resolved off the inbox URL path) and is already
present on every received-side event by the time execute() runs. The
dispatcher (vultron/core/dispatcher.py) has no CaseActor awareness and
needs none — it is a pure routing-table lookup keyed on
semantic_type. The gap is entirely at the use-case/BT boundary, not the
dispatch layer; no change to ADR-0020's proposed process_payload seam
is required by this decision.
Decision Drivers
- The BT is the domain documentation (
specs/behavior-tree-integration.yamlBT-06-001); a cascade that is invisible outside the tree structure is invisible to analysis and audit, regardless of whether the gating logic is "correct" in isolation. - CLP-09-001 already requires the commit to be reached only through a
role-gated composition — a Selector/Sequence/Success idiom evaluated
inside the tree. A second, externally-gated
execute_with_setup()call is not that composition; it duplicates the gate in a second place (Python) on top of the one already inside the guarded-commit tree. receiving_actor_idis already known before any use case runs (see Context). There is no remaining technical reason for a received-side use case to execute more than one BT per inbox delivery under more than one actor identity.- Per-message asserter identities that differ from
receiving_actor_id(e.g.invitee_id,accepting_actor_id) are legitimate inputs to leaf nodes — they are not legitimate values for theactor_idargument ofexecute_with_setup(), which must always be the identity of the DataLayer actually in scope for this invocation.
Considered Options
Option A — Single BT execution per inbox delivery; CASE_MANAGER gating
is an in-tree branch of the same tree (this ADR's decision).
Each received-side use case calls BTBridge.execute_with_setup() exactly
once, with actor_id=receiving_actor_id. The use case's tree-factory
function composes the main operation subtree and the guarded-commit
subtree (create_guarded_commit_case_ledger_entry_tree, or an equivalent
Selector/Sequence/Success branch) as siblings or a sequence within one
tree. Any identity referenced by the message other than
receiving_actor_id (invitee, accepting actor, sender) is threaded into
the tree as leaf-node constructor arguments or blackboard keys — data,
not a second actor_id.
Option B — Keep the current two-call shape; tighten only the
pre-flight guard's correctness (status quo, amended).
Leave each use case's execute() performing two execute_with_setup()
calls, but ensure the second call's actor_id is always provably
receiving_actor_id (already true after ADR-0021/CLP-10-003). This
satisfies CLP-10-002/CLP-10-003 literally but leaves the underlying
cascade-visibility problem unresolved: the decision to run the commit
step at all is still Python control flow outside any tree.
Option C — Move the pre-flight guard into a wrapper BT that always
runs, with the use case's main tree as a child.
Introduce a top-level "received-side dispatch tree" per use case that
always executes under receiving_actor_id, with the existing main tree
and the guarded-commit tree as two children, but keep them as
independent tree objects rather than fully merging composition. This is
a smaller diff than Option A in the short term but reintroduces a
two-tree-construction split that makes it easy to drift back into the
Option B shape during future edits, since the "merge point" is itself
external to either tree's own factory function.
Decision Outcome
Chosen option: Option A — single BT execution per inbox delivery, with CASE_MANAGER gating as an in-tree branch of the same tree.
ReceivedUseCase.execute()
└── ONE call: bridge.execute_with_setup(tree, actor_id=receiving_actor_id)
tree = create_<use_case>_received_tree(...) # single factory function
├── <main operation subtree> # may reference invitee_id /
│ # accepting_actor_id etc. as
│ # leaf-node data, never as a
│ # second actor_id
└── Selector("GuardedCommitOrSkip")
├── Sequence
│ ├── CheckIsCaseManagerNode(case_id=case_id)
│ └── CommitCaseLedgerEntryNode(case_id=case_id)
└── Success("CommitSkippedNotCaseManager")
CheckIsCaseManagerNode resolves the case's CVDRole.CASE_MANAGER
participant and compares it against the actor active for this
invocation (receiving_actor_id, since that is now always the BT's
actor_id) — the same role check already required by CLP-09-001, now
reached through exactly one tree execution instead of two.
This amends ADR-0021's CLP-10-002/CLP-10-003 call-shape (which it leaves
intact at the routing level — the CaseActor's own inbox delivery is still
the only path to a commit) by replacing the external pre-flight guard
with an internal one. CLP-10-001 (every protocol-significant trigger
tree emits to case_manager_id) is unaffected.
Amended by ADR-0111 (2026-09-29). The received-side tree gains a fourth stage in front of the three this ADR ordered: a shared intake node that archives the received activity, exactly as received, runs first, ahead of the precondition guards, the guarded commit, and the protocol effects (CLP-10-006, CLP-10-010, CLP-10-017). Intake writes no core record from an object inlined in the activity; an effect node writes that record from the event's copy after the guards (ADR-0111 amendment of 2026-09-30).
create_receive_activity_treesupplies the intake node; the singleexecute_with_setup()call and the in-tree CASE_MANAGER gate decided here are unchanged.
New Normative Requirement
specs/case-ledger-processing.yaml gains CLP-10-005: a received-side use
case's execute() method MUST do exactly three things — build one BT via
a vultron/core/behaviors/ tree-factory function, call
BTBridge.execute_with_setup() exactly once under
actor_id=receiving_actor_id, and handle the result. No other
domain-significant code (a second BT execution, direct DataLayer
mutation, or a direct import of the guarded-commit factory) may appear in
execute(). A structural test enumerates known violations as a ratchet
(test/architecture/test_single_bt_execution_received_side.py).
Consequences
- Good, because the cascade from "process this message" to "commit a canonical entry if I'm the CaseActor" becomes visible in tree structure, consistent with every other cascade in the codebase (BT-06-001/006).
- Good, because it eliminates a structurally duplicated gate (Python
check + in-tree
CheckIsCaseManagerNode) in favor of one gate, in one place, evaluated once. - Good, because the fix requires no change to the inbox dispatch pipeline
or ADR-0020's proposed seam —
receiving_actor_idis already available before any use case executes. - Neutral, because use cases whose main operation legitimately needs a
different actor identity for some leaf nodes (e.g. the Invite/Accept
embargo handshake, where
UpdateParticipantEmbargoPecNode/RecordParticipantAcceptanceNodeneedinvitee_id/accepting_actor_id) must thread that identity through as leaf-node data rather than asexecute_with_setup'sactor_id— a real design change to those trees, not just a call-site edit. - Bad, because the 9 known call sites need three different kinds of fix (genuine actor-switching repair for 6 of them; moving procedural code into a new BT for 2 of them; just relocating an already-correct inline tree into a factory function for 1 of them), so the implementation issues cannot treat all 9 as the same mechanical change — and discovering the full set required a full-codebase audit rather than fixing the 3 sites originally named in issue #1036, plus the 1 additional site #1047 named independently.
Validation
specs/case-ledger-processing.yamlCLP-10-005 codifies the requirement from this ADR.test/architecture/test_single_bt_execution_received_side.pyenumerates the 6 known-violating modules (9 use cases) as a ratchet set (mirroringtest/architecture/test_core_no_adapter_imports.py): the test fails if a new violation appears, and fails if a known violation is fixed but not removed from the set — forcing each migration to update the test.
More Information
- Issue #1036 — concern that identified the anti-pattern (3 originally cited call sites) and the planning session that broadened the audit to 9.
- Issue #1047 — independent concern naming the same anti-pattern at
status.pyand requesting it be codified as a spec-level invariant; fully covered by this ADR and CLP-10-005, superseded. - Issue #1038 — folded into this ADR's implementation graph; it had
independently proposed the same single-BT shape for the Invite/Accept
embargo pair and is superseded by the implementation issue covering
embargo.py. - ADR-0021 — establishes the CaseActor-inbox-is-sole-commit-path routing rule this ADR's call-shape change builds on; not superseded, only amended at the call-shape level.
notes/bt-integration.md§ "Guarded Commit: Role-Gated Canonical Writes" — the existing Selector/Sequence/Success idiom this decision requires to be reached through a single tree execution.notes/case-communication-model.md§ "Known Implementation Gaps" — tracks the per-module migration status.