Skip to content

feat(runtime, spec): AppPlugin supplies SeedLoaderConfig.locale, so the Seed.locale axis takes effect on the default boot path - #17013

Merged
os-project-manager merged 8 commits into
mainfrom
claude/issue-16595-wire-config-locale
Sep 9, 2026
Merged

feat(runtime, spec): AppPlugin supplies SeedLoaderConfig.locale, so the Seed.locale axis takes effect on the default boot path#17013
os-project-manager merged 8 commits into
mainfrom
claude/issue-16595-wire-config-locale

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #16595

Seed.locale's consumer landed complete in #16510 / PR #16592; its producer did not exist. No first-party call site passed SeedLoaderConfig.locale, so filterByLocale returned its input on its first line and dataset.locale was never read at all on the default boot path. This supplies the producer, then flips the ledger row.

What landed

AppPlugin#resolveSeedLocale reads the app's declared i18n.defaultLocale — the same envelope key, read the same way loadTranslations already reads it for setDefaultLocale — and threads it into all three SeedLoaderRequests this plugin builds:

site path
per-org replayer packages/runtime/src/app-plugin.ts (registered for tenant provisioning)
inline boot seed packages/runtime/src/app-plugin.ts
dev hot-reload seeder packages/runtime/src/app-plugin.ts

An app that declares no locale sends no locale key 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 a locale: ['zh-CN'] dataset on every stack without an i18n block. A blank or non-string defaultLocale is 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 against resolveEnvConfig, and confirming it changed the answer: env is not resolved at its call sites either — #4704 moved it inside load(), the one funnel, precisely so "call site seven cannot reopen the hole". That remedy does not transfer. resolveEnvConfig can read an ambient, process-wide NODE_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 live

packages/spec/liveness/seed.jsonseed.locale: experimentallive, with a producer pointer naming AppPlugin#resolveSeedLocale. state-counts.md regenerated by gen:liveness-counts — never hand-edited (seed live 12 → 13, experimental 1 → 0; total live 850 → 851, experimental 6 → 5).

Is live an 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 the producer field exists so that claim is not made on a consumer pointer alone. After this change, authoring locale on a defineStack() app that declares i18n.defaultLocale changes 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.env was live while 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. So live is 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 reaching load() supplies a locale. If a reviewer reads that bound as still too generous, reverting to experimental with 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.

⚠️ The row's note states the frontier rather than claiming it away. This repo has six request builders, the same six the env row one entry up enumerates. This wires the three on the default boot path — which is what makes authoring locale change runtime behaviour, and therefore what makes the row live. 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.

⚠️ The protocol.ts call site: re-derived, and the card's doubt resolved

The card said PR #16592's body claims a draft-publish call site in protocol.ts while the filing seat's grep at c383352 found none, and told me to trust neither reading.

PR #16592's body was right. packages/metadata-protocol/src/protocol.tsapplySeedBodies builds a SeedLoaderRequest and calls loader.load(parsedRequest.data).

The earlier negative was an anchor artefact, not an absence: a grep for seedLoader.load cannot match that site, because it names its local loader. Re-deriving on SeedLoaderRequestSchema instead — 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:

# THE CENSUS — anchor on the schema every builder must parse through
git grep -n 'SeedLoaderRequestSchema' -- 'packages/*/src/**' | grep -v '\.test\.ts'

# POSITIVE CONTROL 1 — a known-present sibling over the same corpus
git grep -c 'SeedLoaderService' -- 'packages/*/src/**'          # 17 files

# POSITIVE CONTROL 2 — the file the old negative was taken over was being read
grep -c 'publishMetaItem' packages/metadata-protocol/src/protocol.ts   # 34

# THE ANCHOR THAT MISSES TWO — kept so the artefact is reproducible
git grep -n 'seedLoader.load' -- 'packages/*/src/**'            # 4 hits, not 6

Six builders, minus the schema's own definition file:

packages/runtime/src/app-plugin.ts                     :1241 :1337 :1695   3   WIRED here
packages/runtime/src/domains/packages.ts               :1600  applyPublishedSeeds    unwired -> #17011
packages/metadata-protocol/src/protocol.ts             :17180 applySeedBodies        unwired -> #17011
packages/cloud-connection/src/marketplace-install-local-plugin.ts :1513 runInlineSeed unwired -> #17011
packages/spec/src/data/seed-loader.zod.ts              :503   the schema itself, not a builder

The seedLoader.load anchor returns 4 of those 6: it misses protocol.ts and domains/packages.ts, both of which name their local loader. 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.

