Received-Side HandlerResult Carries a Handler Disposition Across the Dispatcher Boundary
Context and Problem Statement
ADR-0040 introduced the UseCaseResult envelope with HandlerResult and
TriggerResult subtypes. It said nothing at all about whether that envelope
crosses the dispatcher boundary — the words "dispatch" and "scope" do not appear
in it. The only place that question was ever addressed is a design note:
The dispatcher's own
dispatch()return type is not changed in this issue. SurfacingUseCaseResultthrough the dispatcher boundary is a separate architectural decision.—
notes/use-case-protocol.md, before this ADR
So the boundary was never decided in either direction, and nothing of ADR-0040's
needs overturning to decide it now. (Concern #1769 cites "ADR-0040 'Out of
Scope'" as having settled the boundary at -> None. There is no such section;
that citation is mistaken, and the note above is what it was reaching for.)
That silence left the received-side half of the envelope without a purpose.
HandlerResult was specified (UCORG-05-002) as a required return type carrying
no required fields, and nothing downstream could read it — so implementing it
literally would change 51 signatures to convey nothing. Concern #1769 asked what
a received-side HandlerResult is for before writing that code.
It is for carrying a handler's own verdict to InboxOutcome.
Two failure paths exist on the received side, and they are distinct:
- Acceptance. "Is this a well-formed activity addressed to me?" Decided
synchronously in
post_actor_inbox, answered as HTTP 400 or 202. Works today. - Processing. "Did handling that well-formed activity actually succeed?"
Decided later, in the background task, and reported as
InboxOutcome.
The second path is unobservable. InboxOutcome already models rejected and
carries failure_reason, but _read_inbox_outcome() assembles it from
inbox-BT blackboard keys rather than from the handler, and DispatchNode treats
"dispatch did not raise" as SUCCESS. Every link between the handler and the
outcome is typed -> None:
execute() -> None
→ DispatcherBase._handle() -> None (core/dispatcher.py — calls execute())
→ DispatcherBase.dispatch() -> None (core/dispatcher.py)
→ ActivityDispatcher Protocol -> None (core/ports/dispatcher.py)
→ dispatch() -> None (adapters/.../inbox_handler.py)
→ FastAPIDispatchAdapter.dispatch() -> None (adapters/.../inbox_orchestration.py)
→ DispatchAdapter Protocol -> None (core/behaviors/inbox/models.py)
→ DispatchNode.update()
_handle() is the link that actually calls execute(); dispatch() only logs
and delegates to it. It is easy to miss and it is the first hop the verdict has
to survive.
So a handler can inspect an activity, find nothing it can act on, log a warning,
return, and the pipeline still reports status="processed". Bug #2255 records
this, names the same three-layer drop, and points at this ADR for the contract.
Decision Drivers
- The handler is the only component that knows what happened to its activity.
InboxOutcomealready exists and already models the destination vocabulary; a second parallel vocabulary would be worse than none.- Not every behavior-tree
FAILUREis a refusal, and not everySUCCESSis applied work. Benign idempotent skips must keep reportingprocessed, and the ledger-sync buffer nodes returnSUCCESSfor an entry they have only parked, so the contract must distinguish all three from a refusal. - The received-side use-case layer has no base class — all 51 handler classes are standalone — so the contract cannot rely on inherited default behavior.
specs/handler-protocol.yamlHP-01-002 (MAYreturn None or HandlerResult) andspecs/use-case-organization.yamlUCORG-05-002 (MUSTreturn HandlerResult) contradict each other. One of them has to lose.
Considered Options
- Empty envelope. Give all 51 handlers a fieldless
HandlerResult. Satisfies UCORG-05-002 literally. - Disposition-carrying envelope threaded through the dispatcher boundary.
HandlerResultcarries the handler's verdict; every link in the chain returns it;InboxOutcomeis derived from it. - Narrow UCORG-05 to the trigger side. Delete UCORG-05-002, keep
HP-01-002, leave received handlers at
-> None. - Side channel. Keep every
dispatch()at-> None; handlers write their verdict to a collector object or blackboard key thatDispatchNodereads.
Decision Outcome
Chosen: option 2.
HandlerResult carries a HandlerDisposition and an optional reason, and the
dispatcher boundary propagates it so that InboxOutcome is derived from the
handler's own verdict.
The disposition vocabulary
HandlerDisposition is a StrEnum with four values:
| Value | Meaning | Maps to InboxOutcome.status |
|---|---|---|
APPLIED |
The handler changed local state to reflect the inbound assertion. | processed |
SKIPPED |
The handler correctly did nothing — duplicate, already-present, or otherwise a legitimate no-op. | processed |
DEFERRED |
The handler parked the item for later replay rather than acting on it or declining it. | deferred |
REFUSED |
The handler rejected the inbound assertion. reason is populated. |
rejected |
DEFERRED is a member because handler-side deferral is real, and it is easy to
talk oneself out of. DeferCheckNode runs before dispatch and handles one kind
of deferral — the case context is not known locally yet — which makes it tempting
to conclude that a handler can never produce one. But the ledger-sync path does
exactly that: BufferOutOfOrderEntryNode and BufferPreGenesisEntryNode
(core/behaviors/sync/nodes/receive.py) return SUCCESS after parking an entry
in the actor-local LedgerGapBuffer, to be replayed once its predecessor or its
VulnerabilityCase seed arrives. AnnounceLedgerEntryReceivedUseCase reaches
them through normal dispatch.
A parked entry is not applied, is not a benign no-op, and is not refused. Forcing
it into APPLIED would claim the assertion landed; forcing it into SKIPPED
would claim there was nothing to do. Both discard the one fact a reader needs —
that a replay is pending — and InboxOutcome already models deferred, so there
is nothing to invent. The two producers of deferred stay distinct: pre-dispatch
(DeferCheckNode, missing case context) and post-dispatch (a handler returning
DEFERRED).
This does not weaken HP-01-004. A handler reports in its own vocabulary;
DispatchNode still owns the mapping onto InboxOutcome.
SKIPPED is narrower than "the handler did nothing".
A receiver that holds no role to act on a message — a CASE_MANAGER-addressed message at an actor that is not the case's CASE_MANAGER, or a copy naming the receiver in neither to nor cc — reports REFUSED, not SKIPPED (HP-01-005, #3752).
The disposition is the receiver's own record of its own handling, and such a message was misaddressed by its sender; reporting it processed hid that.
SKIPPED is reserved for a duplicate or otherwise idempotent re-delivery of a message the receiver was entitled to act on.
That narrowing leaves one receipt with a new name: an addressee's first receipt of a message that is now pending its own decision, such as the Case Owner receiving Offer(CaseParticipant), is APPLIED even though the handler wrote nothing, because it is neither a duplicate nor a refusal and the decision it awaits is the addressee's own.
SKIPPED exists because APPLIED and SKIPPED collapse to the same
InboxOutcome.status but are not the same event. Today both look identical — a
line in a log file — and received/status.py already special-cases
CASE_STATUS_ALREADY_PRESENT to keep a benign skip from being read as a
failure. Naming the distinction in the type preserves it for any future
consumer without changing what the pipeline reports.
Why the dispatcher boundary changes
The dispatcher boundary is the only road from the handler to InboxOutcome, so
option 2 requires deciding a question no prior ADR reached. This ADR decides it:
the boundary returns HandlerResult.
The change is additive and cheap: six return-type declarations — two Protocols
(ActivityDispatcher, DispatchAdapter), two methods on the single concrete
dispatcher (_handle, dispatch), and two adapter-level functions. Callers that
ignore the returned value keep working, so the blast radius is bounded by the
type declarations rather than by call-site rewrites.
Two parts of the chain need explicit handling and are easy to overlook:
_handle()can return without a handler running. It catchesUnroutableActivityError, logs, and returns (nocase_idextractable), and_get_use_case()raisesVultronApiHandlerNotFoundErrorfor unrecognised semantics. In the first case no exception escapes, so a dropped activity is reported asprocessedtoday. AHandlerResultreturn type alone does not fix that: the dispatcher layer has to synthesise a verdict when no handler ran, and UCORG-05-012 requires it.- Most
SKIPPEDdecisions do not live inexecute()._idempotent_createand its peers invultron/core/use_cases/_helpers.pyreturn without storing when the record already exists, and are themselves-> None. Five handlers delegate their entire duplicate-skip decision to that layer, so it has to return a disposition too orSKIPPEDis unreachable for the most common skip in the codebase.
Why not the alternatives
Option 1 (empty envelope) would satisfy UCORG-05-002 and add a ratchet while
leaving the defect untouched: a fieldless object carries no verdict, so
InboxOutcome would still be assembled from blackboard bookkeeping and a no-op
handler would still report processed. It is 51 edits that buy a green test.
Option 3 (narrow to the trigger side) is the honest choice if nothing consumes a handler verdict — but #2255 is a consumer, filed and open. Deleting UCORG-05-002 would leave that bug with no contract to build on.
Option 4 (side channel) preserves ADR-0040's boundary at the cost of putting a protocol-relevant verdict in implicit shared state. It is harder to type-check than a return value and cuts against the project's rule against global mutable state. The boundary is not valuable enough to protect at that price.
Consequences
- Good: a genuine refusal reaches
InboxOutcomewith a populatedfailure_reasoninstead of being observable only in the actor's own log. (One path is already better than that:received/status.pyemits aCreate(ProcessingFault)carryingVULTRON_FAILURE_STATUS_ASSERTION_REFUSEDfor a non-idempotent status failure. That behaviour predates this ADR and must not regress.) - Good: the skip-vs-deferral-vs-refusal distinction becomes a typed fact rather
than something a reader infers from log phrasing. A ledger entry parked pending
its predecessor stops reporting as
processed. - Good:
UseCaseResultgains a real definition, which the trigger side (#3831) needs asTriggerResult's parent. - Good: HP-01-002 and UCORG-05-002 stop contradicting each other.
- Bad: the migration is mostly judgment, not a mechanical pass. Of the 51
handlers, 34 carry at least one early-return guard clause and 9 more log a
failure and fall through without changing state, so roughly 43 need a
refusal-vs-skip determination; only a handful do unconditional work. (#2255
enumerates ~20 BT-non-success call sites — that is the narrower population of
sites where a
Status.FAILUREis logged, not the full set of handlers needing a disposition decision.) #3372 therefore returnsAPPLIEDeverywhere and leaves every classification to #2255, so the contract can land without waiting on 43 judgments. - Bad: the migration touches all 51 handler modules plus the shared helpers in
use_cases/_helpers.py. It is partitioned by module so a stalled pass cannot strand the contract work. - Neutral: ADR-0040 needs a pointer here so a reader of the older ADR learns the boundary has since been decided. Nothing in ADR-0040 becomes wrong.
Validation
Implemented for the received side and the dispatcher boundary; the trigger side is ADR-0110 (#3831, #3832, #3833).
Realized:
- The architecture ratchet
test/architecture/test_use_case_execute_returns_result.py(UCORG-05-004) asserts that every concrete use-case class invultron/core/use_cases/declares anexecute()return annotation that resolves toUseCaseResultor a subtype (#3372). It excludestriggers/until #3831 migrates them, names that issue, and fails once the exclusion is no longer needed. - The
UseCaseProtocol declaresexecute() -> UseCaseResult. No call site is yet typed against the Protocol, so mypy does not report a non-conforming class by itself; the ratchet does. test/adapters/driving/fastapi/test_inbox_outcome_chain.pycovers the two paths a ratchet cannot see, driving the real FastAPI dispatch adapters, dispatcher, and inbox BT with only the use case stubbed (#3373). AREFUSEDdisposition reachesInboxOutcome.status == "rejected"with the handler's reason asfailure_reason, and an unroutable or unrecognised-semantics activity does not reportprocessed(UCORG-05-012). The same file checks thatrun_inbox_pipelinelogs a rejection at WARNING (UCORG-05-013).test_real_handler_refusal_reaches_inbox_outcomein that file drives a real handler, not a stub. An untrusted sender'sAnnounce(VulnerabilityCase)is refused, and the refusal reachesInboxOutcomeasrejected(#2255).test/core/use_cases/received/test_bt_verdict.pycovers the default reading of a BT run (verdict_from_bt,applied_or_raise). In particular, an internal error or missing wiring raises rather than refusing. The per-handler disposition tests sit beside each handler's tests undertest/core/use_cases/received/(#2255).
Per this ADR's own subject matter: no Validation entry here asserts that a test exists until it does. ADR-0040's Validation section claimed the ratchet as existing validation for three months while the file was never written, and that claim is a direct cause of concern #1769.
More Information
Extends ADR-0040, which introduced the
UseCaseResult hierarchy but did not reach the dispatcher boundary. ADR-0040
stands unchanged; it gains only a pointer here.
Design note: notes/use-case-protocol.md.
Source concern: #1769. Consumer: #2255. Trigger-side counterpart: ADR-0110, planned from concern #3354 (#3831, #3832, #3833). Related: #2369 and #2682 (surfacing outcomes to the sender, which this ADR does not address — the 202 is already sent before a handler runs).
Generated spec requirements: specs/use-case-organization.yaml UCORG-05-005
and UCORG-05-009 through UCORG-05-013 (this ADR also relies on the pre-existing
UCORG-05-001 through UCORG-05-008); UCORG-05-004b, the ratchet's temporary
trigger-side exclusion, was retired with #3831 (MS-09-001) once the trigger
side conformed;
specs/handler-protocol.yaml HP-01-002 (amended), HP-01-003, and HP-01-004.