Skip to content

fix(spec,core): HotReloadConfig.stateStrategy refuses the two values it never implemented; distributedConfig retired (#12340) - #12425

Merged
os-warren merged 5 commits into
mainfrom
claude/issue-12340-hotreload-state-strategy
Aug 26, 2026
Merged

fix(spec,core): HotReloadConfig.stateStrategy refuses the two values it never implemented; distributedConfig retired (#12340)#12425
os-warren merged 5 commits into
mainfrom
claude/issue-12340-hotreload-state-strategy

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#12340

Clause-②: yes — this narrows an accept set. Configurations that boot today start failing: any host passing stateStrategy: 'disk' / 'distributed', or a distributedConfig, is now refused. In-tree blast radius is zero (measured, below); the cost is borne by out-of-tree hosts of a published library.

What was wrong

PluginStateManager.saveState had four stateStrategy arms and implemented two. 'disk' and 'distributed' both wrote to the same in-memory Map as 'memory' — the in-source comments said "memory fallback" — and announced the substitution at debug level only. A host that asked for durable or cluster-replicated state got process-local memory and no error: state that does not survive the restart it was configured to survive. distributedConfig had zero readers, so an author could name a Redis endpoint, a TTL and a replication factor and nothing ever opened a connection.

Reproduced before repairing (worktree, base cdbd920):

[SPEC] 'disk' -> parse OK, stateStrategy=disk
[RUNTIME] 'disk' -> reload=true, restored={"hello":"world"}
[RUNTIME] 'disk' -> log: ["debug:State saved to disk (memory fallback)"]
[RUNTIME] 'disk' -> errors/warns: []
[SPEC] distributedConfig accepted -> {"provider":"redis","endpoints":["redis://nope:6379"],"keyPrefix":"k:"}

Zero-reader measurement, with a firing positive control

A zero-hit is not a reading until a control fires.

  • Control: stateStrategy resolves to real readers — packages/core/src/hot-reload.ts (3) and packages/core/examples/phase2-integration.ts (2). The scan sees readers.
  • Target: every distributedConfig reference is inside packages/spec itself (declaration, its own unit test, generated artifacts) plus the generated reference page. Nothing else.
  • objectui: zero — and its own control fired (a repo-scoped search returns 47 files), so that zero is a reading.
  • cloud: not covered. The control did not fire for that repo (empty result with incomplete_results: true), so I am declaring it uncovered rather than counting its zero as evidence.
  • Falsification check the charter named: no live consumer passes 'disk' or 'distributed'. Every real caller passes 'memory' or 'none'. The two values appear only in spec's own unit test — which pinned exactly the arms being deleted.

Route, and why

ADR-0049 enforce-or-remove. The triage charter chose remove/narrow-loudly; the retirement playbook's fork turns on one fact I measured: nothing in the tree parses HotReloadConfigSchema outside its own unit test. That is playbook route 3 — no tombstone, no D2 conversion, because a parse-time prescription reaches nobody and HotReloadConfig is not an authorable surface (no metadata-type binding, stack collection or manifest embed ever carried it). Same route the sibling retirement in this module used yesterday.

Two shapes in one card, and the playbook predicts opposite ratchet readings for them — both confirmed:

ShapePredictedObserved
stateStrategy enum-value narrowinginvisible to all four ratchetskernel/HotReloadConfig:stateStrategy unchanged
DistributedStateConfig whole-def removalratchets must moveapi-surface -3, authorable-surface -8, json-schema.manifest -1

The def-removal route printed its own evidence — the build refused first and named the remedy:

1 previously published schema(s) disappeared from this build:
- json-schema/kernel/DistributedStateConfig.json
... delete the key(s) from the manifest in the same PR AND declare each one
in RETIRED_DEFS_BY_MAJOR

Because route 3 leaves no parse-time prescription, the doors moved to where the host actually meets the library:

  • TypeScript hosts cannot reach it — HotReloadConfigParsed field stateStrategy is now 'memory' | 'none', a compile error at the call site.
  • JS hosts / JSON config get a loud refusal in HotReloadManager.registerPlugin, with an ADR-0112 envelope (code: VALIDATION_ERROR, status: 400) carrying the prescription. Thrown before the enabled check, so a disabled config cannot smuggle the false declaration through.
  • A leftover distributedConfig is refused too, rather than silently stripped — this is the answer to the authorable-surface gate's stated worry, for the one audience that exists.
  • The enum's own error map carries the prescription for anyone who does parse, dispatched by issue.input, so a typo is not told it "was removed".

The one thing to look at

DistributedStateConfigSchema was named in the 2026-08-25 survivor list of the #11825 ruling. This PR reverses that line. The new evidence: that ruling measured the container's six groups and never this key's own readers. Its only referencing key left with the enum value its doc comment called it "required" for, so the schema had nothing left to be the vocabulary of.

The keep itself is intact and still pinned: HotReloadConfigSchema, PluginStateSnapshotSchema and the health vocabularies still export; HotReloadManager / PluginHealthMonitor untouched. The pin moves in the same commit with the reasoning recorded beside it, and now asserts the surrounding keep is intact — never a quiet edit to make a red pin green. This is the call worth a maintainer's eye.

Verification (all at 2d0b7a6)

Gate union derived, not recallednode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack over the real changeset: 45 families. Each exit code captured before any pipe; ratchet families re-run at the final head.

  • 38/45 green on the first pass. Of the 7 red, 5 self-declared "Nothing was measured" (unbuilt dependency closures — sdui-parser, formula, spec dist), and 2 were real: check:strictness-ledger and check:generated.
  • Both real ones fixed and re-run green, plus check:liveness, check:empty-state, check:variant-docs, check:adr-0087-registration, check:changeset-no-major: 11/11 green.
  • check:strictness-ledger: kernel/ 277 to 274. Read, as the gate demands: the removed def carried exactly three z.object nodes (itself plus inline auth and replication). Fully accounted for; the prose ledger has no per-file verdict for this file to re-examine.
  • Not measured locally: check:skill-examples (needs client-react built; the foreground build hit the container's 10-minute cap). It had already cleared the 227 spec+docs blocks this change touches and stopped only on the unrelated client-SDK surface. check-dev-prereqs likewise wants a full monorepo build. Both left to CI.
  • Tests: spec 31/31, core 11/11.

Ablations — direction predicted in writing first, mutation proved on disk by anchored grep -cF counts before any result was read, restore trap on both:

AblationPredictedObserved
delete the registerPlugin refusal callRED, exactly 4 of 11 core tests4 failed / 7 passed — precisely the four named
put 'disk' back in the spec enumRED, exactly 1 failure, the 'disk' refusal1 failed / 15 passed — the 'disk' refusal

Neither rebuilt, and neither needed to: both subjects resolve through relative source imports, not through a package exports field to dist. The only @objectstack/spec/kernel import in the core test is import type, erased at runtime. Both restore legs verified byte-identical to HEAD.

Changeset

minor for both packages, graded deliberately: this module's sibling retirement established the lockstep launch-window convention — v17.0.0 is already cut, so breaking export removals ride minor on the 17.x line with the prescription registered under protocol major 18. check:changeset-no-major is green, which is the mechanical confirmation.

Generated by Claude Code


Generated by Claude Code

…nted; distributedConfig retired (#12340)
ADR-0049 enforce-or-remove, applied one level inside the host-driven library
the 2026-08-25 #11825 ruling kept. The 'disk' and 'distributed' arms of
PluginStateManager.saveState both wrote to the same in-memory Map as 'memory'
and said so at debug level only; distributedConfig had zero readers anywhere.
- spec: stateStrategy narrowed to ['memory','none'] with the prescription on
the enum's own error map (invisible to the four ratchets by construction);
distributedConfig and DistributedStateConfigSchema removed (route 3 — no
authored document, nothing parses this schema).
- core: registerPlugin refuses an unhonoured stateStrategy and a leftover
distributedConfig with an ADR-0112 envelope, before the enabled check.
- ADR-0087: RETIRED_DEFS_BY_MAJOR[18] + D3 semantic entry.
- the #11825 survivor pin for DistributedStateConfigSchema moves here
deliberately, with the reasoning recorded beside it.
Part of #12340
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…emoved')
The refusal fired correctly; the assertion named the singular. Caught by the
run, not by reading.
Part of #12340
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…dStateConfig removal
kernel/ 277 -> 274, fully accounted for: the removed DistributedStateConfigSchema
carried exactly three z.object nodes (the def plus its inline auth and
replication sub-objects). The prose ledger has no per-file verdict for
plugin-lifecycle-advanced.zod.ts, so no Class verdict needed re-examining.
Part of #12340
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
@github-actions

github-actionsBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/spec, touching 16 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/spec/api-surface/kernel.json, packages/spec/authorable-surface/kernel.json, packages/spec/export-origins/kernel.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/kernel/lifecycle.mdx(via registerPlugin (symbol))
What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/kernel.json, packages/spec/authorable-surface/kernel.json, packages/spec/export-origins/kernel.json, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b6c96bcea5d4352c5a63b4aa9a8d6809b9ce15cfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 2001bbe4e6a73549193e5ded1afb552cde65d5ac — the merge of head d76e5e5a61f7748c01061aed52d6306d7d2335cb into base b6c96bcea5d4352c5a63b4aa9a8d6809b9ce15cf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2001bbe4e6a73549193e5ded1afb552cde65d5ac && git checkout 2001bbe4e6a73549193e5ded1afb552cde65d5ac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b6c96bcea5d4352c5a63b4aa9a8d6809b9ce15cf d76e5e5a61f7748c01061aed52d6306d7d2335cb && git checkout -B drift-repro b6c96bcea5d4352c5a63b4aa9a8d6809b9ce15cf && git merge --no-ff d76e5e5a61f7748c01061aed52d6306d7d2335cb
node scripts/docs-audit/affected-docs.mjs --json b6c96bcea5d4352c5a63b4aa9a8d6809b9ce15cf

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b6c96bcea5d4352c5a63b4aa9a8d6809b9ce15cf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…tree
The merge of `main` into this branch left the `kernel/` count as OURS (274) —
the `merge=os-regen` driver defers rather than text-merging, because a
generator run mid-merge reads a half-merged tree. Regenerated from the fully
merged sources, as that driver's pending marker requires.
277 at the merge base; this branch's DistributedStateConfig removal takes 3
(the def plus its inline `auth` and `replication` objects) and #12417's
`contributes.routes` retirement on main takes 1, so 273 is the composition of
both sides, not a choice between them. Predicted before the run; observed
exactly. `check:strictness-ledger` green.
@os-warren
os-warren enabled auto-merge August 26, 2026 05:01
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Conflict with main resolved (PM seat) — head now d76e5e5a61

main moved to b6c96bcea5 and GitHub reported this PR dirty. Merged origin/main into the head rather than rebasing. Measured intersection with main's changes since the merge base cdbd9204b6 was exactly two files, both append-shaped:

fileresolution
packages/spec/src/migrations/registry.tsboth sides' rows kept — verified by regenerating, not by reading the merge
docs/audits/2026-07-unknown-key-strictness-ledger.counts.mdregenerated — the text merge was wrong here

The registry needed no correction, and that is a measurement

Both entries survived: this branch's hot-reload-inert-state-strategies-retired / kernel/DistributedStateConfig, and #12417's plugin-manifest-contributes-routes-retired / kernel/Manifest:contributes.routes. check:migration-registry (--self-test --check) is green — 123 semantic, 59 retired-key, 68 retired-def — so the merged text is byte-identical to what the generator produces from the merged entry files. That is the check that matters; agreeing rows in a diff would not have been.

The counts ledger was silently wrong after a clean merge

This file is merge=os-regen. That driver deliberately does not text-merge — it resolves to OURS as a placeholder and defers, because a generator run mid-merge reads a half-merged tree. So git reported no conflict and left the branch's own number standing:

merge base cdbd9204b6 : kernel/ 277
this branch 2d0b7a6f88 : kernel/ 274 (-3: DistributedStateConfig + inline auth/replication)
main b6c96bcea5 : kernel/ 276 (-1: #12417 contributes.routes)
after merge, before regen : kernel/ 274 <- OURS placeholder, main's -1 dropped

273 is the composition of both sides, not a choice between them. Predicted in writing before running the generator; gen:strictness-ledger produced exactly 273 and no other row moved. The pre-commit hook then confirmed the deferred artifact was current and cleared the pending marker.

Re-verified on the merged tree

Green, each exit code captured before any pipe: check:strictness-ledger (436 sites, counts file current), check:migration-registry, check:spec-changes, check:upgrade-guide, check:adr-0087-registration (1 declared-breaking changeset, disposition registered), check-changeset-no-major (no major bump). Tests: spec plugin-lifecycle-advanced + -retirement19/19, core hot-reload11/11.

No source file was touched by this resolution — the diff over 2d0b7a6f88 is one line in the counts ledger. Auto-merge re-armed; the rest is CI.


Generated by Claude Code

@os-warren
os-warren added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4635f3eAug 26, 2026
34 checks passed
@os-warren
os-warren deleted the claude/issue-12340-hotreload-state-strategy branch August 26, 2026 05:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-warren@claude