Uh oh!
There was an error while loading. Please reload this page.
fix(spec,core): HotReloadManager.startWatching refuses instead of reporting success; watchPatterns retired (#12428) - #12571
Conversation
…atchPatterns retired
`HotReloadManager.startWatching` contained no watcher. Its whole body was a
guard plus `logger.info('File watching started', { patterns })`, above an
in-source note saying real watching "would require chokidar or similar". Where
the sibling defect in this file announced its inert fallback at DEBUG, this
claimed success at INFO: an operator who set `enabled: true` with
`watchPatterns` and read that line had been told the opposite of the truth.
`watchHandles` was only ever read, deleted, iterated and cleared and never set,
so `stopWatching`'s cleanup branch and the teardown loop over its keys were
structurally unreachable rather than merely untaken. The zero was pinned with a
firing positive control before anything was removed: `reloadTimers.set`
resolves a real writer in the same file and the same scan, while
`watchHandles.set` resolves nothing anywhere in the tree.
`watchPatterns` therefore had no reader that acted on it — its only two uses
were log lines — so ADR-0049 enforce-or-remove applies. Neither of the other
two states was available: no runtime composes `HotReloadManager`, so enforcing
would build for a caller that does not exist, and a scan of every planning doc
returned zero mentions of hot-reload file watching against 145 control hits in
the same files, so there is no roadmap for `experimental` to point at.
The key is tombstoned rather than deleted, and the build chose that: the plain
deletion was tried first and `gen:schema` gate (a) refused it, because
`HotReloadConfigSchema` is not `.strict()` and a bare deletion would be a
silent strip (ADR-0104) — the very defect being retired, one layer down.
`kernel/HotReloadConfig:watchPatterns` is registered in
`RETIRED_KEYS_BY_MAJOR[18]` with the D3 semantic entry
`hot-reload-watch-placeholder-retired`. No D2 conversion: `HotReloadConfig` is
not an authorable surface, so a conversion would be a transform with no seam
that ever runs.
Because nothing in the tree parses `HotReloadConfigSchema` outside its own unit
test, the tombstone alone reaches nobody, so the runtime doors carry the
prescription: `startWatching` throws an ADR-0112 envelope pointing at
`scheduleReload`, and `registerPlugin` refuses a leftover `watchPatterns` the
same way, before the `enabled` check. `startWatching` is kept as a throwing
door rather than deleted so that caller meets a prescription instead of a bare
TypeError.
Runtime reload behaviour is unchanged: nothing was ever watched, so nothing
that used to happen stops happening. `stopWatching` keeps the half that always
did something, and `shutdown` lost only a loop that iterated `watchHandles` and
therefore ran zero times.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6ad9becb110e47408da0cd07109e9386c2326832 && git checkout 6ad9becb110e47408da0cd07109e9386c2326832
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aa45919714828002caba204b627debcbe96c3e1f 1d4795c17c0814f043992a0d7d28f3e8eac8b8f9 && git checkout -B drift-repro aa45919714828002caba204b627debcbe96c3e1f && git merge --no-ff 1d4795c17c0814f043992a0d7d28f3e8eac8b8f9
node scripts/docs-audit/affected-docs.mjs --json aa45919714828002caba204b627debcbe96c3e1f
|
os-warren
commented
Aug 26, 2026
PM review — accepted, flipped ready, auto-merge armedMeasured independently: 11 files, +662/−57, all in-scope. Clause ② confirmed yes and the label stays — the accept set genuinely narrows ( The route was chosen by the build, and that is the most interesting thing in this reportThe dev started on #12340's route 3 — plain deletion — because that is what its sibling had just done in the same file four hours earlier. But the distinction that actually separates the two cards is one neither the gate nor the dev's first instinct supplied: #12340 removed a whole DEF, so nothing survives for an author to keep writing. #12428 removes a KEY from a def that survives and keeps emitting — there is no route-3 exit from that, because the author's document is still valid and the stripped key still parses to nothing. Landing on the tombstone here is correct, and the reasoning in the PR body arrives at it. That the right answer came out of a gate message that does not draw that distinction is the part worth recording. Filed as #12574 ( Route 3 is real, it landed today, and gate (a)'s text calls an aged-out tombstone "the ONE legitimate reason to delete". The card states both horns — either the text is incomplete or the playbook is wrong — and deliberately picks neither; that is the spec lane's call. Evidence qualityBoth ablations name the failure count and the specific cases in advance and hit them exactly (2 of 18; 1 of 17), prove the mutation on disk with a two-way anchor — removed text 1→0 and injected sentinel 0→1 — before reading any result, and verify the restore with an empty ENFORCE and EXPERIMENTAL were measured unavailable, not dismissed: no runtime composes Honest non-measurements — all three correctly refused rather than counted
The declared test editThe fixture Two dispositions for the maintainer — surfaced, not decided
CI is the remaining gate. Generated by Claude Code |
Fixes#12428
Clause-②: YES, the label stays — this narrows an accept set. It does not widen public surface. Both halves measured,
git diff --stat origin/main...HEAD:Configurations that boot today start failing: any host passing
watchPatterns, or callingstartWatching, is now refused. Every changed line is a removal, a tombstone, a registration, a test or a comment — no new exported name, and the two type changes both narrow (watchPatternsinputstring[] | undefined→never | undefined;startWatchingreturnvoid→never). In-tree blast radius is zero; the cost is borne by out-of-tree hosts of a published library. Unlike #12394, where the anticipated widening did not materialise and the label came off, here the narrowing is real.What was wrong
HotReloadManager.startWatchingcontained no watcher. Its whole body was a guard pluslogger.info('File watching started', { patterns }), above an in-source note saying real watching "would require chokidar or similar / This is a placeholder for the integration point".watchHandleswas only ever read, deleted, iterated and cleared and never set, sostopWatching's cleanup branch and the teardown loop over its keys were structurally unreachable, not merely untaken.watchPatternstherefore had no reader that acted on it — its only two uses were log lines.Where #12340's inert fallback at least announced itself at DEBUG, this claimed success at INFO. That is the part that needed no ruling: an operator who set
enabled: truewithwatchPatternsand read that line had been told the opposite of the truth.Reproduced before repairing (worktree at base
52a9823887):Unreachability pinned with a firing positive control
A zero-hit is not a reading until a control fires in the same scan.
reloadTimers.set→ 1 hit,packages/core/src/hot-reload.ts:474. The sibling map in the same class, found by the same<map>\.setscan. The scan sees writers.watchHandles.set→ 0 hits, anywhere in the tree. The other references (declaration,.get,.delete,.keys(),.clear()) are all reads.startWatchinghad zero. The only reference was its own declaration; core'sstopWatchinghad exactly one caller — the teardown loop over the map nothing writes.HotReloadManagerat runtime. Only its own unit test andpackages/core/examples/phase2-integration.tsconstruct it.startWatching, inpackages/metadata/src/node-metadata-manager.ts, which really does poll. Same method name, different subsystem.Route, and why the presumption was not overturned
ADR-0049 leaves three states. The other two were measured and both unavailable:
chokidaris a dependency of@objectstack/metadata,@objectstack/metadata-fsand@objectstack/cli, and never of@objectstack/core.DX_ROADMAP,NEXT_STEP,PLUGIN_ECOSYSTEM_MAP,PLATFORM_GAPS_FROM_TEMPLATES,HARDENING,design/,handoff/) returned 0 hits for hot-reload file watching, against 145 control hits forpluginin the same files. The zero is a reading.So the refuse/retire presumption stands. The only recorded statement in the neighbourhood —
docs/protocol-upgrade-guide.md, "kept as the starting point for a future enforce decision" — reserves the enforce question; it is not a roadmap entry, and #12425 already established that per-key readership decides inside this kept container.The one thing to look at — the route was chosen by the build, not by me
I started on #12340's route 3 (plain deletion, no tombstone: nothing parses
HotReloadConfigSchema).gen:schemagate (a) refused it, and the refusal is the evidence:That is correct and I switched. #12340 could take route 3 because what left there was a whole def, which has its own registry and its own ratchet movement. A key leaving a surviving def has no such exit — deleting it would have been a silent strip, i.e. the very defect being retired, one layer down. So:
watchPatternsisretiredKey()-tombstoned; its surface line now carries[RETIRED].kernel/HotReloadConfig:watchPatternsregistered by exact key inRETIRED_KEYS_BY_MAJOR[18].HotReloadConfigis not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it — so a conversion would be a transform with no seam that ever runs. For the same reason the prescription carries noos migrate metasentence, exactly as itsstateStrategysibling in this module does not; the migrate-sentence pin judges only prescriptions that do name the command, so this absence is in scope by construction rather than by exemption.Ratchet reading, and it is the one a key tombstone must produce — not the def-removal shape #12425 saw:
authorable-surface[RETIRED], count unchangedkernel/HotReloadConfig:watchPatterns→… [RETIRED]; kernel keys 859 → 859api-surface/json-schema.manifestBecause the tombstone alone reaches nobody (nothing parses this schema outside its own unit test), the doors carry the prescription:
startWatchingthrows an ADR-0112 envelope (code: VALIDATION_ERROR,status: 400) namingscheduleReload, andregisterPluginrefuses a leftoverwatchPatternsthe same way — before theenabledcheck, so a disabled config cannot smuggle the false declaration through.startWatchingis kept as a throwing door rather than deleted, on purpose: deleting the method leaves a JavaScript host a bareTypeError: not a functionwith no prescription, and this is the one place a caller of the old placeholder is guaranteed to arrive. This is the call worth a maintainer's eye.Runtime reload behaviour is unchanged for every config that worked: nothing was ever watched, so nothing that used to happen stops happening.
stopWatchingkeeps the half that always did something (it cancels a pending debounced reload), andshutdownlost only a loop that iteratedwatchHandlesand therefore ran zero times — both pinned by new tests.Two small in-scope refactors, named rather than slipped in:
assertNoRetiredDistributedConfigis now table-driven (RETIRED_HOT_RELOAD_KEYS) so this retirement is one row rather than a second near-copy — #12340's message is carried across byte-for-byte, and its pins assert content, so they do not move; and the privatestateStrategyRefusalis renamedhotReloadRefusal, since #12340 already used it for a non-strategy key.Declared test edit
The spec fixture
should validate custom hot reload configurationlistedwatchPatternsand assertedtoEqual(config). That assertion passed precisely because the key parsed and did nothing. The key is removed from the fixture and its departure pinned separately as a refusal — declared here, in the report, and on the line in the test file. Never a quiet edit that turns a red pin green.Verification (all at
1d4795c17c, tree clean)Gate union derived, not recalled —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackover the real changeset: 43 matched families plus the convention-triggered ones. Every exit code captured before any pipe (cmd > log 2>&1; e=$?).check:generated,check:authorable-surface,check:migration-registry,check:spec-changes,check:upgrade-guide,check:docs,check:api-surface,check:liveness,check:empty-state,check:variant-docs,check:strictness-ledger,check:skill-refs,check:skill-docs,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:nul-bytes,check:engine-double-contract,check:where-matcher,check:test-source-alias,check:type-source-resolution,check-comment-mask-adoption,check-plugin-teardown-shape,check-doc-frontmatter,check:objectui-changeset,check:type-check-coverage,check:query-options-erasure,check-cross-package-test-inputs,check:type-check-debt.check:type-check-debtfirst refused outright ("--re-measurecannot run: 54 workspace dependencies have no built type entry point") — reported as not measured, never as green. The full closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70, 6m05s) and it re-ran green: "32 ledger entries re-measured, 1843 raw tsc errors total, none above its recorded number. surplus: none."check:api-surface/check:generatedtransiently refused after the ablation, with the stale-dist message ("dist/**/*.d.tsis OLDER thansrc" — the restore bumped source mtimes). Rebuilt and re-run green at the final head. Recorded because that refusal is a not-measured, and the gate says so itself.pnpm lint, left to CI. A baretsc --noEmit -p packages/core/tsconfig.jsonwas also run and is not a valid reading —@objectstack/coredeliberately has notypecheckscript and carries a declaredTEST_DEBTentry of 98 pre-existing errors; that run reported 100 with the closure unbuilt, which the ratchet's own docblock names as "a DIFFERENT WORLD". Zero of the errors were in files this PR touches. The ratchet above is the real measurement.Ablations — direction and exact failure count predicted in writing first, mutation proved on disk by anchored
grep -cFcounts before any result was read, restore undertrap … EXIT INT TERMand verified with an emptygit diff:startWatchingrefusalrefuses startWatching…cases2 failed | 16 passed (18)— precisely those twowatchPatternsin the specrefuses watchPatterns with the retirement prescription (#12428)1 failed | 16 passed (17)— precisely that oneNeither rebuilt, and neither needed to: both subjects resolve through relative source imports (
./hot-reload.js,./plugin-lifecycle-advanced.zod), not through a packageexportsfield todist/. The only@objectstack/spec/kernelimport in the core test isimport type, erased at runtime.Changeset
minorfor both packages, graded deliberately: v17.0.0 is already cut, so this accept-set narrowing rides the 17.x line under the lockstep launch-window convention, with the prescription registered under protocol major 18 whereos migrate metausers look — the same grading #12340 used one day earlier in this module.check:changeset-no-majoris green, which is the mechanical confirmation.Generated by Claude Code
Generated by Claude Code