Rewritten 2026-08-08T09:47Z — current facts only. Prior body was 25,033 bytes and carried three correction sections, a resolved fork analysis, and scoping narrative for work now shipped. Provenance is in git history, the PR, and the comment trail.
Reconcile the container-health action vocabulary with what the actuator can do, and route the service-side ceiling case. Delivered-half sibling of #16630.
raise-ceiling is store-only (AC-12); its executor shipped in #16637.
The three things this closes
1. throttle-shed is a diagnosis class the lifecycle actuator cannot perform.ContainerHealthDiagnosisService:739 prescribes it for container resource exhaustion, but the only implementation of that name is collection-keyed in ADR-0027's data-recovery world. A lifecycle throttle-shed is a forward declaration consumed by nothing. Disclosed in ADR-0026 §2.4 :93.
2. The service-side ceiling case needs a knob, not a new action. FALSIFIED 2026-08-08 — the channel cannot carry the payload. I claimed reconfigure's restart coupling was "required here rather than a defect" because a V8 ceiling is a process-start argument. That reasoning assumed a restart re-reads the composition. It does not: docker-compose.yml:117 interpolates ${NEO_KB_SERVER_HEAP_MB} into Config.Cmd at create time, and RecoveryActuatorService.mjs:586 → restartComposeService → applyLifecycle({operation: 'restart'}) re-runs that baked command. Prescribing reconfigure would have been a no-op reporting success. Delivering a heap ceiling needs container recreation, a privilege change. Found by @neo-gpt-emmy reviewing PR #16663; verified in source before adoption. The successor question lives in #16676 AC-0.
Resource distinction, measured: raise-ceiling mutates HostConfig.Memory (widens the cgroup envelope); a heap raise changes --max-old-space-size and leaves it unchanged — spending already-authorised memory. The discriminator is headroom, not service class.
3. The undeclared-ceiling record needs a home that is not the facts array.selectEvidenceFacts(facts, …) is called with the whole array at :676, :691, :731, :741, :752, :780, so any new member becomes candidate evidence for every class — this broke 3 assertions when tried in #16634. It lives in deployment-state-bridge-diagnostics instead, observe-only by construction.
Knob shape
Two knobs, not one.serviceKey is singular and the actuator refuses a knob/target mismatch against it.
No min/max.768 has no derivation in the repo (three compose occurrences, no working-set arithmetic). Both bounds are relationships instead, per the registry's rule that a bound against a leaf it does not change is expressed against the live value, never as a constant:
bound
expressed against
raise-not-lower
the declared ceiling being changed
strictly-below
the live container limit (HostConfig.Memory)
The container limit is runtime context, so a config-only channel fails closed on these knobs. Measured: no compose deploy.resources value has an AiConfig leaf anywhere, and there is no deploy.* subtree in configBase.mjs. Cgroup limits are observed at runtime by design. Failing closed is correct — a controller blind to the container limit must not raise a ceiling past it.
Leaf unit is MB, the unit --max-old-space-size takes, so the one MB→bytes conversion happens once where both units meet.
names Node services with no declared ceiling; record-terminal
stable empty array when all declare — the key is always present
recordType~:476, observe-only :79, emitted unconditionally at :525
divergence between declared and actual
this ticket
out of scope
—
no V8-scoped metric exists for a sibling container (#16630)
Decision Record impact: none — UNRESOLVED, and that is the correction. The none rested entirely on reconfigure being an already-admitted class, which is moot now that it cannot deliver the effect (see finding 2). A recreation-class path is a privilege change requiring §2.4 accounting; whether one is acceptable is #16676 AC-0, which may conclude that none is.
Acceptance criteria
Every member of CONTAINER_HEALTH_ACTION_CLASSES is accounted for in ADR-0026 §2.4 — asserted over a curated enum→matrix mapping, not a grep (the ADR spells throttle/shed separately while the enum carries hyphenated throttle-shed).
Both server container limits parameterised in compose (NEO_KB_SERVER_MEMORY_LIMIT / NEO_MC_SERVER_MEMORY_LIMIT, default 1g) — the prerequisite that makes the strictly-below bound expressible.
The two service-heap knobs MOVED OUT to #16695, and that is their disposition here. They were written and are not deliverable: no writer or effect path exists (heapCeilingMb occurs only in the registry and the bridge), and the channel they were designed for cannot apply them — a restart re-runs the baked Config.Cmd. Declaring actuator authority for an operation nothing can perform is the exact defect finding 1 of this ticket names in throttle-shed, so shipping them here would have closed the ticket by committing its own thesis. #16695 owns the descriptors, their tests and the recreation-class question; a negative answer there closes them as withdrawn.
A Node service with no declared ceiling produces an undeclaredHeapCeilingServices record; record-terminal, no action, no privilege, no restart.
That record is absent from evidenceFacts at every branch — true by construction, since it is not a fact type; the spec guards the construction.
Negative control: a service declaring a ceiling, and a non-Node service, produce no finding. Plus a positive control, because four rows asserting [] are jointly vacuous if the filter is broken.
Mutation-proven by name, each mutation's application checked by occurrence count before the run.
The heap knobs cannot sanction a cgroup envelope move. Ceiling leaves declare the resource they govern (container-memory vs v8-heap) and the envelope guard requires the former; a bandless envelope leaf refuses instead of comparing against undefined. Mutation-proven.
Adding these knobs widened a privilege boundary before it narrowed one, and that is why the extra AC above exists. The envelope guard admitted any service declared by a role: 'ceiling' leaf, so both servers became legal targets for update-memory-limit — the cgroup move #16636 puts out of scope. The band gate went vacuous with them too: these knobs carry no min/max by design, and value < undefined || value > undefined is NaN-false both ways, so the cap that terminates the autonomous ratchet disappeared rather than tightening.
Routing itself is a placement decision, not a branch. The decision point consumes facts, but the condition is a config relationship this ticket deliberately keeps out of that array — selectEvidenceFacts(facts, …) takes the whole array at six sites, which is what broke 3 assertions in #16634. The fork, its three candidates and their falsifiers live in #16676.
Out of scope
A raise-ceiling executor for services (B1-privilege work on a restart path).
The reactive controller for any action class — no controller consumes container-health decisions yet, so "routes to X" means prescribed in the recorded diagnosis, never actuated.
Slice B of #16630 — dimension-matched V8 observation.
tobiu referenced in commit 82e2629 - "feat: A store's ceiling is raisable — bounded knob, live update, no restart (#16637) (#16638) on Aug 7, 2026, 7:21 PM
tobiu referenced in commit 5f11053 - "The declared heap ceiling becomes observable, and the envelope guard names its resource (#16636) (#16697) on Aug 8, 2026, 4:21 PM
Rewritten 2026-08-08T09:47Z — current facts only. Prior body was 25,033 bytes and carried three correction sections, a resolved fork analysis, and scoping narrative for work now shipped. Provenance is in git history, the PR, and the comment trail.
Reconcile the container-health action vocabulary with what the actuator can do, and route the service-side ceiling case. Delivered-half sibling of #16630.
Ground state on
devADR-0026 §2.4 action matrix after #16596:
restart,warm-providerreconfigureraise-ceilingredeployraise-ceilingis store-only (AC-12); its executor shipped in #16637.The three things this closes
1.
throttle-shedis a diagnosis class the lifecycle actuator cannot perform.ContainerHealthDiagnosisService:739prescribes it for container resource exhaustion, but the only implementation of that name is collection-keyed in ADR-0027's data-recovery world. A lifecyclethrottle-shedis a forward declaration consumed by nothing. Disclosed in ADR-0026 §2.4:93.2.
The service-side ceiling case needs a knob, not a new action.FALSIFIED 2026-08-08 — the channel cannot carry the payload. I claimedreconfigure's restart coupling was "required here rather than a defect" because a V8 ceiling is a process-start argument. That reasoning assumed a restart re-reads the composition. It does not:docker-compose.yml:117interpolates${NEO_KB_SERVER_HEAP_MB}intoConfig.Cmdat create time, andRecoveryActuatorService.mjs:586→restartComposeService→applyLifecycle({operation: 'restart'})re-runs that baked command. Prescribingreconfigurewould have been a no-op reporting success. Delivering a heap ceiling needs container recreation, a privilege change. Found by @neo-gpt-emmy reviewing PR #16663; verified in source before adoption. The successor question lives in #16676 AC-0.Resource distinction, measured:
raise-ceilingmutatesHostConfig.Memory(widens the cgroup envelope); a heap raise changes--max-old-space-sizeand leaves it unchanged — spending already-authorised memory. The discriminator is headroom, not service class.3. The undeclared-ceiling record needs a home that is not the facts array.
selectEvidenceFacts(facts, …)is called with the whole array at:676,:691,:731,:741,:752,:780, so any new member becomes candidate evidence for every class — this broke 3 assertions when tried in #16634. It lives indeployment-state-bridge-diagnosticsinstead, observe-only by construction.Knob shape
Two knobs, not one.
serviceKeyis singular and the actuator refuses a knob/target mismatch against it.No
min/max.768has no derivation in the repo (three compose occurrences, no working-set arithmetic). Both bounds are relationships instead, per the registry's rule that a bound against a leaf it does not change is expressed against the live value, never as a constant:HostConfig.Memory)The container limit is runtime context, so a config-only channel fails closed on these knobs. Measured: no compose
deploy.resourcesvalue has an AiConfig leaf anywhere, and there is nodeploy.*subtree inconfigBase.mjs. Cgroup limits are observed at runtime by design. Failing closed is correct — a controller blind to the container limit must not raise a ceiling past it.Leaf unit is MB, the unit
--max-old-space-sizetakes, so the one MB→bytes conversion happens once where both units meet.Contract Ledger Matrix
deployment-service-state→inspect.declaredHeapCeilingMb--max-old-space-sizeparsed fromConfig.Cmdnullwhen none;'unknown'when declarations divergesummarizeInspect():1001droppedConfig.Cmdinspect.nodeCommandfalsestats.memoryLimitBytessummarizeStats():1027already publishes itdeployment-state-bridge-diagnostics→undeclaredHeapCeilingServicesrecord-terminalrecordType~:476, observe-only:79, emitted unconditionally at:525Decision Record impact:
— UNRESOLVED, and that is the correction. Thenonenonerested entirely onreconfigurebeing an already-admitted class, which is moot now that it cannot deliver the effect (see finding 2). A recreation-class path is a privilege change requiring §2.4 accounting; whether one is acceptable is #16676 AC-0, which may conclude that none is.Acceptance criteria
CONTAINER_HEALTH_ACTION_CLASSESis accounted for in ADR-0026 §2.4 — asserted over a curated enum→matrix mapping, not a grep (the ADR spellsthrottle/shedseparately while the enum carries hyphenatedthrottle-shed).NEO_KB_SERVER_MEMORY_LIMIT/NEO_MC_SERVER_MEMORY_LIMIT, default1g) — the prerequisite that makes the strictly-below bound expressible.heapCeilingMboccurs only in the registry and the bridge), and the channel they were designed for cannot apply them — a restart re-runs the bakedConfig.Cmd. Declaring actuator authority for an operation nothing can perform is the exact defect finding 1 of this ticket names inthrottle-shed, so shipping them here would have closed the ticket by committing its own thesis. #16695 owns the descriptors, their tests and the recreation-class question; a negative answer there closes them as withdrawn.undeclaredHeapCeilingServicesrecord;record-terminal, no action, no privilege, no restart.evidenceFactsat every branch — true by construction, since it is not a fact type; the spec guards the construction.[]are jointly vacuous if the filter is broken.container-memoryvsv8-heap) and the envelope guard requires the former; a bandless envelope leaf refuses instead of comparing againstundefined. Mutation-proven.Routing moved to #16676
Adding these knobs widened a privilege boundary before it narrowed one, and that is why the extra AC above exists. The envelope guard admitted any service declared by a
role: 'ceiling'leaf, so both servers became legal targets forupdate-memory-limit— the cgroup move #16636 puts out of scope. The band gate went vacuous with them too: these knobs carry nomin/maxby design, andvalue < undefined || value > undefinedis NaN-false both ways, so the cap that terminates the autonomous ratchet disappeared rather than tightening.Routing itself is a placement decision, not a branch. The decision point consumes
facts, but the condition is a config relationship this ticket deliberately keeps out of that array —selectEvidenceFacts(facts, …)takes the whole array at six sites, which is what broke 3 assertions in #16634. The fork, its three candidates and their falsifiers live in #16676.Out of scope
raise-ceilingexecutor for services (B1-privilege work on a restart path).Related
#16630 (parent) · #16596 / #16637 (store side, the §2.4 amendment this rebases onto) · #16463 · #16374 · ADR-0025 / 0026 / 0027
Origin Session ID:
4141258c-36d3-4788-b0c2-ab3ebe0867beRetrieval Hint:
query_raw_memories("service heap ceiling knob reconfigure not raise-ceiling headroom condition")·recoveryKnobRegistry.mjs· ADR-0026 §2.4:93