fix(mcp): arm the stdio localization memo by the settings bind, not by the first read - #11878
Conversation
…y the first read #11580 moved the stdio transport's localization read onto a `kernel:bootstrapped` hook, but the memo's lazy entry point kept whatever the FIRST read produced. The transport goes live inside `MCPServerPlugin.start()` — before the remaining plugins' `start()` bodies and before every `kernel:ready` handler, which is where `SettingsServicePlugin` binds its data engine — so a data call racing the boot resolved localization pre-bind, got `UTC` / `en-US` from the manifest defaults, and froze them for the life of the process. The memo is now scoped to the settings bind epoch: a resolution taken while the window is open is kept only until the window closes, and the first one taken after the close lives for the life of the transport. #7279's steady state is unchanged (one resolution, never a per-call settings read) and a host that never fires the boot hooks still answers instead of deadlocking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
PM review — ACCEPT, no changes requested.
Clause ②: I traced the gate logic myself. All four paths are correct.
Also checked what the pins can't: no race on the memo itself. Option 3 was not built, verified in the diff and not taken from the report: nothing changes when the transport attaches or when the stdio server starts answering. The three things that make this better than green1. Both alternatives were eliminated by MEASUREMENT, not by argument. Option 1 hangs a hookless host (an awaited deferred never settles, the MCP call never returns). The card's literal option 2 you built first, and the tree refused it: 2. The price is pinned as its own case, not buried in a comment. 3. The window is driven, and the drive is itself measured. The racing read is issued from inside the stubbed ⭐ The hookless case naming its own hang — a 2s deadline with RefusalsThree, all recorded rather than counted as passes. The second is the one worth keeping: your hand-built TEST_DEBT program reported 0 errors because it compiled nothing ( #11877 verified filed, unassigned and unlabelled — and correctly scoped as not measured and not reachable from these pins, rather than asserted. ⛔ Nothing further from you. Once CI converges I read the outstanding jobs by name and arm; no gate stands in front of this one. Generated by Claude Code |
ARMED — ready flipped, auto-merge enabled (squash).
All 31 check runs Preconditions, each discharged rather than assumed:
One line worth leaving on the record, because it is the reusable part: the shape that landed is the one that survived being measured, not the one the card named. The card's literal option 2 was built first and turned a shipped #7279 pin red on a hookless host; option 1 hangs that same host. Both were eliminated by running them, and the epoch memo is what was left. A card's list of options is a starting point, and "the card said so" is not a reason to ship the option the tree refuses. Generated by Claude Code |
Fixes #11622
The residual, and that it was still there
#11580 moved the stdio transport's localization read onto a
kernel:bootstrappedhook, memoized so it stays one resolution for the life of the transport (#7279). The memo's second, lazy entry point —resolvePrincipal()awaits the same memo — is deliberate: it is why a bare kernel or a test harness that never fires the boot hooks resolves at first use instead of deadlocking on a hook that never arrives.It was also the residual. The transport goes live inside
MCPServerPlugin.start(), atruntime.start(), which runs before the remaining plugins'start()bodies and before everykernel:readyhandler — andSettingsServicePluginbinds its data engine from one of those. A client fast enough to land a data call in that stretch reachedresolvePrincipal()first, resolved localization pre-bind, gotUTC/en-USfrom the manifest defaults, and kept them for the life of the process.The premise was re-checked on the merged ref before anything was written, because the card's blocker had merged in the meantime and "absorbed, nothing to fix" was an acceptable outcome for this dispatch. It was not absorbed. On
origin/mainat589758d2,packages/mcp/src/plugin.tsstill read:localizationOnce ??=is armed by the first READ, whichever phase that read is in. The measurement is the ablation below: with that file restored from589758d2and the new pins in place, four of them fail withexpected 'en-US' to be 'zh-CN'on a call made after the boot completed. Outcome 1 of the three the dispatch named — the window survives as described.What was built, and the option that was NOT
The card lists three closures. Option 3 (defer the transport attach to
kernel:bootstrapped) is out of this seat's discretion and was not built, not even partially — nothing here changes when the stdio server starts answering. Option 1 (await the bind) is ruled out by the host in the last pin below:kernel:bootstrappednever arrives there, so an awaited deferred never settles and the MCP call never returns. A hung call is worse than a wrong locale.So: option 2, in the shape the tree turned out to require. The memo is scoped to the settings bind epoch rather than to the transport. A resolution taken while the window is open is kept only until the window closes; the first one taken after the close is the one that lives for the life of the transport.
Why not the card's literal wording — the tree refused it
The card words option 2 as "cache only once the hook has run; resolve fresh before that", and it names the cost: it "makes #7279's one resolution for the life of the transport conditional on a hook firing, which several bare/lite hosts do not". That version was built first. It is not merely a cost in this tree — it breaks a shipped pin:
That harness drives a
PluginContextwhosehook()is avi.fn()that never fires — the bare-kernel shape the lazy entry exists for. Its window never closes, so a memo armed only by the close resolves on every read: three reads, three resolutions, a declared property gone. The epoch keeps that property on hookless hosts (one memo, kept) and still cannot let a pre-bind answer outlive the boot.The price, stated rather than hidden
While the window is open all callers share one answer. So a call arriving after the settings engine binds but before
kernel:bootstrappedis served the earlier pre-bind value instead of a fresh one. That residue is bounded by the remainingkernel:readyhandlers, is corrected the moment the window closes, and can never outlive the boot. It is pinned as its own case (a mid-window read AFTER a racer …) so it is a recorded decision, not an accident. A raced boot costs exactly one extra resolution, once, ever — never a per-call settings read.The pins, and how the window was actually DRIVEN
packages/mcp/src/__tests__/plugin-prebind-memoization.test.ts, 8 cases, all through the realLiteKernel— realresolvePluginOrder, real phase sequencing, real hook dispatch. Nothing about the window is reconstructed by hand:MCPServerRuntime.start()— the instant the transport claims stdin/stdout, the earliest moment a client can reach this surface and strictly before anykernel:readyhandler.settings.readsAtBind(the settings-read count at the moment the engine bound) is asserted to be1.kernel:readyhandler after the settings provider's — handlers run in registration order, so it lands after the bind and beforekernel:bootstrapped. The case asserts the arrangement it measured (startOrderindex of the probe is greater than the provider's) rather than assuming the kernel produced it.The defect is a persistence defect, so no case stops at the first answer: every one reads again after the boot and asserts the configured
zh-CN/Asia/Shanghai/CNYthere, and one walks calls 1..8.sys_settinganswers empty in the fixture on purpose, so the bound settings service is the single possible source of those values, and a separate control case pins that the double answersUTC/en-USwithsource: 'default'before its bind — a green cannot mean "the double always says zh-CN".Ablation — red before, green after, from the committed state
Both legs run the new file plus the two neighbouring suites this could move (
plugin-execution-context,plugin-settings-bind-window). The mutation isgit checkout 589758d2 -- packages/mcp/src/plugin.ts, and the script carriedtrap … EXIT INT TERM, which is what restored the file.The mutation was proved on disk by grepping both the injected and the removed text on each leg — an editor's or
git checkout's exit code proves nothing. No rebuild is needed between legs: the mutated file ispackages/mcp/src/plugin.tsand the suite imports it as../plugin.js, a relative specifier vitest resolves to source. The workspace deps this suite resolves throughdist/are@objectstack/core,formula,specandtypes(registered inKNOWN_UNALIASED_TEST_IMPORTS); they are identical on both legs and were built before either, withpnpm --workspace-concurrency=2 --filter '@objectstack/mcp^...' build.Four of the eight new cases pass on both legs, and that is correct: they are the fixture-falsifiability control, the racing read's own pre-bind answer, the no-racer baseline, and the hookless host. They are the instrument, not the payload — and the instrument produced a positive on leg B before any of its negatives were counted.
Gates
Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackate7942ddb3(15 path-matched families + 6 convention-triggered), run as a union at that same final commit with a clean tree. Every one exit 0. Verdict lines each gate printed for itself:check:settings-bind-window—✓ settings bind-window: 4 declared / 0 self / 1 structurally upstream / 0 ledgered (68 plugin unit(s) scanned, provider 'com.objectstack.service.settings').check:cross-package-test-inputs—OK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:test-source-alias—check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/; 45 published subpath(s) resolved through every alias table.check:type-source-resolution—check-type-source-resolution OK — 77 packages with a tsconfig.json scanned; 51 registered as still resolving a workspace dep's types through dist/.check:engine-double-contract—385 (file, verb) row(s) held by the RETAINED ledger — a pin that leaves names itself.check:where-matcher—✓ where-matcher conformance holds: 296 matcher(s) discovered, 296 answer the combinator battery correctly or refuse it loudly (183 refuse).check:query-options-erasure—✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed.check:slot-lookup—✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.check:type-check-coverage—✓ check:type-check-coverage --self-test — 47 semantic case(s) + 59 observation case(s) + 29 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) hold.check:published-files—✓ check:published-files — 69 publishable package(s) of 78 workspace member(s) declare a files whitelist …check:plugin-teardown-shape—✓ check:plugin-teardown-shape: 63 Plugin implementation(s) across 4623 source(s) under packages/**; every teardown-shaped method sits beside a real destroy() (0 known-unreached, ⛔ SHRINK-ONLY, baseline fully burned down).check:nul-bytes—check-nul-bytes: OK (scanned 6600 text file(s) … no raw ASCII control bytes).check-changeset-no-major—✓ This diff introduces no major bump.check-empty-changeset—✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).check-adr-0087-registration—✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).check-affected-docs—✓ affected-docs self-test: 417 cases pass.·check-drift-comment—✓ 46 cases pass across 5 fixture diff(s).check:changeset-gate-self-tests,check:objectui-changeset,release-rehearsal-clone --self-test— all✓.Package suite at
e7942ddb3with a clean tree:pnpm --filter @objectstack/mcp test→Test Files 22 passed (22) / Tests 242 passed (242)(234 before this branch, +8 new cases).pnpm --filter @objectstack/mcp typecheck→ exit 0.One gate REFUSED locally — declared, not silently skipped
node scripts/check-type-check-coverage.mjs --re-measurerefused on this worktree, so the ratchet half is NOT MEASURED by the gate itself:It needs the whole workspace closure built — CI's run, and a third concurrent full-workspace build in this shared container. The ratchet for the one ledgered package this diff can move was reproduced by hand instead, and this is stated as a narrowing rather than a pass:
@objectstack/mcp. No other ledgered package's sources, and notsconfig.jsonanywhere, appear in the diff, so no other entry's program moves.**/*.test.tsexclusion. Reproduced atraw tsc error count (test-including program): 53, matching the recordederrors: 53on the nose, composedTS18046 x51 / TS6133 x1 / TS2352 x1— class for class the composition the ledger note records — with 0 attributed to the new pin file. Worth stating plainly:pnpm --filter @objectstack/mcp typecheckdoes not cover the new test file (that tsconfig excludes**/*.test.ts); this measurement is the one that does.0errors — because it reportedTS2688: Cannot find type definition file for 'node'and compiled nothing. It is recorded here rather than quietly re-run: the53above was taken only after explicittypeRoots, and a0from that instrument would have read exactly like a clean result.check:type-check-coverage, the structural half of the pair, ran green with the new test file present.Clause-② — no
Nothing here changes what the platform accepts or rejects, and nothing widens the public surface. No key becomes newly authorable, no schema moves, no capability is added or withdrawn, and — the option-3 stop — the transport still attaches at exactly the same point, so an MCP client's startup handshake, advertised capabilities and tool list are byte-for-byte what they were.
The contrast with #11623 is deliberate and worth naming so a reviewer can overrule this on the record rather than by noticing later. That PR declared
Clause-② yesfor this same seam, on the ground that "a stack's configured locale beginning to take effect is a behaviour change on a declared setting" — and it was right: before it, the stdio surface served the manifest defaults to every deployment. That declaration, and its contract review, attached to #11623. This card declares nothing new; it removes a race that could pin one process back to the pre-#11623 answer. The behaviour being delivered here is the one #11623 already declared and had reviewed.Out of scope
resolveLocalizationContext's 30s failure cache can answer a deliberate post-bind re-read with the pre-bind value #11877 (unassigned, unlabelled):resolveLocalizationContext's 30s failure cache is keyed on(ql, tenant|user)and is written whenever the settings-service leg throws, not only on a backend fault — which makes it reachable inside the pre-bind window, and lets it answer a deliberate post-bind re-read (exactly what this repair does atkernel:bootstrapped) with the cached pre-bind value. Not measured here and not reachable from these pins (the fixture's settings double never throws andsys_settinganswers[], sofailedstays false); the code is in@objectstack/core, outside this card's declared file surface, and either repair moves a read path pinned by theanalytics-timezonedogfood regression. Recorded rather than folded in.start(), inside the pre-bind window under every composition order #11580 and AuthPlugin reads theauthsettings namespace in the pre-bind window — storedsys_settingrows are ignored at boot under the shippedos serveorder #11579 are not addressed here — both closed, and neither is touched by this diff.Housekeeping
Draft, and staying draft: no ready-flip, no auto-merge, no enqueue. One changeset (
@objectstack/mcp, patch).content/docs/releases/untouched;packages/specuntouched.Generated by Claude Code