Skip to content

Commit 5e6290f

Browse files
committed
feat(spec): unknown-key lint descends into nested metadata, not just item roots
The ledger listed "build the @objectstack/lint unknown-key WARNING layer" as the next step. It had already been built — #3786's lintUnknownAuthoringKeys plus #4167's lintUnknownStackKeys, exported from @objectstack/spec and wired into defineStack(), `os validate` and `os compile` as non-blocking warnings, releases ago. Anyone reading that file for what to do next was being sent to build something that shipped. The ledger now says so. What was actually missing was DEPTH. The walk covered each metadata item's top level plus one hard-coded hop into `object.fields`, leaving 227 strip-mode objects nested below those roots reporting nothing — concentrated exactly where authoring volume is: object 71, view 49, page 24, dashboard 18, agent 16, mapping 14. Those sites were both silently dropping keys AND contributing nothing to the evidence base the v18 close-out is supposed to be scheduled on. The walk now follows the authored value alongside its schema through nested objects, arrays and records. Posture rules are unchanged, so it still never double-reports what the parse handles: strict and passthrough stay silent, only strip reports and is descended through. Unions descend only when the authored value picks a branch unambiguously (a discriminated union whose discriminator the author actually wrote). Otherwise the merged posture applies at that level and the walk stops — guessing a branch would invent findings against a shape nobody wrote. `object.fields` keeps reporting as `field` with its curated guidance, now via an explicit override table instead of a special case, so its own nested sites (`fields.*.options[]`) are covered too. The override keys on the path RELATIVE to the item root, and a record's author-chosen keys deliberately do not join that path — otherwise only the first field would resolve as `field`. Audited the result rather than assuming it, since "our own assets are clean" has been wrong before in this campaign: 43 platform objects + 3 apps + 1 dashboard + 3 pages, plus both example apps, report zero unknown keys. No finding this time. Two silences were checked rather than trusted: dashboard widgets are .strict() (the parse owns that), and a component whose `type` matches no discriminator is deliberately not descended into. Verified: spec suite 7172 green; tsc clean; check:docs, liveness, variant-docs and authorable-surface gates green; content/docs/references unchanged (the hook-body lesson from #4207 — a JSDoc in the wrong place rewrites a page). Refs #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
1 parent 86a71d1 commit 5e6290f

4 files changed

Lines changed: 290 additions & 25 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
The unknown-authoring-key lint now descends into nested metadata, not just each
6+
item's top level.
7+
8+
`lintUnknownAuthoringKeys` (#3786) reported unknown keys on each metadata item's
9+
top level plus one hard-coded hop into `object.fields`. That left **227
10+
strip-mode objects** nested below those roots reporting nothing — and they are
11+
concentrated exactly where authoring volume is: `object` 71, `view` 49, `page`
12+
24, `dashboard` 18, `agent` 16, `mapping` 14. Those sites were both silently
13+
dropping keys and contributing nothing to the evidence base the v18 strict
14+
close-out is meant to be scheduled on.
15+
16+
The walk now follows the authored value alongside its schema through nested
17+
objects, arrays and records. Posture rules are unchanged, so the lint still never
18+
double-reports what the parse already handles:
19+
20+
- `strict` → silent (the parse is loud on its own)
21+
- `passthrough` → silent (the key legally survives)
22+
- `strip` → reported, and the descent continues through it
23+
24+
Unions descend only when the authored value picks a branch unambiguously — a
25+
discriminated union whose discriminator the author actually wrote. Otherwise the
26+
merged posture applies at that level and the walk stops, because guessing a
27+
branch would invent findings against a shape nobody wrote.
28+
29+
`object.fields` still reports as the `field` surface with its curated guidance,
30+
now via an explicit override table rather than a special case — so its own nested
31+
sites (`fields.*.options[]`, …) are covered too.
32+
33+
Still non-blocking: these are warnings from `defineStack()`, `os validate` and
34+
`os compile`, exactly as before. Expect existing projects to surface more of
35+
them — each one is a key that was already being dropped, now visible.

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,12 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
213213

214214
## Next steps (verify-then-enforce, one shape at a time)
215215

216-
1. The `@objectstack/lint` unknown-key WARNING layer: non-breaking, shippable
217-
in a minor, and it extends AI-detectable coverage to every remaining
218-
authorable site at once while accumulating evidence (which keys real
219-
tenant projects actually carry) for a v18 strict close-out.
220-
2. Promote this ledger to a machine-checked gate (pattern of
216+
1. Promote this ledger to a machine-checked gate (pattern of
221217
`packages/spec/liveness/` + `check:liveness`) once enough of the surface is
222218
classified that the table above is enforceable rather than descriptive.
219+
2. Let the warning layer run in the wild for a release, then schedule the v18
220+
strict close-out on what it actually reports — which is the whole point of
221+
having built it. Nothing more to do here until there is field data.
223222

224223
Done in step 2: `security/rls.zod.ts` + `security/sharing.zod.ts` strict;
225224
`PositionSchema` strict with the protection envelope declared (closing the
@@ -262,5 +261,36 @@ the flip carried strictness INTO the JSON schema. It did not; approval's schema
262261
would have said `false` regardless. The registration-time rejection it describes
263262
is real, but it came from the published schema, not from the flip.)
264263

264+
## The warning layer (was "next step 1" — it already existed)
265+
266+
The `@objectstack/lint` unknown-key WARNING layer this list carried as a pending
267+
next step **had already been built** by the time the data step landed: #3786's
268+
`lintUnknownAuthoringKeys` plus #4167's `lintUnknownStackKeys`, exported from
269+
`@objectstack/spec` and wired into `defineStack()`, `os validate` and
270+
`os compile` as non-blocking warnings. Anyone reading this file for what to do
271+
next was being sent to build something that shipped releases ago.
272+
273+
What was genuinely missing was **depth**, not existence. The walk covered each
274+
metadata item's top level plus one hard-coded descent into `object.fields`,
275+
which left 227 strip-mode objects nested below those roots reporting nothing —
276+
concentrated exactly where authoring volume is: `object` 71, `view` 49,
277+
`page` 24, `dashboard` 18, `agent` 16, `mapping` 14. Those sites were both
278+
silently eating keys AND contributing nothing to the evidence base the v18
279+
close-out is supposed to be scheduled on.
280+
281+
The walk now descends the authored value alongside its schema through nested
282+
objects, arrays and records, applying the same posture rules (`strict` and
283+
`passthrough` stay silent; only `strip` reports). Unions descend only when the
284+
authored value picks a branch unambiguously — guessing would invent findings
285+
against a shape nobody wrote. `object.fields` keeps reporting as `field` with
286+
its curated guidance, now via an explicit override table rather than a special
287+
case, so its own nested sites (`fields.*.options[]`, …) are covered too.
288+
289+
Audited after the change, since "our own assets are clean" has been wrong
290+
before: 43 platform objects + 3 apps + 1 dashboard + 3 pages, and both example
291+
apps (23 + 6 objects, 20 + 1 pages, 4 + 1 datasets, 3 + 1 dashboards) report
292+
**zero** unknown keys. No finding this time — worth recording precisely because
293+
the app step's `ACCOUNT_APP.defaultOpen` came from exactly this class of check.
294+
265295
Long tail stays gated on a verification pass per shape — never a one-shot
266296
"make all ~453 sites strict" (ADR-0054 ratchet; #4001's own recommendation).

packages/spec/src/kernel/metadata-authoring-lint.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,82 @@ describe('the #4148 behaviours survive the generalization', () => {
144144
});
145145
});
146146

147+
describe('nested descent (#4001 evidence phase)', () => {
148+
// Before this the walk stopped at each item's top level plus a hard-coded
149+
// hop into `object.fields`, leaving 227 strip-mode objects below those roots
150+
// reporting nothing. These pin the four structural moves the descent makes
151+
// and — just as importantly — the two cases where it must stay quiet.
152+
153+
it('reports inside a nested object', () => {
154+
const [finding, ...rest] = lintUnknownAuthoringKeys({
155+
objects: [{ name: 'o1', userActions: { zzz_nested: 1 } }],
156+
});
157+
expect(rest).toEqual([]);
158+
expect(finding).toMatchObject({
159+
path: 'objects.o1.userActions.zzz_nested',
160+
surface: 'object',
161+
key: 'zzz_nested',
162+
});
163+
});
164+
165+
it('reports inside an array element, indexed by position', () => {
166+
const [finding] = lintUnknownAuthoringKeys({
167+
pages: [{ name: 'p1', regions: [{ name: 'r', zzz_nested: 1 }] }],
168+
});
169+
expect(finding).toMatchObject({ path: 'pages.p1.regions.0.zzz_nested', surface: 'page' });
170+
});
171+
172+
it('carries the field surface through a record INTO its nested array', () => {
173+
// The override is keyed on the path relative to the item root, so a record's
174+
// author-chosen keys must not join that path — otherwise only the first
175+
// field would resolve as `field`. This also proves the descent continues
176+
// BELOW the override rather than stopping at it.
177+
const [finding] = lintUnknownAuthoringKeys({
178+
objects: [{
179+
name: 'o2',
180+
fields: { s: { type: 'select', options: [{ label: 'A', value: 'a', zzz_nested: 1 }] } },
181+
}],
182+
});
183+
expect(finding).toMatchObject({
184+
path: 'objects.o2.fields.s.options.0.zzz_nested',
185+
surface: 'field',
186+
});
187+
});
188+
189+
it('stays silent where the nested parse is already strict', () => {
190+
// A dashboard widget is .strict(); reporting here would double-report what
191+
// the parse rejects loudly on its own.
192+
expect(lintUnknownAuthoringKeys({
193+
dashboards: [{ name: 'd1', widgets: [{ id: 'w', type: 'chart', zzz_nested: 1 }] }],
194+
})).toEqual([]);
195+
});
196+
197+
it('does not guess a union branch the author never picked', () => {
198+
// `type: 'not_a_real_component'` matches no discriminator literal. Descending
199+
// into an arbitrary member would invent findings against a shape nobody wrote.
200+
expect(lintUnknownAuthoringKeys({
201+
pages: [{ name: 'p1', regions: [{ name: 'r', components: [{ type: 'not_a_real_component', zzz_nested: 1 }] }] }],
202+
})).toEqual([]);
203+
});
204+
205+
it('still survives malformed nested input rather than throwing', () => {
206+
for (const junk of [
207+
{ objects: [{ name: 'o', userActions: 'nope' }] },
208+
{ pages: [{ name: 'p', regions: 'nope' }] },
209+
{ pages: [{ name: 'p', regions: [null, 7, { name: 'r' }] }] },
210+
{ objects: [{ name: 'o', fields: { f: { type: 'select', options: 'nope' } } }] },
211+
]) {
212+
expect(() => lintUnknownAuthoringKeys(junk)).not.toThrow();
213+
}
214+
});
215+
216+
it('keeps ignoring the underscore channel at depth', () => {
217+
expect(lintUnknownAuthoringKeys({
218+
pages: [{ name: 'p1', regions: [{ name: 'r', _provenance: 'x' }] }],
219+
})).toEqual([]);
220+
});
221+
});
222+
147223
describe('top-level stack keys (#4167)', () => {
148224
const lint = (raw: unknown) => lintUnknownStackKeys(raw, ObjectStackDefinitionSchema);
149225

packages/spec/src/kernel/metadata-authoring-lint.ts

Lines changed: 144 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ import {
5757
STACK_RUNTIME_MEMBERS,
5858
type UnknownAuthoringKeyFinding,
5959
} from '../data/authoring-key-lint';
60-
import { FieldSchema } from '../data/field.zod';
6160
import { PLURAL_TO_SINGULAR } from '../shared/metadata-collection.zod';
6261
import { getMetadataTypeSchema } from './metadata-type-schemas';
6362

@@ -167,6 +166,149 @@ export function listLintableAuthoringCollections(): LintableAuthoringCollection[
167166
return out;
168167
}
169168

169+
/**
170+
* How deep below a metadata item the walk goes. The descent is bounded by the
171+
* AUTHORED data, not by the schema, so this only matters for pathologically
172+
* nested input; 12 clears the deepest real shape (a page's nested region tree).
173+
*/
174+
const MAX_DESCENT_DEPTH = 12;
175+
176+
/**
177+
* Nested surfaces that report under their own name instead of the enclosing
178+
* metadata type, keyed by `<type>:<path relative to the item root>`.
179+
*
180+
* Only `object.fields` qualifies today. It is the surface #4120 mined for the
181+
* curated guidance table, and a finding on it has always said `field` rather
182+
* than `object` — callers and tests depend on that. Everything else nested
183+
* reports under its metadata type, which is the axis authors think in ("this is
184+
* a page problem"), and falls back to the edit-distance suggestion.
185+
*/
186+
const NESTED_SURFACES: Readonly<
187+
Record<string, { surface: string; guidance: Readonly<Record<string, { to?: string; why?: string }>> }>
188+
> = Object.freeze({
189+
'object:fields': { surface: 'field', guidance: FIELD_KEY_GUIDANCE },
190+
});
191+
192+
/**
193+
* Walk the authored value alongside its schema, reporting unknown keys at every
194+
* strip-mode object below the item root (#4001 evidence phase).
195+
*
196+
* Before this, the walk stopped at each metadata item's top level plus one
197+
* hard-coded descent into `object.fields`. That left 227 strip-mode objects
198+
* nested below those roots — `page.regions[].components[]`, `dashboard.widgets[]`,
199+
* `view.config.data`, `action.params[].options[]` — silently eating keys AND
200+
* contributing nothing to the evidence base the v18 strict close-out is meant to
201+
* be scheduled on. The two most-authored types were the worst off: `object` has
202+
* 71 such sites, `view` 49.
203+
*
204+
* Posture rules match {@link keyPosture}, so the lint never double-reports what
205+
* the parse already rejects:
206+
* - `strict` → silent, the parse is loud on its own.
207+
* - `passthrough` → silent, the key legally survives.
208+
* - `strip` → reported, and the descent continues through it.
209+
*
210+
* Unions descend only when the authored value picks a branch unambiguously (a
211+
* discriminated union whose discriminator the author actually wrote). Otherwise
212+
* the merged posture from {@link keyPosture} is applied at this level and the
213+
* walk stops: guessing a branch would invent findings against a shape the author
214+
* never chose.
215+
*/
216+
function descend(
217+
schema: unknown,
218+
raw: unknown,
219+
path: string,
220+
relPath: string,
221+
surface: string,
222+
guidance: Readonly<Record<string, { to?: string; why?: string }>>,
223+
out: UnknownAuthoringKeyFinding[],
224+
depth: number,
225+
): void {
226+
if (depth > MAX_DESCENT_DEPTH || raw == null) return;
227+
const u = unwrap(schema);
228+
const d = u?.def ?? u?._def;
229+
if (!d) return;
230+
231+
if (d.type === 'union' || d.type === 'discriminated_union') {
232+
const branch = d.discriminator && isPlainRecord(raw)
233+
? pickUnionBranch(d, raw[d.discriminator])
234+
: undefined;
235+
if (branch) {
236+
descend(branch, raw, path, relPath, surface, guidance, out, depth + 1);
237+
return;
238+
}
239+
const merged = keyPosture(u);
240+
if (merged?.mode === 'strip' && merged.keys.size > 0 && isPlainRecord(raw)) {
241+
lintAuthoredRecordKeys(raw, merged.keys, guidance, surface, path, out);
242+
}
243+
return;
244+
}
245+
246+
if (d.type === 'object') {
247+
if (!isPlainRecord(raw)) return;
248+
const shape = (typeof d.shape === 'function' ? d.shape() : d.shape) ?? {};
249+
// depth 0 is the item root, already reported by the caller.
250+
if (depth > 0) {
251+
const posture = keyPosture(u);
252+
if (posture?.mode === 'strip' && posture.keys.size > 0) {
253+
lintAuthoredRecordKeys(raw, posture.keys, guidance, surface, path, out);
254+
}
255+
}
256+
for (const [key, child] of Object.entries(shape)) {
257+
if (!(key in raw)) continue;
258+
const childRel = relPath ? `${relPath}.${key}` : key;
259+
const override = NESTED_SURFACES[`${surface}:${childRel}`];
260+
descend(
261+
child,
262+
raw[key],
263+
`${path}.${key}`,
264+
childRel,
265+
override?.surface ?? surface,
266+
override?.guidance ?? guidance,
267+
out,
268+
depth + 1,
269+
);
270+
}
271+
return;
272+
}
273+
274+
if (d.element) {
275+
if (!Array.isArray(raw)) return;
276+
for (let i = 0; i < raw.length; i++) {
277+
descend(d.element, raw[i], `${path}.${i}`, relPath, surface, guidance, out, depth + 1);
278+
}
279+
return;
280+
}
281+
282+
if (d.valueType) {
283+
if (!isPlainRecord(raw)) return;
284+
for (const [key, value] of Object.entries(raw)) {
285+
// A record's KEYS are author-chosen names, so they never join relPath —
286+
// `object.fields` must stay `fields`, not `fields.owner`, or the override
287+
// above would miss every field but one.
288+
descend(d.valueType, value, `${path}.${key}`, relPath, surface, guidance, out, depth + 1);
289+
}
290+
}
291+
}
292+
293+
/** The union member whose discriminator literal matches `value`, if exactly one does. */
294+
function pickUnionBranch(unionDef: any, value: unknown): unknown {
295+
if (value === undefined) return undefined;
296+
for (const option of unionDef.options ?? []) {
297+
const od = unwrap(option);
298+
const shape = (() => {
299+
const dd = od?.def ?? od?._def;
300+
return typeof dd?.shape === 'function' ? dd.shape() : dd?.shape;
301+
})();
302+
const discDef = (() => {
303+
const s = unwrap(shape?.[unionDef.discriminator]);
304+
return s?.def ?? s?._def;
305+
})();
306+
const literals = discDef?.values ?? (discDef?.value !== undefined ? [discDef.value] : []);
307+
if (literals && [...literals].includes(value as never)) return option;
308+
}
309+
return undefined;
310+
}
311+
170312
/**
171313
* Report every key an authored stack sets — on any item of any metadata
172314
* collection — that the item's schema does not declare: every value the parse
@@ -197,25 +339,7 @@ export function lintUnknownAuthoringKeys(rawStack: unknown): UnknownAuthoringKey
197339
const name = typeof item.name === 'string' && item.name ? item.name : String(i);
198340
const basePath = `${collection}.${name}`;
199341
lintAuthoredRecordKeys(item, posture.keys, guidance, type, basePath, out);
200-
201-
// The one nested surface: an object's `fields` record, judged by
202-
// FieldSchema — where #4120 found the worst of the drift.
203-
if (type === 'object' && isPlainRecord(item.fields)) {
204-
const fieldPosture = keyPosture(FieldSchema);
205-
if (fieldPosture && fieldPosture.mode === 'strip' && fieldPosture.keys.size > 0) {
206-
for (const [fieldName, field] of Object.entries(item.fields)) {
207-
if (!isPlainRecord(field)) continue;
208-
lintAuthoredRecordKeys(
209-
field,
210-
fieldPosture.keys,
211-
FIELD_KEY_GUIDANCE,
212-
'field',
213-
`${basePath}.fields.${fieldName}`,
214-
out,
215-
);
216-
}
217-
}
218-
}
342+
descend(schema, item, basePath, '', type, guidance, out, 0);
219343
}
220344
}
221345
return out;

0 commit comments

Comments
 (0)