Skip to content

fix(i18n): read the provenance companion at serving time in all nine bundle sets - #12724

Merged
os-zhuang merged 7 commits into
mainfrom
claude/issue-12642-provenance-serving-seam
Aug 27, 2026
Merged

fix(i18n): read the provenance companion at serving time in all nine bundle sets#12724
os-zhuang merged 7 commits into
mainfrom
claude/issue-12642-provenance-serving-seam

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#12642.

Provenance is now recorded in 9 of 9 bundle sets and served in 9 of 9. It was recorded in 9 and served in 1.

The measurement, re-taken today (not inherited from the card)

Re-run on origin/main at 15bf9e85:

grep -rn "withSourceFallback" --include=*.ts packages/ | grep -v node_modules

Every call site was in @objectstack/platform-objectssrc/apps/translations/setup.translation.ts and src/metadata-translations/index.ts. Counted from the tree rather than from the card: 27 committed provenance companions across 9 bundle sets, 3 of them read at serving time. The card's "recorded 9 of 9, served 1 of 9" held, and PR #12491 (merged the same day, same directory family) had not moved it.

plugin-webhooks was not a latent case: it carries 20 records across its three locales, so the instrument was armed there.

The failure, reproduced before anything was changed

The tree is 0-stale by construction — a record is only ever written for a leaf that IS a byte copy of the CURRENT source — so nothing is observable until a source moves. Revising the source string behind objects.sys_share_link.fields.token.label in plugin-sharing (recorded in es-ES alone, which is why no gate can testify):

observationbefore this PRafter this PR
findStaleFills on the es-ES companion1 stale, named exactly1 stale, named exactly
what es-ES SERVES"Token"the superseded draft"Share token" — the current source
zh-CN (no record for that path — legacy-trusted)"令牌""令牌", untouched

Positive control, proving the probe can observe a substitution at all: the same mutation on @objectstack/platform-objects, where the seam was already wired, moved objects.sys_account._actions.link_social.params.provider.options.apple from "Apple" (raw es-ES module) to "Apple ID" (served bundle). The probe sees substitutions where substitutions happen.

Both mutation legs confirmed on disk by grep counts before and after; both restore legs verified byte-identical to the HEAD blob via git hash-object plus an empty git diff HEAD.

⭐ The route triage preferred does not exist — measured, not assumed

This is the most reusable result in the PR, so it is stated plainly: a future reader will otherwise propose the same route again.

Triage's preferred route was move the seam into the i18n loader, so a set opts in by committing a companion rather than by wiring a call. That seam does not exist. Measured:

  • The loader contract is II18nService.loadTranslations(locale, translations), declared in packages/spec/src/contracts/i18n-service.ts. It receives one locale at a time and has no channel for either the source bundle or the recorded table. Applying provenance there means widening a published contract.
  • There is no shared registration helper either: each of the nine sets registers its own bundle in its own kernel:ready hook.
  • AppPlugin.loadTranslations in @objectstack/runtime does see a whole TranslationBundle, but the nine platform sets never go through it — it serves defineStack({ translations }) app bundles — and it has no companion channel either.

The only place a bundle AND its provenance companion are both in scope is the bundle set's own serving barrel. So the thing that can be centralised is the CODE — already public at @objectstack/platform-objects/apps — and the GUARD, not the call site.

Two relocation routes were measured and rejected. Relocate to @objectstack/core: reaches 8 of 9 free, but platform-objects holds core only as a devDependency, so it trades one new edge for another, and drags the CLI along. Relocate to @objectstack/spec: the only package all nine already depend on, but it widens a published contract for a utility with nine internal consumers and no external pull — clause ②, and the spec seat's call, not this lane's.

The ninth set, and why the dependency edge is the right move

@objectstack/plugin-webhooks was the one set that could not reach the seam: it depends on @objectstack/core, @objectstack/service-messaging and @objectstack/spec — not on @objectstack/platform-objects. That fork was stopped on and reported before being taken, and the measurement that resolved it:

