The Capability Layer: Four Call-Out Shapes, Core-Declared Typed-Port Contracts, and Sentinel as a Call-In Pattern
Context and Problem Statement
ADR-0024 named five capability shapes and ADR-0025 established the factory injection seam that answers a call-out point. Between them they left the middle tier of the three-level taxonomy — the capability — without a formal definition, and three Ideas were filed against that gap:
- #2452 — what must a named capability define?
- #2453 — how is a capability implementation invoked at runtime?
- #2454 — should the shape class names in the codebase be renamed?
Planning group G07 (CONCERN-2835) took all three together, on the premise that answering them separately would produce three inconsistent answers. Reading the code first changed what the questions were.
The calling convention was already decided, by G01
Issue #2453 listed four candidates: synchronous HTTP, async callback with the tree suspended, queue-based dispatch, and an in-process callable. One of them was already impossible and already guarded.
ADR-0080 (planning group G01) established that nothing in Vultron suspends: the
bridge busy-loops on a root RUNNING until it exhausts max_iterations and then
fails opaquely, and finally: bt.shutdown() discards the tree at the end of every
invocation. BT-18-011 turned that into an enforced contract at the call-out seam
specifically — SynchronousCallOut
(vultron/core/behaviors/call_out/guard.py) is applied to every factory a bundle
hands out, in CallOutBundle.__post_init__, and raises CallOutContractError on
a RUNNING return. A static ratchet covers in-repo nodes.
So the convention is settled: uniform across shapes, synchronous, in-process,
name: str → Behaviour. The per-shape variation #2453 anticipated survives only
for Sentinel, which is not invoked by a tree at all.
What #2453 was actually pointing at
Two real questions remained underneath it.
The first is a classification question, and ADR-0080 had already hit it from
the other side: it amended ADR-0076 because an approval gate had been modelled as
an Evaluator call-out, and "at the moment of asking no answer exists and an
Evaluator can therefore only ever answer no." The distinction — answerable now by
a service the actor runs, versus requiring a decision from another actor in the
case — decides whether something is a call-out point at all. It was documented as
prose in docs/topics/capability_model/index.md and was normative nowhere.
The second is latency. The guard forbids RUNNING but says nothing about a
backend that simply blocks. docs/howto/wire_capability.md invites implementers
to call an external service from update(), and the pipeline runs under FastAPI
BackgroundTasks, so a slow backend stalls a worker with no diagnostic.
The contract lives in the wrong layer, and the default cannot honour it
BT-18-002 (MUST) requires a backend to write all declared output keys when it
returns SUCCESS, and MUST NOT return SUCCESS without them. AlwaysSucceed
(vultron/core/behaviors/call_out/nodes.py) returns SUCCESS and writes nothing.
It is the DETERMINISTIC default — which BT-23-001 makes the default for every
tree builder, test, and demo, and which BT-23-008's rationale calls "the
production-usable happy-path backend a real actor uses before a capability
implementation is wired in."
It cannot honour the contract, because the only machine-readable form of the
contract is an output_keys dict on a mixin in vultron/demo/fuzzer/, which core
must not import (BTND-04-002; BT-16-001 is why the probabilistic nodes live
there). The rest of the contract is docstring prose.
Measured at the time of this decision:
| Count | |
|---|---|
| Core DETERMINISTIC factory fields | 65 |
…defaulting to a bare _always_succeed |
49 |
…defaulting to a bare _always_fail |
13 |
…defaulting to _require_case_owner_approval (the BT-23-012 gates) |
2 |
| …wired to a contract-honouring backend | 1 |
| Capabilities declaring non-empty output keys | 60 (45 Evaluator, 9 Composer, 6 Retriever) |
The two halves of that table count different things — bundle fields and
declared capabilities — and are not a clean cross-tabulation: some fields answer
capabilities that produce no data (Actuators, binary Retrievers), and some
declaring capabilities have no bundle field at all, because the tree that would
have consulted them was removed (report_to_others, ADR-0047/#1848). The
load-bearing figure is the last-but-one row: exactly one field resolves to a
backend that writes what its capability declares.
That single exception, _DeterministicPrioritizePublicationIntents, is the
familiar shape of this project's recurring problem: the mechanism exists, built
by hand, once.
The consequence is silent rather than loud. Core guards the read
(required=False), so ShouldPublishFix reads a missing
publication_intent_decision, returns FAILURE, and the arm no-ops. In
DETERMINISTIC mode every data-gated arm quietly does nothing — inverting the
stated premise of BT-23-002 and BT-23-007, which chose AlwaysSucceed "so that
deterministic demo scenarios make forward protocol progress." (BT-23-006 is not
part of that group: it selects AlwaysFail, for a different reason.) The ceiling
rule was written for boolean call-outs and silently generalised to data-producing
ones.
The one place a test guards this is the one place the mechanism was built:
test_publication_tree.py carries a regression test asserting that the
DETERMINISTIC bundle does write PublicationIntentDecision, "Without this
write, all three arms silently take the Inverter-skip path." That test passes
only because _DeterministicPrioritizePublicationIntents exists. No equivalent
guard exists for any other data-producing capability, so for the rest the gap is
unobserved rather than caught.
The blast radius is currently latent: create_publication_tree and
create_acquire_exploit_strategy_tree have no callers outside tests.
The mechanism for a machine-readable contract already exists
ADR-0044 adopted py_trees typed ports as "the standard base for all nodes in
vultron/core/behaviors/", with INPUT_PORTS / OUTPUT_PORTS as the
single source of truth for a node's blackboard contract and runtime type checks
on both read and write. 51 core files declare typed ports. No call-out backend
does. #2452 was not asking for a new artifact; it was asking why call-out
points never adopted ADR-0044.
Sentinel shares none of the machinery
Every rule that makes the capability layer work applies to four shapes and explicitly not to the fifth:
| Evaluator / Retriever / Composer / Actuator | Sentinel | |
|---|---|---|
CallOutBackendFactory |
yes | no |
| Domain bundle field | yes | no |
| Blackboard contract | yes | none |
SynchronousCallOut, BT-18-011 |
yes | not applicable |
| Ceiling/floor rule | yes | not applicable |
| Surface | call-out | call-in |
BT-18-006 exists solely to stop authors misclassifying call-outs as Sentinels.
SentinelCallOutPoint and its three subclasses are dead code that contradicts
the definition — py_trees Behaviours carrying a success_rate, wired into no
bundle and instantiated only by their own unit tests
(test/demo/fuzzer/test_call_out_point.py), while CheckNoNewDeploymentInfoNode reads a
blackboard flag documented as written by a Sentinel that never runs. Annex G even
assigns Sentinel a "returns SUCCESS/FAILURE… used as a precondition guard"
contract, which is precisely the call-out framing it is not.
Meanwhile the real pattern is well attested. ADR-0080 gave it a job — a watcher
calling the reap-expired-asks trigger (ASK-05-002) — and all four Sentinel Ideas
(#1845, #1856, #1893, #1943) share one structure: watch a condition and decide for
themselves when to act on it.
Decision Drivers
- Three Ideas each asked for a cross-cutting answer; giving them three separate answers is the failure mode the grouping existed to prevent
- A MUST-level requirement (BT-18-002) is violated by the default backend for all but one data-producing capability, and cannot be satisfied at all while the contract lives in a layer core may not import
- A decision already taken (ADR-0044) covers the gap, so a new registry would be a second mechanism for a solved problem
- Sentinel's every distinguishing property is "unlike the other four", and its in-code representation contradicts its own definition
- The published vocabulary (glossary, Annex G,
docs/topics/) is load-bearing for external implementers, so a taxonomy change costs a real rewrite and must be worth it - A catalog that no test can check decays into confident wrong answers, which is what the existing 44-entry docs catalog is on track to do
Considered Options
For the capability declaration (#2452)
- Typed ports in core — the capability declares its contract via ADR-0044
INPUT_PORTS/OUTPUT_PORTSon a core-owned declaration. - A new declarative capability registry — one
Capabilityrecord per capability with a stable ID, shape, domain, and input/output schema, which bundle fields reference. - Keep docstrings, add a ratchet — leave the contract as prose plus the
simulation-layer
output_keysdict, and add a test that fails when the bundle fields, fuzzer nodes, and docs catalog disagree.
For Sentinel
- Keep five shapes, split the spec — Sentinel stays named as a shape but gets its own call-in contract.
- Demote Sentinel out of the taxonomy — four call-out shapes; Sentinel becomes a call-in integration pattern under Agentic Participants (#2450).
- Leave Sentinel untouched and spec only the four call-out shapes.
For the shape base classes (#2454)
- Move to core and rename to the capability vocabulary.
- Move to core, keep the names.
- Delete the mixins; shape becomes metadata rather than a class hierarchy.
Decision Outcome
1. A capability declares its blackboard contract with ADR-0044 typed ports, in core
Chosen: typed ports in core. The capability declaration is core-owned and
machine-readable, so AlwaysSucceed can read it, BT-18-002 becomes testable, and
the docstring contract becomes descriptive rather than the only authority.
Option 2 was rejected as a second mechanism for a problem ADR-0044 already
solved — and it would have to duplicate the type information typed ports already
carry and enforce. Option 3 was rejected because it leaves the defect in place:
no ratchet over prose lets AlwaysSucceed honour a contract it cannot read.
Structural discovery follows from this, answering #2452's catalog question: the enumeration of capabilities is derived from the core declarations, not maintained by hand. The docs catalog is generated from or ratcheted against them.
2. The calling convention is uniform, synchronous, and in-process
Chosen: adopt ADR-0080's primitive rather than define a parallel one. All
four call-out shapes share one convention — CallOutBackendFactory,
name: str → Behaviour, answering within a single tick with SUCCESS or FAILURE.
This ADR adds no new mechanism here; it records that #2453's question was
answered by G01 and closes it.
3. Call-out point versus protocol ask is a normative classification rule
A question that a service the actor itself runs can answer within a tick is a call-out point. A question that requires a decision from another actor in the case is a protocol ask (ADR-0080): the actor emits the request, terminates successfully, and the reply starts new work. An action with no representable not-yet state is refused rather than asked about (ASK-02-005).
Modelling an ask as a call-out point produces a gate that can only ever answer
no, which is the defect ADR-0080 found in ADR-0076. The rule existed as prose in
docs/topics/capability_model/index.md; it becomes normative so a future author
applies it when adding a seam rather than discovering it by audit.
4. A call-out backend's answer is time-bounded, and the bound is configuration
A backend MUST answer within a bounded budget and MUST NOT perform unbounded
blocking I/O in update(). Work that cannot meet the budget is not a call-out
point — it is a protocol ask or a call-in monitor.
The spec asserts that a bound exists and is configurable; it does not fix the
number. The distinction that matters is wire visibility, not
configurability. An ask deadline travels on the wire in the AS2 endTime field, so
both parties must read the same value (ASK-03-004); a call-out budget is local to
one actor and observable by nobody else, so two deployments choosing different
values is not divergence. Both are actor configuration — ask deadline durations
are ActorConfig-configurable too (ASK-03-005) — which is why the contrast has to
be drawn on visibility rather than on where the number is set. ActorConfig
(vultron/config/actor.py) already carries timedelta settings
(min_rsvp_window, default_rsvp_window) and is layer-neutral, so it is the home
for this one as well.
The guard is a sibling of SynchronousCallOut, applied at the same place.
Implementation must state honestly whether it interrupts an overrunning backend
or merely detects the overrun after the fact: a py_trees tick is synchronous, so
interruption requires running the backend off the ticking thread.
5. Sentinel is demoted out of the capability-shape taxonomy
Chosen: four call-out capability shapes. Sentinel is reclassified as a call-in integration pattern, and its design work belongs to the Agentic Participants epic (#2450), where an independently-acting process — with its own schedule, its own credentials, and possibly its own protocol identity — belongs.
This partially supersedes ADR-0024: only its enumeration of five shapes. ADR-0024 remains the authority for "call-out point" as the canonical term, the three-level taxonomy, boolean-external-queries-are-Retrievers, the exclusion of message-driven responses, and the deferral of Orchestrator.
BT-18-006 survives and is strengthened — with Sentinel no longer a call-out shape, a synchronous on-demand external query has no competing classification at all. ADR-0047's decision (party discovery belongs to an external monitor rather than an inline tick-driven BT loop) is unaffected; only its vocabulary changes, so it is revised in place.
The four Sentinel Ideas were checked against this before deciding, and the
demotion fits them better than the shape framing did: #1845 (threat feed →
Add(ParticipantStatus)), #1856 (close readiness → notify the Case Owner), #1893
(embargo timer → terminate_embargo), and #1943 (active-embargo review) all
decide for themselves when to act. None needs a call-out node, a blackboard
contract, a bundle field, or a factory.
The discriminator is who initiates, not where the information comes from
It is tempting to define a Sentinel as the shape that reads external
information. That is wrong, and the error matters, because a Sentinel may be a
case participant — an Actor admitted through the ordinary Invite/Accept path,
most naturally holding CVDRole.OBSERVER, the base role with no
vendor-fix-deployment obligations (ADR-0057, CM-25). Such a Sentinel receives
Announce(CaseLedgerEntry) like any participant, so the condition it monitors can
be the case's own state, reached through ordinary replication rather than an
outside feed. Two of the four Ideas already assume this: #1856 observes case state,
and #1845's title has its monitor posting Add(ParticipantStatus) to the case.
So the real discriminator is who initiates. A capability is consulted: the protocol reaches a call-out point, asks, and uses the answer within that tick. A Sentinel is never consulted; it decides for itself that the moment has come and acts. That is the call-in/call-out axis, and it holds regardless of whether the information came from a threat feed or from the case ledger.
This also gives the demotion a stronger basis than "it shares none of the machinery." A participant Sentinel holds protocol identity, a roster seat, a role, and a case replica, and it acts by emitting ordinary protocol messages. That is a peer, not an interface contract at a BT seam — which is the Agentic Participants concept precisely, and why #2450 is the right home.
Two deployment shapes, with different protocol visibility
The distinction is worth naming, because it changes what the case can see:
| Participant Sentinel | Operator-side Sentinel | |
|---|---|---|
| Case identity | an Actor on the roster, typically CVDRole.OBSERVER |
none — not a participant |
| Sees case state by | Announce(CaseLedgerEntry) replication |
whatever its host actor already knows |
| Acts by | emitting ordinary protocol messages (Note, Add(ParticipantStatus)) |
calling its host actor's trigger endpoints |
| Visible to other participants | yes — its observations and actions replicate | no — the resulting messages appear to come from the host actor |
| Admission | Invite / Accept | deployment credentials |
Neither is preferred in general. A threat-intelligence monitor that should be accountable to the case wants the participant form; an embargo timer that is merely part of how one organisation operates its own actor wants the operator-side form. The choice is a protocol-visibility decision, and the Sentinel design work under #2450 owes an answer per monitor rather than one answer for all of them.
One terminology hazard to record: the glossary lists "monitor" and "watcher" among the aliases to avoid for the Observer role. "Sentinel" and "Observer" must stay distinct — Observer is a role a participant holds, Sentinel is a behavioural pattern, and a participant Sentinel is something that holds the one by enacting the other.
6. The four shape base classes move to core and are renamed
Chosen: move and rename. The mixins move from
vultron/demo/fuzzer/call_out_point.py into
vultron/core/behaviors/call_out/, fix the typed-port lifecycle for their
shape, and are
renamed to the capability vocabulary (EvaluatorCapability, RetrieverCapability,
ComposerCapability, ActuatorCapability).
A shape base class cannot hold a per-capability contract, since many
capabilities share one shape — which is why the output_keys dict sits on the
concrete subclasses today. So decision 1 forces a split that this decision
only half completes: each named capability gets a core-owned declaration carrying
its own ports, and the probabilistic node that stands in for it stays in
vultron/demo/fuzzer/ (BT-16-001) and binds to that declaration instead of
restating the contract. Core owns what the capability promises; the simulation
layer owns one way of pretending to keep it. The module layout for those
declarations is left to #3421.
Issue #2454 framed the rename as churn for vocabulary's sake and weighed it
against a permanent docs/code translation burden. That framing does not survive
decision 1:
the classes hold the only machine-readable contract, core needs to read it, and
BTND-04-002 forbids core importing from vultron/demo/. The move is required
regardless, so the rename is a free rider — the migration cost is the move, not
the word. The simulation layer re-exports the old names so the 81 existing
subclasses of the four retained shapes keep working.
SentinelCallOutPoint and its three subclasses are deleted rather than moved, per
decision 5.
7. The capability-layer requirements extend BT-18 and BT-23 in place
No new spec file and no new prefix. BT-18-001…011 and BT-23-001…012 already are the capability layer; giving it a second home would split the corpus and invalidate cross-references from RSH-07, BT-20, and BTND-05.
Consequences
- Good: BT-18-002 becomes enforceable for the first time, by a ratchet rather than by review
- Good: the DETERMINISTIC bundle can actually be what BT-23-008 claims it is — a production-usable happy-path default — instead of silently no-opping every data-gated arm
- Good: #2452, #2453, and #2454 close with one consistent answer, and two of the three close mostly by recording what the code already enforces
- Good: the capability catalog becomes derivable, so it can be ratcheted instead of decaying into confident wrong answers
- Good: no new mechanism is introduced. Typed ports,
CallOutBackendFactory, bundles, the guard pattern, andActorConfigall exist - Neutral: the taxonomy is four shapes, not five. External-facing vocabulary
(glossary, Annex G,
docs/topics/,docs/reference/vultron-taxonomy.md) changes, and 118 lines across 31 files carry the capability-shape sense of "Sentinel" - Neutral: Sentinel work is not cancelled, only rehomed conceptually. #1143 and the four Sentinel Ideas stay parented to #1147 — the G07 planning protocol (#2828) keeps members on their existing domain-epic parents — while their design questions become Agentic Participants questions (#2450). G14's premise ("define Sentinel once, instantiate four times") shifts from a shape contract to a call-in one
- Bad: making the remaining DETERMINISTIC defaults honour their contracts is a real behaviour change, affecting every bundle field whose capability declares output ports except the one already conforming. It is latent today (no live caller reaches a data-gated arm), but it will change what those arms do the moment one is wired
- Bad: the three
SentinelCallOutPointsubclasses are deleted, which also deletes their unit tests intest/demo/fuzzer/test_call_out_point.py, and leaves the change-detection flags they were documented as writing without an attributed writer until #3424 resolves each one - Bad: shape remains a class-hierarchy property rather than queryable metadata, so a capability's shape is still read by inspecting its base class
Validation
- Every bundle field whose capability declares output ports resolves to a default that writes them; a ratchet fails if one does not
- A capability's declared output ports, not its docstring, are what a test reads to verify BT-18-002
- No
*CallOutPointshape class remains invultron/demo/fuzzer/except as a back-compatible re-export, and none is imported byvultron/core/ - No class named
*Sentinel*exists in the call-out taxonomy, and no blackboard flag is documented as written by a Sentinel - The docs capability catalog is derived from or ratcheted against the core capability declarations, and the derivation fails on drift
- A call-out backend that exceeds the configured budget surfaces as an internal error naming the offending node, and the budget is read from configuration rather than a literal
More Information
- Partially supersedes ADR-0024 — only its five-shape enumeration; ADR-0024 remains authoritative for its other five decisions
- Amends ADR-0025 — decision 6 (shape base classes move to core and are
renamed) and its ceiling/floor amendment (a data-producing capability's ceiling
is a contract-honouring default, not bare
AlwaysSucceed) - Revises ADR-0047 in place — its decision stands; its Sentinel vocabulary changes to the call-in pattern
- Builds on ADR-0044 — the typed-port mechanism it mandates, which call-out points had not adopted
- Builds on ADR-0080 — the synchronous-answer invariant and the protocol-ask alternative, rather than defining a parallel async primitive
- Sources: CONCERN-2835 (planning group G07), IDEA-2452, IDEA-2453, IDEA-2454
- Specs:
specs/behavior-tree-integration.yamlBT-18 and BT-23 - Notes:
notes/coordination-agents.md,notes/call-out-configuration.md