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

Close `AriaProps` against unknown keys, and reclassify `widget` + five `i18n` shapes as no-door (#4001 batch 16, 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 — one shape.** `AriaPropsSchema` (`ui/i18n.zod.ts`) now raises a
named, fixable error instead of dropping the key. It is carried as `aria:` on
roughly thirty live shapes under six metadata-type roots — `ListViewSchema`,
`PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`,
`ActionSchema`, and twenty SDUI component defs — so this is the highest-fan-out
single site the `ui/` wave has closed.

**What it was doing.** Through the `view` metadata root, this parsed **clean**:

```ts
getMetadataTypeSchema('view').parse({
listViews: { my_view: { type: 'grid', columns: ['name'],
aria: { label: 'Accounts', describedBy: 'accounts-help' } } },
})
// → aria: {}
```

Both keys gone, reported valid. The accessible name existed in the source file
and nowhere else — a screen-reader user hears the DOM default, and nothing in the
toolchain ever said so. Those two spellings are not hypothetical: they are what
objectui's `ARIA_KEY_ALIASES` normalizer folds at the `ListView` boundary
(objectui#2890), i.e. what stored view metadata actually carries.

**The renames, each anchored to a named sibling contract.**

| you wrote | write instead | where the wrong word comes from |
|---|---|---|
| `label` | `ariaLabel` | objectui's stored legacy spelling, folded by `normalizeListViewSchema` |
| `describedBy` | `ariaDescribedBy` | same |
| `ariaRole` | `role` | this shape's own inconsistency — two of its three keys carry the `aria` prefix and `role` does not |

`arialabel`, `ariaLabell`, `ariadescribedby`, `aria-label` and `roles` are left to
the edit-distance fallback, measured before anything was hand-written: an alias
for a key the fallback already reaches is transcription, not judgement.

**Two keys get a prescription instead of a rename**, because renaming them would
be wrong (the ledger's finding 7 — this campaign's own fix once signposting the
way into the failure it exists to kill):

- `live` is real and rendered — by objectui's `ListView` alone, which reads
`schema.aria?.live` and emits `aria-live`. objectui declares it as
`AriaPropsSchema.extend({ live })`, so **that surface keeps accepting it** (and
now inherits this error map for everything else). On any other surface the
message says where `live` IS valid rather than pointing at a declared key that
means something else. Promoting it into the shared shape would advertise
`aria-live` on twenty-nine renderers that do not implement it; the promotion
question is **#5058**.
- `ariaLabelledBy` / `labelledBy` — `aria-labelledby` references another
element's id, which is not the same thing as `ariaLabel` (a literal string), so
there is nothing to rename it to. The gap is named, and is also #5058.

**A `.strip()` was added to four files this batch did not otherwise touch.**
`animation.zod.ts`, `dnd.zod.ts` (×2), `keyboard.zod.ts` and `touch.zod.ts` build
their config shapes as `z.object({…}).merge(AriaPropsSchema.partial())`, and
`.merge()` adopts the incoming schema's unknown-key posture — so closing
`AriaProps` would have silently closed all five of those shapes too, with zod's
generic message and against #4988's measured verdict that nothing parses them.
The explicit `.strip()` holds their posture; `i18n.test.ts` pins it.

**Nothing in `ui/widget.zod.ts` changed, and five of `ui/i18n.zod.ts`'s six
shapes were left open** — deliberately, on measurement. The ledger scheduled
`widget` as `authorable (p)` / 9 sites and warned that `i18n`'s label shapes were
"wide-open records by design"; resolving both found something more specific.
`widget.zod.ts` has no authoring door at all: nothing under `packages/spec/src`
imports it except the barrel, a BFS from all 24 metadata-type roots plus
`defineStack` never reaches it, and no `.parse()` on any of its shapes exists in
`objectstack`, `objectui` or `cloud` outside its own tests. The same holds for
`I18nObjectSchema`, `PluralRuleSchema`, `NumberFormatSchema`, `DateFormatSchema`
and `LocaleConfigSchema`. `.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
**#5055** — not a breaking change to spend here.

The warning about the open record was aimed one level off, and both levels are
now recorded: `I18nObject.params` is a `z.record` interpolation bag whose key
space is whatever the message template names — openness there is the contract, and
it was never a site this ratchet could close. The config block the map assumed was
open alongside it (`AriaProps`) turned out to be the directory's most widely
carried live shape.

Zero-breakage evidence: full `@objectstack/spec` suite, `tsc --noEmit`, all ten
spec `check:*` gates, `objectstack validate` on app-showcase / app-crm / app-todo,
and an ADR-0087 direct-parse probe over the three apps' **built** artifacts —
zero `aria` slots present, with the probe's negative control proven red on a
legacy-spelled block.
96 changes: 83 additions & 13 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md

Large diffs are not rendered by default.

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

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

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

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

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

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

Expand Down
163 changes: 163 additions & 0 deletions packages/spec/src/ui/door-reachability.testkit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The #4001 campaign's door measurement, as ONE implementation.
*
* "Is this schema reachable from an authoring root?" is the question that
* decides a batch's whole verdict — `strictObject` when the answer is yes,
* `no door` (reclassify, do not tighten) when it is no. Getting it wrong in the
* false-positive direction spends a breaking change to produce *"a precisely
* validated dead slot — the more convincing lie"* (#4583).
*
* Not exported from `ui/index.ts` and not a tsup entry, so it never reaches the
* package's public surface; `*.testkit.ts` is also outside the strictness
* ledger's `*.zod.ts` walk, so it adds no site to any count.
*
* ## Why the obvious walk is wrong twice
*
* Both corrections below were found by a control going red, not by reading:
*
* 1. **`typeof v !== 'object'` silently halves the graph.** `lazySchema`'s Proxy
* target is `function lazyZod() {}`, so every lazy schema is
* `typeof 'function'`. Skipping those makes the BFS stop at the first lazy
* node and report whole families unreachable (批 15; `build-schemas.ts`'s
* equivalent never hit it because it runs under `OS_EAGER_SCHEMAS=1`, where
* there are no proxies).
* 2. **A single shared property is NOT evidence of a derived clone** — this is
* #5056, found at 批 16. `.extend()` / `.strip()` produce a clone that shares
* no identity with its base but DOES share the base's per-property schema
* instances, so a bridge over shared property defs is genuinely needed. The
* bridge as first written fired when **any one** property matched under the
* same name — and zod's `.describe()` returns a clone that shares the
* original `_zod.def` OBJECT, which makes every described
* `SnakeCaseIdentifierSchema` / `I18nLabelSchema` def-identical across the
* whole spec. Two unrelated shapes that both declare `name` and `label` (i.e.
* almost every authorable shape here) therefore bridged, and
* `WidgetManifestSchema` — a file nothing imports — measured as REACHABLE.
* The error is one-directional: it can only produce a false door, i.e. it can
* only cause a batch to tighten something dead.
*
* The fix is to ask how much of the shape is shared rather than whether
* anything is: a real derived clone carries nearly all of its base's
* properties, while a coincidence carries one or two out of twenty.
*/

import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../kernel/metadata-type-schemas';
import { ObjectStackSchema } from '../stack.zod';

/**
* Identity of a schema NODE.
*
* Keyed on `_zod.def`, never on the schema binding: `lazySchema` hands out a
* Proxy unless `OS_EAGER_SCHEMAS=1` while the graph holds the real instances, so
* comparing bindings reports every root as unreachable. `def` survives the Proxy
* (the `_zod` facade delegates to the real internals), so it is the one stable
* key for both identities.
*/
const defOf = (s: unknown): unknown => (s as { _zod?: { def?: unknown } })?._zod?.def;

const shapeOf = (node: unknown): Record<string, unknown> | null => {
const def = defOf(node) as { type?: string; shape?: Record<string, unknown> } | undefined;
return def?.type === 'object' && def.shape ? def.shape : null;
};

function childrenOf(node: unknown): unknown[] {
const out: unknown[] = [];
const seen = new Set<unknown>();
const walk = (v: unknown): void => {
// See correction 1 in the module doc: `typeof v !== 'object'` alone skips
// every lazy schema, because the Proxy's target is a function.
if (v === null || (typeof v !== 'object' && typeof v !== 'function') || seen.has(v)) return;
seen.add(v);
if (defOf(v)) { out.push(v); return; }
if (Array.isArray(v)) { for (const x of v) walk(x); return; }
if (v instanceof Map) { for (const x of v.values()) walk(x); return; }
for (const x of Object.values(v as Record<string, unknown>)) walk(x);
};
walk(defOf(node));
return out;
}

/** How a schema was (or was not) reached from the authoring roots. */
export type DoorVerdict = 'direct' | 'derived-clone' | 'unreachable';

export interface DoorMeasurement {
/** `direct` / `derived-clone` mean there IS a door; `unreachable` means there is not. */
verdict: (schema: unknown) => DoorVerdict;
/** Fraction of the candidate's own shape shared with the best-matching visited object. */
cloneOverlap: (schema: unknown) => number;
/** Nodes walked — a sanity floor for "the graph actually got built". */
nodeCount: number;
/** Roots walked from. */
rootCount: number;
}

/**
* A schema is treated as a derived clone when it shares at least this much of
* its own shape, by property def identity under the same name, with one visited
* object node.
*
* Chosen against both ends of the measured range rather than by taste: 批 15's
* real derivation (`ChartConfigSchema` reached through `ReportChartSchema`,
* which re-narrows two of its keys) sits far above it, and 批 16's false
* positive (`WidgetManifestSchema`, 2 shared keys of 20 — `name` and `label`,
* both shared LEAVES rather than shared structure) sits far below.
*/
const DERIVED_CLONE_MIN_OVERLAP = 0.5;

/**
* BFS the in-memory Zod graph from every metadata-type root plus `defineStack`'s
* `ObjectStackSchema` — the closure `build-schemas.ts` uses for the #4650
* deletion check.
*
* `extraRoots` exists for the control every measurement owes: inject a synthetic
* carrier for the schema under test and the verdict MUST flip. Without it,
* "unreachable" and "the walker is broken" are the same output.
*/
export function measureDoors(extraRoots: readonly unknown[] = []): DoorMeasurement {
const roots: unknown[] = [];
for (const type of listMetadataTypeSchemaTypes()) {
const s = getMetadataTypeSchema(type);
if (s) roots.push(s);
}
roots.push(ObjectStackSchema, ...extraRoots);

const visitedDefs = new Set<unknown>();
const visitedShapes: Array<Record<string, unknown>> = [];
const queue = [...roots];
while (queue.length > 0) {
const node = queue.pop();
const def = defOf(node);
if (!def || visitedDefs.has(def)) continue;
visitedDefs.add(def);
const shape = shapeOf(node);
if (shape) visitedShapes.push(shape);
for (const child of childrenOf(node)) queue.push(child);
}

const cloneOverlap = (schema: unknown): number => {
const shape = shapeOf(schema);
if (!shape) return 0;
const entries = Object.entries(shape);
if (entries.length === 0) return 0;
let best = 0;
for (const visited of visitedShapes) {
let shared = 0;
for (const [name, prop] of entries) {
const d = defOf(prop);
if (d && defOf(visited[name]) === d) shared++;
}
if (shared > best) best = shared;
}
return best / entries.length;
};

const verdict = (schema: unknown): DoorVerdict => {
const def = defOf(schema);
if (!def) return 'unreachable';
if (visitedDefs.has(def)) return 'direct';
return cloneOverlap(schema) >= DERIVED_CLONE_MIN_OVERLAP ? 'derived-clone' : 'unreachable';
};

return { verdict, cloneOverlap, nodeCount: visitedDefs.size, rootCount: roots.length };
}
Loading
Loading