Skip to content

Commit 866ff16

Browse files
feat(spec)!: close AriaProps against unknown keys, reclassify widget + five i18n shapes as no-door (#4001 批 16) (#5069)
Fifteen strip sites measured per the #4852 remeasure; the counts held exactly (widget 9/9, i18n 6/6, confirmed against the ledger gate's own AST counter). Per-schema door measurement then split them one / fourteen. Closed with strictObject: ui/i18n.zod.ts AriaPropsSchema The highest-fan-out single site the ui/ wave has closed — carried as `aria:` on ~30 shapes under six metadata-type roots (ListView, Page, PageComponent, DashboardWidget, ChartConfig, Action, 20 SDUI component defs). Through the view root, `aria: { label, describedBy }` parsed clean and came back `aria: {}`: the accessible name existed in the source file and nowhere else. Those two spellings are objectui's own ARIA_KEY_ALIASES, i.e. what stored view metadata carries. Reclassified as no door, NOT tightened (14 sites): ui/widget.zod.ts all six shapes / nine sites ui/i18n.zod.ts I18nObject, PluralRule, NumberFormat, DateFormat, LocaleConfig Measured three ways with positive AND negative controls in the same run: no carrier key (nothing imports widget.zod but the barrel), unreachable in a 4766-node BFS from the 24 roots + defineStack, zero parse in all three repos (cloud checked by code search against a proven-working index). A synthetic carrier flips all of them, so the verdict is a fact about the graph. Curation anchored on named sibling contracts, not edit distance: the two legacy spellings on objectui's normalizer, and `ariaRole` on this shape's own inconsistent prefix (two of three keys carry `aria`, `role` does not). The four the distance fallback already reaches were measured first and left to it. `live` and `ariaLabelledBy` get a prescription rather than a rename — finding 7 discipline. `live` is real and rendered by objectui's ListView alone, which declares it via `AriaPropsSchema.extend({ live })`, so that surface keeps accepting it and now inherits this error map; promoting it into the shared shape would advertise aria-live on 29 renderers that do not implement it (#5058). A `.strip()` was added to four files this batch does not otherwise touch: animation, dnd (x2), keyboard, touch build their shapes as `z.object({…}).merge(AriaPropsSchema.partial())`, and `.merge()` adopts the incoming posture — closing AriaProps would have silently closed all five of those no-door shapes with zod's generic message and no changeset. Pinned. Ledger finding 20: the campaign's own BFS reported widget.zod.ts as REACHABLE. Zod's `.describe()` returns a clone sharing the original `_zod.def`, so a bridge firing on any ONE shared property under a shared name links unrelated shapes via `name`/`label`. 2 keys of 20. The error is one-directional — it can only make a batch tighten something dead (#4583). Corrected to whole-shape overlap in ui/door-reachability.testkit.ts, pinned as a regression test, filed as #5056. Instruments proven red before trusting green: six sabotages, one per assertion class (walker, clone bridge, strictness, curation, finding-7 guidance, merge-strip), each reverted. Ledger: ui/ 118 strip of 198, authorable subtotal 82 — both recomputed from the surviving rows, never decremented by this batch's count. Refs #4001. Filed out of scope: #5055 (ADR-0049 for the 14 no-door sites), #5056, #5058, #5059. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30ff55b commit 866ff16

11 files changed

Lines changed: 777 additions & 24 deletions
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
Close `AriaProps` against unknown keys, and reclassify `widget` + five `i18n` shapes as no-door (#4001 batch 16, 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 — one shape.** `AriaPropsSchema` (`ui/i18n.zod.ts`) now raises a
13+
named, fixable error instead of dropping the key. It is carried as `aria:` on
14+
roughly thirty live shapes under six metadata-type roots — `ListViewSchema`,
15+
`PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`,
16+
`ActionSchema`, and twenty SDUI component defs — so this is the highest-fan-out
17+
single site the `ui/` wave has closed.
18+
19+
**What it was doing.** Through the `view` metadata root, this parsed **clean**:
20+
21+
```ts
22+
getMetadataTypeSchema('view').parse({
23+
listViews: { my_view: { type: 'grid', columns: ['name'],
24+
aria: { label: 'Accounts', describedBy: 'accounts-help' } } },
25+
})
26+
// → aria: {}
27+
```
28+
29+
Both keys gone, reported valid. The accessible name existed in the source file
30+
and nowhere else — a screen-reader user hears the DOM default, and nothing in the
31+
toolchain ever said so. Those two spellings are not hypothetical: they are what
32+
objectui's `ARIA_KEY_ALIASES` normalizer folds at the `ListView` boundary
33+
(objectui#2890), i.e. what stored view metadata actually carries.
34+
35+
**The renames, each anchored to a named sibling contract.**
36+
37+
| you wrote | write instead | where the wrong word comes from |
38+
|---|---|---|
39+
| `label` | `ariaLabel` | objectui's stored legacy spelling, folded by `normalizeListViewSchema` |
40+
| `describedBy` | `ariaDescribedBy` | same |
41+
| `ariaRole` | `role` | this shape's own inconsistency — two of its three keys carry the `aria` prefix and `role` does not |
42+
43+
`arialabel`, `ariaLabell`, `ariadescribedby`, `aria-label` and `roles` are left to
44+
the edit-distance fallback, measured before anything was hand-written: an alias
45+
for a key the fallback already reaches is transcription, not judgement.
46+
47+
**Two keys get a prescription instead of a rename**, because renaming them would
48+
be wrong (the ledger's finding 7 — this campaign's own fix once signposting the
49+
way into the failure it exists to kill):
50+
51+
- `live` is real and rendered — by objectui's `ListView` alone, which reads
52+
`schema.aria?.live` and emits `aria-live`. objectui declares it as
53+
`AriaPropsSchema.extend({ live })`, so **that surface keeps accepting it** (and
54+
now inherits this error map for everything else). On any other surface the
55+
message says where `live` IS valid rather than pointing at a declared key that
56+
means something else. Promoting it into the shared shape would advertise
57+
`aria-live` on twenty-nine renderers that do not implement it; the promotion
58+
question is **#5058**.
59+
- `ariaLabelledBy` / `labelledBy``aria-labelledby` references another
60+
element's id, which is not the same thing as `ariaLabel` (a literal string), so
61+
there is nothing to rename it to. The gap is named, and is also #5058.
62+
63+
**A `.strip()` was added to four files this batch did not otherwise touch.**
64+
`animation.zod.ts`, `dnd.zod.ts` (×2), `keyboard.zod.ts` and `touch.zod.ts` build
65+
their config shapes as `z.object({…}).merge(AriaPropsSchema.partial())`, and
66+
`.merge()` adopts the incoming schema's unknown-key posture — so closing
67+
`AriaProps` would have silently closed all five of those shapes too, with zod's
68+
generic message and against #4988's measured verdict that nothing parses them.
69+
The explicit `.strip()` holds their posture; `i18n.test.ts` pins it.
70+
71+
**Nothing in `ui/widget.zod.ts` changed, and five of `ui/i18n.zod.ts`'s six
72+
shapes were left open** — deliberately, on measurement. The ledger scheduled
73+
`widget` as `authorable (p)` / 9 sites and warned that `i18n`'s label shapes were
74+
"wide-open records by design"; resolving both found something more specific.
75+
`widget.zod.ts` has no authoring door at all: nothing under `packages/spec/src`
76+
imports it except the barrel, a BFS from all 24 metadata-type roots plus
77+
`defineStack` never reaches it, and no `.parse()` on any of its shapes exists in
78+
`objectstack`, `objectui` or `cloud` outside its own tests. The same holds for
79+
`I18nObjectSchema`, `PluralRuleSchema`, `NumberFormatSchema`, `DateFormatSchema`
80+
and `LocaleConfigSchema`. `.strict()` is a property of a parse; there is no parse.
81+
Retiring them or giving them a carrier is ADR-0049 enforce-or-remove, tracked in
82+
**#5055** — not a breaking change to spend here.
83+
84+
The warning about the open record was aimed one level off, and both levels are
85+
now recorded: `I18nObject.params` is a `z.record` interpolation bag whose key
86+
space is whatever the message template names — openness there is the contract, and
87+
it was never a site this ratchet could close. The config block the map assumed was
88+
open alongside it (`AriaProps`) turned out to be the directory's most widely
89+
carried live shape.
90+
91+
Zero-breakage evidence: full `@objectstack/spec` suite, `tsc --noEmit`, all ten
92+
spec `check:*` gates, `objectstack validate` on app-showcase / app-crm / app-todo,
93+
and an ADR-0087 direct-parse probe over the three apps' **built** artifacts —
94+
zero `aria` slots present, with the probe's negative control proven red on a
95+
legacy-spelled block.

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

Lines changed: 83 additions & 13 deletions
Large diffs are not rendered by default.

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,14 @@ export const ComponentAnimationSchema = lazySchema(() => z.object({
121121
trigger: AnimationTriggerSchema.optional().describe('When to trigger the animation'),
122122
reducedMotion: z.enum(['respect', 'disable', 'alternative']).default('respect')
123123
.describe('Accessibility: how to handle prefers-reduced-motion'),
124-
}).merge(AriaPropsSchema.partial()).describe('Component-level animation configuration'));
124+
}).merge(AriaPropsSchema.partial())
125+
// `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and
126+
// `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape
127+
// would silently become `.strict()` — with zod's generic message, not the campaign's — and
128+
// would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a
129+
// strict shell enforces nothing). Keep it until #4988 says what happens to this file.
130+
.strip()
131+
.describe('Component-level animation configuration'));
125132

