Skip to content

feat(i18n): opt every bundle set into the generated-leaf provenance companion - #12644

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-12559-i18n-source-hash-rollout
Aug 27, 2026
Merged

feat(i18n): opt every bundle set into the generated-leaf provenance companion#12644
os-warren merged 4 commits into
mainfrom
claude/issue-12559-i18n-source-hash-rollout

Conversation

@os-warren

@os-warren os-warren commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes #12559

Triage ruled option 1 — roll --source-hashes out to all eight uncovered bundle sets. This does that, and goes one step further than the recovered branch: all nine sets now opt in, so no set carries a hand-maintained exception. Every number below was measured on this tree at d6049faed2, not carried over from a commit message.

The resumed branch — kept / re-derived / discarded

The seat that started this card was killed by a container restart. Two commits (2718262db3, f463beaa0d) were recovered and pushed by the PM with no PR, no gate output, and — the part that decides whether the rollout is correct — no per-set premise report.

  • Kept, after proving it rather than trusting it — the seven sets' opt-in and, most of all, the extractor fix (narrowToCommittedSections) with its five pinning tests. That fix is not incidental: --source-hashes had exactly one user and that user commits both generated sections, so its interaction with --no-metadata-forms had never run. Unnarrowed, plugin-audit recorded 763 entries, of which 2 were its own objects and 761 were digests of the Studio metadata-form baseline @objectstack/platform-objects owns.
  • Re-derived, not inherited — the merge with origin/main; every committed generated file, by re-running the real extractor over the whole population (node scripts/check-i18n-bundles.mjs --write) and observing that not one byte of the recovered companions or bundles moved; the per-set premise numbers; the gate union; and all three ablation legs.
  • Discardedservice-realtime's legacy-trusted opt-out: its 30-line config declaration and the 76-line presence-bundle-provenance.test.ts written to keep that prose honest. Reasoning below.
  • Addedservice-realtime's three companions, one shared opt-in paragraph across all eight configs, the per-set measurement, and the ablations.

⭐ Step 1 — the per-set premise, with BOTH numbers

"0 stale" is also what a set with no records at all reports, so a stale count alone cannot tell a companion that recorded everything and found no drift from one that recorded nothing. Both numbers, per set per locale, computed with the mechanism's own findStaleFills over the committed bundles and companions, and over the population the gate's own walk enumerates (findExtractConfigs in scripts/i18n-bundle-surface.mjs) rather than a hand-typed list of nine:

set leaves/locale records zh-CN / ja-JP / es-ES stale verdict
platform-objects (already covered) 1397 336 / 389 / 398 0 covered, unchanged
service-messaging 166 3 / 143 / 143 0 covered
plugin-approvals 131 6 / 27 / 28 0 covered
plugin-security 190 11 / 11 / 11 0 covered
plugin-webhooks 47 7 / 6 / 7 0 covered
plugin-audit 103 2 / 2 / 4 0 covered
service-storage 65 1 / 2 / 4 0 covered
plugin-sharing 127 0 / 0 / 1 0 ⚠️ fork — reported below
service-realtime 23 0 / 0 / 0 0 ⚠️ fork — reported below

419 records written across the eight newly opted-in sets, 0 stale, 1542 records over all nine. Day-one neutrality holds everywhere: findStaleFills returns empty for every set and locale, which is neutrality measured, not asserted from the construction.

The two forks, reported rather than skipped

plugin-sharing records nothing in zh-CN and ja-JP, and service-realtime records nothing in any locale. In both cases the reason is the same and it is not a failure: a record is written only for a leaf that is right now a byte copy of the current source, and those locales are fully translated. There is nothing recordable there today.

The recovered branch treated one of these as an opt-out and shipped the other silently, and that asymmetry is why the opt-out is discarded here. Its argument — three tables with zero entries would "announce an instrument that measures nothing" — applies word for word to plugin-sharing's zh-CN and ja-JP, which the same branch opted in. So the property belonged to a fully translated locale, not to service-realtime; and opt-in is per set, so the exception could never have been applied consistently.

An empty companion is the instrument armed, not an instrument that measures nothing. The extractor rewrites it on every run and check:i18n compares it byte-for-byte, so the first leaf that becomes a fill is recorded by itself. The opted-out shape needed a human to re-read a paragraph — which is precisely why it needed a bespoke test to trigger that re-reading. Uniform opt-in needs neither, which is one fewer standing exception and 106 fewer lines to keep true.

What both forks do need is saying out loud, and the shared paragraph now in all eight configs says it: records count the leaves currently RECORDABLE, never the leaves covered. By that measure no set is "covered" — plugin-audit records 2 of 103 leaves — so a per-set record count is the honest instrument reading, and it is in the changeset and above.

