Skip to content

Commit 7055c22

Browse files
feat(spec)!: 收紧 responsive/SDUI 样式形状的未知键,并把另外五个文件按实测改判(#4001 批 13) (#5003)
* feat(spec)!: reject unknown keys on the responsive/SDUI-styling shapes (#4001 批 13) Close all four sites in `ui/responsive.zod.ts` with `strictObject`. The nested gap is what this fixes: `PageComponentSchema` has been `.strict()` since ADR-0089 D3a and strictness does not recurse, so a component whose every styling and layout instruction was written in the wrong breakpoint vocabulary parsed clean and returned `{ responsiveStyles: {}, responsive: {} }`. The file carries TWO breakpoint vocabularies sixteen lines apart on the same component (ADR-0065 buckets vs the Tailwind ramp), so the aliases run both ways and are anchored to the named sibling rather than to edit distance. The other five files in this batch are NOT closed, on a measurement: their 22 sites have no authoring door (no carrier key; unreachable by BFS from all 24 metadata-type roots plus defineStack, with three positive controls passing in the same run; no .parse() anywhere in three repos). ADR-0049 triage is #4988. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * docs(spec): record the measured binding door for the responsive strictness (#4001 批 13) The door is getMetadataTypeSchema('page') — MetadataManager.validate, GET /api/v1/meta, the Studio page form. It is NOT objectstack build/validate: a key PageComponentSchema has rejected since ADR-0089 D3a passes both and lands in the built artifact. Pre-existing, filed as #5000; recorded here so the campaign's usual "three example apps validate" line is not read as evidence for this surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1e67e0 commit 7055c22

14 files changed

Lines changed: 1153 additions & 69 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
Close the responsive/SDUI-styling shapes against unknown keys (#4001 batch 13, ADR-0078)
6+
7+
zod's default is `.strip`: a key a schema does not declare is silently discarded
8+
and the parse still succeeds. On an authoring surface that is the worst failure
9+
mode — the author (increasingly, an AI) gets a success envelope and ships
10+
metadata that quietly ignores what they wrote.
11+
12+
**BREAKING.** All four shapes in `ui/responsive.zod.ts` now raise a named,
13+
fixable error instead of dropping the key: `ResponsiveConfigSchema`,
14+
`ResponsiveStylesSchema`, and the two per-breakpoint maps behind
15+
`responsive.columns` / `responsive.order`.
16+
17+
**What this actually fixes is a nested one.** `PageComponentSchema` has been
18+
`.strict()` since ADR-0089 D3a — and that never reached these blocks, because
19+
strictness does not recurse. So this component parsed **clean**:
20+
21+
```ts
22+
PageComponentSchema.parse({
23+
type: 'element:text', id: 't1',
24+
responsiveStyles: { lg: { fontSize: '40px' } },
25+
responsive: { colums: { lg: 4 }, hideOn: ['xs'] },
26+
})
27+
// → { …, responsiveStyles: {}, responsive: {} }
28+
```
29+
30+
Every styling and layout instruction the author wrote, gone, reported valid — the
31+
node renders unstyled and nothing says why.
32+
33+
**The renames, and where the wrong word comes from.** This file carries TWO
34+
breakpoint vocabularies sixteen lines apart on the same page component:
35+
`responsiveStyles` uses ADR-0065's desktop-first buckets, `responsive` uses the
36+
Tailwind `xs``2xl` ramp. Crossing them is not a typo and edit distance cannot
37+
bridge it, so the aliases run both ways:
38+
39+
| you wrote | write instead | where the other word comes from |
40+
|---|---|---|
41+
| `responsiveStyles: { xs / sm / md }` | `xsmall` / `small` / `medium` | the sibling `responsive` key's `BreakpointName` ramp |
42+
| `responsiveStyles: { lg / xl / 2xl }` | `large` | same, folded onto the unconditional base |
43+
| `columns: { large / medium / small / xsmall }` | `lg` / `md` / `sm` / `xs` | the sibling `responsiveStyles` buckets |
44+
| `columns: { xxl }` | `2xl` | the near-miss this file's own test has pinned as invalid since before #4001 |
45+
| `responsive: { hidden }` / `{ hideOn }` | `hiddenOn` | objectui's resolved `useResponsiveConfig` result |
46+
47+
Two are prescriptions rather than renames, because a rename would be wrong. A
48+
bare breakpoint name at the `responsive` level (`responsive: { sm: … }`) is the
49+
legacy breakpoint-keyed shape from the `view.responsive` retired in 17 (#3896) —
50+
three keys are plausible targets, so each name gets its own text naming all
51+
three. And a `responsiveStyles` bucket written on `responsive` (or vice versa) is
52+
a wrong-layer pointer to the sibling key, not a rename.
53+
54+
`StyleMapSchema` stays **deliberately open** — its key space is every CSS
55+
property, not a contract we own — pinned in the schema JSDoc, in a test, and in
56+
the #4001 ledger.
57+
58+
**Nothing in `ui/touch|animation|dnd|keyboard|offline.zod.ts` changed**, and that
59+
is deliberate. The ledger scheduled their 22 sites as `authorable (p)`; resolving
60+
the `(p)` found no authoring door at all — nothing declares a carrier key for
61+
them, a BFS from all 24 metadata-type roots plus `defineStack` never reaches
62+
them (with three positive controls passing in the same run), and no `.parse()` on
63+
any of them exists in this repo, objectui, or the example apps. `.strict()` is a
64+
property of a parse; there is no parse. Retiring them or giving them a carrier is
65+
ADR-0049 enforce-or-remove, tracked in #4988 — not a breaking change to spend
66+
here.

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ One question decides the class: **who writes this schema's input?**
2020
| **authorable** | A human or AI author, into `*.object.ts` / `defineStack` config / Studio / MCP | `.strict()` + fixable error (the ratchet target) |
2121
| **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 |
2222
| **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) |
23+
| **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 |
24+
25+
A fourth answer to "who writes this input" is **nobody**, and it is only
26+
reachable by measurement rather than by reading the file: `no door` was added at
27+
批 13 after a BFS from every authoring root (with positive controls) came back
28+
empty on five `ui/` files at once. Reading a schema's exports and JSDoc cannot
29+
distinguish it from `authorable` — which is exactly why the `(p)` exists.
2330

2431
Mixed files carry both — classify per schema, not per file. A **response-side
2532
extension of an authoring schema** (e.g. `EffectiveObjectPermissionSchema`)
@@ -495,9 +502,11 @@ not verdicts).
495502
| `dashboard.zod.ts` | 11 | authorable | partially strict |
496503
| `widget.zod.ts` | 9 | authorable (p) | |
497504
| `page.zod.ts` | 7 | authorable | partially strict (ADR-0089) |
498-
| `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 |
499-
| `dataset.zod.ts` / `animation.zod.ts` / `dnd.zod.ts` / `keyboard.zod.ts` / `touch.zod.ts` | 4+4+4+4+7 | authorable (p) | interaction configs |
500-
| `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | |
505+
| `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 |
506+
| `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 |
507+
| `dataset.zod.ts` | 4 | authorable (p) | analytics dimension/measure config |
508+
| `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 |
509+
| `report.zod.ts` | 3 | authorable (p) | |
501510
| `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 |
502511
| `sharing.zod.ts` | 2 | authorable (p) | public-sharing config |
503512

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

667-
#### `ui/`123 strip of 198
676+
#### `ui/`119 strip of 198
668677

669678
| File | Strip | Sites | Class | Batch |
670679
|---|---|---|---|---|
@@ -673,24 +682,53 @@ next person to open that file will look.
673682
| `theme.zod.ts` | 14 | 14 | authorable (p) | Authored themes; `Typography` / `Animation` sub-blocks dominate |
674683
| `widget.zod.ts` | 9 | 9 | authorable (p) | Widget manifest + lifecycle/event/property/source |
675684
| `chart.zod.ts` | 7 | 7 | authorable (p) | Axis / series / annotation / interaction / config / groupBy / aggregate |
676-
| `touch.zod.ts` | 7 | 7 | authorable (p) | Gesture configs |
685+
| `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 |
677686
| `i18n.zod.ts` | 6 | 6 | authorable (p) | ⚠️ the triage row warns label shapes are wide-open records **by design** — verify before closing |
678-
| `animation.zod.ts` | 4 | 4 | authorable (p) | |
679-
| `dnd.zod.ts` | 4 | 4 | authorable (p) | |
680-
| `keyboard.zod.ts` | 4 | 4 | authorable (p) | |
681-
| `responsive.zod.ts` | 4 | 4 | authorable (p) | |
687+
| `animation.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch`#4988 |
688+
| `dnd.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch`#4988 |
689+
| `keyboard.zod.ts` | 4 | 4 | **no door** | ⛔ same as `touch`#4988 |
682690
| `dataset.zod.ts` | 3 | 4 | authorable (p) | `DatasetDimension` / `DatasetMeasure` + `.derived` |
683-
| `offline.zod.ts` | 3 | 3 | authorable (p) | |
691+
| `offline.zod.ts` | 3 | 3 | **no door** | ⛔ same as `touch`#4988 |
684692
| `dashboard.zod.ts` | 2 | 11 | authorable | Only `DashboardWidget.compareTo` and `.layout` left; `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch — see the triage row) |
685693
| `report.zod.ts` | 2 | 3 | authorable (p) | `ReportSort` / `JoinedReportBlock` |
686694
| `sharing.zod.ts` | 2 | 2 | authorable (p) | `SharingConfig` / `EmbedConfig` |
687695
| `action.zod.ts` | 1 | 8 | authorable | `ActionParamSchema.options` — a plain `{ label, value }` pair; the cheapest win in the directory |
688696
| `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 |
689697
| `notification.zod.ts` | 1 | 1 | authorable (p) | `NotificationActionSchema` |
690698

691-
**Authorable strip in `ui/`: 123 of 123** — every remaining strip site in this
692-
directory is authorable. Of those 123, `app.zod.ts`'s single site is held pending
693-
the finding-16 `.extend()` check rather than counted as ready.
699+
`responsive.zod.ts` left this table at **批 13** (#4001) on reverse-pin evidence
700+
— it reached 0 strip, the gate went red on the row still being there, and the row
701+
was deleted. Header and subtotal are **recomputed from the surviving rows** (29 +
702+
20 + 14 + 9 + 7 + 7 + 6 + 4 + 4 + 4 + 3 + 3 + 2 + 2 + 2 + 1 + 1 + 1 = 119), not
703+
decremented by this batch's own count. That is not pedantry: it happened three
704+
times in one day in `automation/` — each branch's arithmetic was right against
705+
itself, git merged the rows cleanly because they do not overlap, and the subtotal
706+
line, which conflicts with nothing, merged clean and wrong on both sides.
707+
`check:strictness-ledger`'s header arithmetic is what settles it. Note this batch
708+
merged alongside #4876, which edits this same section, so the conflict was
709+
expected and both sides' row edits were kept before recomputing.
710+
711+
**Authorable strip in `ui/`: 97 of 119** (was 123 of 123). The subtotal moved by
712+
26 while only 4 sites were CLOSED, and the 22-site gap is the batch's actual
713+
finding rather than a rounding of it: `touch` (7), `animation` (4), `dnd` (4),
714+
`keyboard` (4) and `offline` (3) were reclassified out of `authorable` because
715+
their `(p)` resolved negative — **no metadata document is ever parsed against
716+
them**, so there is no author for strictness to protect. The evidence is in their
717+
triage row above; the live question is ADR-0049 enforce-or-remove (#4988), not
718+
this ratchet. Of the 97 that remain, `app.zod.ts`'s single site is still held
719+
pending the finding-16 `.extend()` check rather than counted as ready.
720+
721+
The reclassification is worth reading as a method note, because batch 13 is the
722+
first time the `(p)` came back negative on a whole run of files rather than on
723+
one. The three `automation/` waves each resolved their `(p)` by finding a door
724+
that the ledger's prose had missed — batch 10's `agent.lifecycle`, batch 11's
725+
boot-time `bootstrapDeclaredWebhooks`. That created a quiet expectation that
726+
verification means *finding* the door. Here the same procedure, run with positive
727+
controls in the same execution, found no door at all five times — and the correct
728+
output of a verification step is whatever it measures, including "this was never
729+
ratchet work". A batch that had skipped the check would have shipped 22 strict
730+
schemas, a breaking changeset, and ~58 curated alias entries that no parse would
731+
ever consult.
694732

695733
The one `open` site this directory carried is **gone, and not by being closed**:
696734
`bulk-action.zod.ts`'s `BulkActionParamSchema.options` was the row that read

packages/spec/src/ui/animation.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,69 @@ describe('TransitionConfigSchema - themeToken', () => {
261261
expect(result.themeToken).toBeUndefined();
262262
});
263263
});
264+
265+
// ---------------------------------------------------------------------------
266+
// #4001 batch 13 -- THIS FILE IS DELIBERATELY NOT `.strict()`, on a measurement.
267+
//
268+
// The strictness ledger scheduled these 4 sites as `authorable (p)`. Resolving
269+
// the `(p)` found no authoring door at all: nothing under `packages/spec/src`
270+
// imports this module except the `ui/index.ts` barrel, a BFS from all 24
271+
// metadata-type roots plus `defineStack`'s `ObjectStackSchema` never reaches
272+
// these schemas (`PageSchema` / `WebhookSchema` / `StateMachineSchema` pass as
273+
// positive controls in the same run), and no `.parse()` on any of them exists
274+
// in `objectstack`, `objectui` or the example apps outside this test file.
275+
// `.strict()` is a property of a PARSE, and there is no parse to gate.
276+
//
277+
// So the strip pinned below is not an unfinished row -- it is the recorded
278+
// verdict. The open question is ADR-0049 enforce-or-remove, filed as #4988.
279+
// These assertions exist so the next sweep stops and reads instead of reaching
280+
// for `strictObject` and shipping a precisely-validated dead slot (#4583). The
281+
// header comment in `animation.zod.ts` and this file's ledger row carry the same verdict.
282+
// ---------------------------------------------------------------------------
283+
describe('unknown-key posture is an open question, not an omission (#4001 batch 13 -> #4988)', () => {
284+
it('TransitionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
285+
const parsed = TransitionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
286+
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
287+
});
288+
289+
it('ComponentAnimationSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
290+
const parsed = ComponentAnimationSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
291+
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
292+
});
293+
294+
it('PageTransitionSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
295+
const parsed = PageTransitionSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
296+
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
297+
});
298+
299+
it('MotionConfigSchema still strips rather than rejecting -- deliberate, pending #4988', () => {
300+
const parsed = MotionConfigSchema.parse({ aKeyThisShapeDoesNotDeclare: 1 }) as Record<string, unknown>;
301+
expect(parsed.aKeyThisShapeDoesNotDeclare).toBeUndefined();
302+
});
303+
304+
// The standing half of measurement 1, so the verdict cannot go stale in
305+
// silence: the day someone gives this vocabulary a carrier they will add an
306+
// import, and this is where they are told to revisit #4988 and the ledger.
307+
it('is still imported by nothing but the ui/ barrel', async () => {
308+
const fs = await import('node:fs');
309+
const path = await import('node:path');
310+
const { fileURLToPath } = await import('node:url');
311+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
312+
const importers: string[] = [];
313+
const walk = (dir: string) => {
314+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
315+
const full = path.join(dir, entry.name);
316+
if (entry.isDirectory()) walk(full);
317+
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')
318+
&& full !== path.join(root, 'ui', 'animation.zod.ts')) {
319+
if (/(?:import|export)[^;]*['"][^'"]*\/animation\.zod['"]/.test(fs.readFileSync(full, 'utf-8'))) {
320+
importers.push(path.relative(root, full));
321+
}
322+
}
323+
}
324+
};
325+
walk(root);
326+
expect(importers, 'a new importer means this vocabulary got a carrier -- re-read #4988')
327+
.toEqual(['ui/index.ts']);
328+
});
329+
});

0 commit comments

Comments
 (0)