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
30 changes: 30 additions & 0 deletions .changeset/translation-groups-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': minor
---

Translation bundles and `translation` items reject unknown keys, at both doors — and a bespoke guard that only covered one of them retires into the rejection message.

Translation data has the cruellest version of the silent-strip failure in the spec. A misspelled group is dropped, the bundle loads without complaint, and the string renders in the source language — **indistinguishable from a translation nobody has written yet.** There is no wrong output to notice, so the bug looks like a coverage gap forever.

**#3778 already knew this, and fixed it for ten keys.** Retiring the object-first (`o.<object>`) dialect meant old-shape items saved cleanly and resolved to nothing, so it added a `z.preprocess` that scanned for the ten retired keys and raised a 422 naming the right destination for each.

That guard has the shape every workaround for `.strip` has:

- **It could only catch mistakes someone had already thought of.** `object` for `objects`, `message` for `messages`, an invented group — still dropped in silence.
- **It ran on one of the two doors.** Only `TranslationItemSchema` (Studio / the metadata API). The same ten keys in a file-authored bundle — the path the examples and the platform apps actually use — were stripped with no complaint at all. The same asymmetry #4522 found in #1535's object guard, two authors solving the problem in front of them.

With the shape closed the guard is redundant, so it is gone and its ten prescriptions ride the rejection as `guidance`. **What was worth keeping was never the detection — it was the prose.** Detection generalizes for free once the default flips; the sentence telling an author where their content goes does not.

Closed across every authorable group: object/field/view/action/section translations, apps and navigation, dashboards and widgets, pages, settings, metadata forms, and the i18n config. Aliases carry the near-misses edit distance cannot: `views` → `_views`, `help` → `helpText` on an action param (`help` is correct one surface over), `label` → `title` on a widget (a dashboard's headline is `label`, its widget's is `title` — one level apart, opposite spellings).

**The i18n config's four removed knobs get tombstones.** #3494 deleted `fileOrganization`, `messageFormat`, `lazyLoad` and `cache` because no runtime read them. Removing a key that was already a no-op leaves the author with the same silence and one more reason for it; the rejection now says which issue removed it and why.

**Two gates were found doing half their job.**

`translation` was on the ADR-0010 envelope debt list — the loader stamps `_packageId`/`_provenance` and the schema could not hold them, so `authored-translation-sync` strips them by hand on the read side. Declared; the list is down to four.

And `metadata-create-seeds.test.ts` — the canonical guard against a designer's create shape drifting from the spec — asserts every seed parses. The `translation` seed ships `{ name, label, locale, objects }` and the type declared neither `name` nor `label`, so **two thirds of the authoritative create shape was being stripped while the gate that exists to catch that reported green.** A gate built on a `.strip` schema catches a missing required key and can never catch an extra undeclared one. `name`/`label` are now declared (`translation` was the only registered type of 25 without a `name`), classified in the liveness ledger as dead *body* keys with the row column as the live one.

Registered types closed at the top level: **17 of 25**. Still open: `action`, `agent`, `dashboard`, `field`, `mapping`, `page`, `view`.

