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 guarantee.
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 and the
periodic resync all call the same reconcile(). A missed trigger costs latency
and never correctness. The one thing the sweep does differently is 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, and the sweep is the caller that knows time has passed. The
periodic sweep is the guarantee; 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 (the
Chief-of-Staff features, each blank until an operator names a model) comes up
on the write rather than on the next restart.
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. |
waiting |
A declared dependency is not here yet; waiting_on names every one. |
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). It logs the reason. |
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.
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: four attach a collaborator to the work pipeline, one
attaches a dispatcher to the Chief-of-Staff proposer. Each grew a read-only
counterpart to its attach_* seam (WorkPipeline.attachments,
has_plan_dispatcher) computed from the same field the seam writes, so the
probe cannot claim an attachment that is not there.
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.