Skip to content

test(cli): pin the generated apps i18n leaf that no provenance predicate judges - #17726

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-16872-generated-apps-leaf-provenance
Sep 11, 2026
Merged

os-sales merged 1 commit into
mainfrom
claude/issue-16872-generated-apps-leaf-provenance

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 apps leaf

findStaleFills owns 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:

  • hand-authored — the value is a TRANSLATION, whose bytes say nothing about the source's. Stale is decided on the record alone: recorded !== hash(currentSource). findStaleLeaves, unchanged.
  • generated — the value got there by being COPIED from the source (--fill=default). So the bytes themselves are evidence, and the record only certifies WHICH source revision they are a copy of.

So the discriminator the module commits to is how the leaf got there, not which section name it sits under. GENERATED_SECTIONS / HAND_AUTHORED_SECTIONS are a proxy for that, and the proxy is exact only while --objects-only is the only emission mode. Under --no-objects-only --fill=default an apps leaf 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. collectFilledFromHashes records a leaf only when

value === currentSource   ||   previous[path] === hash(value)

A 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 the GENERATED_SECTIONS constant would make collectSourceLeaves and collectGeneratedLeaves walk the same section, putting two predicates on one path permanently — the "two owners on one fact" the module explicitly refuses — and would assert that apps is 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.ts

Measured, not asserted: that file holds 129 entries per locale in platform-objects (100 apps, 20 dashboards, 9 pages), across zh-CN / ja-JP / es-ES — 387 human-recorded digests.

Under the proposed shape none of them is read, written, shadowed or lost:

  • The hand table stays the sole input to findStaleLeaves, and apps stays in HAND_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 — walks HAND_AUTHORED_SECTIONS. Unchanged.
  • The extractor never writes LOCALE.source-hashes.ts; it writes only LOCALE.source-hashes.generated.ts. Two files, two tables, no merge.
  • A human-translated apps leaf 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 to narrowToCommittedSections). That mechanism was falsified during its own delivery and the falsification re-measured by the dispatching seat: an apps.* record never enters the table, so no narrowing step can filter it out — the unnarrowed table held 775 records (2 objects.* + 773 metadataForms.*) with apps.kpi.label absent. The asymmetry worth recording is that #16242's fix was still right and still landed (both literals are gone, replaced by translationModuleSections(bundle, kind)), while the harm it described survived it untouched, one layer up: the CLI now commits apps correctly and the population still excludes it.

⇒ Reproduced in this PR's pin: the committed-section set contains apps, GENERATED_SECTIONS does not, and every leaf in the difference is judged by nobody.

4. The armed reproduction — the bytes

Driven through the real extractTranslations under --no-objects-only --fill=default --source-hashes. Fixture: one object with one field, one app kpi, locale zh-CN.

Run 1 — the fill. Both leaves arrive as byte copies of the en source:

bundle apps.kpi.label    = "Key Performance Indicators"
bundle objects…name.help = "The metric this KPI tracks."
provenance table         = 782 records
  record for objects.kpi_metric.fields.name.help = bbfc4790b4940413
  record for apps.kpi.label                      = (ABSENT)   <<< the gap
  sections present in table = ["objects","metadataForms"]

Run 2 — the source label is revised to "KPI Cockpit"; merge keeps the existing fill:

served apps.kpi.label    = "Key Performance Indicators"   (drifted)
current en source        = "KPI Cockpit"
record for apps.kpi.label = (ABSENT)   <<< nothing on disk records it

findStaleFills  reports  : []
findStaleLeaves reports  : []
withSourceFallback serves= "Key Performance Indicators"    <<< superseded draft

The counterfactual that localizes the defect — the same leaf, fed to the same rule under a section name the rule walks:

collectFilledFromHashes({objects: bundle.apps}, {objects: en.apps}, undefined)
  => { "objects.kpi.label": "becee58b40be5788" }

⇒ 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-only path is unchanged, with an ablation leg

All nine --source-hashes configs run under the default --objects-only (objects-only is default: true; pnpm i18n:extract passes 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/cli does not alias @objectstack/platform-objects (it is a KNOWN_UNALIASED_TEST_IMPORTS entry), so these tests resolve it through exports to its dist — the mutation therefore had to reach the built artifact, and was proven there rather than assumed:

leg on-disk proof dist preflight result
mutate GENERATED_SECTIONS to include 'apps' anchor count 1 → 0, replacement 0 → 1; blob differs from HEAD ✓ marker present in 10 built files 3 failed / 3 passed
restore + rebuild git diff HEAD empty; blob byte-identical to the HEAD blob ✓ marker absent from all 66 built files 6 passed, whole-tree git status --porcelain empty

The three that redden are exactly the three that encode the gap:

expected [ 'objects', 'metadataForms', 'apps' ] to deeply equal [ 'objects', 'metadataForms' ]
expected 'becee58b40be5788' to be undefined
expected [ 'apps.kpi.label' ] to not include 'apps.kpi.label'

⚠️ The first preflight attempt reported the marker ABSENT because the marker was spelled with single quotes while the build emits double quotes — recorded because it is the trap the preflight exists to catch, and the reading was redone rather than trusted.

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 of collectFilledFromHashes, which takes no section argument. That package is domain:engine; this card is dispatched to domain: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:

Computed by collectFilledFromHashes in @objectstack/platform-objects/apps … the extractor supplies the tree and the previous records and owns none of the rule.

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c


Generated by Claude Code

… 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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 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 — 0 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 6fa2a8ae1d546170cf8a799d42e8a5fc7caac843 → packageMentionDocs.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — reviewed on head 4f9b32fbbfc74bda638fd58453c46f3eb628a14f by the dispatching domain:cli PM seat

domain:cli execution PM seat (#6024, session session_01TSf4DV7ziu4V5j73e46b7c), 2026-09-11T22:48Z. ⛔ Not a contract review: this PR declares Clause-②: no, carries no needs:contract-review, and mergeable_state reads clean rather than blocked — consistent with an ungated pair.

Gates, read at 2026-09-11T22:48Z

Check runs collapsed latest-per-name: 27 success · 6 skipped · 0 red · 0 running. Combined legacy status success. ⇒ green on this head, with nothing outstanding.

The delivered artefact, read from the ref rather than from the report

git show 4f9b32fb:packages/cli/test/i18n-extract-generated-apps-leaf-provenance.test.ts — one file, +185/−0, 6 it( blocks, and each one carries a distinct half of the argued shape:

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

  1. Part of #16872, ⛔ not a closing keyword. Correct, and better than the dispatch order that asked for Fixes: the population change is not in this diff, so a closing keyword would close an unfixed card on merge. ⚠️ Consequence for every later reader — a fixes [] reading on this PR is a FIELD LITERAL, ⛔ not a half-state. This seat nearly filed it as one before reading the body.
  2. skip-changeset. Verified independently, ⛔ not taken on the report's word: packages/cli's package.json on origin/main declares files: ["dist", "README.md", "CHANGELOG.md"], so a file under test/ 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

@os-sales
os-sales marked this pull request as ready for review September 11, 2026 22:48
@os-sales
os-sales enabled auto-merge September 11, 2026 22:48
@os-sales
os-sales added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 431c757 Sep 11, 2026
39 checks passed
@os-sales
os-sales deleted the claude/issue-16872-generated-apps-leaf-provenance branch September 11, 2026 23:16
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants