Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .changeset/i18n-source-hash-rollout-eight-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
"@objectstack/cli": patch
"@objectstack/plugin-approvals": patch
"@objectstack/plugin-audit": patch
"@objectstack/plugin-security": patch
"@objectstack/plugin-sharing": patch
"@objectstack/plugin-webhooks": patch
"@objectstack/service-messaging": patch
"@objectstack/service-realtime": patch
"@objectstack/service-storage": patch
---

chore(i18n): roll the generated-leaf provenance companion out to the remaining bundle sets (#12559)

`os i18n extract --source-hashes` (#11671, maintainer ruling #12069 Option A)
records, per generated translation leaf, the digest of the source revision that
leaf is **still a byte copy of** — the one signal that tells a stale fill from a
real translation once the source has moved and the two stopped being
distinguishable by value. It shipped opt-in, and exactly one of the nine i18n
bundle sets opted in. A landed detector, a changeset announcing it and a green
gate read together as *"generated translation staleness is now caught"*; for
eight of nine sets it was not, and the thing making it not caught was a single
absent flag in an extract config — invisible from all three of those surfaces.

**All eight remaining sets now opt in** — `plugin-approvals`, `plugin-audit`,
`plugin-security`, `plugin-sharing`, `plugin-webhooks`, `service-messaging`,
`service-realtime`, `service-storage`. Each documents `source-hashes` in its
extract config and commits three `<locale>.source-hashes.generated.ts`
companions, produced by the same extract run as the bundles they sit beside
(`check:i18n` compares them byte-for-byte, so they cannot be written by hand).
`check:i18n` now reports 7 bundles per set where it reported 4, and 11 for
`platform-objects` where it reported 8.

**Records count what is currently RECORDABLE, never what is covered.** A record
is written only for a leaf that *is* right now a byte copy of the current
source, so a fully translated locale starts with an empty table — which is the
instrument armed, not an instrument that measures nothing: the entry appears by
itself on the first extract after a leaf becomes a fill. Measured at this
commit, per set over its three translated locales: `service-messaging` 289,
`plugin-approvals` 61, `plugin-security` 33, `plugin-webhooks` 20,
`plugin-audit` 8, `service-storage` 7, `plugin-sharing` 1 (es-ES only; zh-CN and
ja-JP are fully translated and start empty), `service-realtime` 0 (all three
locales fully translated). **419 records written across the eight sets, 0
stale.**

**One extractor fix the rollout forced.** `--source-hashes` had one user, and
that user commits both generated sections, so the interaction with
`--no-metadata-forms` had never been exercised. The provenance table is computed
over every generated section the extractor builds; the eight sets here commit no
metadata-forms bundle, and their `metadataForms` subtree — absent from their
merge baseline — arrives as a fresh `--fill=default` copy of `en`, so every leaf
of it was recordable. First measured on `plugin-audit`: **763 records, of which
2 were its own objects and 761 were digests of the Studio metadata-form baseline
`@objectstack/platform-objects` owns.** Those records are unreadable in the
package holding them and would have rewritten all 24 companions on any unrelated
`*.form.ts` change in `packages/spec` — the cross-package coupling ADR-0029 D8
and every `bundle-ownership.test.ts` keep out of committed bundles. The
companion now covers exactly the sections a run commits, decided by the same two
predicates that decide the bundle files. `platform-objects` commits both, so its
three committed companions are byte-for-byte unchanged.

**Grade: `patch`, and behaviour on the day it lands is unchanged for every
leaf.** A record is written only where a leaf is currently a byte copy of the
**current** source, so every record written equals the current digest and none
of them can be stale; the mechanism cannot arrive red. No committed translation
bundle changed a byte, no public API moved, and no leaf's rendered text changed.
`narrowToCommittedSections` is new but internal to `@objectstack/cli` — the
package's entrypoint does not re-export the extractor utils.

**What this does not do**, stated so the boundary is not inferred wrongly a
second time: these eight sets now *record* provenance. Reading it at serving
time is `withSourceFallback`, and that is still wired in
`@objectstack/platform-objects` alone — so a stale fill in one of the eight is
now recorded and reportable, but not yet substituted at runtime. Tracked
separately.
49 changes: 48 additions & 1 deletion packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
renderTranslationModule,
renderSourceHashModule,
parseSourceHashModule,
narrowToCommittedSections,
type FillStrategy,
} from '../../utils/i18n-extract.js';

Expand Down Expand Up @@ -198,6 +199,52 @@ export default class I18nExtract extends Command {
const emitsMetadataForms = (locale: string): boolean =>
flags['metadata-forms'] && (metadataFormsCounts[locale] ?? 0) > 0;

/**
* The provenance table for one locale, narrowed to the sections this run
* actually COMMITS (#12559).
*
* `extractTranslations` computes the table over every generated section it
* built — `objects` and `metadataForms` both — because the rule that fills
* it (`collectFilledFromHashes`) is a statement about generated leaves, not
* about files. Which of those sections becomes a committed bundle is this
* layer's decision, and the two must agree: a record describes the leaf
* sitting in a bundle beside it, and a record for a leaf this package does
* not commit describes nothing that exists here.
*
* The mismatch is not hypothetical — it is what the eight-set rollout in
* #12559 measured on first contact. A package that owns only its own
* objects passes `--no-metadata-forms`, and the emitter's own note two
* blocks up says why: "without it, `--check` demands a baseline copy the
* package deliberately does not commit". Its `metadataForms` subtree is
* nonetheless built, and — having no entry in that package's merge
* baseline — arrives as a fresh `--fill=default` copy of `en`, so EVERY
* leaf of it satisfies `value === currentSource` and gets recorded.
* Measured on `plugin-audit`: 763 records, of which **2** were its own
* objects and 761 were digests of the Studio metadata-form baseline that
* `@objectstack/platform-objects` owns. Those records are unreadable here
* (no `metadataForms` bundle exists in this package for them to be about),
* and they would move all three of this package's companions every time an
* unrelated `*.form.ts` in `packages/spec` changed — the same cross-package
* coupling ADR-0029 D8 and each package's `bundle-ownership.test.ts` exist
* to keep out of its committed bundles.
*
* So the section list is decided by the SAME predicates that decide the
* bundle files, never by a second rule: `result.counts` for `objects` and
* {@link emitsMetadataForms} for `metadataForms`. A set that commits both —
* `platform-objects` is the one today — keeps every record it had. The
* narrowing itself is `narrowToCommittedSections`, a pure function in the
* extractor's utils so it can be pinned without driving oclif; this layer
* contributes only the two booleans it alone knows.
*/
const committedSourceHashes = (locale: string): Record<string, string> | undefined => {
const table = result.sourceHashes[locale];
if (!table) return undefined;
const committed: string[] = [];
if ((result.counts[locale] ?? 0) > 0) committed.push('objects');
if (emitsMetadataForms(locale)) committed.push('metadataForms');
return narrowToCommittedSections(table, committed);
};

if (flags.json) {
await emitJson({
totalExpected: result.totalExpected,
Expand Down Expand Up @@ -275,7 +322,7 @@ export default class I18nExtract extends Command {
// The provenance companion rides in the SAME list, so `--check` compares
// it by the same byte-for-byte rule as the bundles it belongs to and can
// never diverge from what a real extract writes.
const table = result.sourceHashes[locale];
const table = committedSourceHashes(locale);
if (flags['source-hashes'] && table) {
emitted.push({
file: path.join(resolvedOutDir, `${locale}.source-hashes.generated.ts`),
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,49 @@ export function renderSourceHashModule(
return lines.join('\n');
}

/**
* Narrow a provenance table to the generated sections a run actually COMMITS
* (#12559).
*
* {@link extractTranslations} computes the table over every generated section it
* built — `objects` and `metadataForms` both — because the rule that fills it
* (`collectFilledFromHashes`) is a statement about generated leaves, not about
* files. Which of those sections becomes a committed bundle is the command
* layer's decision, and the two must agree: a record describes the leaf sitting
* in a bundle beside it, so a record for a section this package does not commit
* describes nothing that exists there.
*
* The mismatch is measured, not hypothetical. A package that owns only its own
* objects passes `--no-metadata-forms`, and the emitter's own note says why:
* "without it, `--check` demands a baseline copy the package deliberately does
* not commit". Its `metadataForms` subtree is built anyway, and — having no
* entry in that package's merge baseline — arrives as a fresh `--fill=default`
* copy of `en`, so EVERY leaf of it satisfies `value === currentSource` and is
* recorded. Measured on `@objectstack/plugin-audit` while rolling the companion
* out in #12559: 763 records, of which **2** were its own objects and 761 were
* digests of the Studio metadata-form baseline `@objectstack/platform-objects`
* owns — unreadable in that package (no `metadataForms` bundle exists there for
* them to be about) and re-written in all three of its companions every time an
* unrelated `*.form.ts` in `packages/spec` moved. That is the cross-package
* coupling ADR-0029 D8 and each package's `bundle-ownership.test.ts` keep out of
* the committed bundles; the companion is not exempt from it.
*
* A section is named by a leaf path's FIRST dotted segment — the same identity
* `collectGeneratedLeaves` seeds its walk with, so the two cannot disagree about
* what section a path belongs to. Callers pass the sections they are committing;
* this function invents none, so a set that commits both (`platform-objects` is
* the one today) keeps every record it had.
*/
export function narrowToCommittedSections(
hashes: Record<string, string>,
committedSections: Iterable<string>,
): Record<string, string> {
const committed = new Set(committedSections);
return Object.fromEntries(
Object.entries(hashes).filter(([leafPath]) => committed.has(leafPath.split('.', 1)[0])),
);
}

/**
* Read a committed `<locale>.source-hashes.generated.ts` back into a table.
*
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/test/i18n-extract-source-hashes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
extractTranslations,
renderSourceHashModule,
parseSourceHashModule,
narrowToCommittedSections,
} from '../src/utils/i18n-extract.js';

const stack = (help: string) => ({
Expand Down Expand Up @@ -84,3 +85,62 @@ describe('the emitted module', () => {
expect(parseSourceHashModule('export const x: Readonly<Record<string, string>> = { "a": 3 };')).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
// The companion covers the sections the run COMMITS (#12559)
// ---------------------------------------------------------------------------
//
// `extractTranslations` fills the table over every generated section it built,
// `metadataForms` included, because the rule behind it is about generated
// leaves rather than about files. A package that owns only its own objects
// passes `--no-metadata-forms` and commits no metadata-forms bundle — and its
// `metadataForms` subtree, absent from its merge baseline, arrives as a fresh
// `--fill=default` copy of `en`, so every leaf of it is recordable. Unnarrowed,
// that is what the table carries: measured on `@objectstack/plugin-audit` during
// the #12559 rollout, 763 records of which 2 were its own objects and 761 were
// digests of the metadata-form baseline `@objectstack/platform-objects` owns.
//
// The narrowing is pinned here rather than mirrored from the command, because a
// mirror of an emit rule is a second contract — it agrees until the day one side
// changes, and nothing says so on that day.

describe('the provenance table is narrowed to the committed sections (#12559)', () => {
const result = extractTranslations(
{ objects: [{ name: 'account', label: 'Account', fields: { name: { label: 'Name' } } }] },
{ defaultLocale: 'en', locales: ['zh-CN'], fill: 'default' },
);
const table = result.sourceHashes['zh-CN'];
const sectionsIn = (t: Record<string, string>) => [...new Set(Object.keys(t).map((k) => k.split('.', 1)[0]))].sort();

it('has both sections to narrow — otherwise every case below would pass vacuously', () => {
// The registry-driven metadata-form baseline is present for any stack, so
// this is the precondition that makes the two cases a measurement rather
// than an empty walk.
expect(sectionsIn(table)).toEqual(['metadataForms', 'objects']);
expect(Object.keys(table).length).toBeGreaterThan(100);
});

it('drops the metadata-forms records for a set that commits no metadata-forms bundle', () => {
const narrowed = narrowToCommittedSections(table, ['objects']);
expect(sectionsIn(narrowed)).toEqual(['objects']);
// Every surviving record is unchanged — this narrows the table, it does not
// recompute it.
for (const [path, digest] of Object.entries(narrowed)) expect(digest).toBe(table[path]);
});

it('keeps every record for a set that commits both, so the covered set is untouched', () => {
expect(narrowToCommittedSections(table, ['objects', 'metadataForms'])).toEqual(table);
});

it('names a section by a leaf path\'s first dotted segment, never by a prefix match', () => {
// `objects` must not be reached through a section that merely starts with
// it, and a nested key called `objects` must not be mistaken for the section.
const odd = { 'objectsExtra.a.label': 'aaaaaaaaaaaaaaaa', 'metadataForms.x.objects.b': 'bbbbbbbbbbbbbbbb' };
expect(narrowToCommittedSections(odd, ['objects'])).toEqual({});
expect(narrowToCommittedSections(odd, ['metadataForms'])).toEqual({ 'metadataForms.x.objects.b': 'bbbbbbbbbbbbbbbb' });
});

it('commits nothing when no section is committed', () => {
expect(narrowToCommittedSections(table, [])).toEqual({});
});
});
24 changes: 24 additions & 0 deletions packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,32 @@
*
* os i18n extract packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts \
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
* --source-hashes \
* --out=packages/plugins/plugin-approvals/src/translations
*
* The `source-hashes` flag on the command above also emits
* `<locale>.source-hashes.generated.ts` — the provenance companion from
* maintainer ruling #12069 Option A (#11671), rolled out to every bundle set by
* #12559. Without it every generated leaf here is LEGACY-TRUSTED: a leaf filled
* from the source and then left behind when the source was revised is
* indistinguishable BY VALUE from a real translation, so it publishes a
* superseded draft under a green `check:i18n` forever, and
* `check:i18n-stale-fill` cannot see it either unless two locales happen to
* hold the same stale bytes.
*
* A record is written only for a leaf that IS currently a byte copy of the
* CURRENT source, so the companion arrives 0-stale by construction and only
* ever reports drift accruing afterwards. Records count the leaves currently
* RECORDABLE, never the leaves covered — a table with few entries, or with none
* at all where a locale is fully translated, is the instrument armed, and an
* entry appears by itself on the first extract after a leaf becomes a fill.
*
* The flag is named in prose WITHOUT its leading dashes on purpose:
* `flagsFromDocstring` (scripts/i18n-bundle-surface.mjs) harvests every
* recognised flag spelling out of this whole comment, so a second spelling
* would keep the opt-in switched on after someone deleted it from the command
* block — the one place that decides.
*
* `--no-metadata-forms` because the Studio metadata-form baseline is owned by
* `@objectstack/platform-objects` — this plugin translates only the objects it
* owns, so it must not commit a second copy. Add `--check` to run the same
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Auto-generated by 'os i18n extract' for locale 'es-ES'. Do not hand-edit.
*
* Each entry is the digest of the SOURCE REVISION that this locale's leaf at
* that path is still a byte copy of — provenance for the generated half of the
* bundles (#11671, maintainer ruling #12069 Option A, extending #8765 Option B).
*
* An entry exists only while the leaf IS such a copy. Re-translate the leaf in
* `<locale>.objects.generated.ts` and the next extract drops its entry by
* itself — the table makes no claim about text a translator wrote. A path with
* no entry is LEGACY-TRUSTED and never reported stale.
*
* ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest
* records that the current text was copied from the current source, which is
* the false claim the mechanism exists to detect. Fix the TRANSLATION.
*/

export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_action.fields.actor_id.label": "b155813f8a7f06e3",
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033",
};
Loading
Loading