Step 2 — the rollout

Every companion in this PR came from a real extract run, never a hand edit: check:i18n compares them byte-for-byte against a fresh extract, so bundles and companions must come from the same run or the gate reds. The whole population was regenerated with the repo-wide command, which drives each config with the flags that config documents:

node scripts/check-i18n-bundles.mjs --write

The per-set command each config documents (and which the gate reproduces), for the eight sets rolled out here:

os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/plugins/plugin-approvals/src/translations
os i18n extract packages/plugins/plugin-audit/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/plugins/plugin-audit/src/translations
os i18n extract packages/plugins/plugin-security/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/plugins/plugin-security/src/translations
os i18n extract packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/plugins/plugin-sharing/src/translations
os i18n extract packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/plugins/plugin-webhooks/src/translations
os i18n extract packages/services/service-messaging/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/services/service-messaging/src/translations
os i18n extract packages/services/service-realtime/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/services/service-realtime/src/translations
os i18n extract packages/services/service-storage/scripts/i18n-extract.config.ts \
  --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
  --source-hashes --out=packages/services/service-storage/src/translations

⭐ The regeneration is itself the strongest check on the recovered work: re-running the extractor over all nine sets left every recovered companion and every bundle byte-identical, with the only new files being service-realtime's three. check:i18n now reports 7 bundles per set where it reported 4, and 11 for platform-objects where it reported 8 — the companions are inside its byte-for-byte compare set and cannot diverge from what a real extract writes.

Clause ② — does anything authorable change?

⚠️ Body edited once after creation to repair sanitizer damage: the first bullet below originally spelled the companion module's exported type with angle brackets, and the inner token was eaten on save — inside backticks — leaving Readonly>. Re-spelled in prose. No claim was changed.

No. git diff --stat against the merge base: 36 files, 1341 insertions, 1 deletion, and every path falls in one of four buckets:

  • 24 generated filesLOCALE.source-hashes.generated.ts, three per set across eight sets. Digest tables — a readonly string-to-string record — imported by nothing.
  • 8 extract configs — docstring only. The change is the --source-hashes line in the documented command plus the shared paragraph; not a line of config code moved.
  • 3 CLI files — the narrowToCommittedSections fix, its call site, and its tests.
  • 1 changeset.

No object definition, no field, no label, description or help string, no schema, no public API. Day-one behaviour is unchanged for every leaf, and the premise that makes that true was measured rather than assumed: findStaleFills returns empty for all 27 set/locale pairs, so no leaf's rendered text can differ from what it rendered before this commit. The CLI fix narrows which records a companion carries; it changes what no bundle serves.

Ablations — direction and exact count written down before the run

Predictions were written to ablation-predictions.md before any leg ran; each mutation was proved on disk with anchored grep -cF counts on the deleted text and the injected text before any result was read; each leg restored under trap … EXIT INT TERM; each restore was verified by an empty tree (RESTORE: dirty entries left = 0). No build is involved — every reader here is tsx/node over src, so no dist can go stale underneath a leg.

The mutation (legs A and B): the token field's source label in packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts, 'Token' becomes 'Share Token'. Chosen because objects.sys_share_link.fields.token.label is that set's one record and it is recorded in es-ES only — the single-locale population #12559 names, the one the shipped cross-locale gate cannot see by construction. MUTATION_ON_DISK deleted-text 1->0 , injected-text 0->1.

leg predicted observed
A — source moved, nothing regenerated check:i18n exits 1, names plugin-sharing and only plugin-sharing; the drift is the en bundle, and the companions do not move (a record carries forward while hash(value) still equals it) exit 1; plugins/plugin-sharing DRIFTED (1), the other 8 sets in sync; the single drifted file is the en bundle
B — regenerate bundles and companion from one run TOTAL_STALE 0 → 1, at objects.sys_share_link.fields.token.label in es-ES only (zh-CN 0, ja-JP 0, every other set 0); records unchanged at 1 / 0 / 0; check:i18n back to green exactly that: TOTAL_STALE=1, es-ES: records=1 stale=1 objects.sys_share_link.fields.token.label; TOTAL_RECORDS=1542 unchanged; regeneration moved only the en bundle, the companion byte-identical — carry-forward proved on disk
B′ — the blindness control on the same tree check:i18n-stale-fill still reports OK / 0 check-i18n-stale-fill: OK (10 bundle set(s) — no new stale fills, 0 baselined). and check-i18n-bundles: OK (9 package(s) …)
C — negative control on a set whose tables are empty (service-realtime, 'Last Seen''Last Seen At') stale stays 0 everywhere; realtime records stay 0 / 0 / 0 TOTAL_STALE=0, realtime records=0 stale=0 in all three locales — no false positive

