Split from #17044, which needs a futility breaker for the self-heal loop. This leaf is the decision, the state and the operator surface — all pure or read-only. The runtime wiring that emits freeze/unfreeze and skips evaluation while frozen stays on the parent, because it changes live actuator behaviour on a plane that is currently running.
Live ledger, external plane 2026-08-19: 5,000 heal events, byStatusdeclined 2,310 · recorded 2,495 · deferred 170 · actioned 15 · failed 10. currentlyFrozen: [] — the freeze has demonstrably never engaged.
The Problem
#17044's AC-1 keyed the breaker on consecutive executor failures.CONTAINER_HEALTH_ACTION_ROUTES gives throttleShed (throttle-shed-has-no-admitted-action) and record (diagnosis-record) an actuatorAction: null, so neither ever invokes an executor. A failure counter sees 10 of 5,000 rows and never engages, while the dominant terminals accrue thousands of no-effect records. AC-1 was widened by its author before this leaf was cut.
And a frozen target had nothing to publish.summarizeHealLedger folds currentlyFrozen as a bare target list, so an operator learns that a target is frozen and neither why nor what clears it.
The Architectural Reality
ai/services/memory-core/helpers/healActionDispatch.mjs — the pure dispatch decider. freeze is already in HEAL_ACTIONS as non-mutating containment, exempt from the rate/thrash bounds.
ai/services/memory-core/helpers/healSystemicCircuit.mjs — the precedent: a decider plus foldSystemicCircuitState over its own event vocabulary. Shape mirrored.
ai/services/memory-core/helpers/healEventLedgerStore.mjs:41 — HEAL_LEDGER_FROZEN_TRANSITIONS, and summarizeHealLedger's existing fold of those two types. This module owns the freeze vocabulary, which is why the state fold lands here rather than beside the decider: healActionDispatch is documented pure and this module does I/O.
ai/daemons/orchestrator/services/DeploymentStateBridgeService.mjs:2058 — collectSelfHealSnapshot, the operator-facing projection.
ai/daemons/orchestrator/services/ContainerHealthControllerService.mjs:90 — where throttle-shed-has-no-admitted-action is raised.
The Fix
decideFutilityFreeze in the dispatch module — keys on consecutive identical verdicts with no state change, whatever the disposition. Verdict identity is {target, recoveryClass, rung, reasonCode}; a changed stateFingerprint breaks the run. Fails open on a bad clock or threshold: a breaker that engaged on malformed input would silence healing.
Escalation distinguishes the two futility shapes — a failed action escalates remedy-ineffective, an unactioned verdict escalates no-admitted-remedy, which is a substrate gap for someone to close rather than a retry to abandon.
foldFutilityFreezeState in the ledger store — per target: escalation, the verdict that froze it, the tier, the required quiet window, and thaw eligibility. Tier N requires baseThawQuietMs * tierMultiplier^(N-1), so a target that keeps returning is progressively harder to clear. An operator thaw does not raise the tier.
collectSelfHealSnapshot publishes freezeState, declared on the disabled and degraded envelopes too so a consumer never branches on its absence.
aligned-with ADR 0026 (recovery actuator) — cooperative containment only. No hard preemption and no new admitted action; the breaker stops futile evaluation, it does not invent a remedy.
Acceptance Criteria
Red-proof: an arm asserting a freeze on a verdict stream containing zerofailed dispositions must pass, and it must be the arm that fails if the signal reverts to executor failures.
Freezes at the threshold for recorded, declined, no-action, deferred and failed alike.
Negative control: one below the threshold does not freeze.
A changed verdict, and separately a changed stateFingerprint, each break the streak with a distinguishable status.
Escalation is remedy-ineffective for a failed action and no-admitted-remedy for an unactioned one.
Fails open on a missing clock, NaN clock, and non-positive threshold — asserted per case, since a breaker that engages on bad input silences healing.
A refrozen target requires a strictly longer quiet window than its first freeze; an operator thaw does not raise the tier.
Never thaw-eligible without a finite clock or with unusable bounds.
selfHeal.freezeState is published with escalation, evidence and thaw condition, and is declared (as null) on the disabled and degraded envelopes.
Out of Scope
Emitting freeze / unfreeze, and skipping evaluation while frozen.#17044 keeps it: it changes live actuator behaviour, and the decision surface should land and be reviewable first.
Automatic thaw execution when thawEligible turns true, and the operator thaw command surface — both need the producer above.
raiseCeiling reachability for the exhaustion class. CONTAINER_HEALTH_ACTION_ROUTES carries it with a container-memory-ceiling knob and exhaustion does not reach it; that is #16676 / #16695.
Hard preemption or any new admitted action — ADR 0026, and #17379 died proposing preemption on the sibling lane.
Avoided Traps
Counting executor failures. The two record-only routes never invoke one, and they are the majority of live rows.
Failing closed. For a dispatch gate, refusing to act is safe; for a breaker it is the opposite — engaging on a bad clock silences the immune system, so this fails open.
Duplicating the freeze vocabulary.HEAL_LEDGER_FROZEN_TRANSITIONS already exists and summarizeHealLedger already folds it; a second copy beside the decider would be two owners for one two-value enum. The fold moved to the owning module instead, which also keeps healActionDispatch free of the ledger store's fs import.
Publishing a freeze with no way out. A frozen target that states no thaw condition converts a bounded containment into an indefinite one.
Related
#17044 — parent; keeps the producer, the consumer gate and thaw execution.
#16823 — the kbSync cooperative-yield precedent for a pure decider consumed by a wired caller.
#16676 / #16695 — raiseCeiling reachability, deliberately not this leaf.
#17336 — if the frozen subject is OOM-killed rather than staying pinned, the resulting provider death is that ticket's scenario.
Context
Split from #17044, which needs a futility breaker for the self-heal loop. This leaf is the decision, the state and the operator surface — all pure or read-only. The runtime wiring that emits freeze/unfreeze and skips evaluation while frozen stays on the parent, because it changes live actuator behaviour on a plane that is currently running.
Live ledger, external plane 2026-08-19: 5,000 heal events,
byStatusdeclined2,310 ·recorded2,495 ·deferred170 ·actioned15 ·failed10.currentlyFrozen: []— the freeze has demonstrably never engaged.The Problem
#17044's AC-1 keyed the breaker on consecutive executor failures.
CONTAINER_HEALTH_ACTION_ROUTESgivesthrottleShed(throttle-shed-has-no-admitted-action) andrecord(diagnosis-record) anactuatorAction: null, so neither ever invokes an executor. A failure counter sees 10 of 5,000 rows and never engages, while the dominant terminals accrue thousands of no-effect records. AC-1 was widened by its author before this leaf was cut.And a frozen target had nothing to publish.
summarizeHealLedgerfoldscurrentlyFrozenas a bare target list, so an operator learns that a target is frozen and neither why nor what clears it.The Architectural Reality
ai/services/memory-core/helpers/healActionDispatch.mjs— the pure dispatch decider.freezeis already inHEAL_ACTIONSas non-mutating containment, exempt from the rate/thrash bounds.ai/services/memory-core/helpers/healSystemicCircuit.mjs— the precedent: a decider plusfoldSystemicCircuitStateover its own event vocabulary. Shape mirrored.ai/services/memory-core/helpers/healEventLedgerStore.mjs:41—HEAL_LEDGER_FROZEN_TRANSITIONS, andsummarizeHealLedger's existing fold of those two types. This module owns the freeze vocabulary, which is why the state fold lands here rather than beside the decider:healActionDispatchis documented pure and this module does I/O.ai/daemons/orchestrator/services/DeploymentStateBridgeService.mjs:2058—collectSelfHealSnapshot, the operator-facing projection.ai/daemons/orchestrator/services/ContainerHealthControllerService.mjs:90— wherethrottle-shed-has-no-admitted-actionis raised.The Fix
decideFutilityFreezein the dispatch module — keys on consecutive identical verdicts with no state change, whatever the disposition. Verdict identity is{target, recoveryClass, rung, reasonCode}; a changedstateFingerprintbreaks the run. Fails open on a bad clock or threshold: a breaker that engaged on malformed input would silence healing.remedy-ineffective, an unactioned verdict escalatesno-admitted-remedy, which is a substrate gap for someone to close rather than a retry to abandon.foldFutilityFreezeStatein the ledger store — per target: escalation, the verdict that froze it, the tier, the required quiet window, and thaw eligibility. Tier N requiresbaseThawQuietMs * tierMultiplier^(N-1), so a target that keeps returning is progressively harder to clear. An operator thaw does not raise the tier.collectSelfHealSnapshotpublishesfreezeState, declared on the disabled and degraded envelopes too so a consumer never branches on its absence.Contract Ledger Matrix
decideFutilityFreezehealActionDispatch.mjs(verified pure, no I/O)failedof 5,000 ledger rowsfoldFutilityFreezeStatehealEventLedgerStore.mjs:41freeze vocabulary (verified present)currentlyFrozenis a bare list todayselfHeal.freezeStatecollectSelfHealSnapshot(verified present)nullon disabled / degradedDecision Record impact
aligned-with ADR 0026(recovery actuator) — cooperative containment only. No hard preemption and no new admitted action; the breaker stops futile evaluation, it does not invent a remedy.Acceptance Criteria
faileddispositions must pass, and it must be the arm that fails if the signal reverts to executor failures.recorded,declined,no-action,deferredandfailedalike.stateFingerprint, each break the streak with a distinguishable status.remedy-ineffectivefor a failed action andno-admitted-remedyfor an unactioned one.NaNclock, and non-positive threshold — asserted per case, since a breaker that engages on bad input silences healing.selfHeal.freezeStateis published with escalation, evidence and thaw condition, and is declared (asnull) on the disabled and degraded envelopes.Out of Scope
thawEligibleturns true, and the operator thaw command surface — both need the producer above.raiseCeilingreachability for the exhaustion class.CONTAINER_HEALTH_ACTION_ROUTEScarries it with acontainer-memory-ceilingknob and exhaustion does not reach it; that is #16676 / #16695.Avoided Traps
HEAL_LEDGER_FROZEN_TRANSITIONSalready exists andsummarizeHealLedgeralready folds it; a second copy beside the decider would be two owners for one two-value enum. The fold moved to the owning module instead, which also keepshealActionDispatchfree of the ledger store'sfsimport.Related
kbSynccooperative-yield precedent for a pure decider consumed by a wired caller.raiseCeilingreachability, deliberately not this leaf.Origin Session ID: 8cbd588b-be06-4a56-9997-1058f2a3a07b
Retrieval Hint:
query_raw_memories("futility breaker identical verdicts no state change record-only routes actuatorAction null freeze evidence thaw tier")