Single-Owner Decisions¶
On-demand reference. The rule in CLAUDE.md is: every decision the loop makes
has exactly one owner: never zero, never two.
Four live end-to-end runs of the general loop, driven through the product's own API and dashboard, produced thirty-eight collapses. They were not thirty-eight independent bugs. Two halves of one shape account for most of them, and a third sub-shape accounts for the rest. The tables below are the living inventory, so a decision found by a later run joins them.
- Two owners is a silent override. Two parts of the system know something, they disagree, the quieter one wins, and nobody is told.
- Zero owners is a deadlock. A state with no reachable exit, and nothing watching for it.
- A lookup that defaults instead of failing is how a decision gets made by nobody at all: the answer is absent, a plausible default stands in, and the code below trusts it.
The corollary is why so many of the collapses were logged as observability defects: if you cannot see who decided, you cannot tell which failure you are in.
The human is not an exception¶
Planning, decisions and approvals look like two owners and are two decisions:
- "Does a human need to decide this?" Owner: the escalation rule.
- "What is the answer?" Owner: the human when the first says yes, the agent when it says no.
Keeping them separate is what makes the escalation rule single-owned. The invariant that holds it is that a judge may only escalate, never de-escalate: a second authority may add a human to the loop, but may never remove one the first authority put there.
A legitimate multi-authority decision is a ladder¶
More than one source of an answer is fine. Two answers is not. The shape that
works is an ordered precedence ladder with exactly one resolver, and
DecompositionPlan.task_structure is the worked example (see
Coordination):
- The planner's declaration, which reasoned over the whole objective and its own subtask graph.
- The task's own explicit
Task.task_structure, when the planner declared nothing. TaskStructureClassifier's keyword heuristic, when neither did.
The heuristic is the last word rather than the first, task_structure is
optional with None meaning "declared nothing", DecompositionService resolves
the ladder before the plan leaves the service, and DecompositionResult refuses
to be constructed around an unresolved one. One resolver, three inputs, no
override.
Two owners, silent override¶
Each row below is one decision with two authorities. In each, authority B won and authority A was never told, which is what made the override silent rather than a disagreement anybody could see. "Owner now" names where the single answer is produced today, and "Evidence" is the collapse that surfaced it.
| Decision | Authority A | Authority B | Winner | Told? | Owner now | Evidence |
|---|---|---|---|---|---|---|
| Which model runs the work | the agent's roster binding | budget auto-downgrade, quota fallback and the stakes router, each rewriting it | B, three times over | no | the roster binding, full stop. All three rewrites are deleted and check_no_bound_pair_rewrite.py keeps them out; the same signals now choose the AGENT instead |
C12, audit |
| What capability a model has | the roster's capability |
the provider capability registry | B | no | ResolvedAgentCapabilityReader, reading the catalogue and falling back to the roster's claim only for a pair the catalogue cannot grade |
C12 |
The plan's task_structure |
the planner's declaration | a keyword regex classifier | B | no | engine/decomposition/service.py, resolving the ladder above |
C10 |
| The plan itself | the researched agent session | a blind single-shot fallback | B | no | engine/decomposition/agent_session.py: a session that SPENDS ITS BUDGET without ever calling its only tool is a failure, and a legitimate fallback stamps planning_strategy so the approval gate says which planner produced what is being approved. Stopping completed with turns left is not that failure and never reaches this decision: it is a resumable state, told so and re-entered |
C9 |
| What produced this plan, as recorded | _fallback_plan, stamping the substitute's name |
the agent-session success path, stamping its own | neither, and that is the point: they sit on exclusive paths, so nothing was overridden and the READER lost instead. A field that names a planner on every plan carries the same information as one that names none | no | engine/decomposition/agent_session.py::_fallback_plan, its only writer, enforced by check_single_planning_strategy_writer.py. The field marks a SUBSTITUTION, so blank IS the researched answer, and every reader decides on presence alone |
a recursion-depth sweep, whose substitution detector read a name on every node and refused every valid tree |
| Is the initiative stalled | coordination middleware's next_action |
stall_reason() |
B | no | engine/initiative/completion.py::stall_reason, with an unresolvable DECISION item counted dead so the plan derives BLOCKED rather than being permanently un-stallable |
C17 |
| Does this initiative have a way forward | the rollup, reading a trigger's presence | the trigger's own generation cap and master switch | B | no | ReplanTriggerService.consider, which decides both BEFORE starting anything and hands back a ReplanDisposition the rollup routes on; the three refusals converge on one escalation |
a later run |
| Is this stalled initiative still stalled, when the answer arrives | the reason the decision was raised for | a re-derivation over the plan's live items | B | no | api/controllers/_approval_initiative_stall.py::_live_stall, branching on the reason the escalation recorded: item-derived stalls re-derive, tail-stage verdicts confirm against the stage that produced them, because every item IS done in both of those and deriving over items answers "recovered" for the case it is least true of |
a later run |
| Who raised this stalled-initiative decision | the action type on the item | the requester the escalation recorded | A, and any writer could set it | no | ESCALATION_ACTOR, checked on the item before either answer acts; the action type says what a decision ASKS, never who asked |
a later run |
May this call carry reasoning_effort |
our capability metadata | the routing library's route validator | B, fatally | no | providers/drivers/litellm_features.py::route_carries_reasoning_effort |
C13 |
| Is a turn empty | the buffered path's rule | the streamed path's private copy | B | no | providers/drivers/mappers.py::normalize_empty_finish, called by both paths |
D27 |
| Is this initiative authorised | the operator, approving a charter | the turn classifier's propose branch, reading a sentence as a work request |
B | no | meta/charter/dispatch.py, the one intake path, binding WorkItem.charter_id to the approval that authorised the spend; the classifier route is deleted and check_charter_authorised_initiative.py keeps it out |
a later run |
| Which agent takes this work | the coordination ranker | the capability floor dispatch refused on | A, then the work was rejected downstream | no | the selection ladder (core/capability_fit.py::partition_by_fit), which the solo path and the coordination path both apply before ranking |
audit |
| May this work run under-capable | selection's own filter | dispatch's own re-check | B | no | CapabilityPolicy.judge(...).sanctioned, one verdict both sides read off the same shared instance |
audit |
| Who contributed to this initiative | Project.team |
the tasks that actually ran | A, and it was always empty | no | engine/initiative/contributors.py::initiative_contributors; the stored field is deleted |
audit |
Three of these are worth reading as a set. The model and capability rows are one
decision split across two registries; the task_structure and plan rows are one
decision where a fallback quietly replaced a considered answer; and the two
capability rows found by code audit are one decision split between a selection
rule and a dispatch refusal that never compared notes, so the coordination path
routed work to an agent dispatch then rejected. In each the fix was the same:
name the resolver, and make the losing authority's answer either honoured or
reported, never discarded in silence.
Zero owners, deadlock¶
| Decision | What went wrong | Owner now |
|---|---|---|
| Can this project be deleted | A task that failed before assignment could reach a terminal only through ASSIGNED, which the Task validator refuses without an assignee. Every exit closed, and one live project was undeletable by any route. |
StateMachine.unconditional_targets, walked by check_lifecycle_exit_reachable.py; FAILED / BLOCKED / INTERRUPTED / SUSPENDED each reach CANCELLED directly. |
| Is a plan with an open DECISION item stuck | stall_reason required every outstanding item's task_status to be dead. A DECISION item never has a task row, so the check never passed and no replan could ever fire. |
engine/initiative/completion.py: an undecided DECISION item with no options can be resolved by nobody, so it counts as dead. |
Is an EXECUTING plan with no tasks deletable |
A pure status check whose refusal message asserted a fact it never checked. | api/services/plan_service.py: whether it can be deleted is derived from live task rows, not asserted from status. |
| Does budget-aware autonomy have a signal | RiskBudgetSignalProvider (security/autonomy/signals.py) had no production implementation, so BUDGET_AWARE was selectable and satisfiable by nothing: the only outcome of choosing it was a construction error naming a dependency no shipped component supplied. |
RiskTracker.headroom_fraction(), supplied to the strategy from the same instance the budget slice records into. Built once in the construction phase because two consumers need that one ledger; a second tracker would have reassured the strategy with records nothing wrote. |
The general "does this state have an exit" question is closed only for core/
state machines. Outside them the rule is carried by review: a new refusal must
name the condition it is refusing on, and a new terminal must be reachable
without something the entity may not have.
A lookup that defaults instead of failing¶
The third sub-shape, and the only one a script can decide. A lookup resolves to a default rather than failing, and a test manufactures the shape the runtime never produces, so the suite agrees with the ghost.
| Case | The read | Why the default was the only outcome |
|---|---|---|
| D21 | getattr(app_state, "tool_registry", None) |
AppState has never carried a tool registry. Every scrape resolved to None, the fail-closed validator downstream rejected every tool name for the life of the process, and the metric reported an empty allowlist as a success. |
| D23 | getattr(state, "_connection_user", None) |
The authenticated user lives on the connection. api/auth/context.py says in its own docstring that its ContextVar binding exists so a missing user is not masked as api; the leftover helper masked it as api on every request, and its test built State({"_connection_user": ...}), a shape nothing in production creates. |
| D22 | the refusal to delete a decided plan | The same shape one level up: it looked only at the plan's status, never at whether the project it is a record about still exists. |
check_no_ghost_attribute_read.py rejects the first two rows and every read
shaped like them: a three-argument getattr whose literal attribute name is
declared as an attribute nowhere in src/synthorg/. D22 is deliberately outside
that scope, and the third row is here to show the shape rather than the gate.
The full rule is the No Ghost Lookups paragraph in CLAUDE.md, and its row
in the gate inventory is in Convention Gates; the short
version is that a three-argument getattr with a literal name is the one
construct that hides an attribute read from mypy, and the gate re-asks mypy's
question at the level it can answer without inference. Whether a name that
does exist somewhere exists on this object is mypy's question, and writing
obj.attr is how you ask it.
Every entry in scripts/ghost_attribute_read_baseline.txt today reads a
third-party object: a psycopg Diagnostic, a sqlite3.Error, a routing-library
response, a NATS delivery record, an lxml element, a stdlib module probed for
an optional function. Those are legitimate, and the file is their history. A new
one carries its reason on the line instead.
Adding a decision¶
A new loop decision passes this checklist before it ships.
- Name the single resolver. One module produces the answer. If two could, one of them is a cache or an input, and it says so.
- If there is more than one source, write the ladder. Ordered, with the weakest evidence last, resolved in one place, and a type that cannot be constructed around an unresolved value.
- If it can decline, name the condition. Already MANDATORY for subsystems
via
check_subsystem_decline_reason.py; the same applies to any refusal an operator will have to read. - If it is a lookup, fail rather than substitute. Read the thing that holds the answer. A default that stands in for an absent answer makes "missing" and "empty" the same value.
- If a human may be asked, keep the two decisions apart. The escalation rule owns whether to ask; the answer is owned by whoever the rule selected. A judge may escalate, never de-escalate.
- Make the decision auditable. A decision nobody can see the owner of is indistinguishable from a decision nobody made, which is why five of the thirty-eight collapses were observability defects about an actor the system knew and never wrote down.
See also¶
- Convention Gates: the full gate inventory, including the three that close this rule's decidable slices.
- Coordination: the
task_structureladder and the typed failure classification, both worked examples of one resolver. - Initiative Tail: reachable lifecycle exits and verified completion.