Leg B is the measurement that justifies the rollout, reproduced on a newly covered set. After the regeneration the tree is green under both shipped gates while es-ES serves a superseded English draft, and the companion is the only artifact in the repo that knows. That is exactly the shape PR #12600 hit on the covered set — and before this PR, plugin-sharing had no companion to know it with.

Gates

Union derived at the final head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (repo assertion held; 36 changed paths, three-dot semantics, derived from the real changeset — not a list I typed). 24 path-matched families + 8 convention-triggered. Every exit code captured before any pipe (cmd > log 2>&1; ec=$?), and each verdict quoted from the gate's own output.

All green. check:nul-bytes · check:changeset-gate-self-tests · check:cli-test-child-env · check:cross-package-test-inputs · check:i18n · check:i18n-stale-fill · check:objectql-double-limit · check:objectui-changeset · check:page-declaration-shape · check:published-files · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check:query-options-erasure · check:engine-double-contract · check:where-matcher · check:type-check-coverage · check:i18n-coverage · check-adr-0087-registration · check-changeset-no-major · check-ci-filter-parity · check-comment-mask-adoption · check-empty-changeset · check-plugin-teardown-shape · docs-audit/check-affected-docs · docs-audit/check-drift-comment · pm/release-rehearsal-clone --self-test.

Quoting the load-bearing ones:

  • check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
  • check-i18n-stale-fill: OK (10 bundle set(s) — no new stale fills, 0 baselined).
  • check-i18n-coverage: OK (12 config(s), 602 baselined untranslated string(s), none new).
  • check-nul-bytes: OK (scanned 7011 text file(s) … no raw ASCII control bytes).
  • ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).

check:i18n-coverage first came back COULD NOT MEASURE, naming an unbuilt @objectstack/connector-mcp and then @objectstack/connector-openapi in this worktree — the gate refusing to measure rather than measuring a different world. Read as not measured, not red; green once the connectors were built. Named here rather than quietly re-run.

The diff adds one exported name (narrowToCommittedSections), so the three families the derivation cannot name were run by hand at the final head: pnpm --filter @objectstack/spec run check:api-surfacepublic API surface + factory signatures unchanged ✓; check:export-originsexport-origins/ is current: 5179 exports across 17 entry points resolve exactly as recorded; and for the docs family, check:docs-audit-scopedocs-accuracy-audit scope is in sync with content/docs/: 189 hand-written doc(s). Neither spec family could have moved — the diff touches no packages/spec path, and the new export is internal to @objectstack/cli, whose entrypoint does not re-export the extractor utils.

Tests. @objectstack/cli test/i18n-extract-source-hashes.test.ts11 passed (the five new narrowing pins included); @objectstack/cli typecheck (tsc --noEmit) → clean; all eight rolled-out packages typecheck29 tasks successful; @objectstack/service-realtime28 passed (3 files), @objectstack/plugin-sharing652 passed (27 files).

Declared narrowing, one family: check:type-check-debt (--re-measure) was left to CI. Its self-test half ran green through check:type-check-coverage, which shares it, and the structural half is green; the ratchet half re-runs tsc per ledger entry at roughly seven minutes against a container that kills a foreground command at ten, with the shared verify lock queueing several minutes ahead of it. In its place the same question was answered directly for every package this diff touches: @objectstack/cli and all eight rolled-out packages typecheck clean. Declared rather than omitted.

Lint — a measurement, not a narrowing. eslint --no-inline-config --format json over the diff: 35 files linted, 0 errors, 0 warnings. Those 35 are 100% of the diff's lint-governed surface — 0 came back File ignored, and the one remaining changed path is the changeset .md. eslint.config.mjs states it "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file", so this diff cannot move an untouched file's verdict.

Changeset

.changeset/i18n-source-hash-rollout-eight-sets.mdpatch for @objectstack/cli and the eight rolled-out packages. Defended: no committed translation bundle changed a byte, no leaf's rendered text changed, no public API moved, and the mechanism cannot arrive red because a record is written only where the leaf already equals the current source. Not minor: unlike #12557 this adds no capability and no new file format — it turns on an existing flag for the sets that did not have it, and narrowToCommittedSections is internal to @objectstack/cli. Not major: nothing existing breaks.

Scope, and what this does not do

Filed as a finding rather than done here, because it is a per-package architecture call: #12642 — provenance is now recorded in 9 of 9 sets and read at serving time in 1. withSourceFallback is wired only in @objectstack/platform-objects; the eight sets' src/translations/index.ts assemble their bundles from the raw generated modules and never consult the companion beside them. So a stale fill in one of the eight is now recorded and reportable — leg B is exactly that — but not yet substituted at runtime. Seven of the eight already depend on @objectstack/platform-objects; @objectstack/plugin-webhooks does not, which is the part that makes it a decision. Stated here, in the changeset, and in that issue, so the boundary is not inferred wrongly a second time.

