Skip to content

Commit f358210

Browse files
claude[bot]claude
andauthored
test(spec): walk every depth of hand-written form lists in the form ↔ Zod reconciliation gate (#14408)
`nestedLists` collected a hand-written list only for a top-level section entry, so a repeater or composite nested inside another nested list — the object designer's `fields.options` / `fields.summaryOperations` rows and its four `lifecycle.*` blocks — was outside the population entirely. The walk is now recursive and keyed by dotted path; each level's sub-schema is resolved by walking `subSchemaOf` down the same path. The nested-list predicate is extracted (`reconcileNestedLists`) and pinned against a synthetic depth-two fixture: form-only and tombstoned keys go red at depth two, a designed subset goes green only with a ledger entry at the full dotted path. Nested-list assertions are soft so one run names every drifted list. Triage of the six newly reached paths, from the gate's own first run: the one form-only offer (`fields.options.icon`) is the already-carded withdrawal; the four zod-only differences are designed and ledgered — the two `fields.*` rows inherit the quick-add subset one level up, and `lifecycle.{retention,ttl}.onlyWhen` is a row-filter map written only by code-declared platform system objects. Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2eab95 commit f358210

1 file changed

Lines changed: 275 additions & 37 deletions

File tree

packages/spec/src/system/metadata-form-zod-reconciliation.test.ts

Lines changed: 275 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@
5757
* `scripts/build-schemas.ts`, which asks the same question of the emitted JSON
5858
* Schema (`{ "not": {} }`, Zod's rendering of `z.never()`).
5959
*
60+
* ## The walk is recursive (#14327)
61+
*
62+
* `nestedLists` once collected a hand-written list only for a **top-level**
63+
* entry carrying `fields`, so a repeater or composite nested inside another
64+
* nested list — the object designer's per-field `options` / roll-up lists and
65+
* its four `lifecycle.*` blocks — was outside the population entirely, not
66+
* reconciled loosely. That is how the options repeater offered an `icon` input
67+
* `SelectOptionSchema` refuses, through three hand-retirements of the same
68+
* offer-vs-door class, with this gate green throughout. The walk now descends
69+
* `entry.fields[*].fields` at every depth, keys each list by its dotted path,
70+
* and resolves the sub-schema by walking `subSchemaOf` down the same path
71+
* (unions looked through, arrays and records peeled). The ledger vocabulary is
72+
* unchanged — a `subset` / `omit` entry simply carries a dotted `path` — and
73+
* the walk is pinned at the bottom against a synthetic fixture, so a gate that
74+
* reaches nothing at depth two cannot report green.
75+
*
6076
* @see control-flow-form-zod-ledger.test.ts — same pattern for the flow designer
6177
*/
6278

@@ -97,6 +113,34 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
97113
path: 'fields',
98114
why: "the object editor's inline column grid is a QUICK-ADD surface covering the common authoring keys; the full per-field editor is `field.form.ts` (registered as the `field` metadata type), which is where the long tail of FieldSchema is authored",
99115
},
116+
// ── Depth two (#14327): the lists nested inside the `fields` quick-add row ──
117+
{
118+
kind: 'subset',
119+
type: 'object',
120+
path: 'fields.options',
121+
why: "one row of the `fields` quick-add grid (the subset entry above), so the same design applies one level down: an option is captured as label / value / color / description, and the long tail — `default`, the per-option `visibleWhen` CEL predicate — is authored in the full per-field editor (`field.form.ts`), whose `options` repeater is schema-derived and so offers every SelectOptionSchema key",
122+
},
123+
{
124+
kind: 'subset',
125+
type: 'object',
126+
path: 'fields.summaryOperations',
127+
why: "one row of the `fields` quick-add grid (the subset entry above): the roll-up is captured as object / field / function; `relationshipField` (auto-detected unless the child references this object twice) and the `filter` FilterCondition are authored in the full per-field editor (`field.form.ts`), whose own `summaryOperations` composite offers both with their dedicated widgets (`ref:object`, `filter-condition`)",
128+
},
129+
// ── Depth two (#14327): the lifecycle policy blocks ──
130+
{
131+
kind: 'omit',
132+
type: 'object',
133+
path: 'lifecycle.retention',
134+
key: 'onlyWhen',
135+
why: "a per-field row-filter map ({ field: value | { $in: [...] } | { $null: bool } }) with no scalar rendering among the block's text inputs; every writer of it today is a platform system object declared in code — sys_job_queue, sys_automation_run, the storage service's system_file / system_upload_session — where the interleaved live-vs-terminal rows it exists for live. A Studio-authored object gets the plain age window; offering the filter needs a structured control, a form-face addition rather than a reconciliation",
136+
},
137+
{
138+
kind: 'omit',
139+
type: 'object',
140+
path: 'lifecycle.ttl',
141+
key: 'onlyWhen',
142+
why: "the mirror of `retention.onlyWhen` — one shape by design (`lifecycleOnlyWhenSchema`, object.zod.ts) — with the same boundary: a row-filter map with no scalar rendering among the ttl block's text inputs, and its one writer today is the code-declared sys_session object (`revoked_at: { $null: true }`). Offering it needs a structured control, a form-face addition rather than a reconciliation",
143+
},
100144
];
101145

102146
// ────────────────────────────────────────────────────────────────────────────
@@ -249,34 +293,107 @@ function topLevelFields(form: any): string[] {
249293
return names.sort();
250294
}
251295

296+
type NestedList = { path: string; depth: number; offered: string[] };
297+
252298
/**
253299
* Every nested list a form spells out by hand — `{ field, fields: [...] }` under
254-
* a composite / repeater / record entry. These are the hand-copied lists; an
255-
* entry with no `fields` is derived from the schema by the renderer and cannot
256-
* drift.
300+
* a composite / repeater / record entry — at **any** depth, keyed by the dotted
301+
* path from the section root (`fields`, `fields.options`, `lifecycle.ttl`).
302+
* These are the hand-copied lists; an entry with no `fields` is derived from
303+
* the schema by the renderer and cannot drift.
304+
*
305+
* Recursive since #14327: the walk used to stop at the section's own entries,
306+
* so a repeater inside a record editor — where the object designer keeps its
307+
* per-field option and roll-up lists — was outside the population entirely.
257308
*/
258-
function nestedLists(form: any): Array<{ path: string; offered: string[] }> {
259-
const out: Array<{ path: string; offered: string[] }> = [];
260-
for (const section of form.sections ?? []) {
261-
for (const entry of (section.fields ?? []) as FormEntry[]) {
309+
function nestedLists(form: any): NestedList[] {
310+
const out: NestedList[] = [];
311+
const walk = (entries: FormEntry[], prefix: string, depth: number) => {
312+
for (const entry of entries) {
262313
if (!entry?.field || !Array.isArray(entry.fields) || entry.fields.length === 0) continue;
314+
const path = prefix ? `${prefix}.${entry.field}` : entry.field;
263315
const offered = entry.fields.map((f) => f?.field).filter((f): f is string => !!f);
264316
// A record editor authors its map key through `keyField`, so that name is
265-
// offered even though it is not in the `fields` array.
317+
// offered even though it is not in the `fields` array — at every depth.
266318
if (entry.keyField?.field) offered.push(entry.keyField.field);
267-
out.push({ path: entry.field, offered: offered.sort() });
319+
out.push({ path, depth, offered: offered.sort() });
320+
walk(entry.fields, path, depth + 1);
268321
}
269-
}
322+
};
323+
for (const section of form.sections ?? []) walk((section.fields ?? []) as FormEntry[], '', 1);
270324
return out;
271325
}
272326

327+
/**
328+
* The sub-schema a dotted form path lands on: one `subSchemaOf` step per
329+
* segment, so every level looks through unions and peels the array / record
330+
* wrapper a repeater or record editor sits under. `undefined` as soon as a
331+
* segment is not declared — the caller reports that as an unanchored list.
332+
*/
333+
function subSchemaAt(root: unknown, path: string): unknown {
334+
let node: unknown = root;
335+
for (const segment of path.split('.')) {
336+
node = subSchemaOf(node, segment);
337+
if (node === undefined) return undefined;
338+
}
339+
return node;
340+
}
341+
342+
type Ledger = ReadonlyArray<OmitEntry | SubsetEntry>;
273343
const TYPES = Object.keys(METADATA_FORM_REGISTRY);
274-
const ledgerFor = (type: string, path: string) =>
275-
LEDGER.filter((e) => e.type === type && e.path === path);
276-
const isSubset = (type: string, path: string) =>
277-
ledgerFor(type, path).some((e) => e.kind === 'subset');
278-
const omittedAt = (type: string, path: string) =>
279-
ledgerFor(type, path).flatMap((e) => (e.kind === 'omit' ? [e.key] : []));
344+
const ledgerFor = (ledger: Ledger, type: string, path: string) =>
345+
ledger.filter((e) => e.type === type && e.path === path);
346+
const isSubset = (ledger: Ledger, type: string, path: string) =>
347+
ledgerFor(ledger, type, path).some((e) => e.kind === 'subset');
348+
const omittedAt = (ledger: Ledger, type: string, path: string) =>
349+
ledgerFor(ledger, type, path).flatMap((e) => (e.kind === 'omit' ? [e.key] : []));
350+
351+
/**
352+
* One hand-written nested list, judged against the sub-schema its dotted path
353+
* resolves to. Empty arrays are the passing state; `unanchored` means the path
354+
* resolved to nothing key-bearing, so the three key sets could not be judged.
355+
*/
356+
type NestedVerdict = {
357+
path: string;
358+
depth: number;
359+
unanchored: boolean;
360+
/** offered by the form, not declared by the Zod — silently dropped on save */
361+
formOnly: string[];
362+
/** offered by the form, tombstoned in the Zod — hard-fails the save */
363+
retired: string[];
364+
/** authorable in the Zod, not offered by the form, not excused by the ledger */
365+
zodOnly: string[];
366+
};
367+
368+
/**
369+
* The nested-list predicate: one form against one root schema and one ledger.
370+
* The `it.each` below applies it to the registry; the self-test at the bottom
371+
* applies the SAME function to a synthetic fixture, which is what makes "the
372+
* gate reaches depth two" a measured fact rather than an assumption.
373+
*/
374+
function reconcileNestedLists(type: string, form: any, root: unknown, ledger: Ledger): NestedVerdict[] {
375+
return nestedLists(form).map(({ path, depth, offered }) => {
376+
const sub = subSchemaAt(root, path);
377+
const subKeys = keysOf(sub);
378+
if (!subKeys) return { path, depth, unanchored: true, formOnly: [], retired: [], zodOnly: [] };
379+
const excused = omittedAt(ledger, type, path);
380+
return {
381+
path,
382+
depth,
383+
unanchored: false,
384+
formOnly: offered.filter((k) => !subKeys.includes(k)),
385+
retired: offered.filter((k) => isRetiredAt(sub, k)),
386+
// A tombstoned key needs no ledger entry to excuse its absence — the
387+
// *only* correct thing to do with it is not offer it. Demanding one back
388+
// (or a ledger row for it) is this gate's blind spot inverted: before
389+
// #5280 the sole thing keeping `object.fields.conditionalRequired` off
390+
// this list was an unrelated `subset` entry, i.e. luck.
391+
zodOnly: isSubset(ledger, type, path)
392+
? []
393+
: subKeys.filter((k) => !offered.includes(k) && !excused.includes(k) && !isRetiredAt(sub, k)),
394+
};
395+
});
396+
}
280397

281398
describe('metadata form ↔ Zod reconciliation (#3786)', () => {
282399
it('the registry is non-empty and every form resolves a schema', () => {
@@ -313,34 +430,29 @@ describe('metadata form ↔ Zod reconciliation (#3786)', () => {
313430
it.each(TYPES)('%s: every hand-written nested list matches its sub-schema', (type) => {
314431
const root = getMetadataTypeSchema(type);
315432

316-
for (const { path, offered } of nestedLists(METADATA_FORM_REGISTRY[type])) {
317-
const sub = subSchemaOf(root, path);
318-
const subKeys = keysOf(sub);
433+
// `expect.soft`, so one run names EVERY list that drifted in this form
434+
// rather than the first: a form carries several hand-written lists, and a
435+
// gate that reports one per red build is the sequential-artifact failure
436+
// mode AGENTS.md describes for `check:generated` — triage wants the table.
437+
for (const v of reconcileNestedLists(type, METADATA_FORM_REGISTRY[type], root, LEDGER)) {
319438
// A non-key-bearing sub-schema (a plain array of scalars, say) has nothing
320439
// to reconcile against — but a hand-written list under it is then
321440
// unanchored, so say so rather than skipping silently.
322-
expect(subKeys, `${type}.${path}: hand-written sub-list over a non-key-bearing schema`).toBeTruthy();
441+
expect.soft(v.unanchored, `${type}.${v.path}: hand-written sub-list over a non-key-bearing schema`).toBe(false);
323442

324-
expect(
325-
offered.filter((k) => !subKeys!.includes(k)),
326-
`${type}.${path}: offered by the form but not declared by the Zod (saved value is dropped)`,
443+
expect.soft(
444+
v.formOnly,
445+
`${type}.${v.path}: offered by the form but not declared by the Zod (saved value is dropped)`,
327446
).toEqual([]);
328447

329-
expect(
330-
offered.filter((k) => isRetiredAt(sub, k)),
331-
`${type}.${path}: offered by the form but RETIRED in the Zod (retiredKey tombstone — filling the control hard-fails the save). Delete the form entry and leave a comment naming the retirement`,
448+
expect.soft(
449+
v.retired,
450+
`${type}.${v.path}: offered by the form but RETIRED in the Zod (retiredKey tombstone — filling the control hard-fails the save). Delete the form entry and leave a comment naming the retirement`,
332451
).toEqual([]);
333452

334-
if (isSubset(type, path)) continue;
335-
const excused = omittedAt(type, path);
336-
// A tombstoned key needs no ledger entry to excuse its absence — the
337-
// *only* correct thing to do with it is not offer it. Demanding one back
338-
// (or a ledger row for it) is this gate's blind spot inverted: before
339-
// #5280 the sole thing keeping `object.fields.conditionalRequired` off
340-
// this list was an unrelated `subset` entry, i.e. luck.
341-
expect(
342-
subKeys!.filter((k) => !offered.includes(k) && !excused.includes(k) && !isRetiredAt(sub, k)),
343-
`${type}.${path}: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry`,
453+
expect.soft(
454+
v.zodOnly,
455+
`${type}.${v.path}: accepted by the Zod but unauthorable in the form — offer it, or add a ledger entry`,
344456
).toEqual([]);
345457
}
346458
});
@@ -356,7 +468,7 @@ describe('metadata form ↔ Zod reconciliation (#3786)', () => {
356468
const list = lists.find((l) => l.path === entry.path);
357469
expect(list, `${entry.type}.${entry.path}: no hand-written list at this path any more`).toBeDefined();
358470

359-
const sub = subSchemaOf(root, entry.path);
471+
const sub = subSchemaAt(root, entry.path);
360472
const subKeys = keysOf(sub);
361473
expect(subKeys, `${entry.type}.${entry.path}: sub-schema is not key-bearing any more`).toBeTruthy();
362474

@@ -452,3 +564,129 @@ describe('retiredKey tombstones are not authoring surface (#5280)', () => {
452564
expect(isRetiredAt(union, 'shared')).toBe(false);
453565
});
454566
});
567+
568+
// ────────────────────────────────────────────────────────────────────────────
569+
// The nested walk itself (#14327).
570+
//
571+
// A gate observed only green is indistinguishable from a gate that matches
572+
// nothing — which is exactly what the depth-one walk was, at depth two, for as
573+
// long as it existed. Two kinds of pin: the live registry's deep lists, by
574+
// name, so the population cannot collapse back to depth one unnoticed; and a
575+
// SYNTHETIC form + schema run through the same `reconcileNestedLists`, so the
576+
// predicate is shown to go red on a depth-two form-only key (positive control)
577+
// and green on a depth-two designed subset carrying its ledger entry (negative
578+
// control).
579+
// ────────────────────────────────────────────────────────────────────────────
580+
581+
describe('the nested walk reaches every depth (#14327)', () => {
582+
it('the live registry has hand-written lists below depth one, and the walk reaches them', () => {
583+
const deep = TYPES.flatMap((type) =>
584+
nestedLists(METADATA_FORM_REGISTRY[type])
585+
.filter((l) => l.depth >= 2)
586+
.map((l) => `${type}.${l.path}`),
587+
);
588+
// The six the depth-one walk never reached, measured over all seventeen
589+
// registered forms when this pin was written: the object designer keeps
590+
// its per-field and lifecycle blocks one level down. Named, not counted —
591+
// a count would pass over any six.
592+
expect(deep).toEqual(
593+
expect.arrayContaining([
594+
'object.fields.options',
595+
'object.fields.summaryOperations',
596+
'object.lifecycle.retention',
597+
'object.lifecycle.ttl',
598+
'object.lifecycle.storage',
599+
'object.lifecycle.archive',
600+
]),
601+
);
602+
});
603+
604+
// A record editor (keyed by `name`) whose rows carry a repeater — the object
605+
// designer's shape in miniature, with one tombstone in the deep shape so the
606+
// retired direction is exercised at depth two as well.
607+
const schema = z.object({
608+
items: z.record(
609+
z.string(),
610+
z.object({
611+
name: z.string(),
612+
label: z.string(),
613+
options: z
614+
.array(
615+
z.object({
616+
label: z.string(),
617+
value: z.string(),
618+
extra: z.string().optional(),
619+
gone: retiredKey('`Probe.options.gone` was removed in @objectstack/spec 17.0.0. Delete the key.'),
620+
}),
621+
)
622+
.optional(),
623+
}),
624+
),
625+
});
626+
const form = (optionInputs: string[]) => ({
627+
sections: [
628+
{
629+
fields: [
630+
{
631+
field: 'items',
632+
type: 'record',
633+
keyField: { field: 'name' },
634+
fields: [
635+
{ field: 'label' },
636+
{ field: 'options', type: 'repeater', fields: optionInputs.map((field) => ({ field })) },
637+
],
638+
},
639+
],
640+
},
641+
],
642+
});
643+
const at = <T extends { path: string }>(xs: T[], path: string) => xs.find((x) => x.path === path);
644+
645+
it('keys the lists by dotted path and resolves each level through the record and the array', () => {
646+
const lists = nestedLists(form(['label', 'value']));
647+
expect(lists.map((l) => [l.path, l.depth])).toEqual([
648+
['items', 1],
649+
['items.options', 2],
650+
]);
651+
expect(at(lists, 'items')?.offered).toEqual(['label', 'name', 'options']);
652+
expect(keysOf(subSchemaAt(schema, 'items.options'))).toEqual(['extra', 'gone', 'label', 'value']);
653+
expect(subSchemaAt(schema, 'items.nothing')).toBeUndefined();
654+
});
655+
656+
it('positive control: a form-only key two levels down is reported at its dotted path', () => {
657+
const verdicts = reconcileNestedLists('probe', form(['label', 'value', 'icon']), schema, []);
658+
expect(at(verdicts, 'items')?.formOnly).toEqual([]);
659+
expect(at(verdicts, 'items.options')?.formOnly).toEqual(['icon']);
660+
});
661+
662+
it('positive control: a tombstoned key offered two levels down is reported as retired, not as form-only', () => {
663+
const verdicts = reconcileNestedLists('probe', form(['label', 'value', 'gone']), schema, []);
664+
expect(at(verdicts, 'items.options')?.retired).toEqual(['gone']);
665+
expect(at(verdicts, 'items.options')?.formOnly).toEqual([]);
666+
});
667+
668+
it('negative control: a designed depth-two subset is green with its ledger entry and red without', () => {
669+
const offered = form(['label', 'value']);
670+
const bare = reconcileNestedLists('probe', offered, schema, []);
671+
// `gone` is a tombstone and is excused automatically; `extra` is the gap.
672+
expect(at(bare, 'items.options')?.zodOnly).toEqual(['extra']);
673+
674+
const asSubset = reconcileNestedLists('probe', offered, schema, [
675+
{ kind: 'subset', type: 'probe', path: 'items.options', why: 'synthetic: the probe row is a quick-add subset' },
676+
]);
677+
expect(at(asSubset, 'items.options')?.zodOnly).toEqual([]);
678+
679+
const asOmit = reconcileNestedLists('probe', offered, schema, [
680+
{ kind: 'omit', type: 'probe', path: 'items.options', key: 'extra', why: 'synthetic: extra is deliberately not offered' },
681+
]);
682+
expect(at(asOmit, 'items.options')?.zodOnly).toEqual([]);
683+
684+
// An entry at the PARENT path excuses nothing one level down — the ledger
685+
// is keyed by the full dotted path, so a subset row cannot cover its
686+
// children by accident.
687+
const misfiled = reconcileNestedLists('probe', offered, schema, [
688+
{ kind: 'subset', type: 'probe', path: 'items', why: 'synthetic: the parent list is a subset' },
689+
]);
690+
expect(at(misfiled, 'items.options')?.zodOnly).toEqual(['extra']);
691+
});
692+
});

0 commit comments

Comments
 (0)