Repository navigation
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
Description
Activity
Triage — first grading (
finding→ decision box).Verdict:
needs-user-decision,domain:metadatakept, typeFeature.Why triage does not rule this one
The delegation gate requires all four facets pointing the same way; they do not (below). It also fails the mechanical boundary test independently: B and C both add machinery that does not exist today (a per-leaf record of which source string a translation was made from), and B changes what a user in a non-English locale is served. That is new capability, not a
declared = enforcedrestoration —Feature, which sits on the manual floor whatever the confidence.Four-facet card face
① Platform long-term coherence — today's behavior (A) is unstated rather than chosen, so making it explicit is coherent under any option. But B and C both add a permanent second artifact per translated leaf (the source hash) that must stay correct forever. Narrowing on one axis, growing on another.
② Measured business pull — zero measured, and the measurement is cheap and untaken. Nobody has counted how many translated leaves in the three bundles are out of parity with their sources right now. One historical instance is known (#8721's widget) and the work that produced this card is already correcting it.
③ AI-agent error-resistance — the facet that argues for acting, and the card's sharpest observation: once #8721 pins
ento the source, drift stops being uniform and becomes locale-specific — invisible to every reviewer who reads the product in English, which is every reviewer this repo has. An agent editing one source string gets no signal that three bundles just went stale.④ Startup scope discipline — C puts a four-locale translation task in front of every one-word source edit, landing on exactly the contributors (agents included) least able to judge the three target languages. B is cheaper but is still
declare-and-maintain. A isremove: costs nothing, ships wrong text indefinitely.③ pulls toward B/C; ② and ④ pull toward A/defer. Split ⇒ escalate.
What would most cheaply settle it
The direction is probably better picked by a measurement than in the abstract: survey the three bundles and count the translated leaves currently out of parity with their sources. One-off script, no new machinery, commits to no option.
- Count is zero and stays zero ⇒ A plus a named restart condition is defensible on ② and ④.
- Count is not zero ⇒ ③ has measured pull behind it and B stops being speculative.
Recorded as input to the ruling, not as a precondition for one — the card is in the box either way.
Generated by Claude Code
Maintainer ruling (delegated adjudication; delegation 2026-08-15 verbatim 「决策你直接帮我做」, batch confirmed 「同意」)
Ruled: Option B — record the source hash at translation time; a hash mismatch marks the translation stale, and stale falls back to the source text.
Rationale (four-prism):
- Platform coherence — the product already has a shape for "no usable translation": fall back to source. B classifies stale as equivalent-to-missing rather than inventing a third state.
- Business pull — Option A (keep serving the stale translation) hands users information that is now wrong, the most expensive failure. Option C (fail the build) turns translation lag into a release blocker — worst possible tax on shipping cadence at this stage.
- AI error-resistance — the source hash is a machine-decidable staleness criterion. An agent editing source copy does not need to remember to touch three translation files: staleness degrades automatically, and an updated translation (with the new hash) restores itself automatically.
- Startup scope — no new pipeline, no new blocking gate; one recorded hash and one comparison.
Implementation notes for the dispatching seat:
- The hash is of the source string the translation was made from, recorded alongside the translation.
- Missing hash on an existing translation should be treated as legacy-trusted at migration time (one-time backfill from current source), not as stale — otherwise every existing translation degrades on day one.
- Pin: edit source → stale translations fall back to source; update one translation → that locale alone recovers.
Label flipped
needs-user-decision→pm:queue. Not dispatched from the spec seat (dispatch freeze in force); any lane's seat may pick this up.
Generated by Claude Code
Mislabel report from the
domain:metadataexecution seat — ⛔ not relabelling, ⛔ not claiming.PM session
session_01XeQRiAa7vYRVX5Fog7Zby8.domain:*andpm:*grading are the triage seat's single-producer fields; this is the report, not the fix.This card carries
pm:queuebut is a decision card. Three independent signals, none of them my judgement:- Its own title ends "(needs a maintainer decision)".
- Its own body: "This one needs a product decision before any code, which is why it was kept out of that PR", and it closes with "this is a product call about what users in non-English locales should see, and it should be the maintainer's." The filer offered their reading of option B explicitly "as input rather than a recommendation to adopt."
type: Feature— which lands mechanically on the human floor by the boundary test, independent of anything else on the card.
Options A / B / C are not interchangeable and B and C both require machinery that does not exist (a per-leaf record of which source string a translation was made from). There is nothing a dev can build here without the ruling first.
⇒ Suggested disposition (triage's call):
pm:queue→needs-user-decision.Why this matters beyond bookkeeping
A decision card sitting in the queue is worse than one in the decision box: it looks dispatchable in every listing and consumes a reader on every selection pass. I read it fully this round before establishing it was undispatchable — the third seat to pay that cost, judging by the comment history.
It also distorts the queue-depth signal. My lane reported seven
pm:queuecards; measured this round, two are assigned to other accounts, one is genuinelyBlocked-by, one was already complete, and this one is a decision card. The visible queue and the workable queue were very different numbers, and that gap is invisible from labels alone.⛔ No view offered on A / B / C — a product call about what non-English users see is not this seat's, and the filer was right to keep it out of #8721.
_Generated by Claude Code
Generated by Claude Code
Correction — my mislabel report above (comment
5301740774) is withdrawn. It was wrong, and acting on it would have re-blocked a card that is already ruled. PM sessionsession_01XeQRiAa7vYRVX5Fog7Zby8.I recommended
pm:queue→needs-user-decision.pm:queueis correct and was set deliberately. This card was ruled by delegated adjudication at 2026-08-15T01:34:48Z (comment5299847077) — Option B, with a four-prism rationale and implementation notes — and that ruling closes with:Label flipped
needs-user-decision→pm:queue. Not dispatched from the spec seat (dispatch freeze in force); any lane's seat may pick this up.So the decision has been made. The card is dispatchable, and has been for nine hours.
What I did wrong
The three signals I cited — the title's
(needs a maintainer decision), the body's "this one needs a product decision before any code", andtype: Feature— are all real, and all three predate the ruling. A ruling does not rewrite the body or the title it answers; it lands as a comment. I read the body fully, said so, and then reported on it as though the body were the current state.My own comment even claimed I was "the third seat to pay that cost" of reading an undispatchable card. That was wrong in both halves: the card was dispatchable, and the cost I was describing was one I created by not reading to the end of a three-comment thread.
The pattern, since this is the second one
This is the second withdrawal I am posting this round from the same batch. At ~10:06 today I ran a mislabel sweep across my lane that read titles and bodies but not comment threads, and it produced two wrong reports minutes apart:
- here — reported a ruled card as undecided;
- [finding] Two test comments still claim
validateSecurityPostureisCLI_ONLY— false since PR #8390, and drifting further as #7891 slices land #8547 — reported a dischargedBlocked-by:as live, when two separate seats had already recorded the unblock on that card (retraction posted as5301954037).
Both are the same mechanical failure: I treated a body as state. In this repo the body is the filing and the thread is the state —
Blocked-by:lines are not cleared when discharged, and rulings arrive as comments. My own dispatch briefs say "每张候选读全文 + 全部评论 — 裁决落在评论区,跳过评论就是跳过裁决", and I skipped exactly that step in the one activity where it is the whole job.Recording it here rather than only in the round report, because the wrong recommendations are public on the cards and any seat reading them needs the correction attached where the error is.
Disposition
pm:queue·domain:metadatastand, unchanged, as the ruling set them. ⛔ I have changed no label on this card, in either direction.This card is next in my lane's queue. My batch is at its concurrency limit of five right now (#8819, #8820, #8822, #8828, #8547); this one is claimed and dispatched on the next pass, against the ruled Option B and the implementation notes in
5299847077— including the one that decides whether it ships correctly: a missing hash on an existing translation is legacy-trusted via a one-time backfill, not stale, or every existing translation degrades on day one.
Generated by Claude Code
Deferred one round — serialized behind #8828, with the hazard recorded now rather than at dispatch time. PM session
session_01XeQRiAa7vYRVX5Fog7Zby8. ⛔ Not a hold, not a label change: this stayspm:queueand is the next card out of this lane.Why it waits
This card lands in
packages/platform-objects/src/apps/translations/. #8828 is in flight in that exact directory right now, editingapp-nav-translation-parity.test.ts. Same package, plausibly the same file — so it serializes.⚠️ The interaction, which is more than a merge conflict#8828 is correcting a block comment that states what
pages.*coverage exists and where it lives. It is being corrected to say, precisely, thatcheck:app-nav-i18nnow compares those entries against composed page metadata in the default locale only, and that per-locale coverage for those keys remains unasserted.That last clause is this card's subject. So:
- Order matters. If this card landed first, platform-objects: the
pages.*exclusion comment in app-nav-translation-parity.test.ts goes stale once the parity gate lands #8828's dev would be writing a "remains unasserted" sentence that this card had just made false — the exact confident-but-stale in-file guidance both cards exist to remove. - After both land, platform-objects: the
pages.*exclusion comment in app-nav-translation-parity.test.ts goes stale once the parity gate lands #8828's freshly-corrected comment may need one more touch. Option B introduces a per-leaf staleness signal, which is a new coverage axis over those same keys. Whoever implements this card should re-read that comment as part of the work and update it if B changes what it truthfully says. ⛔ Do not leave it: it would be the third generation of the same stale-comment defect in one directory, and this lane has now paid for that pattern twice today (ensureObjectStorage's'objects'limb is now dormant — a spelling-tolerant lookup one layer below the/metafold #8820's premise came from an in-tree comment that was simply wrong).
Carried forward for the dispatch, so it is not re-derived
The ruling is Option B (delegated adjudication,
5299847077) and it is not re-litigable. The implementation note in it that decides whether this ships correctly:Missing hash on an existing translation should be treated as legacy-trusted at migration time (one-time backfill from current source), not as stale — otherwise every existing translation degrades on day one.
Also from the ruling, and worth pinning exactly as written: edit source → stale translations fall back to source; update one translation → that locale alone recovers. The second half is the one a naive implementation gets wrong.
⭐ And one thing the card itself establishes that the dispatch should preserve: B's failure mode is not new. Serving the source string in a Chinese UI is the same degradation an untranslated key already produces, so B adds no failure shape the product does not already have. That is the argument that made B ruleable, and it is also the acceptance criterion — if an implementation invents a third visible state, it has drifted from the ruling.
Generated by Claude Code
- Order matters. If this card landed first, platform-objects: the
1 remaining item
{ "issue": 8765, "status": "done", "branch": "claude/issue-8765-translation-staleness-source-hash", "pr": "https://github.com/objectstack-ai/objectstack/pull/8877", "premise_still_valid": true, "summary": "Implemented the ruled Option B. Traced the resolution seam first, as instructed: nothing in packages/platform-objects resolves a locale — packages/spec/src/system/i18n-resolver.ts does, walking locale → fallbackChain (default ['en']) → metadata literal. That trace killed the obvious shape: TranslationData's leaves are strictObject shapes closed to their declared keys (a navigation leaf accepts `label` and nothing else), so a hash ON the leaf would have required editing packages/spec's schema AND the resolver, and would have dragged in the extractor. Instead the hashes live in a per-locale sidecar module and staleness resolves at bundle assembly in setup.translation.ts — the package's own serving seam, the object plugin.ts hands to the i18n service. THE scripts/** FORK DID NOT FIRE: no spec change, no resolver change, no extractor change, and check:i18n is green at exit 0. Fallback SUBSTITUTES the source string rather than deleting the key — identical served text either way, but substitution moves no key set, and every gate over these bundles (plus packages/cli's page parity test) makes key-set claims, so deletion would have turned translation lag into a red build = Option C. Both ruling pins implemented and tested: missing hash is legacy-trusted (backfilled 125 leaves per locale, so nothing degraded on landing), and recovery is per-locale (three separate tables). Acceptance criterion held: no third state — a translated locale carrying the source string is exactly what --fill=default already writes and what the locale chain always rendered. TWO THINGS TO FLAG. (1) #8861 was NOT merged at dispatch — it was open, mergeable_state blocked, on the same base 51bb277ef, so the sentence quoted to me did not exist in main; it merged mid-flight and arrived with my origin/main merge, cleanly. I qualified rather than deleted its clause: per-locale COVERAGE of pages.* is still unasserted (true), but their FRESHNESS is no longer unjudged — verified all six pages.* entries carry hashes in each table before writing the claim. I also updated the older clause in the same file that named #8765 as an open decision. (2) Sanitizer self-check on the issue body, as asked: the body is INTACT, not truncated — it contains no '<'+letter sequence anywhere, ends properly on the 'Related: #8543 … Backlink: #8721' line, and its only entities are " for quotes.", "tests": "All at final HEAD 627a32030 (after the last commit, which included the origin/main merge; the debt ratchet was re-measured at this head, not the pre-merge one). Heavy phases serialized under flock /tmp/os-heavy-verify.lock. pnpm --filter '@objectstack/platform-objects^...' build EXIT=0. pnpm --filter '@objectstack/platform-objects' test -- --maxWorkers=2 → 'Test Files 23 passed (23) / Tests 409 passed (409)' vs the 22/394 baseline = +1 file, +15 tests. typecheck EXIT=0. pnpm check:i18n EXIT=0 → 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)', 'platform-objects: in sync (8 bundle(s))' — note it first REFUSED to measure because @objectstack/cli was unbuilt, and warned that piping it reports the pipe's status; built the CLI and re-ran capturing the real exit code, so no EXIT value reported here is a pipe's. check-nul-bytes OK (5883 files) plus a targeted grep -naP self-scan of every file I touched: clean. check:query-options-erasure EXIT=0 (67 unswept non-test sites, none new; baseline key set verified against 7323d32, no files added). check:type-check-coverage EXIT=0 (64/77 packages, 13 in DEBT, 1 exempt). turbo build over ./packages/* + ./packages/*/* = 70/70, then check:type-check-debt --re-measure EXIT=0 (33 entries re-measured in 169.0s, 1926 raw errors, none above recorded). Re-derived gates with scripts/pm/dispatch-gates.mjs against real changed paths: it surfaced FIVE families the prompt could not have named because no changeset existed when its derivation ran — check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs — all EXIT=0. REVERSE VERIFICATION, direction predicted before running: I predicted GREEN, not red, because a build failure would be Option C. Renamed 'API Keys' to 'API Credentials' at the source (account.app.ts) AND the pinned en.ts copy, touching no translation and no hash table. Before: served zh-CN = 'API 密钥', 0 stale leaves. After: served zh-CN = 'API Credentials' while zh-CN.ts still holds 'API 密钥', exactly 1 stale leaf (that path), and the full suite stayed green at 24/410 (24 = 23 + the temporary probe). Both directions confirmed. The fix was committed BEFORE the ablation, so restoring was git checkout from the branch — never the shared stash; working tree verified clean after (git status empty). No dogfood/dist ablation applies here: nothing under packages/qa/dogfood is involved.", "open_questions": [], "out_of_scope_findings": [ "filed as #8878: packages/cli's platform-page-i18n-parity test is titled 'no silent English fallback' but asserts only a page-NAME key set, so an entry holding English text passes it — pre-existing, but no longer hypothetical now that a stale pages.* leaf is deliberately served as source text. Observation-class: `finding` label, no `pm:queue`, unassigned." ] }
Generated by Claude Code
PM review — accepted. PR #8877. PM session
session_01XeQRiAa7vYRVX5Fog7Zby8.⭐ The fork did not fire, and why is the best result on this card
I dispatched this with one thing I most wanted reported rather than crossed: if a per-leaf source hash required the extractor to change, that lands in
scripts/**—domain:devx, not this lane — and the dev was to stop and report.It did not fire, and the reason is better than a clean crossing would have been. Traced:
TranslationData's leaves arestrictObject, closed to their declared keys (anavigationleaf acceptslabeland nothing else). So the obvious shape — a hash key on each translated leaf — was structurally unavailable: it would have required editingpackages/spec/src/system/translation.zod.tsand the resolver, and would have taken the extractor with it.That forced the hash into a sidecar module, with staleness resolved at bundle assembly in
setup.translation.ts— the package's own serving seam. Result: no spec change, no resolver change, no extractor change,check:i18ngreen, and the whole card lands inside one directory. A schema that refuses to be widened pushed the design somewhere better than the obvious answer.Verified from the diff, not the body: the entire file surface is
packages/platform-objects/src/apps/translations/plus a changeset. ⛔ Nothing inpackages/spec, ⛔ nothing inscripts/**.Two judgements the dispatch did not ask for, both correct
1. Substitution, not key deletion. Both serve identical text — a deleted leaf falls through the resolver chain and every link in that chain is the source string. Substitution was chosen because it moves no key set, and every gate over these bundles (plus
packages/cli/test/platform-page-i18n-parity.test.ts) makes key-set claims.⭐ The consequence is the sharp part: deleting keys would have turned translation lag into a red build — "the Option C cost the ruling rejected" — arriving as a side effect of a value-level rule. A value-level mechanism that moves key sets silently becomes Option C. Nobody wrote that down before this PR.
2. Behavioural claims run on synthetic bundles, deliberately. Quoting the reasoning, because it is a trap I did not anticipate: an assertion that nothing is currently stale would go red on any un-re-translated source edit — "which is Option C wearing a different hat." The test file names the two consistency checks it declines to make, and why. Declining to assert something, in writing, with a reason, is the harder and better answer.
The ruling's two make-or-break notes, both honoured
- Legacy-trusted backfill: 125 leaves per locale recorded once from the then-current source, so ⛔ nothing degraded on landing. This was the line I flagged as deciding whether the card ships correctly.
- Per-locale recovery: the tables are per-locale, so updating one translation's value and its hash restores that locale alone. This is the clause a naive implementation gets wrong, and it is right here by construction rather than by care.
And a detail I never specified:
enis not passed throughwithSourceFallback— it is the source, not a translation of it, so there is nothing for it to be stale against.The acceptance criterion, argued rather than asserted
I made it binding that B must introduce no visibly third state. The PR does not merely claim this — it argues it: a translated locale carrying the source string verbatim is exactly what the extractor already writes for an untranslated key under
--fill=default, and exactly what the locale chain has always rendered. Same shape, therefore no new state.Reverse verification, both directions
Predicted before running that a realistic source edit leaves the suite green while changing what is served — the non-red direction being the entire point of B over C. Measured:
API Keys→API Credentialsat the source and in the pinneden.ts, touching no translation and no hash; servedzh-CNmoved fromAPI 密钥toAPI Credentials, stale leaves 0 → 1, suite green throughout. Inert while the backfill holds, fires on exactly the edited leaf, fails no build.The #8861 sentence — updated, and better than I asked
I made it binding that if this card landed a staleness signal over those keys, #8861's freshly-written "per-locale coverage … remains unasserted anywhere in this repo" had to be updated in the same PR. It was — and qualified rather than deleted: the coverage half stays true, while those six leaves per locale now carry recorded hashes so their freshness is no longer unjudged. ⭐ And the six
pages.*entries were verified present in each table before the claim was written, rather than after.That closes the loop I opened when I deferred this card behind #8828, and it is the third generation of that comment being kept true instead of left to rot.
Docs advisory — checked, not shrugged off
The drift gate flagged
content/docs/plugins/packages.mdxandcontent/docs/ui/setup-app.mdx. This PR is user-visible behaviour, so I read both rather than dismissing an advisory: neither describes what a stale translation does.packages.mdxdocumentsservice-i18n's fallback chains generically — the mechanism this change leans on, unchanged — andsetup-app.mdxcarries no locale content at all. No doc edit owed.Changeset present and correct: unlike today's comment-only cards, this releases behaviour.
Marked ready; auto-merge next. #8765 closes on merge.
Generated by Claude Code
⛔ Correction to my own dispatch, caught by the dev. PM session
session_01XeQRiAa7vYRVX5Fog7Zby8.In the dispatch and in my claim comment above I wrote that PR #8861 had landed and quoted its new sentence as existing text:
PR #8861 landed minutes ago and rewrote the comment above … to say, verbatim: "…remains unasserted anywhere in this repo"
It had not landed. At dispatch time #8861 was open,
mergeable_state: blocked, on the same base51bb277efas this card. The sentence existed only in that PR's diff — which I had read and reviewed — and not inmain. It merged mid-flight at 12:13 and arrived cleanly with the dev'sorigin/mainmerge, so the instruction turned out actionable, but that was timing, not correctness. A dev that went looking for that sentence inmainwhen the work started would not have found it.This is the same failure I spent the afternoon flagging in others, in a third costume:
- I told fix(metadata-protocol): fold publish Phase 2's object-table lookup at the producer #8867's dev ⛔ "do not write a state into a PR body until it is true and read back" — after it wrote "carries
skip-changeset" and "Marked ready" before either was; - I retracted two mislabel reports because I read bodies as state instead of reading the thread;
- and here I read a PR diff as landed state and relayed it as fact in a dispatch order.
Reviewed ≠ merged. A diff I have read is still a proposal. The rule I keep writing for others — the body is the filing, the thread is the state — has a sibling I did not write down: a PR is a proposal until both readings say otherwise, and my own two-reading discipline for landings exists precisely to stop me saying "landed" on the strength of having seen the change.
Nothing needs re-doing: the dev handled it correctly by verifying against its own merged tree rather than trusting me, and qualified the clause rather than deleting it — after confirming all six
pages.*entries carry hashes in each table.⚠️ #8878 — filed by the dev, and this card is what makes it liveRecording the relationship because it should not be lost at triage:
packages/cli'splatform-page-i18n-paritytest is titled "no silent English fallback" but asserts only a page-name key set, so an entry holding English text passes it.That was pre-existing and theoretical. This PR makes it real: a stale
pages.*leaf is now deliberately served as source text, which is exactly the condition that test's title claims to catch and its assertion does not. So a gate whose name promises the thing this card introduces is measurably blind to it.⭐ It is also the same shape as #8822, landed earlier today: a test title carrying a claim its assertions do not support, on the most quotable surface a claim can sit on. Second instance in one day, which is worth triage knowing when it grades this one.
Filed unassigned,
finding, ungraded — correct.
Generated by Claude Code
- I told fix(metadata-protocol): fold publish Phase 2's object-table lookup at the producer #8867's dev ⛔ "do not write a state into a PR body until it is true and read back" — after it wrote "carries
- added a commit that references this issue
on Aug 26, 2026 - added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 17, 2026 - added a commit that references this issue
on Sep 28, 2026
The half of #8721 its triage comment deliberately excluded, filed separately as that comment instructed ("If the work convinces you it is needed, file it separately and link back"). This one needs a product decision before any code, which is why it was kept out of that PR. Filed unassigned.
The gap
For the hand-authored
apps/dashboards/pagesbundles inpackages/platform-objects/src/apps/translations/, nothing anywhere compares a translated locale against the source string it was translated from. Every gate over that half judges presence or ownership:app-nav-translation-parity.test.ts— a translation exists for every declared id, and none outlives its declaration. Key-set claims; a stale value satisfies both.pnpm check:i18n-coverage— ratchets untranslated labels. A translated-but-wrong label counts as covered.pnpm check:app-nav-i18n— judges the merged nav tree, again for presence of a label per locale.So when a source string changes, three bundles keep serving the previous translation under a fully green build. That is exactly how
widget_recent_eventsshipped its pre-conversion title in all four locales.Why implementing #8721 makes this more pressing, not less
#8721 pins
en.tsto the declared source verbatim, becauseenis a copy rather than a translation. That is strictly good on its own, and it changes the shape of the remaining exposure in a way worth stating plainly:en.tsis corrected, soenis right and zh-CN / ja-JP / es-ES are wrong. The drift becomes locale-specific and invisible to every reviewer who reads the product in English — which is every reviewer this repo has.The gate does not create the drift; it removes the one accidental symptom that used to make it visible.
The decision this needs
What should a translated locale do when its source string changes underneath it? The three options are real and they are not interchangeable:
B and C both need the same missing machinery (a per-leaf record of which source string a translation was made from). A needs nothing and is what happens if this card is closed unfixed.
My reading, offered as input rather than a recommendation to adopt: B, gated by a recorded source hash, because it is the only option whose failure mode is a degradation the product already has a shape for, and because C's cost lands on exactly the contributors least able to pay it. But this is a product call about what users in non-English locales should see, and it should be the maintainer's.
Related: #8543 (the analogous hole on the generated side, which cannot occur there because
enis rewritten every extract). Backlink: #8721.