⚠️ warnOnUnresolvedLocaleScope STAYS — 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:

  • the three publish/install-time builders above still reach load() with no locale;
  • an embedding host that builds its own request still supplies none;
  • a stack that declares no i18n block 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.env stayed 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 (⛔ not seed-loader.test.ts, held by PR #16783).

Ablation (neutralise resolveSeedLocale to return undefined, i.e. the pre-change state):

on-disk proof     text deleted: 1 -> 0 occurrences; text injected: 0 -> 1
                  HEAD blob 71123c8a... -> mutated blob fb2e2973...
result            5 of 7 red, in the INERT direction:
                    expected [ 'account', 'plan_en', 'plan_zh' ]
                          to [ 'account', 'plan_en' ]
                  a locale: ['zh-CN'] dataset LOADS on an `en` stack.
                  All three call sites go red, so the pin covers each one.
                  The 2 that stay green are the absence cases, which
                  assert the unrestricted behaviour the ablated build also has.
restore proof     disk blob back to 71123c8a..., `git diff HEAD` empty

Ablation and restore both ran from a committed state under a trap ... EXIT INT TERM with absolute paths. No permanent ablation artefact is left in the tree.

Verification

Everything below was measured at head 06a16c7183 (post-merge of origin/main; the gate union was re-run after the drift work).

  • pnpm --filter @objectstack/runtime typecheckexit 0; check:test-typecheck: OK.
  • pnpm --filter @objectstack/runtime exec vitest run --project localexit 0, 249 files / 3516 tests passed.
  • pnpm --filter @objectstack/spec check:generatedexit 0, all 15 artifacts up to date.
  • Gate families: 98 derived, 98 run, 0 UNRUN (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 holds cloud-connection, hono, observability, spec-monorepo; this diff touches none of them.
  • check:engine-double-contract reported RETAINED [findOne] for the new test's engine double and asked for the ledger row; written with the gate's own --write and re-run exit 0 (846 pinned). The double declares neither delete nor update — the seeds are upsert into an empty store, so neither dispatch verb is reachable.

Lint — a declared, measured narrowing, not a skipped run. The repo-wide eslint . --no-inline-config is CI's; this PR ran the affected files and proves the narrowing excluded nothing:

  1. Population, from eslint's own config: eslint.config.mjs's blocks target **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} and packages/**/*.{ts,tsx,mts,cts}. This diff's only files matching any block's files glob are the two below; the rest are .json / .md / .mdx, which no block names.
  2. Count, from --format json: 2 files, 0 errors, 0 warnings, exit 0.
  3. Invariance: this repo never enables type-aware linting. eslint.config.mjs states it in its own words — "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) 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 --json in packages/spec: 2073 shipped files; liveness/seed.json and liveness/state-counts.md are both present. Positive control package.json present; negative control scripts/ absent.
  • packages/runtime builds to dist, which is its whole files[]; resolveSeedLocale is in dist/index.js, dist/index.cjs, dist/index.d.ts and dist/index.d.cts after pnpm --filter @objectstack/runtime build. Positive control class AppPlugin present in the declarations.

So .changeset/seed-locale-producer-wiring.md: @objectstack/runtime minor, @objectstack/spec patch. check:changeset-no-major exit 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 config.locale / SeedLoaderConfig / warnOnUnresolvedLocaleScope / locale-scoped / Seed.locale, and the bot anchors on AppPlugin — 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 tree e6f974cb0b and my worktree hold byte-identical blobs for all ten pages (git rev-parse on each path, compared), so the audit is answered on the tree the bot read. ⚠️ Its footer warns its checkout carried uncommitted changes, so its sha is not cited here as a pin — the per-file blob equality is.

page anchor row verdict
content/docs/kernel/services-checklist.mdx true not falsified. Its "AppPlugin Auto-Loading" list is explicitly scoped to loading translation data into the i18n service — the four setters plus the graceful skip — none of which this diff touches. i18n.defaultLocale now 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.mdx true not falsified. "declared side effects (translations, seed data) run" — seed data still runs; it is now filtered, not suppressed.
content/docs/protocol/kernel/lifecycle.mdx true not falsified. It quotes AppPlugin's optionalDependencies and requiresServices; the delivered diff changes neither, and the quoted excerpt still matches the class.
content/docs/protocol/kernel/plugin-spec.mdx true not falsified. Subject is the module-level onEnable seam AppPlugin.start() invokes; untouched.
content/docs/permissions/authentication.mdx ⚠️ wrong row AppPlugin occurs once, in an import line of a mock-mode config example. Nothing about seeds, locale or start().
content/docs/permissions/capabilities.mdx ⚠️ wrong row Subject is capability registration through the metadata registry. Disjoint from this diff.
content/docs/plugins/index.mdx ⚠️ wrong row A table of auto-load conditions plus a sentence naming which plugins os dev registers.
content/docs/plugins/packages.mdx ⚠️ wrong row One package-catalog blurb: "Runtime Bootstrap — DriverPlugin, AppPlugin, and capability contracts."

Reported 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 AppPlugin cannot 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.mdx names AppPlugin once, in the ADR-0087 protocol-handshake entry; content/docs/releases/v17.mdx names it once, in the onEnable app-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.

⚠️ The bot's disclosed blind spot lands on this diff, and was swept by hand. It reports that packages/spec/liveness/seed.json and state-counts.md yielded no anchor, so pages documenting them were invisible to that run — and the experimentallive flip is exactly such a claim. Sweep of content/ for liveness / state-counts / seed.locale / not wired / axis is inert / experimental: 118 lines across many pages (so the sweep fires; the coarse control defineSeed also matches), and not one describes the seed.locale row or its status. The only page that stated the axis's wiring state was content/docs/data-modeling/seed-data.mdx, whose callout told readers some host must supply config.locale without saying the platform now does — that page is falsified by this change, is hand-written and not release-owned, and is corrected here.

⚠️ One live claim outside 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 supplies config.locale, that the axis is inert on the default boot path, and that the ledger records seed.locale as experimental. 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 — locale was already on SeedSchema and SeedLoaderConfigSchema before 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 is scripts/engine-double-contract.pinned.json, which is new coverage, written by the gate's own --write. pnpm check:pm-widening-tells exit 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:

⛔ Out of scope and untouched, as the card requires: reconciling rows already written under a different locale — #16596.


Generated by Claude Code

@github-actions github-actions Bot added the size/m label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 3 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/liveness/seed.json, packages/spec/liveness/state-counts.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/kernel/services-checklist.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/permissions/capabilities.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via AppPlugin (symbol, a top-level class))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via AppPlugin (symbol, a top-level class))
  • content/docs/releases/v17.mdx (via AppPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/liveness/seed.json, packages/spec/liveness/state-counts.md) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 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; 100 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 — 136 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 d127f9babce9d483ac4c053194360968e1c7ba0apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86ea7cc8b5ca09d05cda8cb4c83e5e5a4aca0cbf — the merge of head 06a16c71839eb48d9003a3e80009cdab7632fe32 into base d127f9babce9d483ac4c053194360968e1c7ba0a, 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 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

