Subsystem Reconciliation¶
How the backend decides what is wired, when. Every subsystem declares what it needs; a level-triggered reconciler compares those declarations against live state and converges. Boot is the first pass, not a special path.
API Startup Lifecycle covers the two-phase boot this sits inside.
The problem¶
Wiring used to be decided once, at boot. Every _wire_* entry point asked "is
my dependency here?" and froze the answer for the life of the process. A
second, hand-maintained list re-ran fourteen of them after setup; the rest
stayed frozen, and wire_memory_backend was missing from that second list
entirely, which is the drift two parallel lists produce.
The visible cost: choosing an embedding model after first boot left memory, living docs, the project brain, the knowledge substrate, the toolsmith, and the retro tail all inert until someone restarted the process. Every one of them had a dependency that arrived thirty seconds too late.
Boot-time wiring is edge-triggered, and a missed edge in an edge-triggered system diverges permanently.
Shape¶
A subsystem is a declaration, not a call site:
SubsystemSpec(
name="memory_backend",
provides=CapabilityId.MEMORY_BACKEND,
requires=(CapabilityId.PERSISTENCE,),
activate=_activate_memory_backend,
deactivate=_deactivate_memory_backend,
settings=("memory.backend", "memory.embedder_model", ...),
rebuild_on_change=True,
)
activate is the existing wiring function, unchanged. What is new is
requires: the check that used to sit inside the function body, hoisted where
the reconciler can order by it and report on it.
flowchart LR
T[trigger] --> P[one pass]
P --> E{enabled?}
E -- no --> D[deactivate consumers, then it]
E -- yes --> M{deps present?}
M -- no --> D
M -- yes --> C{drifted?}
C -- yes --> R[deactivate, then activate]
C -- no --> A{already up?}
A -- yes --> N[nothing]
A -- no --> ACT[activate]
Invariants¶
Liveness is read from provides. The reconciler asks the capability probe
whether the thing exists, rather than tracking a flag it set itself, so its
idea of "up" cannot drift from what activation installed.
Activation is idempotent. The pass runs again on every trigger; a subsystem already up costs one probe.
A declined activation costs one probe too. A subsystem that ran its activation and installed nothing has no capability to guard on, so without something else the next trigger re-runs the whole wiring to reach the same refusal. A snapshot of every requirement and declared setting is taken at the decline and compared on the next pass: unchanged inputs, no second attempt. An operator naming the model a subsystem was waiting for moves the snapshot and is picked up on that same write. Measured on a wired app, this is the difference between a pass costing 140 ms and one costing single-digit milliseconds.
A trigger is a hint, never an instruction. Boot, a settings write, the
periodic resync, and a successful provider recheck all call the same
reconcile(). A missed trigger costs latency and never correctness. The sweep
and the recheck both ask for retry_declined=True: what a snapshot cannot see
is the undeclared condition that made a subsystem blocked in the first place,
so somebody has to attempt unconditionally. The sweep is the caller that knows
time has passed; the recheck is the caller that knows an operator has just
changed something upstream, which is why it is bounded by its own timeout
(api.recheck_reconcile_timeout_seconds) rather than left to hold a request
open. That is also why only a recheck whose call found the provider serving
triggers a pass: a recheck confirming it is still down knows the opposite, and
re-probing every declined subsystem under the pass lock would end where it
began. The periodic sweep is the invariant; everything else is an optimisation.
One pass at a time, whichever loop asks. The reconciler is cached on an
application state that outlives a single event loop, and an asyncio.Lock
only serialises callers sharing the loop it bound to, so the claim on a pass
is a plain lock rather than an async one. A caller that finds a pass already
running does not wait on it: it hands its trigger to the pass in flight, which
repeats once it finishes, and gets back the current observation rather than one
it produced. That keeps a second loop from blocking on a lock it cannot await,
and the hand-off is what stops the trigger being dropped instead.
Order is derived, never written down. order_subsystems topologically
sorts the declarations, rejecting a cycle or two owners of one capability at
construction, so a bad declaration fails the build rather than quietly never
activating.
A failure is recorded, not fatal. One subsystem that cannot come up must
not stop the rest; the next pass retries it and GET /subsystems names it. The
one caller that cannot live with that is setup completion, which asks a
one-shot question ("is this deployment configured?") and so refuses to persist
setup_complete=true over a subsystem that failed on its pass.
One wiring path per subsystem. A second caller of a wiring function the
registry activates is a hand-kept list of what someone believed needed
rewiring, and two lists drift: that is precisely how wire_memory_backend
came to be absent from the post-setup rewire while thirteen siblings were in
it. Three shapes are all the same defect and all rejected: a post-setup rewire
list, a settings subscriber that re-runs wiring, and a composite wrapper that
runs several registry-owned wire_* functions in a fixed order. Enforced by
scripts/check_subsystems_single_owner.py; opt out per-line with
# lint-allow: subsystem-single-owner -- <reason>.
Rebuild, and why identity matters¶
A subsystem that captures a dependency by value at construction (the engine
reads the memory slice once) declares rebuild_on_change=True. Two things
count as a change:
- a required capability appeared or vanished, and
- a declared setting the activation baked in has a different value.
Availability alone is not enough. A provider rebuilt inside a single pass reads present both before and after, while every consumer still holds the instance being replaced. Each activation therefore bumps a generation counter for the capability it provides, and a consumer's snapshot records the generation of the instance it actually captured. Replacing memory replaces what reads through it.
A rebuild is deactivate-then-activate, so rebuild_on_change=True requires a
deactivate. Without one the subsystem still reads active after the teardown
that did nothing, the pass leaves it alone, and the declaration promises a
replacement that never happens. order_subsystems refuses that pairing at
construction rather than letting it fail silently at runtime.
Declaring settings= without rebuild_on_change is the weaker and commoner
case: it does not replace a running instance, but it does put the key in the
settings subscriber's watched set, so a subsystem waiting on a value comes up
on the write rather than on the next restart.
A per-feature model needs both halves¶
Every Chief-of-Staff feature model is blank by default and baked into its
component at construction, so its declaration has to buy two distinct things.
A blank-to-named write brings an inactive subsystem up, which settings=
alone delivers. A named-to-renamed or named-to-blank write has to replace a
component already serving on its build-time pair, which only
rebuild_on_change plus a deactivate delivers. Declaring the key without
the flag gives an operator a feature that can be switched on without a restart
but never off, and never moved to a different model.
The classifier and the multi-voice router are their own subsystems for the same reason rather than steps inside the proposer's activation: the reconciler leaves an already-active subsystem alone, so a classifier wired from within the proposer's activation could never appear after the proposer was up, which is exactly when an operator names the model.
Making the proposer replaceable makes its consumers replaceable too, which the
graph invariant enforces rather than hopes for. refinement_router wraps the
proposer instance and lives on the work pipeline, so a replaced proposer would
leave it refining through the instance that went away. It declares a
deactivate and rebuild_on_change=True, so it goes down with its provider
and comes back bound to the replacement.
A setting the resolver cannot serve is not a change. Its snapshot records "no
reading" rather than a value, and the comparison skips those positions; the
first successful read afterwards becomes the baseline. Without that, one
transient resolver error compares unequal to the successful read it followed
and tears down every rebuild_on_change subsystem at once.
Teardown runs in reverse¶
Activation order is providers first. Teardown is its mirror: before a subsystem goes down, everything reading through it goes down first. Taking the provider first leaves its consumers live over an instance that has gone away, and a request served in that window reads through a disconnected collaborator (the knowledge engine answering out of a memory backend that has just been replaced).
Which consumers follow depends on why the provider is going:
- Going for good (switched off, or its own requirement vanished): every live consumer follows, transitively. Their requirement is about to be unmet.
- Coming back on this pass (a rebuild): only the consumers that captured
the instance, meaning
rebuild_on_change=True. One that reads the slice per call picks the replacement up on its next read and has nothing to rebuild.
A consumer taken down as part of a rebuild is re-activated later in the same
pass, so ReconcileReport.deactivated names only what is still down at the
end: a rebuild reports as activated, and reporting it as an outage would
send an operator looking for a subsystem that is up.
Phases¶
GET /subsystems reports one phase per subsystem, derived from the same
declarations the reconciler uses, so the surface cannot drift from behaviour.
| Phase | Meaning |
|---|---|
active |
Its capability reads as available. |
degraded |
Up, with a requirement it named gone. Only a subsystem with no teardown can rest here; one with a teardown is taken down instead. |
waiting |
A declared dependency is not here yet; waiting_on names every one. |
unreachable |
Waiting on a dependency whose owner is switched off or has itself declined, so waiting alone will not supply it. waiting_on names the capabilities, detail names the owner to go and fix. |
rebuilding |
Torn down and coming back inside the running pass. |
blocked |
Every declared dependency is present, activation ran, and the subsystem declined on a condition the declaration cannot model (memory with no embedding model chosen). detail always says something: the activation's own reason when it raised SubsystemDeclinedError, else the declared settings that are blank. The third fallback ("declined on a condition it does not declare") is now unreachable for a shipped subsystem, because check_subsystem_decline_reason.py refuses one that can decline without naming its condition. |
disabled |
An operator turned it off via enabled_by. |
failed |
Activation raised; detail carries the redacted description. |
waiting and disabled are resting states, not errors. blocked exists
because reporting that case as waiting would name no dependency and leave an
operator with nowhere to look. It is also the phase the retry snapshot is for:
a blocked subsystem is re-attempted when something it declares moves, and
otherwise on the next sweep.
unreachable exists because level-triggering rests on "a dependency absent at
boot is not a verdict: the next pass picks it up", and that holds for a
dependency that is merely late, not for one an operator switched off or that
declined on its own condition. Reporting those as waiting promises a pass that
will change nothing, which leaves a kanban board waiting indefinitely on a
setting-disabled sprint service. It is re-derived every pass, so the operator
action that fixes the owner clears it on the next one: what it says is "this
needs a change, not more time".
rebuilding covers the window between a teardown and the re-activation that
follows it in the same pass. Without it a concurrent read lands mid-rebuild and
answers waiting with an empty waiting_on, which claims the contract's shape
for "these capabilities are missing" while naming none of them.
A subsystem that can decline names its own condition¶
A blocked subsystem's detail is never null and never hand-written at the
reporting end. The code that decided owns the reason. An activation backing
out raises SubsystemDeclinedError(reason): the reconciler records that reason
verbatim and treats the pass as a decline rather than a failure. Absent one, it
resolves the spec's own settings= keys and reports the blank ones, hedged as
the likely reason because the declining condition lives inside the activation.
Reaching the second branch used to be routine. Only 11 of the 64 shipped specs
declare settings, so a live run had five of seven blocked subsystems answering
"declined on a condition it does not declare; see the wiring log": the endpoint
whose whole job is to say why told the operator to read a container log.
check_subsystem_decline_reason.py closes it. A spec passes three ways:
- it declares
settings=(the reconciler reads them live and names a blank one), - its activation chain raises
SubsystemDeclinedErrorwith the condition, or - it cannot decline at all: no guarded bare
returnon an absence, so it installs the capability or raises.
An idempotency guard (if already is not None: return) is not a decline and
needs nothing; the complement, an absence guard, does. No baseline and no
per-line opt-out: an activation that cannot name its condition IS the defect.
Not every condition is a setting. agent_tool_execution declines on the
platform: this process cannot spawn a subprocess, or cannot reach the container
backend, or cannot describe its workspace to it. It takes the second route,
raising with a reason that names what each condition COSTS ("so the terminal
and code_execution tools cannot run and no CodeExecutionRecord can be
written") rather than only what it is, because the cost is what an operator
acts on. It is also why the waiting phase would be wrong for it: it requires
no capability, so nothing is coming that would change the answer, and the pass
that re-attempts it is the periodic sweep.
Because activations now raise on their idempotency-adjacent paths too, liveness
is read from provides alone. A declared reason supplies the WHY, never the
WHETHER: an activation declining while its capability is already installed still
reads active, which is the same "up cannot drift from what activation
installed" rule stated one layer down.
A caller outside the reconciler that legitimately calls a wiring function (the
knowledge settings subscriber re-running the build) catches
SubsystemDeclinedError specifically and logs it: a decline is not a failed
settings write, and failing the operator's write would blame them for a missing
backend.
A named reason still has to reach somebody¶
GET /subsystems is pull-only, and nothing in the system pulls it. A subsystem
that declines is therefore visible only to an operator already reading a health
payload or a log stream, so it can stay down indefinitely while the org keeps
executing around the hole. SubsystemEscalator (api/subsystems/escalation.py)
runs at the tail of every pass and pushes what the phase table above already
knows: blocked at WARNING, failed at ERROR, carrying the same detail the
endpoint would have shown, through the HEALTH notification category.
Only those two phases. waiting and disabled are resting states, and
degraded is serving. unreachable reads like a third stuck phase and is
deliberately excluded: it is only produced for a subsystem whose dependency has
a blocked or disabled owner, so escalating it would either repeat the alert
that owner already raised or interrupt an operator about the direct consequence
of a switch they threw themselves. The subsystem that can actually be acted on
is the one that gets the alert.
Deduplicated on (subsystem, phase, reason) rather than rate-limited, because
every settings write and every sweep runs a full pass, so alerting per pass
would turn one unreachable embedder into a notification every sweep and an
operator would filter the channel. Keying on the reason means a subsystem
moving from one blocking condition to another says so; forgetting the key once
the subsystem leaves those phases means a fault that returns after a genuine
recovery alerts again. A condition is recorded as alerted only once a sink has
accepted it: nothing in the dispatch chain retries, so claiming it earlier would
let one transient sink outage suppress that exact condition permanently.
Contained and bounded throughout: the pass has already done the real work by the time this runs, so a missing dispatcher or a flaky sink must not turn a successful convergence into a failed one, and the sends fan out concurrently because the reconciler is still holding its pass lock while they run.
Why not the alternatives¶
Crash-only. One way to stop, one way to start, no duplicated paths. It is the cleanest answer and it is disqualified here by the cold-boot budget: the backend takes minutes to come back, so "just restart" is an outage.
A supervision tree with restart strategies. Solves failure propagation, not late arrival, which is the actual defect. A dependency that shows up after boot is not a crash and no restart strategy fires on it.
An explicit active predicate on the spec. Smaller than making each
subsystem install an observable marker, and it reintroduces exactly the drift
this design removes: two statements of "is it up" that can disagree.
The subsystems that forced the question mutate something in place rather than
publishing a service: five attach a collaborator to the work pipeline, and the
initiative tail attaches onto an already-wired project rollup. Each grew a
read-only counterpart to its attach_* seam (WorkPipeline.attachments)
computed from the same field the seam writes, so the probe cannot claim an
installation that is not there. The rollup is the clearest case for why the
probe cannot simply be "does the owner exist": it comes up once
persistence and the task engine do, which is before a provider is configured,
so its presence would tell the reconciler the tail had converged before the
activation ran once.
Readiness is not a dependency roll-up¶
/readyz reports whether this process can serve, never whether every optional
collaborator is reachable. Gating readiness on a shared external dependency is
the well-known cascading-failure shape: one unreachable provider takes every
replica out at once. An unreachable LLM provider degrades what agents can do;
it does not stop the API answering. synthorg start gates on /healthz for
the same reason, and prints the degraded subsystems by name instead.