Authoring impact: a key none of these shapes declares is now rejected instead of silently discarded — it was already being ignored, so no working translation changes. Verified against the real bundles in `examples/app-crm`, `examples/app-todo` and `platform-objects`, all of which `.parse()` at module load, and against the live `GET /translations/:locale` body.
10 changes: 7 additions & 3 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,10 @@ const zh: TranslationData = {
`o.` (with `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`) was once
documented here for runtime-authored translations. No resolver ever read it,
so translations authored in that shape saved successfully and rendered
nothing. It was removed in #3778; the metadata door now rejects those keys
with a message naming the group to use instead. Author everything —
nothing. It was removed in #3778, which rejected those ten keys at the
metadata door; #4001 closed both shapes entirely, so **any** undeclared key
is now rejected in a runtime item and in a file-authored bundle alike, with
the retired ones still naming the group to use instead. Author everything —
files and runtime items alike — under `objects.`.
</Callout>

Expand Down Expand Up @@ -850,7 +852,9 @@ stack's metadata, so there is no set of legal names to resolve against.
**Design intent — not yet implemented.** `i18n.translationService` is **not** a
recognized key on `TranslationConfigSchema`
(`packages/spec/src/system/translation.zod.ts`), and no auto-translate provider
integration ships today. The snippet below describes planned behaviour.
integration ships today. The snippet below describes planned behaviour — since
#4001 the config shape is closed, so copying it into a real `defineStack` is a
build-time rejection rather than a key silently dropped on the floor.
</Callout>

ObjectStack integrates with professional translation services:
Expand Down
23 changes: 21 additions & 2 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ description: Translation protocol schemas

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

Field Translation Schema
Shared history sentence for every shape in this file (#4001).

Translation data for a single field.
Translation data has the most literal version of the silent-strip failure in

the whole spec: a misspelled group or key is dropped, the bundle saves or

loads without complaint, and the string it was meant to translate renders in

the source language. There is no error, no log line, and no difference

between "not translated yet" and "translated into a key nothing reads" — so

the bug looks like missing coverage forever.

<Callout type="info">
**Source:** `packages/spec/src/system/translation.zod.ts`
Expand Down Expand Up @@ -208,6 +218,15 @@ One locale of translations — the `translation` metadata type
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, { label?: string; description?: string }>; fields?: Record<string, { label?: string; helpText?: string; placeholder?: string }> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |
| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent |
| **label** | `string` | optional | Human-readable label shown in metadata lists |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |
| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). |
| **_packageId** | `string` | optional | Owning package machine id. |
| **_packageVersion** | `string` | optional | Owning package version. |
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |


---
Expand Down
16 changes: 12 additions & 4 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,18 @@ Two things to know:

- `locale` is required. An item whose locale can't be resolved is skipped, and
a silent skip is the hardest kind of missing translation to diagnose.
- Only the groups on this page are accepted. Keys from the retired `o.<object>`
shape (`o`, `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`, …) are
rejected at save time with a message naming the group to use instead — they
used to save cleanly and then render nothing (#3778).
- Only the groups on this page are accepted, and since #4001 that is literally
true: a key none of them declares is rejected, in a runtime item **and** in a
file-authored bundle. Keys from the retired `o.<object>` shape (`o`, `app`,
`nav`, `dashboard`, `_globalOptions`, `_meta`, …) carry a message naming the
group to use instead — they used to save cleanly and then render nothing
(#3778). Everything else gets the nearest declared key suggested.

Before that, only those ten keys were checked and only on the runtime door,
so a misspelled group in a bundle file was dropped in silence. On this
surface that is the worst possible outcome: **a translation that resolves to
nothing looks exactly like a translation nobody has written yet** — no wrong
string appears, just the source language, indefinitely.

## Draft with the CLI, gate in CI

Expand Down
41 changes: 41 additions & 0 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,47 @@ dropped at parse, and nothing failed.
this file keeps re-deriving: before trusting a green check, make it go red on
something you know is there.**

10. **A bespoke guard against silent stripping could only catch the ten
mistakes someone had already thought of — and only at one of the two
doors.** `translation` (step 5). #3778 retired the object-first (`o.<object>`)
dialect and hit this campaign's problem head-on: an item authored in the old
shape saved cleanly and then resolved to nothing. The fix available then was
a `z.preprocess` that scanned for ten named keys and raised a 422 with the
right destination for each.

It worked, and it had the shape every workaround for `.strip` has. Ten keys
were enumerated; `object` for `objects`, `message` for `messages`, a group
invented wholesale — all still stripped in silence. And it ran on the *item*
door only, so those same ten keys written into a file-authored bundle (the
path the examples and the platform apps actually use) were dropped with no
complaint at all. **The same asymmetry #4522 found in #1535's object guard**:
covered at one door, open at the other, by two different authors solving the
problem in front of them.

With the shape closed the guard is redundant — `.strict()` catches all ten
and everything else, at both doors — so it was retired and its ten
prescriptions became `guidance` on the rejection. Which is the general
lesson: **what was worth keeping from a bespoke guard is never the
detection, it is the prose.** Detection generalizes for free the moment the
default flips; the sentence telling an author where their content goes does
not, and is the part these workarounds were really carrying.

11. **The canonical "create seed ≠ spec" gate could only fail in one
direction.** `metadata-create-seeds.test.ts` exists so a designer's minimal
create shape cannot drift from the schema — it asserts every seed parses.
The `translation` seed ships `{ name, label, locale, objects }`, and the type
declared neither `name` nor `label`, so **two thirds of the authoritative
create shape was being stripped while the gate that exists to catch exactly
that reported green.** A gate built on a `.strip` schema can catch a MISSING
required key and can never catch an EXTRA undeclared one; it was doing half
its job and reading as if it did all of it.

Fixed by declaring them — `translation` was the only registered type of 25
without a `name`, and that irregularity is what an AI author trips on — and
classified honestly in the liveness ledger as dead *body* keys, the row
column being the live one. Fifth instance of finding 9's pattern, and the
first where the blinded instrument was a gate rather than a measurement.

This is the empirical argument for the ratchet: the inference "no metadata in
the repo carries unknown keys" was **false three times over**, and only the
strict gate could prove it. Note the asymmetry in the two schema gaps — both
Expand Down
9 changes: 9 additions & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -7056,12 +7056,21 @@
"system/TranslationDiffItem:objectName",
"system/TranslationDiffItem:sourceHash",
"system/TranslationDiffItem:status",
"system/TranslationItem:_lock",
"system/TranslationItem:_lockDocsUrl",
"system/TranslationItem:_lockReason",
"system/TranslationItem:_lockSource",
"system/TranslationItem:_packageId",
"system/TranslationItem:_packageVersion",
"system/TranslationItem:_provenance",
"system/TranslationItem:apps",
"system/TranslationItem:dashboards",
"system/TranslationItem:globalActions",
"system/TranslationItem:label",
"system/TranslationItem:locale",
"system/TranslationItem:messages",
"system/TranslationItem:metadataForms",
"system/TranslationItem:name",
"system/TranslationItem:objects",
"system/TranslationItem:pages",
"system/TranslationItem:settings",
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/liveness/translation.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
{
"type": "translation",
"_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). Registered schema is a z.preprocess pipe (the retired object-first-dialect guard), which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects.<n>.fields.<f>.label, settings.<ns>.keys.<k>.options.<v>, …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through — the resolvers read only the declared conventions. 10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost. Seeded 2026-08-01 (#4488).",
"_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). NO LONGER A PIPE: the schema was a z.preprocess wrapping the retired object-first-dialect guard, which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. #4001 closed the shape with `.strict()` and folded the guard's ten prescriptions into the unknown-key `guidance`, so the preprocess is gone and the registered schema is a plain strict object. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects.<n>.fields.<f>.label, settings.<ns>.keys.<k>.options.<v>, …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through on rows already stored — the resolvers read only the declared conventions. Since #4001 no NEW row can acquire one: the metadata door rejects an undeclared key instead of stripping it, so that residue is a finite set that only shrinks. 10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost. Seeded 2026-08-01 (#4488).",
"props": {
"name": {
"status": "dead",
"verifiedAt": "2026-08-01",
"note": "Item identity, declared in #4001 and dead as a BODY key — which is the honest reading of a copy nobody reads. The row's `name` column is the live one: the metadata door takes it from the URL (`saveMetaItem({ name })`, rest-server.ts:3903), and `authored-translation-sync` reads `row.name` as the locale fallback for pre-#3778 rows (:140-148) while explicitly destructuring the body copy back out before merging (:154-159). Declared anyway, and deliberately not authorWarn'd, for two reasons: the platform's own create seed sends it (`metadata-create-seeds.ts`, whose stated contract is that EVERY seed carries placeholder identity), so a warning would fire on our own shape; and `translation` being the one registered type of 25 without a `name` is exactly the irregularity an AI author trips on. Undeclared it was silently stripped, which is how the seed gate — the canonical 'designer create shape ≠ spec' guard — passed for years while two thirds of the seed it validated was being thrown away."
},
"label": {
"status": "dead",
"verifiedAt": "2026-08-01",
"note": "Display metadata with no runtime consumer in this repo. Not authorWarn'd: the README's rule is that pure display annotation (`description`, `tags`, `icon`) must not warn, and the `job.label` precedent is exactly this — docs-shaped, deliberately kept, ADR-0033 exempt from enforce-or-remove. Shipped with `name` in the create seed for the same reason."
},
"locale": {
"status": "live",
"verifiedAt": "2026-08-01",
Expand Down
5 changes: 3 additions & 2 deletions packages/spec/src/kernel/metadata-type-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ const PROBE: Record<string, unknown> = {
*
* The structural walk found 8 of these; the probe it replaced had been hiding 7.
* `job` and `book` were closed in the same pass, leaving 6; `validation` came off
* when its six union variants were converted, leaving 5.
* when its six union variants were converted, leaving 5; `translation` came off
* when its groups were closed, leaving 4.
*/
const UNDECLARED_ENVELOPE = new Set<string>([
'action', 'field', 'mapping', 'page', 'translation',
'action', 'field', 'mapping', 'page',
]);

/**
Expand Down
Loading
Loading