feat(runtime, spec): AppPlugin supplies SeedLoaderConfig.locale, so the Seed.locale axis takes effect on the default boot path - #17013
Conversation
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 86ea7cc8b5ca09d05cda8cb4c83e5e5a4aca0cbf && git checkout 86ea7cc8b5ca09d05cda8cb4c83e5e5a4aca0cbf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d127f9babce9d483ac4c053194360968e1c7ba0a 06a16c71839eb48d9003a3e80009cdab7632fe32 && git checkout -B drift-repro d127f9babce9d483ac4c053194360968e1c7ba0a && git merge --no-ff 06a16c71839eb48d9003a3e80009cdab7632fe32
node scripts/docs-audit/affected-docs.mjs --json d127f9babce9d483ac4c053194360968e1c7ba0a
|
…does not, and flag the release-note reconciliation
|
Docs Drift Check — all ten rows re-verified. Details are in the PR body under Docs drift; summary here so it sits beside the bot comment. ⛔ My first pass reported a zero that was not an answer to the question the bot asked: I swept Answered on the bot's tree: 8 hand-written rows — 4 true, 4 wrong. None falsified, so no edit is owed on any of them.
⛔ 2 release-owned pages — audited read-only, NOT edited, and neither is wrong.
That sweep also found one live claim outside Generated by Claude Code |
Fixes #16595
Seed.locale's consumer landed complete in #16510 / PR #16592; its producer did not exist. No first-party call site passedSeedLoaderConfig.locale, sofilterByLocalereturned its input on its first line anddataset.localewas never read at all on the default boot path. This supplies the producer, then flips the ledger row.What landed
AppPlugin#resolveSeedLocalereads the app's declaredi18n.defaultLocale— the same envelope key, read the same wayloadTranslationsalready reads it forsetDefaultLocale— and threads it into all threeSeedLoaderRequests this plugin builds:packages/runtime/src/app-plugin.ts(registered for tenant provisioning)packages/runtime/src/app-plugin.tspackages/runtime/src/app-plugin.tsAn app that declares no locale sends no
localekey at all rather than an'en'default. Absence is the loader's unrestricted spelling, so a stack that never opted in keeps loading every dataset exactly as before; a default would have turned a wiring change into a data change, silently dropping alocale: ['zh-CN']dataset on every stack without ani18nblock. A blank or non-stringdefaultLocaleis treated as absence for the same reason.Why the locale is resolved at the call sites and not inside
load(). The card asked me to confirm this shape againstresolveEnvConfig, and confirming it changed the answer:envis not resolved at its call sites either — #4704 moved it insideload(), the one funnel, precisely so "call site seven cannot reopen the hole". That remedy does not transfer.resolveEnvConfigcan read an ambient, process-wideNODE_ENV; a locale has no ambient source, and the only layer that knows which locale a stack runs in is the app config the loader is never handed. So this axis genuinely needs a producer at the call sites.The ledger flip, and the exact scope of its
livepackages/spec/liveness/seed.json→seed.locale:experimental→live, with aproducerpointer namingAppPlugin#resolveSeedLocale.state-counts.mdregenerated bygen:liveness-counts— never hand-edited (seedlive 12 → 13, experimental 1 → 0; total live 850 → 851, experimental 6 → 5).Is
livean overclaim at three of six? Stated so it can be disagreed with. The ledger's own criterion is "authoring the property changes runtime behaviour", and theproducerfield exists so that claim is not made on a consumer pointer alone. After this change, authoringlocaleon adefineStack()app that declaresi18n.defaultLocalechanges what gets seeded — proven by the ablation below, not by a passing suite. The #4837 specimen was false in the only sense the criterion has:Seed.envwaslivewhile zero of six builders supplied it, so authoring changed nothing anywhere. This is three of six including the entire default boot path, with a real producer cited. Soliveis correct and bounded, and the row now spells the bound out in its own words — what it asserts, and, marked with a ⛔, what it does not: that it is NOT a claim that every path reachingload()supplies a locale. If a reviewer reads that bound as still too generous, reverting toexperimentalwith the corrected six-site enumeration is a coherent alternative and I would not argue it is wrong — but it would understate a producer that demonstrably bites.envrow one entry up enumerates. This wires the three on the default boot path — which is what makes authoringlocalechange runtime behaviour, and therefore what makes the rowlive. The other three are publish/install-time paths, are handed no stack config, and still pass no locale. They are listed in the note by symbol, and filed as #17011.protocol.tscall site: re-derived, and the card's doubt resolvedThe card said PR #16592's body claims a draft-publish call site in
protocol.tswhile the filing seat's grep atc383352found none, and told me to trust neither reading.PR #16592's body was right.
packages/metadata-protocol/src/protocol.ts→applySeedBodiesbuilds aSeedLoaderRequestand callsloader.load(parsedRequest.data).The earlier negative was an anchor artefact, not an absence: a grep for
seedLoader.loadcannot match that site, because it names its localloader. Re-deriving onSeedLoaderRequestSchemainstead — the shape every builder must use — found six, not three, and not four:The census, in a form that can be re-driven verbatim. Instrument, corpus and controls, at head
06a16c7183:Six builders, minus the schema's own definition file:
The
seedLoader.loadanchor returns 4 of those 6: it missesprotocol.tsanddomains/packages.ts, both of which name their localloader. That is the whole artefact, and it is why the census is anchored on the schema rather than on a variable name.⭐ The pre-flip ledger note itself named only four of the six. That enumeration is corrected in this PR.
warnOnUnresolvedLocaleScopeSTAYS — the judgement, with its reason⛔ Not deleted, and not kept by default. It is not a signpost for an unwired state that has now gone away:
load()with no locale;i18nblock deliberately sends none.Every one of those is a live population, and the warning is the only thing standing between them and a silent no-op — which is the property this row spent a release having. Deleting it would re-create the #4837 failure one layer down.
Evidence — the negative leg, not a green suite
⛔ "The tests pass" is not a measurement here: a green suite is equally consistent with the axis still being inert, which is exactly how
Seed.envstayed green for releases while unwired. So the new coverage asserts on rows that reach the engine, and it is verified by ablation.New file
packages/runtime/src/app-plugin.seed-locale-producer.test.ts— 7 cases, covering all three call sites (⛔ notseed-loader.test.ts, held by PR #16783).Ablation (neutralise
resolveSeedLocaletoreturn undefined, i.e. the pre-change state):Ablation and restore both ran from a committed state under a
trap ... EXIT INT TERMwith absolute paths. No permanent ablation artefact is left in the tree.Verification
Everything below was measured at head
06a16c7183(post-merge oforigin/main; the gate union was re-run after the drift work).pnpm --filter @objectstack/runtime typecheck— exit 0;check:test-typecheck: OK.pnpm --filter @objectstack/runtime exec vitest run --project local— exit 0, 249 files / 3516 tests passed.pnpm --filter @objectstack/spec check:generated— exit 0, all 15 artifacts up to date.scripts/pm/dispatch-gates.mjs --ran, re-derived on the merged head after the docs edit pulled in the docs families). 96 green; 2 returned exit 3 = PREREQUISITE NOT MET, read as NOT MEASURED, not as green:check:dual-build-cjs-loads— needs a whole-workspace build. This diff adds no export entry, so it cannot move.check:type-check-debt --re-measure— needs the built closure. Its DEBT ledger holdscloud-connection,hono,observability,spec-monorepo; this diff touches none of them.check:engine-double-contractreportedRETAINED [findOne]for the new test's engine double and asked for the ledger row; written with the gate's own--writeand re-run exit 0 (846 pinned). The double declares neitherdeletenorupdate— the seeds areupsertinto an empty store, so neither dispatch verb is reachable.Lint — a declared, measured narrowing, not a skipped run. The repo-wide
eslint . --no-inline-configis CI's; this PR ran the affected files and proves the narrowing excluded nothing:eslint.config.mjs's blocks target**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}andpackages/**/*.{ts,tsx,mts,cts}. This diff's only files matching any block'sfilesglob are the two below; the rest are.json/.md/.mdx, which no block names.--format json: 2 files, 0 errors, 0 warnings, exit 0.eslint.config.mjsstates it in its own words — "this repo runs oneeslint.config.mjs, which never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test or not", recorded there with a positive control. So no untouched file's verdict is a function of this diff.Changeset — measured, not guessed. ⛔ No
skip-changeset: both packages ship changed bytes.npm pack --dry-run --jsoninpackages/spec: 2073 shipped files;liveness/seed.jsonandliveness/state-counts.mdare both present. Positive controlpackage.jsonpresent; negative controlscripts/absent.packages/runtimebuilds todist, which is its wholefiles[];resolveSeedLocaleis indist/index.js,dist/index.cjs,dist/index.d.tsanddist/index.d.ctsafterpnpm --filter @objectstack/runtime build. Positive controlclass AppPluginpresent in the declarations.So
.changeset/seed-locale-producer-wiring.md:@objectstack/runtimeminor,@objectstack/specpatch.check:changeset-no-majorexit 0.Docs drift — the bot's answer, not mine. ⛔ My first pass reported a zero that was not an answer to the question the bot asked: I swept⚠️ Its footer warns its checkout carried uncommitted changes, so its sha is not cited here as a pin — the per-file blob equality is.
config.locale/SeedLoaderConfig/warnOnUnresolvedLocaleScope/locale-scoped/Seed.locale, and the bot anchors onAppPlugin— the class this diff adds a method to — which is disjoint from every token I swept. The bot's ten rows are re-verified below. Its treee6f974cb0band my worktree hold byte-identical blobs for all ten pages (git rev-parseon each path, compared), so the audit is answered on the tree the bot read.content/docs/kernel/services-checklist.mdxi18n.defaultLocalenow has a second consumer, but the list does not claim to enumerate every consumer. Considered adding a cross-reference and declined: it would widen this PR into a page whose subject is the i18n service.content/docs/protocol/kernel/index.mdxcontent/docs/protocol/kernel/lifecycle.mdxAppPlugin'soptionalDependenciesandrequiresServices; the delivered diff changes neither, and the quoted excerpt still matches the class.content/docs/protocol/kernel/plugin-spec.mdxonEnableseamAppPlugin.start()invokes; untouched.content/docs/permissions/authentication.mdxAppPluginoccurs once, in animportline of a mock-mode config example. Nothing about seeds, locale orstart().content/docs/permissions/capabilities.mdxcontent/docs/plugins/index.mdxos devregisters.content/docs/plugins/packages.mdxReported rather than silently dropped, as the bot asks: 4 of 8 hand-written rows are wrong, all from the same cause — a class-level anchor on
AppPlugincannot distinguish a page that documents the class's behaviour from one that names it in an import or a catalog list. Four of them are true rows and each was read; none is falsified, so no edit is owed on those eight.⛔ Release-owned pages, audited read-only and NOT edited:
content/docs/releases/v15.mdxnamesAppPluginonce, in the ADR-0087 protocol-handshake entry;content/docs/releases/v17.mdxnames it once, in theonEnableapp-bundle contract entry. Neither is touched by this diff and neither is wrong, so there is nothing to file and no docs-only PR is owed.packages/spec/liveness/seed.jsonandstate-counts.mdyielded no anchor, so pages documenting them were invisible to that run — and theexperimental→liveflip is exactly such a claim. Sweep ofcontent/forliveness/state-counts/seed.locale/not wired/axis is inert/experimental: 118 lines across many pages (so the sweep fires; the coarse controldefineSeedalso matches), and not one describes theseed.localerow or its status. The only page that stated the axis's wiring state wascontent/docs/data-modeling/seed-data.mdx, whose callout told readers some host must supplyconfig.localewithout saying the platform now does — that page is falsified by this change, is hand-written and not release-owned, and is corrected here.content/, found by that same sweep and deliberately not edited. The sibling changeset.changeset/seed-locale-axis.md(from the PR that landed the consumer half, unreleased) states in the present tense that no first-party call site suppliesconfig.locale, that the axis is inert on the default boot path, and that the ledger recordsseed.localeasexperimental. This change ends all three. ⛔ It is not rewritten here — it accurately records what its own PR did, and release notes are compiled centrally at release time — so the reconciliation is called out in this PR's own changeset instead, where the compiler cannot miss it. (packages/spec/src/data/seed-loader.zod.ts's "the locale axis is inert" is conditional on "when not specified" and stays true.)⛔ No page under
content/docs/releases/is edited.Clause-②: no
Re-derived from the DELIVERED diff, not copied from the dispatch. This supplies a producer, adds a test, flips one liveness row, adds one pinned-ledger row, writes a changeset and corrects one hand-written docs page. It relaxes no accepted set, widens no schema, adds no closed-set member, withdraws no capability and migrates no stored data shape —
localewas already onSeedSchemaandSeedLoaderConfigSchemabefore this branch. The behavioural direction is the opposite of widening: a locale-scoped dataset that used to load everywhere now loads only where it is declared. The one accepted set that grows isscripts/engine-double-contract.pinned.json, which is new coverage, written by the gate's own--write.pnpm check:pm-widening-tellsexit 0.验收备注
Findings from this work that are not filed, with the seat that will meet them named:
noted, not filed: the pre-flip seed.locale ledger note enumerated four of the six SeedLoaderRequest builders, and one of the four was called unconfirmed when it is real. Corrected in this PR rather than filed. 承接者: whoever next re-verifies this row — the corrected enumeration and the reason the old grep missed two sites are both in the note.noted, not filed: a grep for seedLoader.load is not a call-site census for this family — two of the six builders name their local loader. 承接者: #17011's implementer, who needs the same census; the working expression is recorded in that card's body.Filed as its own card:
localescope is silently inert on the three publish/install-time seed-load paths — package apply, draft publish and marketplace install pass noconfig.locale#17011 — the three publish/install-time seed-load paths pass noconfig.locale, so alocale-scoped dataset applied through package apply, draft publish or marketplace install loads for every locale. Filed unassigned and ungraded, with the three decisions its implementer must answer first.⛔ Out of scope and untouched, as the card requires: reconciling rows already written under a different locale — #16596.
Generated by Claude Code