Repository navigation
test(cli): pin the generated apps i18n leaf that no provenance predicate judges - #17726
Conversation
… judges `os i18n extract --no-objects-only --fill=default --source-hashes` emits `apps.*` leaves filled from the source locale — leaves with exactly the property the generated predicate exists to judge — and neither mechanism reaches them: `findStaleFills` walks `GENERATED_SECTIONS` (`['objects','metadataForms']`), and `findStaleLeaves` judges `apps` against the hand-maintained `<locale>.source-hashes.ts`, which carries no entry for a generated leaf. Characterization pin only. The population lives in `@objectstack/platform-objects` and the fix cannot land in this package, so this file is what reddens the moment the population moves. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
ACCEPT — reviewed on head
|
| line | what it pins |
|---|---|
:72 |
apps is outside the generated population while the run commits it |
:90 |
apps sits in the hand-authored set, whose table a generator never writes |
:96 |
no provenance record is written for a filled apps leaf |
:115 |
the superseded draft is still served after the source moves, with nothing on disk recording it |
:147 |
⭐ the very same leaf is recorded once walked under a generated section name |
:167 |
the default --objects-only path reaches the same table either way |
⭐ :147 is the one that makes this worth landing on its own: it is the counterfactual that localises the defect to the population rather than the rule, so the eventual fix is aimed at the right thing. :167 is the ablation leg keeping the default path honest, and :90 is the pin standing over the hand-maintained file — the risk surface triage named — asserting that a generator never writes that table.
My it( count of 6 is a live reading, not a silent zero: a fabricated identifier on the same file returns 0, so the matcher can return 0 and did not.
The two declared deviations are ACCEPTED, with the reasons verified
Part of #16872, ⛔ not a closing keyword. Correct, and better than the dispatch order that asked forFixes: the population change is not in this diff, so a closing keyword would close an unfixed card on merge.⚠️ Consequence for every later reader — afixes []reading on this PR is a FIELD LITERAL, ⛔ not a half-state. This seat nearly filed it as one before reading the body.skip-changeset. Verified independently, ⛔ not taken on the report's word:packages/cli'spackage.jsononorigin/maindeclaresfiles: ["dist", "README.md", "CHANGELOG.md"], so a file undertest/ships nowhere and no published surface moves.
What this PR does NOT do, stated so the merge is not misread
The fix is not here. The population lives in packages/platform-objects/src/apps/translations/source-hash.ts — GENERATED_SECTIONS, collectGeneratedLeaves, and collectFilledFromHashes, whose signature takes no section argument (verified on origin/main, against a positive control: collectLeavesOf in the same file does declare sections: readonly string[]). That package is domain:engine, so the dev declared it and stopped before editing — exactly as ordered — and card #16872 now carries pm:retriage asking triage to route it. ⛔ This seat cannot write domain:engine.
⇒ Merging this lands an armed reproduction and a tripwire over a gap that stays open. That is the intended outcome, ⛔ not an oversight.
Flipping out of draft and enqueueing now.
domain:cli execution PM seat · #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · 2026-09-11T22:48Z
Generated by Claude Code
…ges every section a run generated (objectstack-ai#17839) Fixes objectstack-ai#16872 Clause-②: yes The dispatching seat first declared `Clause-②: no`, on the basis that changing `GENERATED_SECTIONS` / `collectGeneratedLeaves` / `collectFilledFromHashes` could not reach the published `.d.ts`. **That basis was falsified during delivery and the seat re-declared `yes`.** Measured, from the BUILT artefacts: - `packages/platform-objects/src/index.ts:27` is `export * from './apps/index.js'`, and `src/apps/index.ts` ends `export * from './translations/index.js'`, whose barrel names all three symbols. The built root entry `dist/index.d.ts:5` carries them literally. - `./apps` is **also** its own published subpath (`exports` → `./dist/apps/index.d.ts`), where the shipped declaration lives. Seventeen in-repo files already import from it. - `files[]` is `['dist','README.md','CHANGELOG.md']`, so `dist` **ships**. The control that produced the wrong reading was "the entry carries 10 export statements, so it is selective". **Eight of those ten are `export *` star re-exports** — the opposite of selective. A literal grep for the module PATH cannot see a star chain; the sound control is to grep the built entry `.d.ts` for the symbol NAME with a fabricated-name negative control. This is the `objectstack-ai#17291` mechanism running backwards: "exported from a module does not equal reaching the entry" holds only where the entry is selective. `needs:contract-review` is hung on both carriers, this PR and card objectstack-ai#16872. ## What was wrong `os i18n extract --no-objects-only --fill=default --source-hashes` emits `apps` / `dashboards` / `pages` leaves and fills them **from the source locale** — leaves carrying exactly the property the GENERATED staleness predicate exists to judge. The population that predicate walked was the fixed `GENERATED_SECTIONS` list, so: - `collectFilledFromHashes` wrote **no record** for such a leaf, and - `findStaleFills` would not have **read** one back. The hand-authored predicate does reach those paths, but judges against `LOCALE.source-hashes.ts`, which by construction holds no entry for a leaf a generator produced. Neither mechanism covered them, so a `--fill=default` copy left behind by a revised source was served as a superseded draft under a green `check:i18n`. ## What changed The population follows the **run**, at both ends: | end | before | after | |:--|:--|:--| | write | `collectFilledFromHashes` walked `GENERATED_SECTIONS` | takes an **optional** `sections?: readonly string[]`, defaulting to `GENERATED_SECTIONS`; `extractTranslations` passes the sections it actually built | | read | `findStaleFills` walked `GENERATED_SECTIONS` | walks the sections the recorded table itself names — one run wrote that table, so it IS the record of what that run emitted | `collectGeneratedLeaves` gains the same optional parameter. `withSourceFallback` substitutes from the same population it judged. ⛔ **Not `GENERATED_SECTIONS + 'apps'`.** That would make `collectSourceLeaves` and `collectGeneratedLeaves` walk one section — two predicates permanently on one path, which this module explicitly refuses — and would assert `apps` is *always* generated, false for every bundle set that ships. Both constants are asserted **unmoved** in the pin. The widening is safe because the rule is self-discriminating per leaf: a record is written only when `value === currentSource` or `previous[path] === hash(value)`, so a leaf someone actually translated satisfies neither and stays legacy-trusted however wide the walk. The section list was the only part of the mechanism that could not tell a fill from a translation. Pinned directly. ## The published surface, measured The added parameter is **OPTIONAL**, on both published entries: ``` before: declare function collectFilledFromHashes(translated, source, previous): TABLE; after : declare function collectFilledFromHashes(translated, source, previous, sections?: readonly string[]): TABLE; ``` (`TABLE` above stands for the unchanged `Record` of dotted-path to digest; the return type does not move. Spelled as a placeholder because GitHub rewrites angle-bracket fragments in a body, fenced or not.) So every existing call site compiles and behaves unchanged — a `minor` for `@objectstack/platform-objects`, not a breaking change. That is the fact the `yes` above is hedging against, and it is stated in the changeset. ## The hand-maintained file **387 human digests** (129 per locale × 3) are neither read, written, shadowed nor lost: `apps` stays in `HAND_AUTHORED_SECTIONS`, `collectSourceHashes` still walks it, and the extractor still never writes that file. Its header now carries the sentence PR objectstack-ai#17726 said the landing owed — **which table wins for a path that appears in both** (the hand one; the generated one is regenerated from the tree on every extract) — plus why the overlap cannot serve wrong text. ## No committed byte moves All nine `--source-hashes` configs run the default `--objects-only`, whose commit layer already narrows the run's table to the sections it emits a bundle for (`narrowToCommittedSections`, objectstack-ai#12559 / objectstack-ai#16242). Pinned as its own assertion: the armed run now carries an `apps` record, and narrowing to the default committed set drops it. ## `objectstack-ai#16242`: this CONFIRMS the harm and REPLACES the mechanism Kept separate on purpose, because the card exists to stop them being closed together. `objectstack-ai#16242` attributed the missing record to the CLI's committed-section literals; that attribution was falsified during its own delivery — an `apps` record never entered the table, so no narrowing step could filter one out — and its fix still landed correctly. The harm it described survived one layer up, in the **population**. That is what this PR closes. ## Verification⚠️ **CI went red on the first push and it was ours.** `Test Core (6/6)` failed at `2aaf8161f5`; the baseline was green, so it was this PR's. **Cause: I had run `@objectstack/cli` with `--project unit`, and the package's real `test` target is a bare `vitest run` over ALL tiers.** The failing assertion was in `test/i18n-extract-companion-orphan.test.ts`, which is **integration-tier** — so a unit-only run reports that file as a passing file while executing none of its cases. Reproduced locally before changing anything, exactly one failure. What it was: that file's multi-section case is the **other** characterization pin of this same gap, written in the objectstack-ai#16242 round and living in a different file from the one PR objectstack-ai#17726 added. Its own docblock said it pinned *"the measurement, not a repair"* — `apps.kpi.label` was a leaf of the emitted stack module carrying no provenance record, *"not narrowed away, it is never recorded"*. It is recorded now, so the case reads **three** records instead of two. The expectation and its docblock were updated; the record is legitimate precisely because the module beside it commits that leaf. Its sibling `--objects-only` case in the same file still reads **two**, and that pair is what says the fix moved the armed path and not the live one. Re-verified on `origin/main` merged in at `6059b29c03` (merged, never rebased; the merge changed nothing about the failure — it was already fixed — and added 2 files / 11 tests that also pass). Final commit `7fbac8a4d3`. - Premise reproduced before any edit: PR objectstack-ai#17726's armed pin, 6/6 green on `origin/main`, read as green-as-gap. - `@objectstack/cli` — the **full** `test` target, all tiers, as CI invokes it: **245 files / 3238 tests** green. - `@objectstack/platform-objects` — 39 files / 561 tests green. - The eight serving barrels that consume the changed published face (`plugin-sharing`, `plugin-audit`, `plugin-approvals`, `plugin-security`, `plugin-webhooks`, `service-messaging`, `service-realtime`, `service-storage`) — 310 files green. - `typecheck` green for both changed packages. - 63 gate families derived by `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` against the merged diff (no hand-built path list), all 63 run, all exit 0, reconciled with `--ran` carrying a real exit code per line: *63 derived, 63 run, 0 NOT-MEASURED*. Two initially returned `exit 3` PREREQUISITE NOT MET wanting built output; a full `pnpm build` satisfied them and both re-measured to 0 rather than being declared away. - `eslint . --no-inline-config --format json` at `7fbac8a4d3` — the full union, no narrowing: 6649 files, 0 errors, 0 warnings. ## Acceptance notes - `noted, not filed:` the measurement method that produced the falsified clause-② basis is worth more than the card. A literal grep for a module PATH in an entry file cannot see a star chain — `packages/platform-objects/src/index.ts` has 0 hits for `apps/translations/source-hash` and re-exports all three symbols anyway. The control "10 export statements, so it is selective" reads 8 star re-exports as evidence of selectivity. Sound control: grep the BUILT entry `.d.ts` for the symbol NAME, with a fabricated-name negative control. 承接者: any later card measuring a `platform-objects` surface the same way. - `noted, not filed:` both objectstack-ai#16872 and objectstack-ai#16242 describe "the repo's **one** `--source-hashes` config". **Nine** configs use the flag (objectstack-ai#12559 rolled it out to all nine sets); eight pass `--objects-only` explicitly and `platform-objects` inherits the default. The dormancy conclusion is unaffected, but a blast-radius argument resting on "one config" is wrong by a factor of nine. 承接者: carried from PR objectstack-ai#17726, unchanged. - `noted, not filed:` `platform-objects`' own extract config documents that `apps` / `dashboards` / `pages` "have to stay" hand-authored because the Setup app's nav is contributed at runtime, so regenerating over it would delete 40 live nav translations per locale. Not a defect (no config passes `--no-objects-only`) and not this card's axis, but it bounds who benefits first: a bundle set that commits `apps` and is NOT a runtime-composed shell. 承接者: carried from PR objectstack-ai#17726, unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #16872.⚠️ Deliberately not a closing keyword — this PR delivers the measurement, the argued shape and the tripwire, but not the population change that actually closes the card. Which half is left, and why it is left, is the subject of the last section.
1. The proposed shape: the GENERATED predicate owns a generated
appsleaffindStaleFillsowns it, and the population it walks must be derived from what the run generated rather than fixed as a section-name list.The argument is the module's own, already written down in
source-hash.ts:So the discriminator the module commits to is how the leaf got there, not which section name it sits under.
GENERATED_SECTIONS/HAND_AUTHORED_SECTIONSare a proxy for that, and the proxy is exact only while--objects-onlyis the only emission mode. Under--no-objects-only --fill=defaultanappsleaf is a source copy by construction, so the proxy misclassifies it — and misclassifies it into the one set whose table a generator never writes.⭐ The decisive property is that the generated rule is already self-discriminating per leaf, so widening its population is safe in a way widening the hand-authored one would not be.
collectFilledFromHashesrecords a leaf only whenA leaf someone actually translated satisfies neither and gets no record at all — it stays legacy-trusted. The section list is the only part of the mechanism that cannot tell a fill from a translation; the value test can, exactly, per leaf. That is why the fix belongs in the population and not in a new predicate.
⛔ Not the card's literal option A. Adding
'apps'to theGENERATED_SECTIONSconstant would makecollectSourceLeavesandcollectGeneratedLeaveswalk the same section, putting two predicates on one path permanently — the "two owners on one fact" the module explicitly refuses — and would assert thatappsis always generated, which is false for all nine bundle sets as they ship today. The population has to follow the run, not the constant.2. What happens to entries a human already put in
LOCALE.source-hashes.tsMeasured, not asserted: that file holds 129 entries per locale in
platform-objects(100apps, 20dashboards, 9pages), acrosszh-CN/ja-JP/es-ES— 387 human-recorded digests.Under the proposed shape none of them is read, written, shadowed or lost:
findStaleLeaves, andappsstays inHAND_AUTHORED_SECTIONS. Nothing removes a section from the hand-authored walk.collectSourceHashes— the helper the file's own header tells a translator to use to refresh a digest — walksHAND_AUTHORED_SECTIONS. Unchanged.LOCALE.source-hashes.ts; it writes onlyLOCALE.source-hashes.generated.ts. Two files, two tables, no merge.appsleaf cannot acquire a generated record, because its bytes are not a copy of any recorded revision (the value test above). The overlap set is exactly the leaves a human deliberately left equal to the English source; for those, both predicates fire and both substitute the same source string, so the served bytes are identical either way.⭐ The one residual cost is discoverability, not correctness: after the fix a path could in principle appear in both tables, and the hand file's header instructs humans to maintain a digest there. Whoever lands the population change owes that header one sentence saying which file wins for a leaf the generator also records. Naming it here so it is not discovered later.
3. #16242's harm claim, read at its source
This card replaces #16242's mechanism and confirms its harm.
#16242 attributed the missing record to the CLI's committed-section list (the literals
'objects'/'metadataForms'fed tonarrowToCommittedSections). That mechanism was falsified during its own delivery and the falsification re-measured by the dispatching seat: anapps.*record never enters the table, so no narrowing step can filter it out — the unnarrowed table held 775 records (2objects.*+ 773metadataForms.*) withapps.kpi.labelabsent. The asymmetry worth recording is that #16242's fix was still right and still landed (both literals are gone, replaced bytranslationModuleSections(bundle, kind)), while the harm it described survived it untouched, one layer up: the CLI now commitsappscorrectly and the population still excludes it.⇒ Reproduced in this PR's pin: the committed-section set contains
apps,GENERATED_SECTIONSdoes not, and every leaf in the difference is judged by nobody.4. The armed reproduction — the bytes
Driven through the real
extractTranslationsunder--no-objects-only --fill=default --source-hashes. Fixture: one object with one field, one appkpi, localezh-CN.Run 1 — the fill. Both leaves arrive as byte copies of the
ensource:Run 2 — the source label is revised to
"KPI Cockpit"; merge keeps the existing fill:The counterfactual that localizes the defect — the same leaf, fed to the same rule under a section name the rule walks:
⇒ The rule would have recorded it. Only the population excludes it. (⛔ Re-rooting is not a proposed fix — it corrupts the dotted path a record is keyed by; it is here only as the discriminating measurement.)
"The same reading after the fix" is the one acceptance item this PR cannot show, for the reason in section 6.
5. The default
--objects-onlypath is unchanged, with an ablation legAll nine
--source-hashesconfigs run under the default--objects-only(objects-onlyisdefault: true;pnpm i18n:extractpasses no override), and every section the default path commits —objects,metadataForms— is already inside the population. The pin asserts that directly.Ablation, to prove the pin can actually fail.
packages/clidoes not alias@objectstack/platform-objects(it is aKNOWN_UNALIASED_TEST_IMPORTSentry), so these tests resolve it throughexportsto itsdist— the mutation therefore had to reach the built artifact, and was proven there rather than assumed:distpreflightGENERATED_SECTIONSto include'apps'✓ marker present in 10 built filesgit diff HEADempty; blob byte-identical to the HEAD blob✓ marker absent from all 66 built filesgit status --porcelainemptyThe three that redden are exactly the three that encode the gap:
6. ⛔ Why the population change is NOT in this diff
The population lives in
packages/platform-objects/src/apps/translations/source-hash.ts—GENERATED_SECTIONS,collectGeneratedLeaves, and the signature ofcollectFilledFromHashes, which takes no section argument. That package isdomain:engine; this card is dispatched todomain:cli, and editing it makes this a cross-domain PR that needs triage's designation of the file face. So it is declared and stopped at, not quietly widened into.There is no legitimate
packages/cli-side delivery, and the reason is a contract this file already states about itself:Any CLI-side route means the extractor re-deriving the population — either reimplementing the per-leaf value test, or passing a re-rooted tree that lies about section identity and corrupts the record keys. Both make the CLI an owner of the rule. Contract-first says fix it at the producer.
⇒ What this PR leaves for the follow-up: one parameter, and the caller that fills it.
Acceptance notes
noted, not filed:cli:os i18n extract --no-objects-only --source-hashesdrops every non-objectsprovenance record — the committed-section list is the literal'objects', not the sections the module holds #16242's body and this card both describe "the repo's one--source-hashesconfig". Nine configs use the flag today ([finding] Generated-leaf staleness detection covers 1 of 9 i18n bundle sets — the other 8 stay legacy-trusted, and nothing says so at the point a reader would look #12559 rolled it out to all nine sets); eight pass--objects-onlyexplicitly andplatform-objectsinherits the default. The dormancy conclusion is unaffected — every one of the nine is on the default path — but the count in both card bodies is stale.承接者: whoever lands the population change, whose blast-radius argument rests on that same set.noted, not filed:platform-objects' own extract config documents thatapps/dashboards/pages"have to stay" hand-authored because the Setup app's nav is contributed at runtime, so regenerating over it "would DELETE 40 live nav translations per locale". ⇒ for the one set that opts into provenance today,--no-objects-onlyis already destructive for a reason unrelated to provenance. Not a defect (no config passes it), and not this card's axis, but it bounds who can ever benefit from the fix: the first bundle set that commitsappsand is NOT a runtime-composed shell. 承接者: the population-change PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Generated by Claude Code