From 82f060c9070aad24c876eb741f1ca73786aa1c55 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:29:45 +0000 Subject: [PATCH 1/8] wip(spec): the view write door's wire members refuse an unknown top-level key Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 Co-authored-by: Claude --- packages/spec/src/ui/view.zod.ts | 131 +++++++++++++++++++++++-------- 1 file changed, 97 insertions(+), 34 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 49aa268b69a..4981aa0da10 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -51,7 +51,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; +import { closedObject, strictObject, strictObjectError, type StrictObjectOptions } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { ConditionalFormattingRuleSchema } from '../shared/conditional-formatting.zod'; @@ -5596,34 +5596,94 @@ function viewItemWireFields() { }; } +/** + * [#26711] What the write door's three wire members said about an unknown + * top-level key before they were closed: nothing. The save answered success + * and stored the parsed body, without the key. + */ +const VIEW_WIRE_HISTORY = + 'Until the `view` write door closed this shape an unknown top-level key was dropped silently — ' + + 'the save answered success and the key was not stored.'; + +/** + * [#26711] The alias spellings the #20456 census found on stored `view` rows, + * each mapped to the declared key it means. Undeclared on purpose (see + * {@link VIEW_CONSOLE_ROUND_TRIP_KEYS}); named here so the refusal points at + * the declared spelling instead of leaving the author to guess. + */ +const VIEW_WIRE_ALIASES = { objectName: 'object', id: 'name' } as const; + +/** [#26711] The unknown-key refusal of member 1, the ViewItem record. */ +const VIEW_ITEM_WIRE_SURFACE: StrictObjectOptions = { + surface: 'this view item record', + history: VIEW_WIRE_HISTORY, + aliases: VIEW_WIRE_ALIASES, +}; + +/** [#26711] The unknown-key refusal of member 3, the flattened list overlay. */ +const LIST_OVERLAY_WIRE_SURFACE: StrictObjectOptions = { + surface: 'this inline list view', + history: VIEW_WIRE_HISTORY, + aliases: VIEW_WIRE_ALIASES, +}; + +/** [#26711] The unknown-key refusal of member 4, the flattened form overlay. */ +const FORM_OVERLAY_WIRE_SURFACE: StrictObjectOptions = { + surface: 'this inline form view', + history: VIEW_WIRE_HISTORY, + aliases: VIEW_WIRE_ALIASES, +}; + +/** + * [#26711] Close a wire member's TOP level, with its own unknown-key refusal. + * + * The flattened overlays are built by extending an authoring shape, and an + * extension inherits that shape's refusal: the surface named in it, and the + * candidate list a typo is pointed at, which would miss every key the overlay + * adds (`isPinned`, `columnState`, …). So the member is closed with a refusal + * built from its OWN shape — the same `strictObjectError` map + * {@link strictObject} attaches — and re-declared as a `closedObject`, so the + * refusal is terminal on this surface and the union keeps its envelope. + * + * Nested blocks keep their own posture, as before: closing the top level does + * not recurse any more than `.strip()` did, and the console's row ids are + * still removed before the parse by {@link stripViewConsoleDecorations}. + */ +function closeWireMember(member: T, options: StrictObjectOptions) { + const strict = member.strict(); + const def = strict._zod.def; + return closedObject(strict.clone({ ...def, error: strictObjectError(options, def.shape) })); +} + /** * [#5074] The WIRE variant of {@link ViewItemSchema} — member 1 of - * {@link ViewMetadataSchema}, re-opened with `.strip()`. + * {@link ViewMetadataSchema}. * - * Exactly the pattern the two flattened members below already use - * (`ListViewSchema.extend(flattenedViewOverlayFields()).strip()`), reached one - * member further. `z.discriminatedUnion` cannot be `.extend()`ed, so the two - * postures share {@link viewItemArmShape} rather than a `.extend()` chain — - * derive-by-reference either way, one shape, two doors. + * `z.discriminatedUnion` cannot be `.extend()`ed, so the two postures share + * {@link viewItemArmShape} rather than a `.extend()` chain — derive-by-reference + * either way, one shape, two doors. The wire variant adds the console's + * round-trip keys ({@link viewItemWireFields}) and nothing else. * - * `.strip()` covers the TOP level only. Nested console decorations - * (`config.filter[].id`, `config.sort[].id`) are handled by - * {@link stripViewConsoleDecorations} on the wire door — see that function for - * why a recursive strip is the piece a posture flip cannot provide. + * [#26711] Closed, like the authoring schema: an unknown top-level key is + * refused by name. Until then this member `.strip()`ped it, and because the + * save stores the parsed body (#20051) a misspelt key answered success and was + * not stored. Nested console decorations (`config.filter[].id`, + * `config.sort[].id`) are still removed before the parse by + * {@link stripViewConsoleDecorations} on the wire door. */ export const ViewItemWireSchema: z.ZodDiscriminatedUnion<[ - z.ZodObject, z.core.$strip>, - z.ZodObject, z.core.$strip>, + z.ZodObject, z.core.$strict>, + z.ZodObject, z.core.$strict>, ], 'viewKind'> = lazySchema(() => z.discriminatedUnion('viewKind', [ - z.object({ + strictObject(VIEW_ITEM_WIRE_SURFACE, { ...viewItemArmShape('list', ListViewSchema.describe('List-family view configuration.')), ...viewItemWireFields(), - }).strip(), - z.object({ + }), + strictObject(VIEW_ITEM_WIRE_SURFACE, { ...viewItemArmShape('form', FormViewSchema.describe('Form view configuration.')), ...viewItemWireFields(), - }).strip(), + }), ]), ); @@ -6439,22 +6499,24 @@ function formOverlayColumnsField(): z.ZodOptional { * identity/round-trip fields a personalization PUT carries. Published as * `VIEW_METADATA_MEMBERS.listOverlay`. * - * `.strip()` is load-bearing, not leftover. `.extend()` INHERITS strictness, so - * closing `ListViewSchema` for authoring (#4001) silently made this overlay - * strict too — and this member exists precisely to carry Studio's auxiliary - * round-trip keys (`isPinned`, `sortOrder`, …), which the save keeps because - * they are declared. Strict here is a 422 on a shape the platform itself writes. The - * ledger names this as the trap to watch while batching: a response-side - * extension of an authoring schema must strip back, or an upstream field - * addition becomes a crash. + * [#26711] Closed at the top level, with its own refusal + * ({@link closeWireMember}). It used to `.strip()`: `.extend()` inherits + * strictness, so closing `ListViewSchema` for authoring (#4001) made this + * overlay strict too while Studio's round-trip keys (`isPinned`, `sortOrder`, + * …) were still undeclared, and strict would have been a 422 on a shape the + * platform itself writes. #20456 declared every one of them + * ({@link VIEW_CONSOLE_ROUND_TRIP_KEYS}), and #20051 made the save store the + * parsed body, so the strip had become a silent loss: a misspelt key answered + * success and was not stored. An undeclared key the console writes is now a + * refusal that names it — declare it here, with its meaning, as #20456 did. * * [#7741] `object` + `viewKind` are required on this arm (and its form * sibling) — see {@link flattenedViewOverlayFields} for the ruling and the * measured serving filter that decides exactly this pair. * * [#20051] …and `options` is DECLARED here, judged by - * {@link ListViewOverlayOptionsSchema}: `.strip()` re-opens the top level for - * round-trip keys, and a bag the renderer reads is not one of those. + * {@link ListViewOverlayOptionsSchema}: a bag the renderer reads is not a + * round-trip key. * * [#20186] This member judges `viewKind: 'list'` only (the form sibling * `'form'` only), and it judges a column-less PATCH as well as a full inline @@ -6470,19 +6532,19 @@ const ListViewOverlayWireSchema = lazySchema(() => // [#13216] Built from {@link ListViewShapeSchema}, not {@link ListViewSchema}: // `flattenedViewOverlayFields()` re-declares `name` and `label`, and zod 4 // refuses to overwrite a key on an object that carries refinements. So the - // binding check is re-attached AFTER `.strip()` instead of inherited — this + // binding check is re-attached AFTER the close instead of inherited — this // is the runtime write door (`PUT /api/v1/meta/view`), the one an MCP/AI // author reaches, so it is the last place the refusals may go missing. // `viewDoorsCarryingObjectLevelChecks` in `view.test.ts` fails if any of the // three attachment points is dropped. - ListViewShapeSchema.extend({ + closeWireMember(ListViewShapeSchema.extend({ ...flattenedViewOverlayFields('list'), options: ListViewOverlayOptionsSchema.optional(), ...listOverlayPatchFields(), // [#20456] The console's round-trip keys on this row shape, declared so - // the parse keeps them instead of `.strip()`ping them unread. + // the parse keeps them. ...listOverlayRoundTripFields(), - }).strip() + }), LIST_OVERLAY_WIRE_SURFACE) .superRefine(checkListOverlayTypeNeedsColumns) .superRefine(checkListViewCalendarVisualization) .superRefine(checkListViewChartBinding) @@ -6492,7 +6554,8 @@ const ListViewOverlayWireSchema = lazySchema(() => /** * [#6391] Member 4 of {@link ViewMetadataSchema} — a flattened runtime FORM * overlay, published as `VIEW_METADATA_MEMBERS.formOverlay`. Same construction - * and the same `.strip()` rationale as {@link ListViewOverlayWireSchema}. + * as {@link ListViewOverlayWireSchema}, and closed at the top level the same way + * (#26711). * * [#20051] `options` is pinned ABSENT here — see * {@link FORM_OVERLAY_OPTIONS_REFUSED} for the fall-through it closes. @@ -6508,11 +6571,11 @@ const FormViewOverlayWireSchema = lazySchema(() => // (`columns`, see {@link formOverlayColumnsField}), and {@link FormViewSchema} // carries refinements, which `.extend()` refuses to combine with an overwrite. // The two differ in that throw only — the refinements ride along either way. - FormViewSchema.safeExtend({ + closeWireMember(FormViewSchema.safeExtend({ ...flattenedViewOverlayFields('form'), options: z.undefined({ error: () => FORM_OVERLAY_OPTIONS_REFUSED }).optional(), columns: formOverlayColumnsField(), - }).strip(), + }), FORM_OVERLAY_WIRE_SURFACE), ); /** From f9d3a78d930db853bf255560c7b08e5ac305b8ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:34:48 +0000 Subject: [PATCH 2/8] test(spec): pin the view write door's unknown-key refusal; re-judge the fixtures that froze the strip Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 Co-authored-by: Claude --- .../src/ui/view-authoring-wire-split.test.ts | 9 +- .../src/ui/view-filter-rule-wire-id.test.ts | 14 +- .../spec/src/ui/view-metadata-schema.test.ts | 27 ++-- .../src/ui/view-overlay-viewkind-arm.test.ts | 4 +- .../src/ui/view-strictness-batch18.test.ts | 18 +-- .../src/ui/view-union-diagnostics.test.ts | 25 ++-- .../ui/view-wire-unknown-key-refusal.test.ts | 134 ++++++++++++++++++ packages/spec/src/ui/view.test.ts | 22 +-- packages/spec/src/ui/view.zod.ts | 11 +- 9 files changed, 215 insertions(+), 49 deletions(-) create mode 100644 packages/spec/src/ui/view-wire-unknown-key-refusal.test.ts diff --git a/packages/spec/src/ui/view-authoring-wire-split.test.ts b/packages/spec/src/ui/view-authoring-wire-split.test.ts index 59cd0895a4d..828498fa5f4 100644 --- a/packages/spec/src/ui/view-authoring-wire-split.test.ts +++ b/packages/spec/src/ui/view-authoring-wire-split.test.ts @@ -228,8 +228,13 @@ describe('the wire door accepts what the platform itself writes', () => { ).toBe(false); }); - it('an undeclared aux key still rides on the wire member (`.strip()`, as before)', () => { - accept(ViewMetadataSchema, { ...RECORD, someFutureStudioAuxKey: 1 }); + // [#26711] It used to ride through `.strip()` and was not stored: the save + // stores the parsed body (#20051). A new console key is declared on the + // member with its meaning, as #20456 did, or it is refused here by name. + it('an undeclared aux key is refused by name on the wire member', () => { + const issues = reject(ViewMetadataSchema, { ...RECORD, someFutureStudioAuxKey: 1 }); + expect(issues).toContain('"code":"unrecognized_keys"'); + expect(issues).toContain('someFutureStudioAuxKey'); }); // ── the acceptance criteria named in the scope addendum ────────────────── diff --git a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts index 530e46f9335..51e182b1f10 100644 --- a/packages/spec/src/ui/view-filter-rule-wire-id.test.ts +++ b/packages/spec/src/ui/view-filter-rule-wire-id.test.ts @@ -201,15 +201,19 @@ describe('why the flattened member could not rescue this block', () => { // assertions about the fix. Remove the decoration strip from the wire door // and case 3/3 above goes red while these two stay green: that gap is still // the finding, now pointing at the mechanism instead of the posture. - it('the flattened member re-opens the TOP level: an unknown aux key rides along', () => { + // [#26711] The member's TOP level is closed now too, so an unknown aux key is + // refused there. The point this block makes is unchanged: a top-level + // posture, open or closed, never reached the nested rows, and only the + // decoration strip before the parse lets the console's row ids through. + it('the flattened member judges its TOP level: an unknown aux key is refused', () => { expect( ViewMetadataSchema.safeParse({ ...OVERLAY_BASE, someStudioAuxKey: 1 }).success, - ).toBe(true); + ).toBe(false); }); - it('…but a still-CLOSED nested block rejects through that same member', () => { - // `emptyState` was closed at 批 18 and stays closed. The top level rides, - // the nested block does not: `.strip()` re-opened one level, not the tree. + it('…and a CLOSED nested block rejects through that same member', () => { + // `emptyState` was closed at 批 18 and stays closed. Its posture is its + // own: when the member's top level was `.strip()`ped it still refused. expect( ViewMetadataSchema.safeParse({ ...OVERLAY_BASE, diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts index f7572c78e25..d327163c5ee 100644 --- a/packages/spec/src/ui/view-metadata-schema.test.ts +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -267,11 +267,16 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha // an expanded ViewItem always carries both — so a console PUT on a REAL // view arrives bound, exactly as spelled here. The precondition-inertness // claim is asserted separately below on the bare, baseline-less shapes. + // + // [#26711] The filter-only overlay is a LIST overlay: `filter` is a list + // key. Spelled `viewKind: 'form'` it was accepted only because the form + // member `.strip()`ped the filter unread, so the save stored a form overlay + // without it; the form member now refuses `filter` by name. it.each([ ['a pin PUT with no stored item to merge', { isPinned: true, object: 'crm_lead', viewKind: 'list' }], ['a switcher-reorder PUT', { sortOrder: 3, object: 'crm_lead', viewKind: 'list' }], ['a column-only overlay', { columns: ['name'], object: 'crm_lead', viewKind: 'list' }], - ['a filter-only overlay', { filter: [{ field: 'name', operator: 'contains', value: 'x' }], object: 'crm_lead', viewKind: 'form' }], + ['a filter-only overlay', { filter: [{ field: 'name', operator: 'contains', value: 'x' }], object: 'crm_lead', viewKind: 'list' }], ['an order-only overlay', { order: 2, object: 'crm_lead', viewKind: 'form' }], ['a renamed view that still carries its config', { label: 'New name', columns: ['name'], object: 'crm_lead', viewKind: 'list' }], ])('leaves %s alone', (_label, body) => { @@ -384,14 +389,20 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha expect(ViewMetadataSchema.safeParse({ groupByField: 'stage' }).success).toBe(false); }); - it('does NOT close the arms — `.strip()` round-tripping is untouched', () => { - // The ruling on #5599 kept every arm's `.strip()`: a body that speaks the - // vocabulary still carries undeclared aux keys through without a 422. - // This is the deliberate residue of the minimal fix, pinned so a later - // batch cannot mistake it for an oversight. ([#7741] bound form — the - // openness under test is about UNKNOWN keys, not the binding pair.) + it('the arms judge an unknown key themselves — the precondition admits the body, the arm refuses the key', () => { + // The ruling on #5599 kept every arm's `.strip()` while the save stored + // the ORIGINAL body, so undeclared aux keys round-tripped. [#26711] Since + // #20051 the save stores the parsed body and #20456 declared every + // round-trip key, so the strip only dropped a misspelt key in silence; + // the arms are closed. The precondition is unchanged: this body speaks + // the vocabulary (`isPinned`), and the refusal is the arm's, naming the + // key. ([#7741] bound form — the case is about UNKNOWN keys, not the + // binding pair.) const r = ViewMetadataSchema.safeParse({ isPinned: true, someFutureStudioKey: 'x', object: 'crm_lead', viewKind: 'list' }); - expect(r.success).toBe(true); + expect(r.success).toBe(false); + expect(JSON.stringify(r.success ? [] : r.error.issues)).toContain('Unrecognized key(s) on this inline list view: `someFutureStudioKey`'); + // …and without the unknown key the same body is accepted. + expect(ViewMetadataSchema.safeParse({ isPinned: true, object: 'crm_lead', viewKind: 'list' }).success).toBe(true); }); }); diff --git a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts index 23924c4b5a4..0fb524541f2 100644 --- a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts +++ b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts @@ -146,7 +146,9 @@ describe('a column-less list overlay that NAMES a `type` stays refused — at `c // refused now, and still wrapped as `invalid_union` (the refusal is an // ABORTING issue, so the list member is not the union's lone survivor). it.each([ - ['a kanban config with no columns', { name: 'x', object: 'crm_lead', viewKind: 'list', type: 'kanban', groupByField: 'stage' }], + // [#26711] `groupByField` is spelled under `kanban`: at the top level it is + // an unknown key, refused by name ahead of the `columns` check. + ['a kanban config with no columns', { name: 'x', object: 'crm_lead', viewKind: 'list', type: 'kanban', kanban: { groupByField: 'stage', columns: ['name'] } }], ['a grid config with no columns', { ...LIST, type: 'grid', sort: [{ field: 'name', order: 'asc' }] }], ])('%s', (_label, body) => { const r = refusal(body); diff --git a/packages/spec/src/ui/view-strictness-batch18.test.ts b/packages/spec/src/ui/view-strictness-batch18.test.ts index 83e74333919..423c78b89ad 100644 --- a/packages/spec/src/ui/view-strictness-batch18.test.ts +++ b/packages/spec/src/ui/view-strictness-batch18.test.ts @@ -424,16 +424,18 @@ describe('批 18 — the shapes left open, and what became of them', () => { ).toBe(true); }); - it('…and the mechanism, which is why a posture flip could NOT have fixed it: `.strip()` does not recurse', () => { + it('…and the mechanism, which is why a posture flip could NOT have fixed it: a top-level posture does not recurse', () => { // The load-bearing fact for every nested block in this file. A member's - // `.strip()` re-opens the TOP level only, so a nested block closed inside - // `ListViewSchema` is still reached at full strictness through that member. - // #5074's answer is not a deeper strip — it is removing the named - // decoration ahead of the parse. These two assertions are the CONTROLS for - // that claim and must hold whichever way the decorated blocks are written. + // top-level posture reaches the TOP level only, so a nested block closed + // inside `ListViewSchema` is reached at its own strictness through that + // member, whatever the member does. #5074's answer is not a deeper strip — + // it is removing the named decoration ahead of the parse. These two + // assertions are the CONTROLS for that claim and must hold whichever way + // the decorated blocks are written. const overlay = { type: 'grid', columns: ['name'], name: 'o.default', viewKind: 'list', object: 'o' }; - // top level: an unknown aux key rides along, because the member strips. - expect(ViewMetadataSchema.safeParse({ ...overlay, someStudioAuxKey: 1 }).success).toBe(true); + // top level: [#26711] the member is closed, so an unknown aux key is + // refused (it used to `.strip()` and the save dropped the key). + expect(ViewMetadataSchema.safeParse({ ...overlay, someStudioAuxKey: 1 }).success).toBe(false); // nested: a CLOSED sub-block that is NOT a declared decoration still rejects. expect(ViewMetadataSchema.safeParse({ ...overlay, emptyState: { title: 'x', notAnEmptyStateKey: 1 } }).success).toBe(false); }); diff --git a/packages/spec/src/ui/view-union-diagnostics.test.ts b/packages/spec/src/ui/view-union-diagnostics.test.ts index 0448cf5d6d0..3b273fe1bb7 100644 --- a/packages/spec/src/ui/view-union-diagnostics.test.ts +++ b/packages/spec/src/ui/view-union-diagnostics.test.ts @@ -257,11 +257,12 @@ describe('the acceptance face of ViewMetadataSchema — frozen by the diagnostic * are in the table precisely because a surprising accept is the one a * refactor silently loses: * - * - `overlay.badOperator` / `overlay.console*Id` ACCEPT with the payload + * - `overlay.badOperator` / `overlay.console*Id` ACCEPTED with the payload * stripped to the binding + `{type:'simple'}` — the form overlay does not - * declare `filter`/`sort`, and `.strip()` drops them. Pre-existing, - * load-bearing for Studio's round-trip, and untouched by #7741 (openness - * is about unknown keys; the binding is about required ones). + * declare `filter`/`sort`, and its `.strip()` dropped them. [#26711] The + * form overlay is closed now, so all three moved to REFUSED; the console's + * row ids on a LIST overlay, where `sort` / `filter` are declared, are the + * `overlay.list.console*Id` rows and still ACCEPT with the ids removed. * - `overlay.list.min` (`{type:'grid'}` alone) REJECTS. * - `container.empty` REJECTS with `custom`, not `invalid_union`: when * exactly one branch is non-aborted zod's `handleUnionResults` returns THAT @@ -292,12 +293,10 @@ describe('the acceptance face of ViewMetadataSchema — frozen by the diagnostic { type: 'simple', sections: [SECTION], ...BOUND_FORM }], ['overlay.form.identity', { name: 'x', object: 'crm_lead', viewKind: 'form', type: 'wizard' }, { type: 'wizard', name: 'x', object: 'crm_lead', viewKind: 'form' }], - ['overlay.badOperator', { filter: [{ field: 'name', operator: 'sorta_equals', value: 'x' }], ...BOUND_FORM }, - { type: 'simple', ...BOUND_FORM }], - ['overlay.consoleSortId', { sort: [{ id: 'row-1', field: 'name', order: 'asc' }], ...BOUND_FORM }, - { type: 'simple', ...BOUND_FORM }], - ['overlay.consoleFilterId', { filter: [{ id: 'row-1', field: 'name', operator: '=', value: 'x' }], ...BOUND_FORM }, - { type: 'simple', ...BOUND_FORM }], + ['overlay.list.consoleSortId', { sort: [{ id: 'row-1', field: 'name', order: 'asc' }], ...BOUND_LIST }, + { type: 'grid', sort: [{ field: 'name', order: 'asc' }], ...BOUND_LIST }], + ['overlay.list.consoleFilterId', { filter: [{ id: 'row-1', field: 'name', operator: 'equals', value: 'x' }], ...BOUND_LIST }, + { type: 'grid', filter: [{ field: 'name', operator: 'equals', value: 'x' }], ...BOUND_LIST }], // [#20186] A lean `viewKind: 'list'` PUT is judged by the LIST member now, // so it parses to a list (`type: 'grid'`). It used to parse to // `type: 'simple'` — a FORM — because the form member was the one accepting @@ -329,6 +328,12 @@ describe('the acceptance face of ViewMetadataSchema — frozen by the diagnostic ['overlay.list.aux.retiredHidden', { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false, ...BOUND_LIST }, ['invalid_union']], ['put.hidden', { hidden: true, ...BOUND_FORM }, ['invalid_union']], ['put.owner', { owner: 'usr_7', ...BOUND_LIST }, ['invalid_union']], + // [#26711] Formerly ACCEPTED with `filter` / `sort` stripped by the form + // overlay, which declares neither: the save stored a bare form overlay. + // The member is closed now and refuses both keys by name. + ['overlay.badOperator', { filter: [{ field: 'name', operator: 'sorta_equals', value: 'x' }], ...BOUND_FORM }, ['invalid_union']], + ['overlay.consoleSortId', { sort: [{ id: 'row-1', field: 'name', order: 'asc' }], ...BOUND_FORM }, ['invalid_union']], + ['overlay.consoleFilterId', { filter: [{ id: 'row-1', field: 'name', operator: '=', value: 'x' }], ...BOUND_FORM }, ['invalid_union']], // [#7741] The corpus's former unbound ACCEPTED entries, each re-measured: // an overlay that names no `object`/`viewKind` would be stored as a row no // object-bound read path can serve, so the members now refuse it with the diff --git a/packages/spec/src/ui/view-wire-unknown-key-refusal.test.ts b/packages/spec/src/ui/view-wire-unknown-key-refusal.test.ts new file mode 100644 index 00000000000..23db5247e77 --- /dev/null +++ b/packages/spec/src/ui/view-wire-unknown-key-refusal.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #26711 — the `view` write door refuses an unknown TOP-LEVEL key, as the + * object, page and dashboard doors do. + * + * Three of `ViewMetadataSchema`'s four members — the ViewItem record and the + * two flattened overlays — used to `.strip()` their top level. Since the save + * stores the parsed body (#20051), a misspelt or retired key answered success + * and was simply not stored. The container member was already closed. + * + * Pinned per member, because each is closed on its own: the refusal (code, + * key, the member named), a retired key's prescription in both channels the + * protocol has for it, the alias spellings the #20456 census found, and a + * clean body of each shape still saving with what the console writes. + */ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '../shared/protocol-upgrade-guide'; +import { ViewMetadataSchema, diagnoseViewMetadata, type ViewMetadataBranch } from './view.zod'; + +const DATA = { provider: 'object', object: 'crm_lead' } as const; + +/** One clean body per member the change closed, as the write path delivers it. */ +const CLEAN: Record<'viewItem' | 'listOverlay' | 'formOverlay', { body: Record; surface: string }> = { + viewItem: { + body: { + name: 'crm_lead.mine', + object: 'crm_lead', + viewKind: 'list', + label: 'Mine', + config: { type: 'grid', data: DATA, columns: ['name'] }, + isPinned: true, + }, + surface: 'this view item record', + }, + listOverlay: { + body: { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', type: 'grid', columns: ['name'], isPinned: true }, + surface: 'this inline list view', + }, + formOverlay: { + body: { name: 'crm_lead.edit', object: 'crm_lead', viewKind: 'form', type: 'simple' }, + surface: 'this inline form view', + }, +}; + +const MEMBERS = Object.keys(CLEAN) as Array; + +/** The claimed member's issues, read the way Studio and an MCP author get them. */ +function refusal(body: unknown): { branch: ViewMetadataBranch | null; issues: readonly z.core.$ZodIssue[] } { + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success, 'expected the write door to refuse this body').toBe(false); + const diagnosis = diagnoseViewMetadata(body); + if (diagnosis.success) throw new Error('diagnoseViewMetadata accepted a body the union refused'); + return { branch: diagnosis.branch, issues: diagnosis.issues }; +} + +describe('a clean body of each closed member still saves', () => { + it.each(MEMBERS)('%s', (member) => { + const r = ViewMetadataSchema.safeParse(CLEAN[member].body); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues)).toBe(true); + expect(diagnoseViewMetadata(CLEAN[member].body)).toMatchObject({ success: true, branch: member }); + }); + + it("the console's row ids are still removed before the parse, not refused", () => { + const r = ViewMetadataSchema.safeParse({ + ...CLEAN.listOverlay.body, + sort: [{ id: 'row-1', field: 'name', order: 'asc' }], + filter: [{ id: 'row-2', field: 'name', operator: 'equals', value: 'x' }], + }); + expect(r.success).toBe(true); + const data = r.data as { sort: unknown[]; filter: unknown[] }; + expect(data.sort).toEqual([{ field: 'name', order: 'asc' }]); + expect(data.filter).toEqual([{ field: 'name', operator: 'equals', value: 'x' }]); + }); +}); + +describe('an unknown top-level key is refused by name', () => { + it.each(MEMBERS)('%s', (member) => { + const { branch, issues } = refusal({ ...CLEAN[member].body, zzz_junk_key: true }); + expect(branch).toBe(member); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', path: [], keys: ['zzz_junk_key'] }); + expect(issues[0]!.message).toMatch( + new RegExp(`^Unrecognized key\\(s\\) on ${CLEAN[member].surface}: \`zzz_junk_key\`\\.`), + ); + }); + + it('the container member was already closed, and still is', () => { + const { branch, issues } = refusal({ object: 'crm_lead', list: { type: 'grid', data: DATA, columns: ['name'] }, zzz_junk_key: true }); + expect(branch).toBe('container'); + expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', keys: ['zzz_junk_key'] }); + }); +}); + +describe("a retired key gets the prescription its retirement kit gives it", () => { + // `striped` was retired with the list view's other display keys, and its kit + // has aged out (ADR-0087, amendment 2026-10-26): the refusal names the key and + // points at the protocol upgrade guide, which holds its prescription now. + // Before this change the overlay door answered success and dropped it. + it.each(['listOverlay', 'viewItem'] as const)('`striped` (aged out) on %s: named, with the upgrade guide', (member) => { + const { branch, issues } = refusal({ ...CLEAN[member].body, striped: true }); + expect(branch).toBe(member); + expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', keys: ['striped'] }); + expect(issues[0]!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); + }); + + // `filterableFields` still has its live tombstone on the list view shape, so + // the overlay raises its own prescription at the key, and the union lifts it + // to the top-level message an author at `PUT /api/v1/meta/view` reads. + it('`filterableFields` (live tombstone) on the inline list view: its prescription, at the key', () => { + const body = { ...CLEAN.listOverlay.body, filterableFields: ['name'] }; + const { branch, issues } = refusal(body); + expect(branch).toBe('listOverlay'); + expect(issues[0]).toMatchObject({ code: 'invalid_type', expected: 'never', path: ['filterableFields'] }); + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success ? '' : parsed.error.issues[0]!.message).toMatch(/^`view\.list\.filterableFields` was removed in @objectstack\/spec 18/); + }); +}); + +describe('the alias spellings the census found point at the declared key', () => { + it.each(MEMBERS)('`objectName` on %s → `object`', (member) => { + const { issues } = refusal({ ...CLEAN[member].body, objectName: 'crm_lead' }); + expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', keys: ['objectName'] }); + expect(issues[0]!.message).toContain('Did you mean `objectName` → `object`?'); + }); + + it.each(MEMBERS)('a top-level `id` on %s → `name`', (member) => { + const { issues } = refusal({ ...CLEAN[member].body, id: 'crm_lead.mine' }); + expect(issues[0]).toMatchObject({ code: 'unrecognized_keys', keys: ['id'] }); + expect(issues[0]!.message).toContain('Did you mean `id` → `name`?'); + }); +}); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 08662c86393..ed6d2ccba7b 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -4871,20 +4871,20 @@ describe('ListViewSchema — `viewType` is not a spelling of `type`', () => { } }); - // ⚠️ The overlay door is `PUT /api/v1/meta/view` — the only door a Studio - // tenant or an MCP/AI author has — and it is `.strip()`ed by design (it - // carries Studio's round-trip keys). So `viewType` is DROPPED there and the - // view parses as the defaulted `type: 'grid'`: an author who spells the - // retired alias gets a 200 and a grid, never a calendar. Pinned because the - // finding assumed the opposite, and because accepted-and-ignored is the - // failure mode this file's `VIEW_HISTORY` exists to record. - it("is STRIPPED at the overlay write door, leaving the defaulted `type: 'grid'`", () => { + // The overlay door is `PUT /api/v1/meta/view` — the only door a Studio + // tenant or an MCP/AI author has. It used to be `.strip()`ed, so `viewType` + // was DROPPED there and the view parsed as the defaulted `type: 'grid'`: an + // author who spelled the retired alias got a 200 and a grid, never a + // calendar. [#26711] The door is closed now and refuses it by name, like the + // two authoring doors — accepted-and-ignored was the failure mode this + // file's `VIEW_HISTORY` exists to record. + it('is an UNKNOWN key at the overlay write door too — refused, not dropped', () => { const r = VIEW_METADATA_MEMBERS.listOverlay.safeParse({ object: 'crm_lead', viewKind: 'list', viewType: 'calendar', columns: ['name'], }); - expect(r.success).toBe(true); - expect((r as { data: Record }).data).not.toHaveProperty('viewType'); - expect((r as { data: { type?: unknown } }).data.type).toBe('grid'); + expect(r.success).toBe(false); + const issue = (r as { error: z.ZodError }).error.issues.find((i) => i.code === 'unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['viewType']); }); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4981aa0da10..9d72dc8cc0c 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -6083,11 +6083,14 @@ function speaksViewVocabulary(body: unknown): boolean { * **Why a precondition and not a required floor on member 4.** Giving the form * arm a required key (the other candidate fix) would 422 the flattened overlays * Studio writes, whose required-ness nobody has measured; the ruling on #5599 - * took this route and deferred that one explicitly. This route also leaves every + * took this route and deferred that one explicitly. This route also left every * arm's `.strip()` untouched — the round-trip capability #5074 traced and - * documented is load-bearing and is not what was broken. What was broken is that - * an arm which strips AND requires nothing is a wildcard for the whole union, so - * the fix belongs one level up, at the union's door. + * documented was load-bearing then, while the save stored the ORIGINAL body. + * What was broken is that an arm which strips AND requires nothing is a + * wildcard for the whole union, so the fix belonged one level up, at the + * union's door. [#26711] The arms are closed now: #20051 made the save store + * the parsed body and #20456 declared every round-trip key, so the strip no + * longer carried anything — it only dropped a misspelt key in silence. * * **Why it lives in the existing `z.preprocess` stage.** `/api/v1/meta/types/view` * serves `z.toJSONSchema()` of this schema to Studio's SchemaForm, and both pins From e7c8756c38dde40270ba90c31393e14c05aa0750 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:44:13 +0000 Subject: [PATCH 3/8] test(metadata-protocol): pin the view save door's unknown-key refusal; changeset Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 Co-authored-by: Claude --- ...711-view-write-door-unknown-key-refused.md | 22 +++++++++ .../src/protocol.save-union-issues.test.ts | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 .changeset/26711-view-write-door-unknown-key-refused.md diff --git a/.changeset/26711-view-write-door-unknown-key-refused.md b/.changeset/26711-view-write-door-unknown-key-refused.md new file mode 100644 index 00000000000..593762b16b1 --- /dev/null +++ b/.changeset/26711-view-write-door-unknown-key-refused.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: the `view` write door refuses an unknown top-level key on a view item record and on an inline list or form view, as the object, page and dashboard doors do, instead of saving without it + +`ViewMetadataSchema` judges every `view` body saved through `PUT /api/v1/meta/view/:name` (draft or publish), the MCP metadata tools and Studio. Three of its four members (the view item record `{ name, object, viewKind, config }`, and the inline list and form views that carry their config at the top level) dropped a top-level key they do not declare. The save stores the parsed body, so a misspelt or retired key answered success and was not stored. They now refuse it with `422 INVALID_METADATA` / `unrecognized_keys`, naming the key and the shape: `Unrecognized key(s) on this inline list view: \`striped\`.` The `defineView` container was already closed. + +Clause-②: no (narrowing) — the `view` write door refuses a body carrying an undeclared top-level key on a view item record or an inline list or form view, which it saved before without the key. Nothing is widened. + +Metadata-protocol: narrowed (a saved `view` body may no longer carry a top-level key its shape does not declare) + + + +**BREAKING** accept-set narrowing at the `view` write door (`@objectstack/spec`), served by every door that saves a `view`. + +- **Before.** `PUT /api/v1/meta/view/:name` with a valid inline list view plus `"zzz_junk_key": true`, or a retired list key such as `"striped": true`, answered `200`, and the key was absent from the stored view. The same held for a view item record and an inline form view. +- **Now.** The same body answers `422 INVALID_METADATA` with an `unrecognized_keys` issue naming the key. A retired key gets what its retirement gives it: a key whose retirement is still current (`filterableFields`, `pageName`, `tabs`, `owner`, `hidden`) raises its own prescription, as before; one whose retirement has aged out (`striped`, `bordered`, `virtualScroll`) is named and pointed at the protocol upgrade guide. `objectName` is answered with `Did you mean \`objectName\` → \`object\`?`, and a top-level `id` with `name`. +- **Unchanged.** Every key the console writes and reads back on a stored view (`isDefault`, `isPinned`, `sortOrder`, `visibility`, `columnState`, `_isOverride`) is declared and saves as before. The console's row ids on `filter[]` / `sort[]` are still removed before the check. The read decorations (`_diagnostics`, `_draft`) and the server-stamped provenance (`_packageId`, `_packageVersion`, `_provenance`) are still removed by the save before it validates. `defineView` / `defineViewItem` were already closed. +- **Who sees it.** An author (a person, an MCP client or an AI agent) who sends a `view` body with a key its shape does not declare. That key was not stored before. A stored view row written before the save began storing the parsed body may still carry such a key; the next save of that row through the console is refused, naming it. + +**The remedy.** Remove the key, or move it to where it is declared: a list view setting on a view item record goes under `config`, `objectName` is `object`, a top-level `id` is `name`. diff --git a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts index f6d4d003cb0..c9d20663cf6 100644 --- a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts +++ b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts @@ -504,3 +504,51 @@ describe('#20230 a flattened overlay carrying a retired owner/hidden is refused } }); }); + +// [#26711] The write door's unknown TOP-LEVEL key. Three of the union's four +// members `.strip()`ped their top level, and since the save stores the parsed +// body (#20051) a misspelt or retired key answered success and was not stored: +// `PUT /api/v1/meta/view/…?mode=draft` with `zzz_junk_key` → 200, key absent. +describe('#26711 the view write door refuses an unknown top-level key', () => { + const OVERLAY = { name: 'task_list', object: 'task', viewKind: 'list', type: 'grid', columns: ['title'] }; + const RECORD = { + name: 'task.mine', object: 'task', viewKind: 'list', + config: { type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title'] }, + }; + + it.each([ + ['an inline list view, draft', OVERLAY, 'task_list', 'draft', 'zzz_junk_key', 'this inline list view'], + ['an inline list view, publish', OVERLAY, 'task_list', undefined, 'striped', 'this inline list view'], + ['a view item record, draft', RECORD, 'task.mine', 'draft', 'zzz_junk_key', 'this view item record'], + ] as const)('%s: 422 INVALID_METADATA naming the key, nothing stored', async (_label, body, name, mode, key, surface) => { + const { protocol, rows } = makeProtocol(); + + const err = await rejection(protocol.saveMetaItem({ type: 'view', name, item: { ...body, [key]: true }, ...(mode ? { mode } : {}) })); + + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + expect(rows.size).toBe(0); + const unknownKey = err.issues.find((i: any) => i.code === 'unrecognized_keys'); + expect(unknownKey?.message).toMatch(new RegExp(`^Unrecognized key\\(s\\) on ${surface}: \`${key}\`\\.`)); + }); + + it.each([ + ['an inline list view', OVERLAY, 'task_list'], + ['a view item record', RECORD, 'task.mine'], + ] as const)('CONTROL: %s without the key saves, one row', async (_label, body, name) => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ type: 'view', name, item: body, mode: 'draft' }); + expect(result.success).toBe(true); + expect(rows.size).toBe(1); + }); + + it('READ PATH: a row stored with such a key is served badged invalid, naming it', async () => { + const { protocol } = makeProtocol([ + { type: 'view', name: 'task_list', metadata: { ...OVERLAY, zzz_junk_key: true } }, + ]); + + const served = (await protocol.getMetaItem({ type: 'view', name: 'task_list' })).item; + expect(served._diagnostics?.valid).toBe(false); + expect(JSON.stringify(served._diagnostics)).toContain('zzz_junk_key'); + }); +}); From efe9bc1166955fefdb920aac765fb5706e55c64c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:53:58 +0000 Subject: [PATCH 4/8] =?UTF-8?q?fix(spec):=20keep=20the=20closed=20overlay?= =?UTF-8?q?=20members'=20shape=20types;=20record=20the=20view.zod.ts=20str?= =?UTF-8?q?ip=20sites=204=20=E2=86=92=202=20in=20the=20strictness=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 Co-authored-by: Claude --- .../2026-07-unknown-key-strictness-ledger.counts/ui.md | 10 +++++----- docs/audits/2026-07-unknown-key-strictness-ledger.md | 2 +- packages/spec/src/ui/view.zod.ts | 5 ++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 49537cc14cd..725fcd426a4 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 217 | 206 | 4 | 0 | 7 | +| `ui/` | 217 | 208 | 4 | 0 | 5 | ## `ui/` — sites @@ -54,21 +54,21 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**7 strip of 217**, in 4 file(s). +**5 strip of 217**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 4 | 59 | +| `view.zod.ts` | 2 | 59 | | `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **217** | +| **total** | **5** | **217** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 1 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 4 | +| wire / open — out of forced scope | 2 | | no door — no carrier, ADR-0049 territory | 1 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 1 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 01d86b36ab6..ebac7ae894a 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -946,7 +946,7 @@ next person to open that file will look. | File | Class | Batch | |---|---|---| -| `view.zod.ts` | mixed · 1 authorable, 3 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 visibility error map (`strictVisibilityError` when this was measured; folded into the shared template as `VISIBILITY_STRICT_OPTIONS` + `strictObjectError` at #6619, deliberately WITHOUT closing the base — the site keeps its literal `z.object(` spelling so this instrument keeps counting it); the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — still true of zod — and **at this reopen (2026-08-04) the #5014 flattening then meant the key that caused it was not in the message the author saw, which is why this sat on `main` unnoticed**. ✅ **That second half is LIFTED and no longer describes the tree:** #5014 closed on 2026-08-05 alongside **#4971** (`formatZodError`) and **#5341** (the CLI), and both spec-side walks now rank the union's branches through `selectUnionBranches` (`packages/spec/src/shared/union-branch-policy.ts`, consolidated there at **#8318**), so the offending key, its surface and its rename all arrive indented under the wrapper line — which is the union's only remaining cost. And the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row). **#9933 added the file's fourth strip site, and it is `wire` by design, not debt: `ViewColumnStateSchema`** — the inner shape of the runtime-only `columnState` personalization overlay key (per-user column order/widths the console grid persists; maintainer-accepted ruling of 2026-08-19 on objectui#5233, mirroring objectui's `gridNonAuthorKeys` disposition). It is deliberately NOT `.strict()`: the console owns the internals of this per-user state, so a future console-written inner key must not 422 against an older server (`saveMetaItem` stores the original body verbatim either way). It is module-private, carried only by the wire faces (`flattenedViewOverlayFields` on the two overlay members, `viewItemWireFields` on the ViewItem wire member), and the AUTHORING doors reject the carrier key by name with a runtime-only prescription (`VIEW_ITEM_SURFACE.guidance` + `ListViewSchema`'s `guidance`) — pinned both ways in `view-metadata-schema.test.ts` §#9933 ⚠️ **#15469 (2026-09-05) closed the file's two `strictObject(…).passthrough()` sites — `GanttConfigSchema` and `TreeConfigSchema` now read `strict`; `ui/` passthrough 5 → 3, strict 157 → 162 (the two closures plus three NEW nested strict sites the declaration adds — `interactions`, `timeSegments` and its band entry — so this file's site count is 58 → 61), this file's strip count unchanged at 4.** Maintainer ruling A (director comment on the card, decision batch #41 item 2, verbatim 「同意」): both windows close. The ten keys plugin-gantt read through the gantt window (`borderColorField`, `lockField`, `objectField`, `summaryExtent`, `defaultCollapsedDepth`, `dependencyTypes`, `timeZone`, `exportFileName`, `interactions`, `timeSegments` — types from objectui's `GanttConfigExtensionFields` at pin `a472b07`) are DECLARED with a describe each; plugin-tree's `getTreeConfig` was measured to read exactly the four keys already declared from the `tree` block, so the tree close declares nothing and the ruled fallback (a dated close) was moot. **Triage's open question, answered by reading the gate:** a `.passthrough()` site is NOT invisible to `check:strictness-ledger` — `postureOf` (`scripts/lib/strictness-ledger.ts`) walks the chain since #5072 and the counts artifact's posture table records it as `passthrough` — but it is not DEBT the ratchet retires either: the remaining-strip map, the bucket subtotals and the reverse pin all count `strip` sites only, and `passthrough` is a non-strip posture, so an open-by-passthrough site never enters a row's strip count, never needs a `Class` verdict, and never moves the ratchet when it is closed or reopened. Its only ledger trace is the per-directory posture column and the row prose — which is why this closure is written here and not left to the numbers. Pinned in `view-gantt-tree-config-closed-15469.test.ts` (the card's five-schema probe with both controls; every declared key accepted at its type and refused at a wrong one; `ListView.gantt.colourField` refused through `ObjectStackDefinitionSchema`) and in `scripts/strictness-ledger.test.ts` (both sites read `strict`, with a mutated-copy red control) | +| `view.zod.ts` | mixed · 1 authorable, 1 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 visibility error map (`strictVisibilityError` when this was measured; folded into the shared template as `VISIBILITY_STRICT_OPTIONS` + `strictObjectError` at #6619, deliberately WITHOUT closing the base — the site keeps its literal `z.object(` spelling so this instrument keeps counting it); the door is closed, the ledger counts the base. The two remaining strip sites beyond it were `ViewItemWireSchema`'s arms, `wire` by design (closed at #26711, below). `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — still true of zod — and **at this reopen (2026-08-04) the #5014 flattening then meant the key that caused it was not in the message the author saw, which is why this sat on `main` unnoticed**. ✅ **That second half is LIFTED and no longer describes the tree:** #5014 closed on 2026-08-05 alongside **#4971** (`formatZodError`) and **#5341** (the CLI), and both spec-side walks now rank the union's branches through `selectUnionBranches` (`packages/spec/src/shared/union-branch-policy.ts`, consolidated there at **#8318**), so the offending key, its surface and its rename all arrive indented under the wrapper line — which is the union's only remaining cost. And the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row). **#9933 added the file's fourth strip site, and it is `wire` by design, not debt: `ViewColumnStateSchema`** — the inner shape of the runtime-only `columnState` personalization overlay key (per-user column order/widths the console grid persists; maintainer-accepted ruling of 2026-08-19 on objectui#5233, mirroring objectui's `gridNonAuthorKeys` disposition). It is deliberately NOT `.strict()`: the console owns the internals of this per-user state, so a future console-written inner key must not 422 against an older server (`saveMetaItem` stores the original body verbatim either way). It is module-private, carried only by the wire faces (`flattenedViewOverlayFields` on the two overlay members, `viewItemWireFields` on the ViewItem wire member), and the AUTHORING doors reject the carrier key by name with a runtime-only prescription (`VIEW_ITEM_SURFACE.guidance` + `ListViewSchema`'s `guidance`) — pinned both ways in `view-metadata-schema.test.ts` §#9933 ⚠️ **#15469 (2026-09-05) closed the file's two `strictObject(…).passthrough()` sites — `GanttConfigSchema` and `TreeConfigSchema` now read `strict`; `ui/` passthrough 5 → 3, strict 157 → 162 (the two closures plus three NEW nested strict sites the declaration adds — `interactions`, `timeSegments` and its band entry — so this file's site count is 58 → 61), this file's strip count unchanged at 4.** Maintainer ruling A (director comment on the card, decision batch #41 item 2, verbatim 「同意」): both windows close. The ten keys plugin-gantt read through the gantt window (`borderColorField`, `lockField`, `objectField`, `summaryExtent`, `defaultCollapsedDepth`, `dependencyTypes`, `timeZone`, `exportFileName`, `interactions`, `timeSegments` — types from objectui's `GanttConfigExtensionFields` at pin `a472b07`) are DECLARED with a describe each; plugin-tree's `getTreeConfig` was measured to read exactly the four keys already declared from the `tree` block, so the tree close declares nothing and the ruled fallback (a dated close) was moot. **Triage's open question, answered by reading the gate:** a `.passthrough()` site is NOT invisible to `check:strictness-ledger` — `postureOf` (`scripts/lib/strictness-ledger.ts`) walks the chain since #5072 and the counts artifact's posture table records it as `passthrough` — but it is not DEBT the ratchet retires either: the remaining-strip map, the bucket subtotals and the reverse pin all count `strip` sites only, and `passthrough` is a non-strip posture, so an open-by-passthrough site never enters a row's strip count, never needs a `Class` verdict, and never moves the ratchet when it is closed or reopened. Its only ledger trace is the per-directory posture column and the row prose — which is why this closure is written here and not left to the numbers. Pinned in `view-gantt-tree-config-closed-15469.test.ts` (the card's five-schema probe with both controls; every declared key accepted at its type and refused at a wrong one; `ListView.gantt.colourField` refused through `ObjectStackDefinitionSchema`) and in `scripts/strictness-ledger.test.ts` (both sites read `strict`, with a mutated-copy red control). **#26711 closed the write door's top level, and this file's strip count went 4 → 2.** `ViewItemWireSchema`'s two arms are `strictObject` now, and the two flattened overlay members (`.extend(…).strip()`, which this instrument never counted as sites) are closed by `closeWireMember` with a refusal built from their own shape. The `wire` verdict on those arms was right when it was written: the save stored the ORIGINAL body, so `.strip()` was what let Studio's undeclared round-trip keys through. #20051 made the save store the PARSED body and #20456 declared every round-trip key (`VIEW_CONSOLE_ROUND_TRIP_KEYS`), so the strip no longer carried anything; it only answered success for a misspelt or retired key and dropped it (the studio-qa finding: `PUT /api/v1/meta/view/…` with `striped: true` → 200, key absent). Measured with the members closed: every `view` body the three examples with views ship, as authored and as expanded, still parses (59 of 59), and on a live dev boot every one of the 36 served views saves back unchanged. The two sites left are `FormFieldBaseSchema` (authorable, its door closed one level up) and `ViewColumnStateSchema` (wire, the console owns its internals). Pinned in `view-wire-unknown-key-refusal.test.ts` | | `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage was **#5055**, and it is ANSWERED: eight of the nine sites were REMOVED (the whole widget-registration vocabulary). The row does not disappear, because the NINTH — `FieldWidgetPropsSchema` — was deliberately KEPT: it is a React props contract rather than authorable metadata, it never appeared in the authorable surface at all, and objectui PR #3289 gave it a live compile-time consumer. ⛔ **Do not close it and do not finish this file** — this is the fourth row in the ledger parked at a deliberate floor (after `flow` 批 11, `etl` 批 12 and `i18n` above), and the reverse pin fires on ZERO either way, so only this cell separates "parked" from "unfinished". See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | | `app.zod.ts` | covered | **批 19 ran the check and it came back NEGATIVE — no posture change; the `Class` was held at `verify` pending #5249 and is now `covered`, the verdict that ruling created (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question was the VOCABULARY, not the measurement** — which is why 批 19 left the cell alone, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolved carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. ✅ **RESOLVED at #5249 (maintainer ruling 2026-08-06, option A): the vocabulary grew a ninth verdict, `covered`, and this row is its first and — as of the sweep below — its ONLY instance.** The ruling took the same route 批 15 took for `no gate` rather than rounding to the nearest wrong answer, on the ground that the cell's readers are later agents and a verdict naming the wrong ACTION is amplified by whoever acts on it. The re-review the ruling required was run over all **197** strip sites in the five triaged directories, not just this file, and it is mechanical rather than a reading: `covered` requires the keys to reach consumers by `...X.shape` SPREAD (a spread lands them in a fresh `z.object` with its own posture, so the base is inert), whereas `.extend()`/`.merge()`/`.omit()` inherit posture and keep the base a real door. Exactly **one** of the 197 sites spreads — this one, into eight of the nine branches (`SeparatorNavItemSchema` declares its own two keys and spreads nothing, and is `.strict()` all the same). The three other module-private strip bases all resolve elsewhere and stay put: `view.zod.ts`'s `FormFieldBaseSchema` is `.extend()`ed at `:1475` → posture inherits → a real door → stays `authorable`; `query.zod.ts`'s `BaseQuerySchema` is `.extend()`ed at `:485` into `QuerySchema` → same → stays `open`; `component.zod.ts`'s `EmptyProps` is used as a VALUE under eleven `ComponentPropsMap` carrier keys → carrier present → not carrier-absent at all. The remaining ~50 sites are inline nested literals under a property, so they carry a carrier by construction and cannot be `covered`. Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | | `action-params.zod.ts` | wire | **out of scope** — `ActionSessionSchema`, the action-body `ctx.session` the runtime hands a body (#5697). Tolerant on purpose, same disposition as `data/hook.zod.ts`'s `HookContextSchema`. What this surface needed was never a closed door but a gate that RUNS: its consistency with the real producer is pinned in `packages/runtime/src/action-session-shape-contract.test.ts`, which asserts that a non-strict parse of the built object returns it UNCHANGED — so a key the builder starts producing without declaring it here is stripped, and the pin goes red | diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 9d72dc8cc0c..1f9ee94ff0d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5649,7 +5649,10 @@ const FORM_OVERLAY_WIRE_SURFACE: StrictObjectOptions = { * not recurse any more than `.strip()` did, and the console's row ids are * still removed before the parse by {@link stripViewConsoleDecorations}. */ -function closeWireMember(member: T, options: StrictObjectOptions) { +function closeWireMember( + member: z.ZodObject, + options: StrictObjectOptions, +): z.ZodObject { const strict = member.strict(); const def = strict._zod.def; return closedObject(strict.clone({ ...def, error: strictObjectError(options, def.shape) })); From a44242ee28c84e882077abe1b34aa7012b56b36d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:11:40 +0000 Subject: [PATCH 5/8] test: re-judge the reverse pins and save fixtures the closed view door moves STILL_STRIP empties now that the registered view schema is closed; the authoring lint's view divergence pin asserts the refusal instead of the old strip finding; the save-path fixtures that froze a dropped undeclared key now pin its refusal by name. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 --- ...rotocol.graft-folded-form-sections.test.ts | 92 ++++++++++++------- .../kernel/metadata-authoring-lint.test.ts | 26 ++++-- .../src/kernel/metadata-type-schemas.test.ts | 37 ++++++-- 3 files changed, 108 insertions(+), 47 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts index 51c3ec92e86..afda7e4ab23 100644 --- a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts @@ -148,6 +148,25 @@ async function storedViewBody(name: string, item: unknown): Promise { return JSON.parse(row!.metadata); } +/** + * [#26711] Save a view the door refuses: the 422 envelope, NO row written, and + * the `unrecognized_keys` issue the claimed member raised for the undeclared keys. + */ +async function refusedUndeclared(item: Record): Promise<{ message: string }> { + const { protocol, rows } = makeProtocol(); + let err: any; + try { + await (protocol as any).saveMetaItem({ type: 'view', name: item.name as string, item }); + } catch (e) { err = e; } + expect(err?.code).toBe('INVALID_METADATA'); + expect(err?.status).toBe(422); + expect(Array.from(rows.values()).filter((r) => r.type === 'view')).toHaveLength(0); + const issue = (err.issues as Array<{ code?: string; message: string }>) + .find((i) => i.code === 'unrecognized_keys'); + expect(issue, JSON.stringify(err.issues)).toBeDefined(); + return { message: issue!.message }; +} + /** The one section every fixture below declares, in the authored spelling. */ const SECTION = { label: 'About you', fields: ['name', 'email'] }; const SHARING = { allowAnonymous: true, publicLink: '/forms/contact-us' }; @@ -214,22 +233,23 @@ describe('#7134 the save path persists the folded `sections`, at every depth a f expect(body).not.toHaveProperty('groups'); }); - it('[#20051] the fold and the form overlay\'s declared keys coexist on one save; its undeclared ones do not', async () => { - // Re-judged at stage (iv) of #20051, which stores a view's parsed - // body. The fold is stored, and so is `isDefault`, which the flattened - // FORM overlay declares. `isPinned` and `sortOrder` are the list - // switcher's row state: the form member declares neither and the - // console writes neither on a form row (`VIEW_CONSOLE_ROUND_TRIP_KEYS` - // maps no key to `formOverlay`), so the stored row drops both. - const body = await storedViewBody( - 'contact_us', - flatForm({ groups: [SECTION], isPinned: true, isDefault: false, sortOrder: 3 }), - ); + it('[#26711] the fold and the form overlay\'s declared keys coexist on one save; its undeclared ones are refused by name', async () => { + // Re-judged twice. At stage (iv) of #20051, which stores a view's parsed + // body, the undeclared keys were dropped from the row on a `200`. Since + // #26711 the form member is closed, so they are refused. The fold is + // stored, and so is `isDefault`, which the flattened FORM overlay + // declares. `isPinned` and `sortOrder` are the list switcher's row + // state: the form member declares neither and the console writes + // neither on a form row (`VIEW_CONSOLE_ROUND_TRIP_KEYS` maps no key to + // `formOverlay`). + const body = await storedViewBody('contact_us', flatForm({ groups: [SECTION], isDefault: false })); expect(body.sections).toEqual([SECTION]); expect(body).not.toHaveProperty('groups'); expect(body.isDefault).toBe(false); - expect(body).not.toHaveProperty('isPinned'); - expect(body).not.toHaveProperty('sortOrder'); + const refusal = await refusedUndeclared( + flatForm({ groups: [SECTION], isPinned: true, isDefault: false, sortOrder: 3 }), + ); + expect(refusal.message).toMatch(/^Unrecognized key\(s\) on this inline form view: `isPinned`, `sortOrder`\./); }); }); @@ -258,22 +278,22 @@ describe('#7134 what the save path must NOT change', () => { expect(await storedViewBody(authored.name, authored)).toEqual(authored); }); - it('[#20051] a form authored with canonical `sections` is stored as authored — no section default, no `sharing.enabled`, no undeclared key', async () => { + it('[#20051] a form authored with canonical `sections` is stored as authored — no section default, no `sharing.enabled`', async () => { // Re-judged at stage (iv): was "stored byte-identical", with an - // `isPinned` the form member does not declare riding along. The parse - // adds `collapsible` / `collapsed` to the section and `enabled: false` - // to `sharing`; ruling B stores none of them (ADR-0087's `storable` - // rule), and the undeclared `isPinned` is dropped. A section's `columns` - // has no default: undeclared, the parse adds no key (the renderer picks - // the density by available width). - const authored = flatForm({ sections: [SECTION], isPinned: true }); + // `isPinned` the form member does not declare riding along, which the + // row then dropped. The parse adds `collapsible` / `collapsed` to the + // section and `enabled: false` to `sharing`; ruling B stores none of + // them (ADR-0087's `storable` rule). A section's `columns` has no + // default: undeclared, the parse adds no key (the renderer picks the + // density by available width). [#26711] The undeclared `isPinned` is + // now refused by name (the test above), so the fixture no longer + // carries it and the row is the body as authored. + const authored = flatForm({ sections: [SECTION] }); const parsed = (ViewMetadataSchema as any).parse(authored); expect(parsed.sections[0]).toMatchObject({ collapsible: false, collapsed: false }); expect('columns' in parsed.sections[0]).toBe(false); expect(parsed.sharing.enabled).toBe(false); - const body = await storedViewBody('contact_us', authored); - const { isPinned: _dropped, ...declared } = authored as Record; - expect(body).toEqual(declared); + expect(await storedViewBody('contact_us', authored)).toEqual(authored); }); it('GUARD: a LIST overlay is untouched — this walk is form-shaped only', async () => { @@ -511,7 +531,10 @@ describe('#20186 the save door judges a flattened overlay by the member its view }); it('a column-less list overlay that names a `type`: 422 at `columns`, with the prescription', async () => { - const issue = at(await refused({ ...LIST, type: 'kanban', groupByField: 'stage' }), 'columns'); + // [#26711] The kanban setting goes where the list view declares it: a + // top-level `groupByField` is now refused as an unknown key, before + // the column check runs. + const issue = at(await refused({ ...LIST, type: 'kanban', kanban: { groupByField: 'stage', columns: ['name'] } }), 'columns'); expect(issue.code).toBe('custom'); expect(issue.message).toMatch(/^This list view overlay sets `type` but lists no `columns`\./); }); @@ -583,9 +606,14 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key expect(stored.sort).toEqual([{ field: 'name', order: 'desc' }]); }); - it('an undeclared key is dropped: the saved-view toolbar toggle\'s `id` / `objectName`, and a form-only `layout`', async () => { - // W6b's shape: `buildPersistedViewBody`'s saved-view branch writes the - // whole tab back, and the tab carries `id` and `objectName`. + it('[#26711] an undeclared key is refused by name: a saved-view tab\'s `id` / `objectName`, and a form-only `layout`', async () => { + // W6b's shape: `buildPersistedViewBody`'s saved-view branch wrote the + // whole tab back, and the tab carried `id` and `objectName`. At stage + // (iv) the row dropped them on a `200`; since #26711 the list member is + // closed and the save is refused, naming each key and pointing the two + // alias spellings at the declared ones. The console no longer writes + // this shape: `buildViewConfigSaveBody` copies only the list view's + // declared keys under the ViewItem envelope. const declared = { name: 'crm_lead.my_leads', object: 'crm_lead', @@ -596,10 +624,12 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key data: { provider: 'object', object: 'crm_lead' }, isDefault: false, }; - const stored = await storedViewBody(declared.name, { + expect(await storedViewBody(declared.name, declared)).toEqual(declared); + const refusal = await refusedUndeclared({ ...declared, id: 'crm_lead.my_leads', objectName: 'crm_lead', layout: 'diagonal', }); - expect(stored).toEqual(declared); + expect(refusal.message).toMatch(/^Unrecognized key\(s\) on this inline list view: `id`, `objectName`, `layout`\./); + expect(refusal.message).toContain('Did you mean `id` → `name`, `objectName` → `object`?'); }); it('a declared key keeps its normalised value', async () => { @@ -641,7 +671,7 @@ describe('[#20051] stage (iv): a saved view stores the parsed value of every key it('the persisted body parses to exactly what the save parsed', async () => { const bodies: Array> = [ ...TOOLBAR_SAVES.map(([, patch]) => ({ ...patch, ...OVERLAY_ID })), - flatForm({ groups: [SECTION], isPinned: true }), + flatForm({ groups: [SECTION], isDefault: true }), { name: 'lead.contact_us', object: 'lead', viewKind: 'form', label: 'Contact us', config: { type: 'simple', data: DATA, sharing: SHARING, groups: [SECTION] }, diff --git a/packages/spec/src/kernel/metadata-authoring-lint.test.ts b/packages/spec/src/kernel/metadata-authoring-lint.test.ts index cb956561fb2..53ccea63ebb 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.test.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.test.ts @@ -108,16 +108,26 @@ describe('coverage derivation (no third hand-written list)', () => { const registrySchema = getMetadataTypeSchema('view'); expect(registrySchema, 'the registry entry itself must still exist — only the lint stopped reading it') .toBeDefined(); - // The registry schema is still STRIP-mode, which is what makes reading it - // wrong rather than merely redundant: run the walk against it explicitly - // and the old contradicting finding comes straight back. This keeps the - // card's evidence alive — if `ViewMetadataSchema` ever goes strict, this - // assertion fails and the exclusion above needs restating, rather than - // quietly becoming true for a second, unrelated reason. + // The registry schema WAS strip-mode, which is what made reading it wrong + // rather than merely redundant: walked explicitly, it gave the old + // contradicting finding (`views.v1.bogusViewKey`) straight back. + // + // [#26711] `ViewMetadataSchema` closed too: the `view` write door now + // refuses an unknown top-level key on every member, so the explicit walk + // finds nothing — and the parse refuses the key, which is what makes that + // empty answer a graduation and not a walker that broke. The exclusion + // above now holds for both schemas; the card's point (ask the schema the + // parse applies) still stands for the next type that serves two shapes. expect( lintUnknownKeysAgainstSchema(registrySchema, VIEW_ENTRY, 'view', 'views.v1').map((f) => f.path), - 'ViewMetadataSchema still strips — the divergence this card fixed is real, not stale', - ).toEqual(['views.v1.bogusViewKey']); + 'ViewMetadataSchema is closed — the walk has nothing to warn about', + ).toEqual([]); + const parsed = registrySchema!.safeParse(VIEW_ENTRY); + expect(parsed.success, 'the registry schema REFUSES the key the walk no longer reports').toBe(false); + // The union carries the claimed branch's refusal inside `invalid_union`. + expect(JSON.stringify(parsed.success ? [] : parsed.error.issues)).toContain('"keys":["bogusViewKey"]'); + const { bogusViewKey: _bogus, ...clean } = VIEW_ENTRY; + expect(registrySchema!.safeParse(clean).success, 'control: the entry without the key saves').toBe(true); }); it('excludes the strict types — the parse is already loud there', () => { diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index c5e56943267..135314de568 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -92,6 +92,9 @@ * regime C), closed — `SharingRuleSchema` is `.strict()` and an authoring * surface only. This was the last bump: closed 27 of 28 when the counts were * replaced by the rule. + * - `STILL_STRIP` emptied on 2026-10-27: `view` closed (#26711). Its open union + * members were the wire shapes, and the reason they stayed open (Studio's + * round-trip keys) had been paid by declaring those keys; see that list's note. */ import { describe, expect, it } from 'vitest'; @@ -520,7 +523,20 @@ describe('each bound entry IS its stack collection\'s schema', () => { * type fails this suite until the list shrinks, so the list cannot outlive the * debt and start exempting types that no longer need exempting. * - * ## `view` is the end state, not the last item of debt + * ## `view` closed (#26711) — the "end state" below was a premise, and it moved + * + * The next paragraphs are kept as history. They said `view` would never close, + * because three union members carried Studio's round-trip keys and the save + * persisted the body verbatim. Both halves stopped being true: #20456 declared + * every round-trip key the console writes (`VIEW_CONSOLE_ROUND_TRIP_KEYS`), and + * #20051 made the save store the PARSED body, so an open member no longer kept + * an undeclared key — it answered success and lost it. Closing the three wire + * members then refused nothing the platform writes (measured: every example + * view body, and every view a showcase boot serves, round-tripped through the + * closed door), and `STILL_STRIP` is empty. A new entry still has to answer the + * question below before it is accepted. + * + * ### History: `view` as the end state * * Every other registered type is closed and `view` will not be, for a reason * worth stating so nobody "finishes the job" by force. The registered `view` schema is a UNION @@ -588,7 +604,7 @@ describe('each bound entry IS its stack collection\'s schema', () => { * envelope had a layer that could own it. A future entry on this list should be * asked which of the two it is before it is accepted as permanent. */ -const STILL_STRIP = new Set(['view']); +const STILL_STRIP = new Set([]); /** The registered schema's own top-level posture: `.strict()` sets a `never` catchall. */ function topLevelPosture(schema: unknown, depth = 0): 'strict' | 'strip' | null { @@ -644,12 +660,17 @@ describe('registered-type closure is derived, not tallied', () => { ).toBe('strict'); }); - it.each([...STILL_STRIP])('%s is still on the strip list (remove it once closed)', (type) => { - expect(types).toContain(type); - expect( - topLevelPosture(getMetadataTypeSchema(type)), - `'${type}' is now strict — remove it from STILL_STRIP.`, - ).toBe('strip'); + // One test over the whole list, not one per entry: the list is empty since + // `view` closed (#26711), and the open-set rule below pins that empty set on + // purpose. Kept as the reverse pin for the next exception, if one is accepted. + it('every STILL_STRIP entry is registered and still strips (remove it once closed)', () => { + for (const type of STILL_STRIP) { + expect(types).toContain(type); + expect( + topLevelPosture(getMetadataTypeSchema(type)), + `'${type}' is now strict — remove it from STILL_STRIP.`, + ).toBe('strip'); + } }); it('the open set, computed from the registry, IS `STILL_STRIP` — no count to bump', () => { From a41bce51694cc08577839b455606218e3e04b206 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:13:49 +0000 Subject: [PATCH 6/8] fix(data-objectstack): createView sends no top-level data on a ViewItem envelope The view write door now refuses a key the envelope does not declare, and data is declared only inside config there. A flat list view keeps its default data. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 --- .../26711-create-view-envelope-data.md | 8 ++ .../src/createView.envelopeData-26711.test.ts | 78 +++++++++++++++++++ ui/packages/data-objectstack/src/index.ts | 8 +- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 ui/.changeset/26711-create-view-envelope-data.md create mode 100644 ui/packages/data-objectstack/src/createView.envelopeData-26711.test.ts diff --git a/ui/.changeset/26711-create-view-envelope-data.md b/ui/.changeset/26711-create-view-envelope-data.md new file mode 100644 index 00000000000..dd39414a0f3 --- /dev/null +++ b/ui/.changeset/26711-create-view-envelope-data.md @@ -0,0 +1,8 @@ +--- +'@object-ui/data-objectstack': patch +--- + +`ObjectStackAdapter.createView` no longer adds a top-level `data` to a ViewItem envelope (`{ name, object, viewKind, config }`). `data` belongs inside `config` there, and the platform's `view` write door now refuses a top-level key the envelope does not declare (objectstack#26711), so such a create was refused naming `data`. Before, the door dropped the key and the view saved. A flat list view still gets its default `data`, where that shape declares it. The console's own create flow writes through `createRuntimeMetadata` and was not affected. + +Clause-②: no +Metadata-protocol: none diff --git a/ui/packages/data-objectstack/src/createView.envelopeData-26711.test.ts b/ui/packages/data-objectstack/src/createView.envelopeData-26711.test.ts new file mode 100644 index 00000000000..4f576e4baf0 --- /dev/null +++ b/ui/packages/data-objectstack/src/createView.envelopeData-26711.test.ts @@ -0,0 +1,78 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26711 — the platform's `view` write door refuses a top-level key + * the body's shape does not declare. `createView` defaulted a top-level `data` + * onto every spec it sent. On a flat list view `data` is declared; on a + * ViewItem envelope (`{ name, object, viewKind, config }`) it is declared only + * inside `config`, so the save was refused with `unrecognized_keys` naming + * `data` (before, the door dropped it unread). Each body the adapter sends is + * judged here by the platform's own `ViewMetadataSchema`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ViewMetadataSchema } from '@objectstack/spec/ui'; +import { ObjectStackAdapter } from './index'; + +function makeDS() { + const saveItem = vi.fn(async (_type: string, _name: string, item: any) => ({ success: true, item })); + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { meta: { saveItem } }; + return { ds, saveItem }; +} + +const sent = (saveItem: ReturnType) => saveItem.mock.calls[0]![2] as Record; + +describe('createView sends a body the view write door accepts (objectstack#26711)', () => { + it('a ViewItem envelope is sent without a top-level `data`, and the write door accepts it', async () => { + const { ds, saveItem } = makeDS(); + await ds.createView('crm_lead', { + name: 'crm_lead.probe', + object: 'crm_lead', + viewKind: 'list', + label: 'Probe', + config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] }, + }); + const body = sent(saveItem); + expect(body).not.toHaveProperty('data'); + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); + }); + + it('CONTROL: the envelope WITH the old top-level `data` is refused, naming the key', () => { + const parsed = ViewMetadataSchema.safeParse({ + name: 'crm_lead.probe', + object: 'crm_lead', + viewKind: 'list', + label: 'Probe', + config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] }, + data: { provider: 'object', object: 'crm_lead' }, + }); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.success ? [] : parsed.error.issues)).toContain('"keys":["data"]'); + }); + + it('a flat list view still gets its default `data`, where the flat shape declares it', async () => { + const { ds, saveItem } = makeDS(); + await ds.createView('crm_lead', { name: 'crm_lead.custom', viewKind: 'list', label: 'Custom', type: 'grid', columns: ['name'] }); + const body = sent(saveItem); + expect(body.data).toEqual({ provider: 'object', object: 'crm_lead' }); + const parsed = ViewMetadataSchema.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); + }); +}); diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index f5827b29f64..64e9a14a15c 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -6035,11 +6035,17 @@ export class ObjectStackAdapter implements DataSource { const suffix = Date.now().toString(36); name = `${base}_${suffix}`; } + // `data` is a list view key. A flat spec declares it at the top level; a + // ViewItem envelope (`{ name, object, viewKind, config }`) declares it only + // inside `config`, and the platform's `view` write door refuses a key the + // envelope does not declare (objectstack#26711), so an envelope is sent + // without one. + const isEnvelope = spec?.config !== undefined && spec?.config !== null; const fullSpec = { ...spec, name, object: spec?.object || objectName, - data: spec?.data || { provider: 'object', object: objectName }, + ...(isEnvelope ? {} : { data: spec?.data || { provider: 'object', object: objectName } }), }; const result: any = await this.client.meta.saveItem('view', name, fullSpec); // `saveItem` is an UPSERT: an explicit `spec.name` that already exists From 1c246bd224f0611615600da0d32317174be7d587 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:53:31 +0000 Subject: [PATCH 7/8] chore(changeset): move the data-objectstack createView entry to the root changeset directory Every @object-ui package releases through the root .changeset; the imported ui/.changeset is read by nothing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 --- {ui/.changeset => .changeset}/26711-create-view-envelope-data.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {ui/.changeset => .changeset}/26711-create-view-envelope-data.md (100%) diff --git a/ui/.changeset/26711-create-view-envelope-data.md b/.changeset/26711-create-view-envelope-data.md similarity index 100% rename from ui/.changeset/26711-create-view-envelope-data.md rename to .changeset/26711-create-view-envelope-data.md From a3aeacbf21a2b7fbdd7888fb45cc2e6c79cb6e5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:56:47 +0000 Subject: [PATCH 8/8] test: re-judge the repo-project and objectql pins the closed view door moves The strip-site count for view.zod.ts reads 2 now that the item wire arms closed; the filterableFields absence walk excludes the refusal pin that authors the key on purpose; two objectql save fixtures stop carrying an undeclared key and pin its refusal instead. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziYCUHZQE3MXucvWe9 --- packages/objectql/src/protocol-meta.test.ts | 28 +++++++++++++------ .../src/protocol-publish-rollback.test.ts | 7 +++-- .../spec/scripts/strictness-ledger.test.ts | 7 ++++- ...-list-filterable-fields-retirement.test.ts | 4 +++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 8e9f3d5743e..7d06653341f 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -486,34 +486,46 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(mockEngine.insert).toHaveBeenCalled(); }); - it('[#20051] stores the console\'s declared round-trip keys, and drops the `objectName` alias', async () => { + it('[#20051] stores the console\'s declared round-trip keys; [#26711] refuses the `objectName` alias by name', async () => { // Re-judged at stage (iv) of #20051: was "preserves Studio-only // auxiliary fields verbatim". A saved view now stores the parsed // value of every key its body carried. `isPinned` / `isDefault` / // `sortOrder` survive because the list overlay DECLARES them - // (`VIEW_CONSOLE_ROUND_TRIP_KEYS`); `objectName` is the + // (`VIEW_CONSOLE_ROUND_TRIP_KEYS`). `objectName` is the // console's alias of `object`, which the member does not - // declare, so it is dropped and `object` carries the binding. - const itemWithExtras = { + // declare: stage (iv) dropped it on a success, and since #26711 + // the `view` write door refuses it, pointing at `object`. + const declared = { ...validView, isPinned: true, isDefault: false, sortOrder: 5, - objectName: 'lead', }; await protocol.saveMetaItem({ type: 'view', name: 'all_leads', - item: itemWithExtras, + item: declared, }); const insertCall = mockEngine.insert.mock.calls[0]; const persisted = JSON.parse(insertCall[1].metadata); - const { objectName: _alias, ...declared } = itemWithExtras; expect(persisted).toEqual(declared); - expect(persisted).not.toHaveProperty('objectName'); expect(persisted.object).toBe('lead'); + + mockEngine.insert.mockClear(); + let caught: any; + try { + await protocol.saveMetaItem({ + type: 'view', + name: 'all_leads', + item: { ...declared, objectName: 'lead' }, + }); + } catch (e) { caught = e; } + expect(caught?.code).toBe('INVALID_METADATA'); + expect(caught?.status).toBe(422); + expect(caught.message).toContain('Did you mean `objectName` → `object`?'); + expect(mockEngine.insert).not.toHaveBeenCalled(); }); it('rejects an invalid view (container with a listView missing `columns`) with 422', async () => { diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index 5cd84a48454..eac76f3eeca 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -344,16 +344,17 @@ describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { }); await protocol.saveMetaItem({ type: 'view', name: 'case_grid', - item: { name: 'case_grid', type: 'grid', label: 'B', columns: ['id', 'title'], rowHeight: 'compact', extra: 1, object: 'case', viewKind: 'list' }, // [#7741] the inline arm requires the object binding pair + item: { name: 'case_grid', type: 'grid', label: 'B', columns: ['id', 'title'], rowHeight: 'compact', object: 'case', viewKind: 'list' }, // [#7741] the inline arm requires the object binding pair }); const diff = await protocol.diffMetaItem({ type: 'view', name: 'case_grid', fromVersion: 1, toVersion: 2, }); // [#20051] Re-judged at stage (iv): the added key is a DECLARED one - // (`rowHeight`). The undeclared `extra` never reaches a version — a + // (`rowHeight`). The undeclared `extra` never reached a version — a // view stores the parsed value of every key its body carried — so no - // diff can show it. + // diff could show it. [#26711] The `view` write door now refuses an + // undeclared top-level key, so the fixture no longer carries `extra`. expect((diff as any).added.map((e: any) => e.path)).toEqual(['rowHeight']); expect((diff as any).changed.map((e: any) => e.path).sort()).toEqual(['columns', 'label']); }); diff --git a/packages/spec/scripts/strictness-ledger.test.ts b/packages/spec/scripts/strictness-ledger.test.ts index 5187dd6ea04..bb407778595 100644 --- a/packages/spec/scripts/strictness-ledger.test.ts +++ b/packages/spec/scripts/strictness-ledger.test.ts @@ -380,7 +380,12 @@ describe('posture reading, with a red control for each', () => { // console-written inner key must not 422 against an older server). Wire, // not debt; the ledger row carries the intent, and the authoring doors // reject the carrier key by name (`view-metadata-schema.test.ts` §#9933). - expect(countStripSites(at('ui/view.zod.ts'))).toBe(4); + // + // 4 → 2 at #26711: the two `ViewItemWireSchema` arms closed with the rest + // of the `view` write door (the save stores the parsed body, so a strip + // there lost a key silently). The two sites left are `ViewColumnStateSchema` + // (above) and the form field's `base` shape, both unchanged by #26711. + expect(countStripSites(at('ui/view.zod.ts'))).toBe(2); }); it('lets a chained posture override the idiom in either direction (#5072)', () => { diff --git a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts index 1d58170d5b6..b95f112362c 100644 --- a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts +++ b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts @@ -368,6 +368,10 @@ describe('tree-scoped absence: no list-view payload inside the declared radius s // both took this key over from `striped` when its 16.x tombstone aged out. 'packages/spec/src/ui/view-union-retirement-prescription.test.ts', 'packages/lint/src/lint-liveness-properties.test.ts', + // [#26711] The closed view write door's pin authors the key on the inline + // list view to assert the live tombstone still raises its own prescription + // there, not a bare unrecognized-key refusal. + 'packages/spec/src/ui/view-wire-unknown-key-refusal.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement payload on purpose.