content/docs/releases/, docs/adr/**, .claude/**, skills/**, AGENTS.md and CLAUDE.md are untouched.


Generated by Claude Code

claude added 4 commits August 26, 2026 16:47
…emaining bundle sets

Seven of the eight uncovered i18n bundle sets now document `--source-hashes`
in their extract config and commit the three `<locale>.source-hashes.generated.ts`
companions produced by the same extract run as their bundles. The eighth,
`service-realtime`, declares itself legacy-trusted with the measurement behind
that choice, and a test beside its bundles fails the day the measurement stops
holding.

Also narrows the provenance table to the sections a run actually commits: the
seven sets pass `--no-metadata-forms`, and their unrecorded `metadataForms`
subtree arrived as a fresh fill of `en`, so the table carried 761 digests of a
baseline `@objectstack/platform-objects` owns. `platform-objects` commits both
sections, so its committed companions are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…ments

`flagsFromDocstring` harvests every recognised flag spelling out of a config's
whole docstring, so the paragraph explaining `--source-hashes` was a SECOND
declaration of it: deleting the flag from the documented command left the opt-in
switched on, which is not what anyone editing that command would read. Measured
while ablating the opt-out path — the extract still emitted 7 bundles for a
config whose command block no longer named the flag.

The prose now spells it without leading dashes and says why, the same rule
service-realtime's legacy-trusted declaration already states from the other side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…ompanion

Rolls `--source-hashes` out to the eighth and last uncovered set
(`service-realtime`) and puts all eight remaining sets on ONE shared opt-in
paragraph, so no set carries a hand-maintained exception.

The recovered branch left `service-realtime` opted out on the ground that its
companions would hold zero entries, and paired the opt-out with a bespoke test
to keep that prose honest. Measured here, the same is true of `plugin-sharing`
in zh-CN and ja-JP, which the rollout opted in anyway — so the exception was not
a property of `service-realtime`, it was a property of a fully translated
locale, and two of the nine sets shipped exactly the artifact the exception
argued against.

An empty companion is the instrument ARMED, not an instrument that measures
nothing: the extractor rewrites it on every run, so a leaf that becomes a fill
is recorded by itself. The opted-out shape needed a human to re-read a
paragraph, which is why it needed a test. Uniform opt-in needs neither.

Records therefore count what is currently RECORDABLE, never what is covered —
stated in the shared paragraph, since it is the inference the card was filed
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 9 package(s): @objectstack/cli, @objectstack/plugin-approvals, @objectstack/plugin-audit, @objectstack/plugin-security, @objectstack/plugin-sharing, @objectstack/plugin-webhooks, @objectstack/service-messaging, @objectstack/service-realtime, @objectstack/service-storage, touching 7 documentable anchor(s).

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

  • content/docs/protocol/kernel/i18n-standard.mdx (via metadataForms (literal), os i18n extract (command))
  • content/docs/ui/translations.mdx (via os i18n extract (command))

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

  • content/docs/releases/v15.mdx (via os i18n extract (command))
  • content/docs/releases/v16.mdx (via os i18n extract (command))
  • content/docs/releases/v17.mdx (via os i18n extract (command))

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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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; 102 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 — 43 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 9c32357ea670f38f503350c9a092fb476be85967packageMentionDocs.

Which tree this was computed on

This run read content/docs from fdc4ed4a8ab14a66188d413083b9dd9509230b6b — the merge of head d6049faed27ad1fe3abad148d829999275ee0eef into base 9c32357ea670f38f503350c9a092fb476be85967, 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 fdc4ed4a8ab14a66188d413083b9dd9509230b6b && git checkout fdc4ed4a8ab14a66188d413083b9dd9509230b6b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c32357ea670f38f503350c9a092fb476be85967 d6049faed27ad1fe3abad148d829999275ee0eef && git checkout -B drift-repro 9c32357ea670f38f503350c9a092fb476be85967 && git merge --no-ff d6049faed27ad1fe3abad148d829999275ee0eef

node scripts/docs-audit/affected-docs.mjs --json 9c32357ea670f38f503350c9a092fb476be85967

⚠️ 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 9c32357ea670f38f503350c9a092fb476be85967 → 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 Aug 27, 2026
@os-warren
os-warren marked this pull request as ready for review August 27, 2026 03:28
@os-warren
os-warren added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit de47336 Aug 27, 2026
37 checks passed
@os-warren
os-warren deleted the claude/issue-12559-i18n-source-hash-rollout branch August 27, 2026 03:47
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/xl tests tooling

Projects

None yet

2 participants