126133
export type ComponentAnimation = z.infer<typeof ComponentAnimationSchema>;
127134

packages/spec/src/ui/dnd.zod.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,14 @@ export const DropZoneSchema = lazySchema(() => z.object({
9292
maxItems: z.number().optional().describe('Maximum items allowed in drop zone'),
9393
highlightOnDragOver: z.boolean().default(true).describe('Highlight drop zone when dragging over'),
9494
dropEffect: DropEffectSchema.default('move').describe('Visual effect on drop'),
95-
}).merge(AriaPropsSchema.partial()).describe('Drop zone configuration'));
95+
}).merge(AriaPropsSchema.partial())
96+
// `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and
97+
// `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape
98+
// would silently become `.strict()` — with zod's generic message, not the campaign's — and
99+
// would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a
100+
// strict shell enforces nothing). Keep it until #4988 says what happens to this file.
101+
.strip()
102+
.describe('Drop zone configuration'));
96103

97104
export type DropZone = z.infer<typeof DropZoneSchema>;
98105

@@ -107,7 +114,14 @@ export const DragItemSchema = lazySchema(() => z.object({
107114
constraint: DragConstraintSchema.optional().describe('Drag movement constraints'),
108115
preview: z.enum(['element', 'custom', 'none']).default('element').describe('Drag preview type'),
109116
disabled: z.boolean().default(false).describe('Disable dragging'),
110-
}).merge(AriaPropsSchema.partial()).describe('Draggable item configuration'));
117+
}).merge(AriaPropsSchema.partial())
118+
// `.strip()` is LOAD-BEARING (#4001 批 16): `AriaPropsSchema` became `strictObject` and
119+
// `.merge()` adopts the incoming schema's unknown-key posture, so without this the shape
120+
// would silently become `.strict()` — with zod's generic message, not the campaign's — and
121+
// would contradict this file's measured `no door` verdict (#4988: nothing parses it, so a
122+
// strict shell enforces nothing). Keep it until #4988 says what happens to this file.
123+
.strip()
124+
.describe('Draggable item configuration'));
111125

112126
export type DragItem = z.infer<typeof DragItemSchema>;
113127

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The #4001 campaign's door measurement, as ONE implementation.
5+
*
6+
* "Is this schema reachable from an authoring root?" is the question that
7+
* decides a batch's whole verdict — `strictObject` when the answer is yes,
8+
* `no door` (reclassify, do not tighten) when it is no. Getting it wrong in the
9+
* false-positive direction spends a breaking change to produce *"a precisely
10+
* validated dead slot — the more convincing lie"* (#4583).
11+
*
12+
* Not exported from `ui/index.ts` and not a tsup entry, so it never reaches the
13+
* package's public surface; `*.testkit.ts` is also outside the strictness
14+
* ledger's `*.zod.ts` walk, so it adds no site to any count.
15+
*
16+
* ## Why the obvious walk is wrong twice
17+
*
18+
* Both corrections below were found by a control going red, not by reading:
19+
*
20+
* 1. **`typeof v !== 'object'` silently halves the graph.** `lazySchema`'s Proxy
21+
* target is `function lazyZod() {}`, so every lazy schema is
22+
* `typeof 'function'`. Skipping those makes the BFS stop at the first lazy
23+
* node and report whole families unreachable (批 15; `build-schemas.ts`'s
24+
* equivalent never hit it because it runs under `OS_EAGER_SCHEMAS=1`, where
25+
* there are no proxies).
26+
* 2. **A single shared property is NOT evidence of a derived clone** — this is
27+
* #5056, found at 批 16. `.extend()` / `.strip()` produce a clone that shares
28+
* no identity with its base but DOES share the base's per-property schema
29+
* instances, so a bridge over shared property defs is genuinely needed. The
30+
* bridge as first written fired when **any one** property matched under the
31+
* same name — and zod's `.describe()` returns a clone that shares the
32+
* original `_zod.def` OBJECT, which makes every described
33+
* `SnakeCaseIdentifierSchema` / `I18nLabelSchema` def-identical across the
34+
* whole spec. Two unrelated shapes that both declare `name` and `label` (i.e.
35+
* almost every authorable shape here) therefore bridged, and
36+
* `WidgetManifestSchema` — a file nothing imports — measured as REACHABLE.
37+
* The error is one-directional: it can only produce a false door, i.e. it can
38+
* only cause a batch to tighten something dead.
39+
*
40+
* The fix is to ask how much of the shape is shared rather than whether
41+
* anything is: a real derived clone carries nearly all of its base's
42+
* properties, while a coincidence carries one or two out of twenty.
43+
*/
44+
45+
import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../kernel/metadata-type-schemas';
46+
import { ObjectStackSchema } from '../stack.zod';
47+
48+
/**
49+
* Identity of a schema NODE.
50+
*
51+
* Keyed on `_zod.def`, never on the schema binding: `lazySchema` hands out a
52+
* Proxy unless `OS_EAGER_SCHEMAS=1` while the graph holds the real instances, so
53+
* comparing bindings reports every root as unreachable. `def` survives the Proxy
54+
* (the `_zod` facade delegates to the real internals), so it is the one stable
55+
* key for both identities.
56+
*/
57+
const defOf = (s: unknown): unknown => (s as { _zod?: { def?: unknown } })?._zod?.def;
58+
59+
const shapeOf = (node: unknown): Record<string, unknown> | null => {
60+
const def = defOf(node) as { type?: string; shape?: Record<string, unknown> } | undefined;
61+
return def?.type === 'object' && def.shape ? def.shape : null;
62+
};
63+
64+
function childrenOf(node: unknown): unknown[] {
65+
const out: unknown[] = [];
66+
const seen = new Set<unknown>();
67+
const walk = (v: unknown): void => {
68+
// See correction 1 in the module doc: `typeof v !== 'object'` alone skips
69+
// every lazy schema, because the Proxy's target is a function.
70+
if (v === null || (typeof v !== 'object' && typeof v !== 'function') || seen.has(v)) return;
71+
seen.add(v);
72+
if (defOf(v)) { out.push(v); return; }
73+
if (Array.isArray(v)) { for (const x of v) walk(x); return; }
74+
if (v instanceof Map) { for (const x of v.values()) walk(x); return; }
75+
for (const x of Object.values(v as Record<string, unknown>)) walk(x);
76+
};
77+
walk(defOf(node));
78+
return out;
79+
}
80+
81+
/** How a schema was (or was not) reached from the authoring roots. */
82+
export type DoorVerdict = 'direct' | 'derived-clone' | 'unreachable';
83+
84+
export interface DoorMeasurement {
85+
/** `direct` / `derived-clone` mean there IS a door; `unreachable` means there is not. */
86+
verdict: (schema: unknown) => DoorVerdict;
87+
/** Fraction of the candidate's own shape shared with the best-matching visited object. */
88+
cloneOverlap: (schema: unknown) => number;
89+
/** Nodes walked — a sanity floor for "the graph actually got built". */
90+
nodeCount: number;
91+
/** Roots walked from. */
92+
rootCount: number;
93+
}
94+
95+
/**
96+
* A schema is treated as a derived clone when it shares at least this much of
97+
* its own shape, by property def identity under the same name, with one visited
98+
* object node.
99+
*
100+
* Chosen against both ends of the measured range rather than by taste: 批 15's
101+
* real derivation (`ChartConfigSchema` reached through `ReportChartSchema`,
102+
* which re-narrows two of its keys) sits far above it, and 批 16's false
103+
* positive (`WidgetManifestSchema`, 2 shared keys of 20 — `name` and `label`,
104+
* both shared LEAVES rather than shared structure) sits far below.
105+
*/
106+
const DERIVED_CLONE_MIN_OVERLAP = 0.5;
107+
108+
/**
109+
* BFS the in-memory Zod graph from every metadata-type root plus `defineStack`'s
110+
* `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650
111+
* deletion check.
112+
*
113+
* `extraRoots` exists for the control every measurement owes: inject a synthetic
114+
* carrier for the schema under test and the verdict MUST flip. Without it,
115+
* "unreachable" and "the walker is broken" are the same output.
116+
*/
117+
export function measureDoors(extraRoots: readonly unknown[] = []): DoorMeasurement {
118+
const roots: unknown[] = [];
119+
for (const type of listMetadataTypeSchemaTypes()) {
120+
const s = getMetadataTypeSchema(type);
121+
if (s) roots.push(s);
122+
}
123+
roots.push(ObjectStackSchema, ...extraRoots);
124+
125+
const visitedDefs = new Set<unknown>();
126+
const visitedShapes: Array<Record<string, unknown>> = [];
127+
const queue = [...roots];
128+
while (queue.length > 0) {
129+
const node = queue.pop();
130+
const def = defOf(node);
131+
if (!def || visitedDefs.has(def)) continue;
132+
visitedDefs.add(def);
133+
const shape = shapeOf(node);
134+
if (shape) visitedShapes.push(shape);
135+
for (const child of childrenOf(node)) queue.push(child);
136+
}
137+
138+
const cloneOverlap = (schema: unknown): number => {
139+
const shape = shapeOf(schema);
140+
if (!shape) return 0;
141+
const entries = Object.entries(shape);
142+
if (entries.length === 0) return 0;
143+
let best = 0;
144+
for (const visited of visitedShapes) {
145+
let shared = 0;
146+
for (const [name, prop] of entries) {
147+
const d = defOf(prop);
148+
if (d && defOf(visited[name]) === d) shared++;
149+
}
150+
if (shared > best) best = shared;
151+
}
152+
return best / entries.length;
153+
};
154+
155+
const verdict = (schema: unknown): DoorVerdict => {
156+
const def = defOf(schema);
157+
if (!def) return 'unreachable';
158+
if (visitedDefs.has(def)) return 'direct';
159+
return cloneOverlap(schema) >= DERIVED_CLONE_MIN_OVERLAP ? 'derived-clone' : 'unreachable';
160+
};
161+
162+
return { verdict, cloneOverlap, nodeCount: visitedDefs.size, rootCount: roots.length };
163+
}

0 commit comments

Comments
 (0)