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
66 changes: 66 additions & 0 deletions .changeset/unknown-key-strictness-ui-batch13.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
'@objectstack/spec': major
---

Close the responsive/SDUI-styling shapes against unknown keys (#4001 batch 13, ADR-0078)

zod's default is `.strip`: a key a schema does not declare is silently discarded
and the parse still succeeds. On an authoring surface that is the worst failure
mode — the author (increasingly, an AI) gets a success envelope and ships
metadata that quietly ignores what they wrote.

**BREAKING.** All four shapes in `ui/responsive.zod.ts` now raise a named,
fixable error instead of dropping the key: `ResponsiveConfigSchema`,
`ResponsiveStylesSchema`, and the two per-breakpoint maps behind
`responsive.columns` / `responsive.order`.

**What this actually fixes is a nested one.** `PageComponentSchema` has been
`.strict()` since ADR-0089 D3a — and that never reached these blocks, because
strictness does not recurse. So this component parsed **clean**:

```ts
PageComponentSchema.parse({
type: 'element:text', id: 't1',
responsiveStyles: { lg: { fontSize: '40px' } },
responsive: { colums: { lg: 4 }, hideOn: ['xs'] },
})
// → { …, responsiveStyles: {}, responsive: {} }
```

Every styling and layout instruction the author wrote, gone, reported valid — the
node renders unstyled and nothing says why.

**The renames, and where the wrong word comes from.** This file carries TWO
breakpoint vocabularies sixteen lines apart on the same page component:
`responsiveStyles` uses ADR-0065's desktop-first buckets, `responsive` uses the
Tailwind `xs`…`2xl` ramp. Crossing them is not a typo and edit distance cannot
bridge it, so the aliases run both ways:

| you wrote | write instead | where the other word comes from |
|---|---|---|
| `responsiveStyles: { xs / sm / md }` | `xsmall` / `small` / `medium` | the sibling `responsive` key's `BreakpointName` ramp |
| `responsiveStyles: { lg / xl / 2xl }` | `large` | same, folded onto the unconditional base |
| `columns: { large / medium / small / xsmall }` | `lg` / `md` / `sm` / `xs` | the sibling `responsiveStyles` buckets |
| `columns: { xxl }` | `2xl` | the near-miss this file's own test has pinned as invalid since before #4001 |
| `responsive: { hidden }` / `{ hideOn }` | `hiddenOn` | objectui's resolved `useResponsiveConfig` result |

Two are prescriptions rather than renames, because a rename would be wrong. A
bare breakpoint name at the `responsive` level (`responsive: { sm: … }`) is the
legacy breakpoint-keyed shape from the `view.responsive` retired in 17 (#3896) —
three keys are plausible targets, so each name gets its own text naming all
three. And a `responsiveStyles` bucket written on `responsive` (or vice versa) is
a wrong-layer pointer to the sibling key, not a rename.

`StyleMapSchema` stays **deliberately open** — its key space is every CSS
property, not a contract we own — pinned in the schema JSDoc, in a test, and in
the #4001 ledger.

**Nothing in `ui/touch|animation|dnd|keyboard|offline.zod.ts` changed**, and that
is deliberate. The ledger scheduled their 22 sites as `authorable (p)`; resolving
the `(p)` found no authoring door at all — nothing declares a carrier key for
them, a BFS from all 24 metadata-type roots plus `defineStack` never reaches
them (with three positive controls passing in the same run), and no `.parse()` on
any of them exists in this repo, objectui, or the example apps. `.strict()` is a
property of a parse; there is no parse. Retiring them or giving them a carrier is
ADR-0049 enforce-or-remove, tracked in #4988 — not a breaking change to spend
here.
64 changes: 51 additions & 13 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ One question decides the class: **who writes this schema's input?**
| **authorable** | A human or AI author, into `*.object.ts` / `defineStack` config / Studio / MCP | `.strict()` + fixable error (the ratchet target) |
| **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash |
| **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) |
| **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch |

A fourth answer to "who writes this input" is **nobody**, and it is only
reachable by measurement rather than by reading the file: `no door` was added at
批 13 after a BFS from every authoring root (with positive controls) came back
empty on five `ui/` files at once. Reading a schema's exports and JSDoc cannot
distinguish it from `authorable` — which is exactly why the `(p)` exists.

Mixed files carry both — classify per schema, not per file. A **response-side
extension of an authoring schema** (e.g. `EffectiveObjectPermissionSchema`)
Expand Down Expand Up @@ -495,9 +502,11 @@ not verdicts).
| `dashboard.zod.ts` | 11 | authorable | partially strict |
| `widget.zod.ts` | 9 | authorable (p) | |
| `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) |
| `chart.zod.ts` / `i18n.zod.ts` / `responsive.zod.ts` | 7+6+4 | authorable (p) | i18n label shapes are wide-open records by design — verify. **`chart` 6 → 7 at the re-measurement** — again no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break |
| `dataset.zod.ts` / `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` | 4+4+4+4+7 | authorable (p) | interaction configs |
| `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | |
| `chart.zod.ts` / `i18n.zod.ts` | 7+6 | authorable (p) | i18n label shapes are wide-open records by design — verify. **`chart` 6 → 7 at the re-measurement** — again no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break |
| `responsive.zod.ts` | 4 | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row |
| `dataset.zod.ts` | 4 | authorable (p) | analytics dimension/measure config |
| `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` / `offline.zod.ts` | 4+4+4+7+3 | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 13)** — the `(p)` resolved NEGATIVE and the row is kept only so the arithmetic stays complete. Three independent measurements on 2026-08-03: (1) nothing under `packages/spec/src` imports these modules except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for them; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650 deletion check — reaches none of the 22 sites, while its three positive controls (`PageSchema`, batch 11's `WebhookSchema`, batch 10's `StateMachineSchema`) all resolve `root-graph` in the same run; (3) no `.parse()` / `.safeParse()` on any of them exists in `objectstack`, `objectui` or the example apps outside their own unit tests — objectui re-exports the inferred TYPES only and says so (#2561). `.strict()` is a property of a PARSE and there is no parse, so closing them would enforce nothing and would spend a v17 breaking change to leave *"a precisely validated dead slot — the more convincing lie"* (the #4583 row below). The live question is ADR-0049 enforce-or-remove, filed as **#4988**; each file's header comment and its test file carry the same verdict (the batch 12 three-places standard). **Do not reschedule these as strictness work** — that is what the `(p)` was for, and it has been answered |
| `report.zod.ts` | 3 | authorable (p) | |
| `notification.zod.ts` | 1 | authorable (p) | **#4610 dropped two sites** — the `./ui` `Notification` (toast/banner instance) and `NotificationConfig` (toaster global config) shapes were removed: zero importers in all three repos, and both shadowed live names owned elsewhere (`./api` owns the inbox row). What remains is `NotificationActionSchema`, part of the presentation vocabulary the ui entry keeps |
| `sharing.zod.ts` | 2 | authorable (p) | public-sharing config |

Expand Down Expand Up @@ -664,7 +673,7 @@ it the same way: the decision is also written beside the schema and pinned in a
test (`flow.test.ts`, `etl.test.ts`), because a row in a table is not where the
next person to open that file will look.

#### `ui/` — 123 strip of 198
#### `ui/` — 119 strip of 198

| File | Strip | Sites | Class | Batch |
|---|---|---|---|---|
Expand All @@ -673,24 +682,53 @@ next person to open that file will look.
| `theme.zod.ts` | 14 | 14 | authorable (p) | Authored themes; `Typography` / `Animation` sub-blocks dominate |
| `widget.zod.ts` | 9 | 9 | authorable (p) | Widget manifest + lifecycle/event/property/source |
| `chart.zod.ts` | 7 | 7 | authorable (p) | Axis / series / annotation / interaction / config / groupBy / aggregate |
| `touch.zod.ts` | 7 | 7 | authorable (p) | Gesture configs |
| `touch.zod.ts` | 7 | 7 | **no door** | ⛔ **not strictness work** — measured unreachable from every authoring root (#4001 批 13); ADR-0049 triage is #4988. See the triage row above |
| `i18n.zod.ts` | 6 | 6 | authorable (p) | ⚠️ the triage row warns label shapes are wide-open records **by design** — verify before closing |
| `animation.zod.ts` | 4 | 4 | authorable (p) | |
| `dnd.zod.ts` | 4 | 4 | authorable (p) | |
| `keyboard.zod.ts` | 4 | 4 | authorable (p) | |
| `responsive.zod.ts` | 4 | 4 | authorable (p) | |
| `animation.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 |
| `dnd.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 |
| `keyboard.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch` — #4988 |
| `dataset.zod.ts` | 3 | 4 | authorable (p) | `DatasetDimension` / `DatasetMeasure` + `.derived` |
| `offline.zod.ts` | 3 | 3 | authorable (p) | |
| `offline.zod.ts` | 3 | 3 | **no door** | ⛔ same as `touch` — #4988 |
| `dashboard.zod.ts` | 2 | 11 | authorable | Only `DashboardWidget.compareTo` and `.layout` left; `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch — see the triage row) |
| `report.zod.ts` | 2 | 3 | authorable (p) | `ReportSort` / `JoinedReportBlock` |
| `sharing.zod.ts` | 2 | 2 | authorable (p) | `SharingConfig` / `EmbedConfig` |
| `action.zod.ts` | 1 | 8 | authorable | `ActionParamSchema.options` — a plain `{ label, value }` pair; the cheapest win in the directory |
| `app.zod.ts` | 1 | 18 | verify | `BaseNavItemSchema` — the base the strict discriminated-union members extend. Closing a base that is `.extend()`ed is the #4001 trap that bit `view` (finding 16); confirm the members' strictness is not already covering it before touching |
| `notification.zod.ts` | 1 | 1 | authorable (p) | `NotificationActionSchema` |

**Authorable strip in `ui/`: 123 of 123** — every remaining strip site in this
directory is authorable. Of those 123, `app.zod.ts`'s single site is held pending
the finding-16 `.extend()` check rather than counted as ready.
`responsive.zod.ts` left this table at **批 13** (#4001) on reverse-pin evidence
— it reached 0 strip, the gate went red on the row still being there, and the row
was deleted. Header and subtotal are **recomputed from the surviving rows** (29 +
20 + 14 + 9 + 7 + 7 + 6 + 4 + 4 + 4 + 3 + 3 + 2 + 2 + 2 + 1 + 1 + 1 = 119), not
decremented by this batch's own count. That is not pedantry: it happened three
times in one day in `automation/` — each branch's arithmetic was right against
itself, git merged the rows cleanly because they do not overlap, and the subtotal
line, which conflicts with nothing, merged clean and wrong on both sides.
`check:strictness-ledger`'s header arithmetic is what settles it. Note this batch
merged alongside #4876, which edits this same section, so the conflict was
expected and both sides' row edits were kept before recomputing.

**Authorable strip in `ui/`: 97 of 119** (was 123 of 123). The subtotal moved by
26 while only 4 sites were CLOSED, and the 22-site gap is the batch's actual
finding rather than a rounding of it: `touch` (7), `animation` (4), `dnd` (4),
`keyboard` (4) and `offline` (3) were reclassified out of `authorable` because
their `(p)` resolved negative — **no metadata document is ever parsed against
them**, so there is no author for strictness to protect. The evidence is in their
triage row above; the live question is ADR-0049 enforce-or-remove (#4988), not
this ratchet. Of the 97 that remain, `app.zod.ts`'s single site is still held
pending the finding-16 `.extend()` check rather than counted as ready.

The reclassification is worth reading as a method note, because batch 13 is the
first time the `(p)` came back negative on a whole run of files rather than on
one. The three `automation/` waves each resolved their `(p)` by finding a door
that the ledger's prose had missed — batch 10's `agent.lifecycle`, batch 11's
boot-time `bootstrapDeclaredWebhooks`. That created a quiet expectation that
verification means *finding* the door. Here the same procedure, run with positive
controls in the same execution, found no door at all five times — and the correct
output of a verification step is whatever it measures, including "this was never
ratchet work". A batch that had skipped the check would have shipped 22 strict
schemas, a breaking changeset, and ~58 curated alias entries that no parse would
ever consult.

The one `open` site this directory carried is **gone, and not by being closed**:
`bulk-action.zod.ts`'s `BulkActionParamSchema.options` was the row that read
Expand Down
66 changes: 66 additions & 0 deletions packages/spec/src/ui/animation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,69 @@ describe('TransitionConfigSchema - themeToken', () => {
expect(result.themeToken).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement.
//
// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving
// the `(p)` found no authoring door at all: nothing under `packages/spec/src`
// imports this module except the `ui/index.ts` barrel, a BFS from all 24
// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches
// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as
// positive controls in the same run), and no `.parse()` on any of them exists
// in `objectstack`, `objectui` or the example apps outside this test file.
// `.strict()` is a property of a PARSE, and there is no parse to gate.
//
// So the strip pinned below is not an unfinished row -- it is the recorded
// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988.
// These assertions exist so the next sweep stops and reads instead of reaching
// for `strictObject` and shipping a precisely-validated dead slot (#4583). The
// header comment in `animation.zod.ts` and this file's ledger row carry the same verdict.
// ---------------------------------------------------------------------------
describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => {
it('TransitionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
const parsed = TransitionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
});

it('ComponentAnimationSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
const parsed = ComponentAnimationSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
});

it('PageTransitionSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
const parsed = PageTransitionSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
});

it('MotionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
const parsed = MotionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
});

// The standing half of measurement 1, so the verdict cannot go stale in
// silence: the day someone gives this vocabulary a carrier they will add an
// import, and this is where they are told to revisit #4988 and the ledger.
it('is still imported by nothing but the ui/ barrel', async () => {
const fs = await import('node:fs');
const path = await import('node:path');
const { fileURLToPath } = await import('node:url');
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const importers: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')
&& full !== path.join(root, 'ui', 'animation.zod.ts')) {
if (/(?:import|export)[^;]*['"][^'"]*\/animation\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) {
importers.push(path.relative(root, full));
}
}
}
};
walk(root);
expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988')
.toEqual(['ui/index.ts']);
});
});
Loading
Loading