plugin-webhooks deps: @objectstack/core, @objectstack/service-messaging, @objectstack/spec
service-messaging deps: @objectstack/core, @objectstack/platform-objects, @objectstack/spec, @objectstack/types

platform-objects is already in plugin-webhooks' install closure through service-messaging. Declaring it adds no package to the graph — it declares a resolution that already resolves. Relying on that resolution without declaring it is the phantom-dependency shape under this repo's strict package manager, so declaring it is the correct move rather than a shortcut. The barrel is then wired identically to the other seven: one mechanism, no variant.

Why a new gate verdict, and why structural

check:i18n-stale-fill gains a second verdict, UNSERVED PROVENANCE, kept distinct from the stale-fill verdict the way check:i18n keeps its two. It extends the guard that already walks every bundle set and already knows the companions by name, rather than adding a tenth script.

Wiring nine sets without it would fix today's instance while leaving "recorded implies served" an assumption — rebuilding the exact inference hazard the card was filed about, one layer further on. The card's hazard is what a reader concludes from "recorded 9 of 9"; this verdict is what makes that conclusion true and keeps it true for a tenth set that lands tomorrow.

The behavioural alternatives are vacuous today and were measured as such: served-versus-source bytes are equal whether or not the seam is wired (0-stale by construction), and object identity does not separate them either, because withSourceFallback deliberately returns its input by reference when nothing is stale. A gate observable only green is indistinguishable from a gate matching nothing. So the verdict asks the structural question — is this companion's own exported identifier passed as an ARGUMENT to a withSourceFallback(...) call in this package? — and publishes the recognised spelling in its failure text, the trade check:cross-package-test-inputs documents.

Naming the identifier is not enough and neither is calling the seam: the near-miss the rule must separate is a set that wires two locales and forgets the third. Both halves are required per companion, which is per locale.

⚠️ The UNSERVED_PROVENANCE ledger is EMPTY, and that is where a guard usually dies

plugin-webhooks held the only entry while its dependency question was open; wiring it deleted that entry in the same change, because the ledger is shrink-only and fails both ways — a stale entry reds the gate. An empty registry is exactly the state where a guard degenerates into a no-op and nobody notices, so the discrimination is proven rather than assumed, at two levels:

Mechanically, in --self-test — the verdict now compares through the same two-sided ratchet the stale-fill verdict uses, so these cases are its decision procedure rather than a model of it:

  • empty ledger + every set serving ⇒ no finding (passes, correctly)
  • ⭐ empty ledger + a set recording-but-not-serving ⇒ still reported (the emptied ledger did not disarm it)
  • a ledger entry whose set now serves ⇒ reported for deletion

End to end, on the real tree — un-wiring plugin-webhooks, the very set whose entry was just deleted, with the ledger empty:

  • ARM 1 (empty ledger, all served): exit 027 provenance companion(s), 27 served at serving time, 0 bundle set(s) unserved (0 declared)
  • ARM 2 (empty ledger, one set records-without-serving): exit 1UNSERVED PROVENANCE — 1 bundle set(s) record provenance and never read it, naming all three companions and the identifier each exports

Ablation — both new guards were observed RED

