From 3b4073a1bc25639d7280debc63796f1f90b4f425 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:05:40 +0000 Subject: [PATCH 1/7] feat(spec)!: retire the flattened view overlay's owner and hidden keys (ADR-0049) Tombstone both keys on the two flattened overlay members with the view item's own prescription texts, add the D2 conversion view-overlay-owner-hidden-removed (views + viewItems, flattened spelling), wire it into step 18, register ui/ViewMetadata:owner|hidden, and add the family's one D3 semantic entry view-owner-hidden-retired. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 134 ++++++++++++++++-- .../18.ui__ViewMetadata__hidden.ts | 20 +++ .../18.ui__ViewMetadata__owner.ts | 10 ++ .../semantic/18.view-owner-hidden-retired.ts | 47 ++++++ packages/spec/src/migrations/registry.ts | 74 +++++++++- packages/spec/src/ui/view.zod.ts | 51 +++++-- 6 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__hidden.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__owner.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.view-owner-hidden-retired.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index cee304a07e5..56f64d04c7a 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10334,12 +10334,13 @@ const objectTenancyOrganizationFieldRemoved: MetadataConversion = { * `config` holds the payload, the discriminator {@link mapViewPayloads} uses for * its case 1 — and deliberately NOT walked through `mapViewPayloads`, whose * mapper sees a record's `config`, never the record's own top level. A - * flattened overlay (no `config`) keeps its `owner` / `hidden`: those are - * declared on a different door (`flattenedViewOverlayFields()`), which this - * retirement does not touch, and stripping them here would change what that - * door stores. A container carries neither key. Deletion is the whole - * conversion and it is lossless: neither key ever changed what a view showed or - * to whom, so removing it changes no render. + * flattened overlay (no `config`) declares its `owner` / `hidden` on a different + * door (`flattenedViewOverlayFields()`); [#20230] that door's pair is retired + * too, and stripped by its own entry, {@link viewOverlayOwnerHiddenRemoved} — + * the two are disjoint by `config`, so no row is judged by both. A container + * carries neither key. Deletion is the whole conversion and it is lossless: + * neither key ever changed what a view showed or to whom, so removing it + * changes no render. */ const viewItemOwnerHiddenRemoved: MetadataConversion = { id: 'view-item-owner-hidden-removed', @@ -10390,9 +10391,11 @@ const viewItemOwnerHiddenRemoved: MetadataConversion = { }, // A record that never authored either key rides through untouched. { name: 'crm_lead.intake', object: 'crm_lead', viewKind: 'form', config: { type: 'simple' } }, - // A FLATTENED OVERLAY (no `config`): its `hidden` belongs to the - // overlay door, which this retirement does not touch — kept. - { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list', hidden: true }, + // A FLATTENED OVERLAY (no `config`) rides through untouched. [#20230] + // Its own `owner` / `hidden` are `view-overlay-owner-hidden-removed`'s + // business, so this neighbour carries neither — the fixtures stay + // disjoint when the whole table replays. + { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list', isDefault: true }, ], }, after: { @@ -10414,7 +10417,7 @@ const viewItemOwnerHiddenRemoved: MetadataConversion = { config: { type: 'grid', columns: ['name'] }, }, { name: 'crm_lead.intake', object: 'crm_lead', viewKind: 'form', config: { type: 'simple' } }, - { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list', hidden: true }, + { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list', isDefault: true }, ], }, // `stripKeys` emits one notice per KEY removed: two on the `views` record, @@ -10423,6 +10426,116 @@ const viewItemOwnerHiddenRemoved: MetadataConversion = { }, }; +/** + * The flattened overlay's `owner` / `hidden` leave the authorable surface + * (protocol 18, #20230 — ADR-0049 enforce-or-remove; triage direction, verbatim: + * 「follow #20085's disposition for the same key pair」). + * + * The overlay door — the lean personalization PUT with no `config`, members 3 + * and 4 of the `view` union (`flattenedViewOverlayFields()` in + * `ui/view.zod.ts`) — declared both keys separately from the view item's pair, + * accepted them, and `saveMetaItem` stored them verbatim; nothing read either. + * Writer census before removal: none in this framework or its examples, in + * objectui at its pin and at `main`, or in the HotCRM app (cloud was not + * reachable). The prescriptions are the view item's own texts. + * + * **Retired from the load path** — both keys are `retiredKey()` tombstones on + * the overlay members, so a live author is refused at parse with the + * prescription. The entry exists because a STORED overlay row can carry them: + * the write door accepted and persisted both until this release, and every + * read of a stored `view` row replays the chain through + * `applyConversionsToStoredItem` as `{ views: [row] }` before it is served or + * badged. Without the strip, such a row would be served with the retired key, + * badged invalid, and refused on the console's next read-merge-write of it + * (a toolbar toggle re-sends the row it read) over a key that never had an + * effect. It also lets `os migrate meta --from 17` list the edits for sources. + * + * **Two collections, like the view item's entry.** `views` is the stack + * collection and the stored-row seam's wrapping; `viewItems` + * ({@link ASSEMBLED_VIEW_ITEMS_KEY}) is the assembled-manifest channel, whose + * registration parse (`AssembledViewArtifactSchema`, built from the same + * members) now refuses the keys too. + * + * ⚠️ Scoped to the FLATTENED spelling: a body with no `config` and no container + * slot — the guard `mapViewPayloads` applies before its case 3, and the + * `z.undefined()` guards the two overlay members declare. `viewKind` is NOT + * required here, unlike `mapViewPayloads`' case 3: that walk needs the family + * to pick a payload transform, while deleting these two keys needs none, and a + * flat row stored before the #7741 binding carries no `viewKind` until the + * write path heals it — then the save would refuse the key it still held. A + * record (`config` present) is {@link viewItemOwnerHiddenRemoved}'s business, + * so no row is judged by both. Deletion is the whole conversion and it is + * lossless. + */ +const viewOverlayOwnerHiddenRemoved: MetadataConversion = { + id: 'view-overlay-owner-hidden-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'view.owner / view.hidden — on a flattened view overlay ({ name, object, viewKind, …, no config })', + summary: + "flattened view overlay keys 'owner'/'hidden' removed (#20230, ADR-0049 — the view item's pair on " + + 'the overlay door: declared, accepted by the write door and stored verbatim, read by nothing, so a ' + + '`hidden: true` overlay hid no view and an `owner` scoped none)', + apply(stack, emit) { + const stripFromOverlay = (view: Dict, path: string): Dict => { + if (view.config !== undefined) return view; + if (view.list !== undefined || view.form !== undefined) return view; + if (view.listViews !== undefined || view.formViews !== undefined) return view; + return stripKeys(view, ['owner', 'hidden'], emit, path); + }; + return mapCollection( + mapCollection(stack, 'views', stripFromOverlay), + ASSEMBLED_VIEW_ITEMS_KEY, + stripFromOverlay, + ); + }, + fixture: { + before: { + // The assembled-manifest channel: an overlay a package export carried + // before this release. + viewItems: [ + { name: 'crm_deal.pipeline', object: 'crm_deal', viewKind: 'list', hidden: false, order: 1 }, + ], + views: [ + // A bound overlay carrying both keys: both go, and the live + // round-trip keys beside them (`isDefault`, `label`) are untouched. + { + name: 'crm_deal.all', + object: 'crm_deal', + viewKind: 'form', + label: 'All deals', + isDefault: true, + owner: 'usr_7', + hidden: true, + }, + // An overlay that never authored either key rides through untouched. + { name: 'crm_deal.by_stage', object: 'crm_deal', viewKind: 'list', order: 2 }, + // A container carries neither key and is not an overlay: untouched. + { object: 'crm_deal', form: { type: 'simple' } }, + ], + }, + after: { + viewItems: [ + { name: 'crm_deal.pipeline', object: 'crm_deal', viewKind: 'list', order: 1 }, + ], + views: [ + { + name: 'crm_deal.all', + object: 'crm_deal', + viewKind: 'form', + label: 'All deals', + isDefault: true, + }, + { name: 'crm_deal.by_stage', object: 'crm_deal', viewKind: 'list', order: 2 }, + { object: 'crm_deal', form: { type: 'simple' } }, + ], + }, + // One notice per KEY removed: two on the `views` overlay, one on the + // `viewItems` overlay. + expectedNotices: 3, + }, +}; + /** * The page-component types whose `properties.filter` is a converged rule-array * door: every `ComponentPropsMap` row whose `filter` answers the record form @@ -11007,6 +11120,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly