Skip to content

feat(platform-objects,cli): record which source revision a generated translation leaf was filled from - #12557

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-11671-i18n-stale-leaf-drift
Aug 26, 2026
Merged

feat(platform-objects,cli): record which source revision a generated translation leaf was filled from#12557
os-warren merged 3 commits into
mainfrom
claude/issue-11671-i18n-stale-leaf-drift

Conversation

@os-warren

@os-warrenos-warren commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11671

Implements maintainer ruling #12069 Option A: extend the existing #8765 source-hash mechanism to the generated bundles, correct the false "cannot occur" note that kept them out of it, no second mechanism, no change to check:i18n's key-set comparison.

⚠️ Body edited once after creation to repair sanitizer damage: this section originally spelled the companion file with an angle-bracket locale placeholder, and the placeholder token was eaten on save — inside backticks — leaving a filename that reads as if it had no locale prefix. Re-spelled below as LOCALE.source-hashes.generated.ts. No claim was changed.

The defect

check:i18n compares bundles by key. A leaf filled with the source string under --fill=default is still present after the source is revised, so the gate reports OK forever while the locale serves a superseded draft — and the drift is sticky, because --fill=default fills gaps only and never revisits a non-empty leaf. Measured on #11659 at bbe0b17: three locales serving a 602-char draft of a 411-char help string under 31 green checks.

Once the source has moved, that stale fill is indistinguishable by value from a real translation, which is why the "at minimum" fallback in the original triage was unimplementable: 2648 of 3010 leaves differ from en, so "untranslated AND differing from the source" names an empty set.

What this does

os i18n extract --source-hashes writes one LOCALE.source-hashes.generated.ts companion per locale, beside each generated bundle: per leaf, the digest of the source revision that leaf is still a byte copy of. withSourceFallback takes that table as a fourth argument and now judges objects / metadataForms alongside the hand-authored sections, so a leaf whose source moved underneath it serves the current source string instead of a superseded draft.

The generated half needs one conjunct the hand-authored half does not — the leaf must still hold the recorded bytes. Its hash table is itself generated, so a translator cannot be asked to refresh a digest by hand; without that conjunct, re-translating a stale leaf would report the fresh translation as stale forever. With it, editing the value clears the flag by itself.

Provenance of this branch — kept / re-derived / discarded

This card was resumed after a dev seat stalled. One commit (00078b736b) existed only on container-local disk and was pushed by the PM so it would survive; it carried no PR, no gate run, no test run, and no ablation.

  • Kept, after proving it — the whole of 00078b736b. I read it against the ruling and re-derived its claims rather than trusting them; every number quoted below is one I measured on this tree, not one carried over from its commit message. git reset was on the table and I did not need it: the shape is the ruled one (extends the existing module, one hash function, one table shape).
  • Re-derived — the merge with origin/main (46 commits, one real conflict in packages/cli/src/utils/i18n-extract.ts, an import line resolved as a union); the gate union, derived from the real changeset rather than recalled; and the day-one staleness claim, which I re-measured after the merge because 46 commits of source-string movement could have invalidated it (it holds: TOTAL_STALE=0).
  • Added — three tests pinning single-locale detection (43f2f81f54). The suite pinned per-locale recovery for the hand-authored half but had no analogue for the generated half's per-locale detection, which is the entire reason Option A was ruled over the status quo. Named here because it is the one thing in this PR that is not in the recovered commit.
  • Discarded — nothing.

Why a change that only records provenance would be half the card

It does not only record. withSourceFallback reads the records at serving time and substitutes the current source, and findStaleFills reports them. Note this is deliberately a serving rule, not a red build: making translation lag fail CI is Option C, which the #8765 ruling rejected for putting a four-locale translation task in front of every one-word source edit.

Evidence

Every ablation below had its direction and exact count written down before the run, proved its mutation on disk with anchored grep -cF counts before any result was read, restored under trap … EXIT INT TERM, and had the restore verified by an empty git diff. No build was involved: these tests import the module relatively, so src is what runs and no dist can go stale underneath them.

Baseline (merged tree): TOTAL_STALE=0 — zh-CN 336 records / ja-JP 389 / es-ES 398, over 1397 generated leaves each. A zero is not a reading until a positive control fires, hence:

legmutationpredictedobserved
A positiverevise en at a leaf recorded in all three locales0 → 3, one per locale3, correct path, served text switched to the new source
B negativerevise en at a leaf recorded in no localestays 00 — no false positive
C mechanismdelete conjunct 3 of findStaleFills2 named tests fail2 failed / 28 passed, exactly those two
C′ re-runsame, against the final suite3 named tests fail3 failed / 30 passed, exactly those three
D single-localerevise en at a leaf recorded in es-ES onlyprovenance sees 1; the shipped cross-locale gate sees 0es-ES STALE=1, zh-CN 0, ja-JP 0 — and check:i18n-stale-fillexit 0, "0 stale-fill leaves"

Leg D is the measurement that justifies the ruling. Same tree, same drift: check:i18n-stale-fill infers provenance from two locales holding byte-identical text, so a leaf stranded in exactly one locale has no second witness and is invisible to it by construction. A recorded hash needs no witness. That population is real, not hypothetical — 18 generated leaves are recorded in exactly one locale today (zh-CN 2, ja-JP 3, es-ES 13): English-looking terms one locale left as a fill while the others translated them (Variables (JSON), Reply-To, Checksum).

Gates. Union derived at the merge head with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (repo assertion held; 16 changed paths, three-dot semantics) → 33 families. All 33 green, each exit code captured before any pipe. Two first reported COULD NOT MEASUREcheck:i18n-coverage and check:type-check-debt, both naming an unbuilt workspace package in this worktree, both refusing to measure rather than measuring a different world. Read as not measured, not red, and both green once the named packages were built. Quoting each gate's own verdict line:

  • check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys). ⭐ platform-objects reports 11 bundles here, up from 8 — the three provenance companions are inside the gate's byte-for-byte compare set, so they cannot diverge from what a real extract writes. They still match after 46 commits of main.
  • 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-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 415.7s, 1843 raw tsc error(s) total, none above its recorded number.
  • check:entry-guard: 170 scripts/ file(s) … 126 export bindings, 124 of them inert on import

Ratchet families re-run at the final commit 43f2f81f54, after the test addition, together with @objectstack/platform-objects typecheck (rc=0) and 15 other affected families — all green.

Tests at 43f2f81f54:platform-objects translations 85 passed (7 files); cli i18n extract source-hashes 6 passed.

Lint.eslint --no-inline-config --format json over the diff: 14 files linted, 0 errors, 0 warnings. This is a measurement, not a narrowing — the 14 are 100% of the diff's lint-governed surface (0 came back File ignored; the other 2 changed paths are package.json and the changeset .md), and 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 any untouched file's verdict.

Changeset

.changeset/i18n-generated-leaf-source-provenance.mdminor for @objectstack/platform-objects and @objectstack/cli. Defended: both changes are additive and backward compatible — a new CLI flag that is off by default, a new optional fourth parameter on withSourceFallback, and new exports. No existing call site changes behaviour, and no committed bundle moved a byte. Not patch, because it adds a published capability and a new emitted file format; not major, because nothing existing breaks.

Scope

--source-hashes is off by default and @objectstack/platform-objects is the one bundle set that opts in, by documenting the flag in its extract config. The other eight sets keep exactly today's behaviour — a set with no companion is entirely legacy-trusted, never reported and never wrong about. Behaviour on the day this lands is unchanged for every leaf: records are written only where a leaf is currently a byte copy of the current source, so day-one staleness is 0 by construction, which I re-measured after the merge rather than inheriting.

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


Generated by Claude Code

…translation leaf was filled from
Extends the #8765 Option B source-hash mechanism to the generated bundles, per
maintainer ruling #12069 Option A, and corrects the false "this hole cannot
occur there" note that kept them out of it.
`os i18n extract --source-hashes` emits <locale>.source-hashes.generated.ts —
per leaf, the digest of the source revision that leaf is still a byte copy of.
withSourceFallback takes it as a fourth argument and judges objects /
metadataForms alongside the hand-authored sections.
Records are written only where a leaf is currently a copy of the CURRENT
source, so day-one stale count is 0 by construction: 9030 translated leaves
measured, 1543 recorded, 7487 left legacy-trusted. No committed bundle moved a
byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…8n-stale-leaf-drift
# Conflicts:
#	packages/cli/src/utils/i18n-extract.ts
…property the ruling turns on
The suite pinned per-locale RECOVERY for the hand-authored half but had no
analogue for the generated half's per-locale DETECTION, which is the whole of
what maintainer ruling #12069 Option A buys over the gate that shipped first.
`check:i18n-stale-fill` infers provenance from two locales holding byte-identical
text, so a leaf stranded in exactly one locale has no second witness and is
invisible to it by construction. A recorded hash needs no witness. Measured on
this tree: 18 generated leaves are recorded in exactly one locale (zh-CN 2,
ja-JP 3, es-ES 13) — English-looking terms one locale left as a fill while the
others translated them.
Verified falsifiable: deleting the third conjunct of findStaleFills turns these
3 tests into 1 failure among 3 total, predicted in writing before the run.
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 2 package(s): @objectstack/cli, @objectstack/platform-objects, touching 25 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/platform-objects/src/apps/translations/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 1 changed file(s) yielded no anchor (packages/platform-objects/src/apps/translations/index.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 52a98238876fd5183ff087ce6cca1adb195a9636packageMentionDocs.

Which tree this was computed on

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

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

@github-actionsgithub-actionsBot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@os-warren
os-warren marked this pull request as ready for review August 26, 2026 10:39
@os-warren
os-warren enabled auto-merge August 26, 2026 10:39
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — accepted, flipped ready, auto-merge armed

Re-measured independently of the report, because a resumed branch carrying another seat's unreviewed commit is exactly where inherited numbers go unchecked.

The load-bearing claim, recounted from the artefacts

Leg D is what justifies Option A over the already-shipped cross-locale gate, and its population claim is the part that has to be true. Counted straight out of the three committed companions rather than from the PR body:

zh-CN 336 records · ja-JP 389 · es-ES 398
leaves recorded in EXACTLY ONE locale: 18 (zh-CN 2 · ja-JP 3 · es-ES 13)

Every figure matches to the unit. So the argument holds on measured ground: check:i18n-stale-fill infers provenance from two locales holding byte-identical text, and those 18 leaves have no second witness — they are invisible to it by construction, not by oversight. A recorded hash needs no witness. That is a real population today, not a hypothetical one.

The ruling's three constraints, checked one at a time

Ruling #12069 Option A (os-trump, 2026-08-25, maintainer accepted verbatim 「同意你的建议」) carried three:

constraintverdict
extend the existing source-hash.tsheld — it is in the diff, no new module
⛔ no second mechanismheld — one hash function, one table shape
⛔ do not touch check:i18n's key-set comparisonheld, and worth naming explicitly

On the third: scripts/check-i18n-bundles.mjs is not in the changed-file set. What did change is i18n-bundle-surface.mjs's flagsFromDocstring, adding --source-hashes to the recognised flags. That leaves the comparison rule untouched and grows the file set it applies to — and it has to, or the companions would sit outside the gate and drift silently. The in-source comment makes the right distinction ("it decides WHICH FILES the extract emits … a flag that only changed a message would not belong here"). Flagging it so a maintainer reading the constraint more tightly sees the call rather than discovering it.

Other claims spot-checked

  • --source-hashes is default: false. Confirmed at the flag definition.
  • The root package.json change is one word — --source-hashes added to the existing i18n:extract script. That is the opt-in, and it is the whole footprint outside the two packages.
  • The previousSourceHashes read-back before emitting is the guard that matters most and is easy to miss: without it a re-extract would snap every digest to the current tree and forget the drift it exists to hold. It is there.

On the resumed-branch provenance

The recovered commit was kept in full, and the report says every number was re-derived after merging 46 commits of main rather than inherited from its commit message — including the day-one TOTAL_STALE=0, which 46 commits of source-string movement could plausibly have invalidated. That is the right instinct for work nobody had vouched for. The three added tests (43f2f81f54) are named as the one thing not in the recovered commit, and leg C′ re-runs the mechanism ablation against the final suite to show they are falsifiable rather than decorative — 3 failed / 30 passed, exactly the three predicted.

Recorded, not held against the PR

One scope boundary the reader must not misread

--source-hashes is off by default and @objectstack/platform-objects is the only bundle set that opts in. The other eight keep exactly today's behaviour and stay entirely legacy-trusted. The ruling named a platform-objects module, so this is within scope — but "the repo now detects stale fills" would be the wrong conclusion to draw from this landing, and the per-package rollout is a triage decision rather than an append here. Filing it as a finding so it is written down rather than rediscovered.

CI is the remaining gate.


Generated by Claude Code

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/xlteststooling

Projects

None yet

2 participants

@os-warren@claude