Beyond the empty-ledger arms above, the earlier ablation on plugin-sharing (barrel reverted to the origin/main shape, confirmed on disk as 3 → 0 withSourceFallback( occurrences):

  • check:i18n-stale-fillexit 1, UNSERVED PROVENANCE naming all three companions.
  • packages/plugins/plugin-sharing/src/translations/serving-seam.test.tsexit 1, AssertionError: expected 'Token' to be 'Share token' — the measured failure itself.

No rebuild was needed between legs and this is load-bearing rather than an omission: the barrel is TypeScript source read relatively by both the test and the gate, and withSourceFallback comes from platform-objects/dist, which the ablation does not touch. Every restore verified byte-identical to the HEAD blob, under a trap on EXIT INT TERM with absolute paths.

Scope

  • No new exported surface. withSourceFallback was already public at @objectstack/platform-objects/apps — the CLI has imported that barrel since the recording rollout.
  • No key set moves. Substitution rather than deletion is the point of ruling i18n: a source-string edit still leaves zh-CN / ja-JP / es-ES silently stale — and pinning en to the source makes the asymmetry sharper, not smaller (needs a maintainer decision) #8765 Option B, and every key-set claim over these bundles is untouched.
  • packages/spec not touched — read-only for this seat. content/docs/releases/** untouched.
  • One manifest line: @objectstack/platform-objects declared in plugin-webhooks, for the reason measured above.
  • Four consequential config changes the gates demanded, none discretionary: paths rules resolving the seam to source in plugin-approvals and plugin-audit (check:type-source-resolution), and matching anchored vitest aliases in plugin-audit and plugin-webhooks (check:test-source-alias). Both registries are shrink-only; neither was widened.

Verification

Gate union derived from the ACTUAL changed paths with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack — not from a hand-written diff — and re-derived after the manifest change, which pulled in three families the first derivation could not see (check:override-consistency, check-osv-exemptions, check-changeset-fixed). Every exit code captured before any pipe. All runs below are on ae7a467d, the final commit of this branch, with a clean tree.

Green: check:nul-bytes · check:i18n · check:i18n-stale-fill (both verdicts, plus its own self-test: 27 assertions, 12 of them new) · check:override-consistency · check-osv-exemptions · check-changeset-fixed · check:agent-test-spelling · check:bash32-floor · check:cli-command-ids · check:entry-guard · check:parse-guard · check:pnpm-filter-targets · check:page-declaration-shape · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:cross-package-test-inputs (both invocations) · check:type-check-coverage · check:type-check-debt (--re-measure: 31 ledger entries, 1687 raw errors, none above its recorded number — unchanged by this diff; the one surplus it reports is a pre-existing @objectstack/runtime entry) · check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:objectql-double-limit · check-comment-mask-adoption · check-plugin-teardown-shape · check-ci-filter-parity · check:changeset-gate-self-tests · check:objectui-changeset · check:pm-half-states · check-changeset-no-major · check-empty-changeset · check-adr-0087-registration · release-rehearsal-clone --self-test · bare-root-worklist --self-test · check:pm-dispatch-gates.

Tests for the eight wired packages: 239 test files, 4049 tests, all passing. typecheck green for the seven that declare the script.

Not measured, and recorded as not measured rather than green:

  • @objectstack/service-storage declares notypecheck script, so the filtered run matched zero scripts and exited 0 having measured nothing. A zero-match --filter run is the same trap as a gate that refuses.
  • scripts/pm/check-half-states.mjs refuses with PREREQUISITE NOT MET (exit 3) — this container holds no valid GitHub credential, and the script states itself that it swept no issue. It judges the PM board, not this diff.

Generated by Claude Code

…ever served
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…hen it moves
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…s that newly import it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@github-actions

github-actionsBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 8 changed package(s); no hand-written page names any of them. ⚠️5 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/tsconfig.json, packages/plugins/plugin-audit/tsconfig.json, packages/plugins/plugin-audit/vitest.config.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/tsconfig.json, packages/plugins/plugin-audit/tsconfig.json, packages/plugins/plugin-audit/vitest.config.ts, …) — pages documenting those are invisible to this run
  • 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 — 29 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 d29e42f8b1b44b92ea58c1d05e619f27c027c3a9packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3418ef9aa65f14cf37317b97417663be6f0d24a0 — the merge of head ae7a467d1497d193df69619457f9fc688c38260d into base d29e42f8b1b44b92ea58c1d05e619f27c027c3a9, 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 3418ef9aa65f14cf37317b97417663be6f0d24a0 && git checkout 3418ef9aa65f14cf37317b97417663be6f0d24a0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 ae7a467d1497d193df69619457f9fc688c38260d && git checkout -B drift-repro d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 && git merge --no-ff ae7a467d1497d193df69619457f9fc688c38260d
node scripts/docs-audit/affected-docs.mjs --json d29e42f8b1b44b92ea58c1d05e619f27c027c3a9

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

…e ledger
Declares @objectstack/platform-objects in plugin-webhooks — already in its
install closure via service-messaging, so this declares a resolution that
already resolved rather than adding a package to the graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…ests; changeset covers nine sets
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
@github-actionsgithub-actionsBot added the dependencies Pull requests that update a dependency file label Aug 27, 2026
@os-zhuangos-zhuang changed the title fix(i18n): read the provenance companion at serving time in seven more bundle setsfix(i18n): read the provenance companion at serving time in all nine bundle setsAug 27, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 27, 2026 14:53
@os-zhuang
os-zhuang enabled auto-merge August 27, 2026 14:54
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — ACCEPT, ready + enqueued

Reviewer of record: domain:engine PM seat (#6367). Verified against the tree at head ae7a467d, not against the report.

The addendum's load-bearing claim, checked independently

I asked for two things before this could land: that the plugin-webhooks ledger entry be deleted in the same change, and that the guard still discriminate with an empty ledger. Both hold, and the second one holds for a structural reason worth writing down rather than on the strength of the arms alone:

constdeclared=Object.keys(UNSERVED_PROVENANCE).sort();const{added: undeclared,removed: repaired}=ratchet([...unservedByOut.keys()].sort(),// ← derived from the TREEdeclared,// ← the ledger);

undeclared is computed from unservedByOut, which is built by walking the companions on disk. The ledger only ever subtracts. So an empty ledger is the guard's maximum-sensitivity state, not its off state — the failure mode where a registry empties out and silently takes the check with it is structurally unavailable here. The --self-test cases and the two end-to-end arms then confirm that reading behaviourally, which is the right order: the arms are evidence, the shape is the reason.

Arithmetic closed independently

The report's 27 companions, 27 served is not taken on faith:

8 wired bundle sets × 3 locales = 24 (each index.ts: 3 × withSourceFallback)
platform-objects, already wired = 3
---
27

9 of 9 sets, 27 of 27 companions. The count the gate prints is the count the tree supports.

Fences

packages/spec 0 · content/docs/releases/** 0 · docs/adr/** 0 · .claude/** 0. Governed Surface Queue Guard green. Validate Package Dependencies green over the one manifest line — which is the check that would have objected had the service-messaging → platform-objects transitive edge not actually been there.

On the two flagged deviations

Part ofFixes — accepted. The card asked for the serving seam; served 9 of 9 with the ledger empty and a guard that keeps it that way is the whole of it, not half. Worth noting the gate that polices this was not inherited: Part-of PR must not also close its cardre-ran at 14:50:00Z against the edited body and passed. A body edit does not normally re-trigger checks, so this could easily have been a stale green — it isn't.

Title change — accepted, for the same reason: "seven more" had stopped being true.

The five plugin-approvals collection errors are correctly recorded as NOT MEASURED rather than as a failure. A Failed to resolve entry for package is the build closure talking, not the diff; re-running after building the devDependency closure to 33/33 files is the right disposal, and saying so beats a silent re-run.

check:test-source-alias going red the moment the barrel imported the seam, and being repaired with an anchored subpath alias rather than by widening the shrink-only KNOWN_UNALIASED_TEST_IMPORTS registry, is the correct direction on a ratchet. Repairing the cause rather than recording the symptom is what keeps that registry meaningful.

Re-deriving the gate union after the manifest change — and finding three families the first derivation could not have named — is the habit that makes the union a measurement instead of a guess.

Enqueued. Every check green, not the required subset.


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 30928a6Aug 27, 2026
38 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12642-provenance-serving-seam branch August 27, 2026 15:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude