feat(i18n): opt every bundle set into the generated-leaf provenance companion - #12644
Conversation
…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
…8n-source-hash-rollout
…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
📓 Docs Drift CheckThis PR changes 9 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 43 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 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
|
Fixes #12559
Triage ruled option 1 — roll
--source-hashesout 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 atd6049faed2, 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.narrowToCommittedSections) with its five pinning tests. That fix is not incidental:--source-hasheshad exactly one user and that user commits both generated sections, so its interaction with--no-metadata-formshad never run. Unnarrowed,plugin-auditrecorded 763 entries, of which 2 were its own objects and 761 were digests of the Studio metadata-form baseline@objectstack/platform-objectsowns.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.service-realtime's legacy-trusted opt-out: its 30-line config declaration and the 76-linepresence-bundle-provenance.test.tswritten to keep that prose honest. Reasoning below.service-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
findStaleFillsover the committed bundles and companions, and over the population the gate's own walk enumerates (findExtractConfigsinscripts/i18n-bundle-surface.mjs) rather than a hand-typed list of nine:platform-objects(already covered)service-messagingplugin-approvalsplugin-securityplugin-webhooksplugin-auditservice-storageplugin-sharingservice-realtime419 records written across the eight newly opted-in sets, 0 stale, 1542 records over all nine. Day-one neutrality holds everywhere:
findStaleFillsreturns empty for every set and locale, which is neutrality measured, not asserted from the construction.The two forks, reported rather than skipped
plugin-sharingrecords nothing in zh-CN and ja-JP, andservice-realtimerecords 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 toservice-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:i18ncompares 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-auditrecords 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:i18ncompares 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:The per-set command each config documents (and which the gate reproduces), for the eight sets rolled out here:
⭐ 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:i18nnow reports 7 bundles per set where it reported 4, and 11 forplatform-objectswhere 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?
No.
git diff --statagainst the merge base: 36 files, 1341 insertions, 1 deletion, and every path falls in one of four buckets:LOCALE.source-hashes.generated.ts, three per set across eight sets. Digest tables — a readonly string-to-string record — imported by nothing.--source-hashesline in the documented command plus the shared paragraph; not a line of config code moved.narrowToCommittedSectionsfix, its call site, and its tests.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:
findStaleFillsreturns 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.mdbefore any leg ran; each mutation was proved on disk with anchoredgrep -cFcounts on the deleted text and the injected text before any result was read; each leg restored undertrap … EXIT INT TERM; each restore was verified by an empty tree (RESTORE: dirty entries left = 0). No build is involved — every reader here istsx/node oversrc, so nodistcan go stale underneath a leg.The mutation (legs A and B): the
tokenfield's source label inpackages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts,'Token'becomes'Share Token'. Chosen becauseobjects.sys_share_link.fields.token.labelis 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.check:i18nexits 1, namesplugin-sharingand onlyplugin-sharing; the drift is theenbundle, and the companions do not move (a record carries forward whilehash(value)still equals it)plugins/plugin-sharing DRIFTED (1), the other 8 setsin sync; the single drifted file is theenbundleTOTAL_STALE0 → 1, atobjects.sys_share_link.fields.token.labelin es-ES only (zh-CN 0, ja-JP 0, every other set 0); records unchanged at 1 / 0 / 0;check:i18nback to greenTOTAL_STALE=1,es-ES: records=1 stale=1 objects.sys_share_link.fields.token.label;TOTAL_RECORDS=1542unchanged; regeneration moved only theenbundle, the companion byte-identical — carry-forward proved on diskcheck:i18n-stale-fillstill reports OK / 0check-i18n-stale-fill: OK (10 bundle set(s) — no new stale fills, 0 baselined).andcheck-i18n-bundles: OK (9 package(s) …)service-realtime,'Last Seen'→'Last Seen At')TOTAL_STALE=0, realtimerecords=0 stale=0in 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-sharinghad 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-coveragefirst came back COULD NOT MEASURE, naming an unbuilt@objectstack/connector-mcpand then@objectstack/connector-openapiin 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-surface→public API surface + factory signatures unchanged ✓;check:export-origins→export-origins/ is current: 5179 exports across 17 entry points resolve exactly as recorded; and for the docs family,check:docs-audit-scope→docs-accuracy-audit scope is in sync with content/docs/: 189 hand-written doc(s). Neither spec family could have moved — the diff touches nopackages/specpath, and the new export is internal to@objectstack/cli, whose entrypoint does not re-export the extractor utils.Tests.
@objectstack/clitest/i18n-extract-source-hashes.test.ts→ 11 passed (the five new narrowing pins included);@objectstack/cli typecheck(tsc --noEmit) → clean; all eight rolled-out packagestypecheck→ 29 tasks successful;@objectstack/service-realtime→ 28 passed (3 files),@objectstack/plugin-sharing→ 652 passed (27 files).Declared narrowing, one family:
check:type-check-debt(--re-measure) was left to CI. Its self-test half ran green throughcheck:type-check-coverage, which shares it, and the structural half is green; the ratchet half re-runstscper 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/cliand all eight rolled-out packages typecheck clean. Declared rather than omitted.Lint — a measurement, not a narrowing.
eslint --no-inline-config --format jsonover the diff: 35 files linted, 0 errors, 0 warnings. Those 35 are 100% of the diff's lint-governed surface — 0 came backFile ignored, and the one remaining changed path is the changeset.md.eslint.config.mjsstates it "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file", so this diff cannot move an untouched file's verdict.Changeset
.changeset/i18n-source-hash-rollout-eight-sets.md— patch for@objectstack/cliand 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. Notminor: 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, andnarrowToCommittedSectionsis internal to@objectstack/cli. Notmajor: 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.
withSourceFallbackis wired only in@objectstack/platform-objects; the eight sets'src/translations/index.tsassemble 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-webhooksdoes 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.mdandCLAUDE.mdare untouched.Generated by Claude Code