⚠️ 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 d127f9babce9d483ac4c053194360968e1c7ba0a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
…does not, and flag the release-note reconciliation
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

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 config.locale / SeedLoaderConfig / warnOnUnresolvedLocaleScope / locale-scoped / Seed.locale, and the bot anchors on AppPlugin — the class this diff adds a method to. The two token sets are disjoint, so my zero was about a different question. Corrected below rather than re-run.

Answered on the bot's tree: git rev-parse on each of the ten paths shows my worktree and e6f974cb0b hold byte-identical blobs, so the pages read here are the pages the bot read. ⚠️ The bot's sha is not cited as a pin — its own footer says that checkout carried uncommitted changes; the per-file blob equality is what makes the audit answerable.

8 hand-written rows — 4 true, 4 wrong. None falsified, so no edit is owed on any of them.

  • true, read, not falsified: kernel/services-checklist.mdx (its AppPlugin list is scoped to loading translations into the i18n service, and does not claim to enumerate every consumer of i18n.defaultLocale) · protocol/kernel/index.mdx ("declared side effects (translations, seed data) run" — seed data still runs, it is now filtered) · protocol/kernel/lifecycle.mdx (quotes optionalDependencies and requiresServices; this diff changes neither and the excerpt still matches) · protocol/kernel/plugin-spec.mdx (the module-level onEnable seam, untouched).
  • ⚠️ wrong rows, reported as the bot asks rather than dropped: permissions/authentication.mdx (one import line in a mock-mode example) · permissions/capabilities.mdx (capability registration) · plugins/index.mdx (auto-load condition table) · plugins/packages.mdx (one package-catalog blurb). Same cause in all four: a class-level anchor cannot tell a page that documents AppPlugin's behaviour from one that merely names it.

2 release-owned pages — audited read-only, NOT edited, and neither is wrong. releases/v15.mdx names AppPlugin in the ADR-0087 handshake entry; releases/v17.mdx in the onEnable app-bundle contract entry. Nothing to file, no docs-only PR owed.

⚠️ The disclosed blind spot lands on this diff, and was swept by hand. packages/spec/liveness/seed.json and state-counts.md yielded no anchor, and the experimentallive flip is exactly such a claim. Sweeping content/ for liveness / state-counts / seed.locale / not wired / axis is inert / experimental returns 118 lines (so the sweep fires), and not one describes the seed.locale row or its status. The one page that did state the axis's wiring state — content/docs/data-modeling/seed-data.mdxwas falsified, is hand-written, and is corrected in this PR.

That sweep also found one live claim outside content/: the sibling changeset .changeset/seed-locale-axis.md still says, in the present tense, that no first-party call site supplies config.locale and that the ledger records seed.locale as experimental. This change ends both. ⛔ It is not rewritten here — it accurately records what its own PR did, and release notes are compiled centrally — so the reconciliation is called out in this PR's own changeset, where the compiler cannot miss it.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants