diff --git a/.changeset/view-overlay-owner-hidden-retired.md b/.changeset/view-overlay-owner-hidden-retired.md new file mode 100644 index 00000000000..726a7ed32a8 --- /dev/null +++ b/.changeset/view-overlay-owner-hidden-retired.md @@ -0,0 +1,101 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire the flattened view overlay's `owner` and `hidden` keys — accepted at the save door, stored, and read by nothing (#20230) + +**BREAKING** — `owner` and `hidden` are removed from the flattened view overlay: +the lean `view` body with no `config` that `PUT /api/v1/meta/view/:name` (the +Studio and MCP save) accepts, members 3 and 4 of the `view` metadata door, and +the same members in the assembled-manifest `viewItems:` channel. ADR-0049 +enforce-or-remove; triage direction, verbatim: 「follow #20085's disposition for +the same key pair」. This completes the family: the view item record's `owner` / +`hidden` are retired in this same release by its own entry, with the same texts. + +⚠️ **This supersedes one sentence of the view item retirement's note in this same +release.** That note says the flattened overlay's own `owner` / `hidden` are +untouched and that a `{ object, viewKind, hidden: true }` overlay still parses. +True of that change alone; after this one, such an overlay is refused too. Read the +two notes together: after this release, neither door accepts either key. + +Clause-②: no (narrowing) + +The overlay door declared both keys separately from the view item's pair. A bound +overlay such as `{ object, viewKind, hidden: true }` saved clean and one row was +stored with the key, and nothing ever read it. Both view-switcher read paths +(`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` + `object` +and sort on `order`, so `hidden: true` hid nothing, and a view with `owner` set +was listed for every user who can read the object. + +Writer census, taken before removal: no writer of either overlay key in this +framework or its examples, in objectui at its pinned commit and at `main` (the +toolbar writes only `rowHeight`, `sort`, `hiddenFields`, `columnState` and +`inlineEdit`; the switcher only `label`, `isPinned`, `isDefault` and `sortOrder`), +or in the HotCRM app. The cloud repository was not reachable from the census. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| flattened overlay `owner` | delete the key. Nothing restricts a view to one user today; a view is visible to everyone who can read its object. | +| flattened overlay `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). | + +**The one-line fix: delete `owner:` and `hidden:` from every view body you save.** +`os migrate meta --from 17` lists the mechanical edits for existing sources. + +⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what +a view showed or to whom. What changes is the answer an author gets: a save that +carries either key is refused `422 INVALID_METADATA`, with the prescription +located at the key, instead of being stored with no effect. The prescriptions are +the view item's own texts, so the family answers with one voice on both doors. + +### Stored rows + +Every read of a stored `view` row replays the conversion chain before the row is +served or badged, and the D2 conversion strips both keys there. What that leaves +depends on what else the row holds: + +- **A row with any other view key** (a column state, a sort, a default flag, an + order): served and badged valid without the keys. A GET then a PUT of the whole + row saves (if it was otherwise valid), so the console's next read-merge-write of + it saves, and `os migrate meta --stored --apply` rewrites it. +- **A hide-only row**, holding nothing but its identity (`name`, `object`, + `viewKind`, `label`) and `owner` / `hidden`, such as + `{ object, viewKind, hidden: true }`: the strip leaves identity only, which the + `view` door refuses ("only identity fields"). The row is served badged invalid + (it was badged valid before this release). A whole-row re-save, or one that adds + only identity (a rename sets `label`), answers `422 INVALID_METADATA`. + `--apply` reports it `failed` and leaves it as stored; every read strips it + again. A write that adds a real view key, such as a toolbar toggle, saves. + **Fix: delete the row** (it never changed what anyone saw), or add the + personalization setting its author meant and save that. + +### The retirement kit + +- **Tombstones on both overlay members.** `retiredKey()` in + `flattenedViewOverlayFields()`, with the view item's prescription texts. Both + members `.strip()`, so a bare deletion would have dropped the key in silence + (ADR-0104). +- **D2 conversion `view-overlay-owner-hidden-removed`** (step 18, retired from the + load path). A lossless delete from the flattened spelling (no `config`, no + container slot) in `views` (stack sources and stored rows) and `viewItems` + (assembled artifacts). It is disjoint from `view-item-owner-hidden-removed` by + `config`, so no row is judged by both. +- **D3 semantic entry `view-overlay-owner-hidden-retired`**: the family's one D3 + record, naming its D2 conversion. The view item record's pair is a separate + family with its own conversion and its own D3 entry; the two share the + prescription texts. +- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewMetadata:owner`, `ui/ViewMetadata:hidden`. + `ui/ViewMetadata` is unemitted (its `z.undefined()` guards have no JSON Schema + form), so no build gate judges these rows and the four surface ratchets are + byte-identical on this retirement. The rows are pinned by the retirement test. +- **No liveness row**: the `view` ledger walks the container keys only. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo population is NOT MEASURED.** `@objectstack/spec` is published, +and production `sys_metadata` rows are not reachable from the repository. Stored +rows are stripped on read by the conversion above, and a hide-only row among them +needs the fix above. A client that still sends either key is refused at its next +save. + + diff --git a/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts b/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts index 23b9701b733..d00aa031db2 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts @@ -193,7 +193,18 @@ describe('#5599 a stored `view` that is not a view is no longer badged valid', ( // inherits `object`/`viewKind` from the shadowed entry (#2555), so a // stored lean overlay of a REAL view looks exactly like this. expect(computeMetadataDiagnostics('view', { isPinned: true, object: 'task', viewKind: 'list' })).toEqual({ valid: true }); - expect(computeMetadataDiagnostics('view', { hidden: true, object: 'task', viewKind: 'list' })).toEqual({ valid: true }); + expect(computeMetadataDiagnostics('view', { order: 2, object: 'task', viewKind: 'list' })).toEqual({ valid: true }); + // [#20230] `hidden` used to stand in the line above. The overlay's + // `owner` / `hidden` are retired (ADR-0049): a body that still carries + // one is badged invalid with the retirement prescription at the key. + // A STORED row never reaches this badge with the key — the read path + // replays the chain first (`convertStoredItem`), and + // `view-overlay-owner-hidden-removed` strips it. + const bound = { object: 'task', viewKind: 'list' } as const; + const retired = computeMetadataDiagnostics('view', { ...bound, hidden: true }); + expect(retired?.valid).toBe(false); + const atKey = retired?.errors?.find((e) => e.path === 'hidden'); + expect(atKey?.message).toMatch(/^`view\.hidden` was removed in @objectstack\/spec/); // …while a stored row with NO binding is a row no object-bound read // path can serve — the #7741 dead row — and is badged invalid now. expect(computeMetadataDiagnostics('view', { isPinned: true })?.valid).toBe(false); 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 08b171c83aa..1fc0f3f0095 100644 --- a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts +++ b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts @@ -31,6 +31,7 @@ import { describe, expect, it } from 'vitest'; // of this package's (file, verb) pairs sat in the gate's DEBT ledger until // #5619 sank the two predicates into a package both sides already depend on. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { applyConversionsToStoredItem } from '@objectstack/spec'; import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; import { ObjectStackProtocolImplementation, zodIssuesToMetadataIssues } from './protocol.js'; @@ -46,8 +47,16 @@ interface Row { const keyOf = (w: Record) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; -/** The engine surface the repository write path touches. */ -function makeProtocol() { +/** + * The engine surface the repository write path touches. + * + * [#20230] `seed` (optional; default none, so every other test's double is + * unchanged) makes `findOne` answer the READ a `getMetaItem` performs against + * `sys_metadata` with a stored row, and gives the registry the two read verbs + * that path consults — answering nothing, so the served item is the stored row + * after the rehydration seam and nothing else. + */ +function makeProtocol(seed: Array<{ type: string; name: string; metadata: Record }> = []) { // ⚠️ Keyed BY TABLE. `find`/`findOne` below answer nothing, so this harness // cannot serve a `sys_metadata_history` row as a `sys_metadata` row the way // #16223 measured — but one flat map still made `rows.size` the total of @@ -65,7 +74,15 @@ function makeProtocol() { let nextId = 0; const engine: any = { async findOne(object: string, query?: EngineFindOneQueryInput) { - assertEngineFindOnePredicate(object, query); return null; }, + assertEngineFindOnePredicate(object, query); + if (object !== 'sys_metadata' || seed.length === 0) return null; + const where = ((query as { where?: Record } | undefined)?.where ?? {}); + const hit = seed.find((r) => r.type === where.type && r.name === where.name + && (where.state ?? 'active') === 'active' && (where.organization_id ?? null) === null); + return hit + ? { id: `seed_${hit.name}`, type: hit.type, name: hit.name, organization_id: null, state: 'active', metadata: JSON.stringify(hit.metadata) } + : null; + }, async find() { return []; }, async insert(table: string, data: Record) { nextId += 1; @@ -81,7 +98,9 @@ function makeProtocol() { assertEngineDeleteDispatch(opts); return { deleted: 0 }; }, - registry: { registerItem: () => {}, registerObject: () => {} }, + registry: seed.length === 0 + ? { registerItem: () => {}, registerObject: () => {} } + : { registerItem: () => {}, registerObject: () => {}, getItem: () => undefined, getObject: () => undefined }, }; const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); return { protocol, rows }; @@ -363,3 +382,107 @@ describe('#5364 zodIssuesToMetadataIssues — the shared ranking, verbatim', () expect(zodIssuesToMetadataIssues(null)).toEqual([]); }); }); + +/** + * #20230 — the flattened overlay's retired `owner` / `hidden`, at the door the + * retirement exists for: `saveMetaItem`, the `PUT /api/v1/meta/view/:name` write + * path the console and an MCP author reach. + * + * Before: a bound lean overlay `{ object, viewKind, hidden: true }` saved, one + * row persisted with the key, and nothing ever read it — measured on this same + * harness by the #20085 dev. After: refused with the ADR-0112 envelope, nothing + * persisted, and the retirement prescription located at the key. Pinned HERE + * and not only in spec because this envelope is what Studio and an MCP caller + * receive — a spec tombstone that did not reach it would be a refusal nobody sees. + */ +describe('#20230 a flattened overlay carrying a retired owner/hidden is refused at the save door', () => { + // Spread, not a literal: the spec's tree-scoped absence pin reads object + // literals, and these bodies are refusals, not authorings. + const BOUND_LIST = { object: 'task', viewKind: 'list' } as const; + const BOUND_FORM = { object: 'task', viewKind: 'form' } as const; + const PRESCRIPTION: Record<'owner' | 'hidden', RegExp> = { + owner: /^`view\.owner` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049/, + hidden: /^`view\.hidden` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049/, + }; + + for (const [key, value] of [['owner', 'usr_7'], ['hidden', true]] as const) { + for (const [family, bound] of [['list', BOUND_LIST], ['form', BOUND_FORM]] as const) { + it(`a bound ${family} overlay with \`${key}\` answers 422 INVALID_METADATA and persists nothing`, async () => { + const { protocol, rows } = makeProtocol(); + + const err = await rejection(save(protocol, { name: 'task_list', ...bound, [key]: value })); + + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + expect(rows.size).toBe(0); + // The prescription, located at the key the author sent. + const atKey = err.issues.find((i: any) => i.path === key); + expect(atKey, `an issue located at \`${key}\``).toBeDefined(); + expect(atKey.code).toBe('invalid_type'); + expect(atKey.message).toMatch(PRESCRIPTION[key]); + expect(err.message).toContain(`\`view.${key}\` was removed`); + }); + } + } + + /** + * The hide-only residue, at the door it is refused by. The card's measured + * stored shape `{ object, viewKind, hidden: true }` (plus the stamped + * `name`) is served stripped by the rehydration seam — identity only — and + * a whole-row PUT of what was served is refused by the identity + * precondition. Stated in the D2 docblock, the D3 acceptance criteria and + * the changeset; the remedy is to delete the row or add the setting its + * author meant. + */ + it('RESIDUE: a whole-row PUT of a stripped hide-only row answers 422 INVALID_METADATA ("only identity fields")', async () => { + const stored = { name: 'task_list', ...BOUND_LIST, hidden: true }; + const served = applyConversionsToStoredItem('view', stored) as Record; + expect(served).toEqual({ name: 'task_list', ...BOUND_LIST }); + + const { protocol, rows } = makeProtocol(); + const err = await rejection(save(protocol, served)); + + expect(err.code).toBe('INVALID_METADATA'); + expect(err.status).toBe(422); + expect(rows.size).toBe(0); + expect(err.message).toContain('only identity fields'); + // Not the retirement prescription: the key is already gone. + expect(err.message).not.toContain('was removed in @objectstack/spec'); + }); + + it('RESIDUE CONTROL: the same stripped row plus a real view key (a toolbar toggle) saves', async () => { + const served = applyConversionsToStoredItem('view', { name: 'task_list', ...BOUND_LIST, hidden: true }) as Record; + const { protocol, rows } = makeProtocol(); + const result = await save(protocol, { ...served, isDefault: true }); + expect(result.success).toBe(true); + expect(rows.size).toBe(1); + }); + + it('READ PATH: `getMetaItem` serves a stored overlay without `owner` / `hidden` — valid with content, invalid when hide-only', async () => { + const { protocol } = makeProtocol([ + { type: 'view', name: 'task_list', metadata: { name: 'task_list', ...BOUND_LIST, isDefault: true, order: 2, owner: 'usr_7', hidden: true } }, + { type: 'view', name: 'task_hidden', metadata: { name: 'task_hidden', ...BOUND_FORM, hidden: true } }, + ]); + + const content = (await protocol.getMetaItem({ type: 'view', name: 'task_list' })).item; + expect(content).not.toHaveProperty('owner'); + expect(content).not.toHaveProperty('hidden'); + expect(content.isDefault).toBe(true); + expect(content.order).toBe(2); + expect(content._diagnostics).toEqual({ valid: true }); + + const hideOnly = (await protocol.getMetaItem({ type: 'view', name: 'task_hidden' })).item; + expect(hideOnly).not.toHaveProperty('hidden'); + expect(hideOnly._diagnostics?.valid).toBe(false); + expect(JSON.stringify(hideOnly._diagnostics)).toContain('only identity fields'); + }); + + it('CONTROL: the same bound overlays without the keys still save, one row each', async () => { + for (const bound of [BOUND_LIST, BOUND_FORM]) { + const { protocol, rows } = makeProtocol(); + const result = await save(protocol, { name: 'task_list', ...bound, isDefault: true, order: 2 }); + expect(result.success).toBe(true); + expect(rows.size).toBe(1); + } + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 4e76af6ca83..922080b53ca 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10345,12 +10345,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', @@ -10401,9 +10402,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: { @@ -10425,7 +10428,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, @@ -10434,6 +10437,134 @@ 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. It also lets `os migrate meta --from 17` list the edits for sources. + * What the strip leaves depends on what else the row holds — two classes, both + * pinned (`ui/view-overlay-owner-hidden-retirement.test.ts`, and the save door + * in `@objectstack/metadata-protocol`'s `protocol.save-union-issues.test.ts`): + * + * - **Content-bearing** — any view key besides the identity the write path + * stamps (`name` / `object` / `viewKind` / `label`): served and badged valid + * without the two keys, a GET then a PUT of the whole row saves (if it was + * otherwise valid), and `os migrate meta --stored --apply` rewrites it. + * Without the strip it would be served with the retired key, badged + * invalid, and refused on the console's next read-merge-write of it. + * - **Hide-only** — identity plus `owner` / `hidden` and nothing else, the + * shape the card measured (`{ object, viewKind, hidden: true }`): the strip + * leaves IDENTITY ONLY, which the `view` door's identity precondition + * refuses (#7741, "only identity fields"). The row is served without the + * keys but badged invalid (it was badged valid before this release); a + * whole-row re-save, or one that adds only identity (a rename is `label`), + * answers `422 INVALID_METADATA`; `--apply` reports it `failed` and leaves it + * as stored, and every read strips it again. A write that adds a real view + * key (a toolbar toggle) saves. Remedy: delete the row — it never changed + * what anyone saw — or add the personalization setting its author meant. + * Not convertible: which setting, if any, the author wanted is theirs to say. + * + * **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 @@ -11314,6 +11445,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { - const { out } = convertViewRow({ + const { out, notices } = convertViewRow({ name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', @@ -355,9 +360,11 @@ describe('view conversions reach the flattened-overlay spelling (payload at top expect(out.isDefault).toBe(true); expect(out.order).toBe(3); expect(out.scope).toBe('user'); - expect(out.owner).toBe('usr_1'); expect(out.columnState).toEqual({ order: ['name'], widths: { name: 120 } }); - expect('striped' in out).toBe(false); // the one key that IS retired + // The two retired keys on this row — each stripped by its own entry. + expect('striped' in out).toBe(false); + expect('owner' in out).toBe(false); + expect(pathsFor(notices, 'view-overlay-owner-hidden-removed')).toEqual(['views[0].owner']); }); }); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__hidden.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__hidden.ts new file mode 100644 index 00000000000..fedcd6ee9c4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__hidden.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20230 (ADR-0049 enforce-or-remove; triage direction 「follow #20085's +// disposition for the same key pair」). The flattened view overlay's `hidden` — +// the lean personalization PUT with no `config`, members 3 and 4 of the `view` +// union `saveMetaItem` validates — was declared by `flattenedViewOverlayFields()` +// separately from the view item's, accepted, stored verbatim, and read by +// nothing: both switcher read paths filter on `viewKind` + `object` only, so a +// `hidden: true` overlay hid no view. No writer in this framework, its examples, +// objectui at its pin and at `main`, or the HotCRM app (cloud not reachable). +// Tombstoned with `retiredKey()` on both overlay members, with the view item's +// own prescription text; `.strip()` members, so a bare deletion would have +// dropped the key in silence. Registered under `ui/ViewMetadata`, the exported +// door the overlay members are reached through (the members themselves are not +// exported). ⚠️ No gate below can JUDGE this row: `ui/ViewMetadata` is in +// `unemitted-schemas.baseline.json` (its `config: z.undefined()` guards have no +// JSON Schema form), so `authorable-surface/` carries no `ui/ViewMetadata:*` +// line and check (b) never sees the tombstone — the row is declared, not +// checked. D2: `view-overlay-owner-hidden-removed`. +export const entry = 'ui/ViewMetadata:hidden'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__owner.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__owner.ts new file mode 100644 index 00000000000..c623fc2f5ca --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ViewMetadata__owner.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20230 — the overlay door's `owner`, the sibling of `ui/ViewMetadata:hidden` +// (see that row for the measurement and the registration's def key). It named +// a user nothing ever read: no per-user scope exists for a view (ADR-0017, +// parked), so an overlay marked as one user's changed nothing for anyone. +// Tombstoned with `retiredKey()` on both overlay members, with the view item's +// own prescription text. Same blind spot as its sibling: the def is unemitted, +// so no gate judges this row. D2: `view-overlay-owner-hidden-removed`. +export const entry = 'ui/ViewMetadata:owner'; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts index 3b39d0cf2a3..fce86ff6d9f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-item-owner-hidden-retired.ts @@ -24,8 +24,9 @@ export const entry: SemanticMigration = { + 'A view an author marked as one user\'s, or hid from the switcher, has always been listed to ' + 'every user who can read the object — its name, its columns, its filters and its sort. ' + 'Whether anything in such a view was meant to stay private, and whether it should now be ' - + 'deleted rather than kept, is the author\'s call. A flattened view overlay keeps its own ' - + '`owner` and `hidden`: those live on a different door that this retirement does not touch.', + + 'deleted rather than kept, is the author\'s call. A flattened view overlay\'s own `owner` and ' + + '`hidden` are a separate family on a different door, with their own D2 conversion ' + + '`view-overlay-owner-hidden-removed` and their own D3 entry `view-overlay-owner-hidden-retired`.', acceptanceCriteria: 'No view item record in `views` or in an assembled artifact carries `owner` or ' + '`hidden`; the parse refuses both by name, and an artifact assembled before the upgrade ' + 'registers without a refusal over them. For every view that had carried either key, the ' diff --git a/packages/spec/src/migrations/entries/semantic/18.view-overlay-owner-hidden-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-overlay-owner-hidden-retired.ts new file mode 100644 index 00000000000..7943c57e655 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-overlay-owner-hidden-retired.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20230 (ADR-0049 enforce-or-remove; triage direction 「follow #20085's +// disposition for the same key pair」) — the D3 entry of the +// `view-overlay-owner-hidden-removed` family (ruling B on #17152: one D3 entry +// per retirement family, even when D2 is lossless). Registered keys: `owner` / +// `hidden` on `ui/ViewMetadata`, the door the two flattened overlay members are +// reached through. The same key pair on the view item RECORD is a separate +// family with its own conversion and its own D3 entry, disjoint from this one +// by `config`; the two share the prescription texts, not a conversion. +export const entry: SemanticMigration = { + id: 'view-overlay-owner-hidden-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'view.owner / view.hidden on a flattened view overlay — the lean PUT /api/v1/meta/view/:name body ' + + 'with no config that the console saves for a view it personalizes', + replacement: + '(removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone ' + + 'who can read its object. A view that must not be listed is deleted, or no longer shipped from ' + + 'source; per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism.', + reason: + 'The D2 conversion `view-overlay-owner-hidden-removed` deletes both keys from every flattened ' + + 'overlay (a view body with no `config` and no container slot) — in `views` (stack sources, and ' + + 'every stored row, replayed on each read before it is served or badged) and in the ' + + 'assembled-manifest view item channel — and the delete is lossless: both switcher read paths ' + + 'filter on the view kind and object and sort on `order`, so an overlay saved with ' + + '`hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure, ' + + 'the same one the view item record\'s retirement leaves. A view someone hid or marked as one ' + + 'user\'s through its overlay has always been listed to every user who can read the object. ' + + 'The delete is lossless but does not always close the row: an overlay row that held nothing ' + + 'but its identity and these keys is left identity-only, a body the view door refuses, so ' + + 'that row needs its author (see the acceptance criteria). ' + + 'Measured writers in this repository and its sibling UI: zero (no source, example or skill, ' + + 'and objectui at its pinned commit and at main writes neither key on an overlay; the HotCRM ' + + 'app writes neither). NOT MEASURED: clients outside this repository, and production stored ' + + 'rows — the write door accepted and stored both until this release, and no deployment store ' + + 'is reachable from here.', + acceptanceCriteria: + 'No flattened view overlay you save carries `owner` or `hidden`: the write door refuses either ' + + 'with 422 INVALID_METADATA, the issue located at the key and the retirement prescription as ' + + 'its message. A stored overlay row that held either is stripped of it on every read, and what ' + + 'follows depends on what else the row holds. (1) A row with any other view key (a column ' + + 'state, a sort, a default flag, an order) is served and badged valid without the keys, a GET ' + + 'then a PUT of the whole row answers 200 (if it was otherwise valid), and ' + + '`os migrate meta --stored --apply` rewrites it. (2) A hide-only row — nothing but its ' + + 'identity (name, object, viewKind, label) and `owner` / `hidden`, such as ' + + '`{ object, viewKind, hidden: true }` — is left with identity only, which the view door ' + + 'refuses ("only identity fields"): it is served badged invalid (it was badged valid before ' + + 'this release), a whole-row re-save or one that adds only identity answers 422 ' + + 'INVALID_METADATA, and `--apply` reports it `failed` and leaves it as stored. A write that ' + + 'adds a real view key, such as a toolbar toggle, saves. Resolve each such row: delete it (it ' + + 'never changed what anyone saw), or add the personalization setting its author meant and ' + + 'save that. For every view whose overlay had carried either key, its author has confirmed ' + + 'that the view may be listed to all readers of its object, or has deleted it. No switcher ' + + 'read path ever read either key, so which views the switcher lists does not change.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 56ac04f573f..826dd727c97 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5324,8 +5324,7 @@ const step18: MigrationStep = { + 'as a lossless delete, in both collections a record travels in — `views` (stack sources ' + 'and stored rows) and the assembled-manifest `viewItems` channel (package export, ' + 'environment artifacts), whose registration parse would otherwise refuse an artifact ' - + 'assembled before this release; a flattened overlay keeps its own `owner` / `hidden`, ' - + 'which are declared on a different door this retirement does not touch. ' + + 'assembled before this release. ' + 'It also retires a `joined` report\'s `chart` at both coordinates (#20161, ADR-0049 ' + 'enforce-or-remove): the joined renderer draws each block as a table and returns before ' + 'the one container `chart` read, and no renderer reads a block\'s `chart` at all, so a ' @@ -5336,7 +5335,21 @@ const step18: MigrationStep = { + '`report-joined-chart-removed` strips both as a pure lossless delete — neither ever had ' + 'an effect to lose — because a stored report row CAN carry them (the Studio report form ' + 'offered a block `chart` input until this change); it is retired from the load path, so ' - + 'authors are refused at parse rather than rewritten.', + + 'authors are refused at parse rather than rewritten. ' + + 'It retires the view item\'s `owner` / `hidden` pair on the flattened overlay door too ' + + '(#20230, ADR-0049; triage ' + + 'direction 「follow #20085\'s disposition for the same key pair」): the lean personalization ' + + 'PUT with no `config` declared its own `owner` / `hidden`, accepted and stored them, and ' + + 'nothing read either. Both are `retiredKey()` tombstones on the two overlay members with ' + + 'the view item\'s own prescription texts, and the D2 conversion ' + + '`view-overlay-owner-hidden-removed` strips them from the flattened spelling (no `config`, ' + + 'no container slot) in `views` and `viewItems`, so a stored overlay row is served without ' + + 'them. A row that held other view keys is then valid again and re-saves; a row that held ' + + 'nothing but its identity and the two keys is left identity-only, which the door refuses, ' + + 'so it is badged invalid, refused on a whole-row re-save and reported `failed` by ' + + '`os migrate meta --stored --apply` until it is deleted or given the setting its author ' + + 'meant. Its D3 record is the semantic entry ' + + '`view-overlay-owner-hidden-retired`.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5375,6 +5388,7 @@ const step18: MigrationStep = { 'page-component-filter-record-to-rule-array', 'view-item-owner-hidden-removed', 'report-joined-chart-removed', + 'view-overlay-owner-hidden-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -15903,8 +15917,9 @@ const step18: MigrationStep = { + 'A view an author marked as one user\'s, or hid from the switcher, has always been listed to ' + 'every user who can read the object — its name, its columns, its filters and its sort. ' + 'Whether anything in such a view was meant to stay private, and whether it should now be ' - + 'deleted rather than kept, is the author\'s call. A flattened view overlay keeps its own ' - + '`owner` and `hidden`: those live on a different door that this retirement does not touch.', + + 'deleted rather than kept, is the author\'s call. A flattened view overlay\'s own `owner` and ' + + '`hidden` are a separate family on a different door, with their own D2 conversion ' + + '`view-overlay-owner-hidden-removed` and their own D3 entry `view-overlay-owner-hidden-retired`.', acceptanceCriteria: 'No view item record in `views` or in an assembled artifact carries `owner` or ' + '`hidden`; the parse refuses both by name, and an artifact assembled before the upgrade ' + 'registers without a refusal over them. For every view that had carried either key, the ' @@ -15994,6 +16009,61 @@ const step18: MigrationStep = { + 'saved. Verify by re-saving each stored overlay that carries `options` (a GET then a PUT of the ' + 'same body) and reading a `200`.', }, + // #20230 (ADR-0049 enforce-or-remove; triage direction 「follow #20085's + // disposition for the same key pair」) — the D3 entry of the + // `view-overlay-owner-hidden-removed` family (ruling B on #17152: one D3 entry + // per retirement family, even when D2 is lossless). Registered keys: `owner` / + // `hidden` on `ui/ViewMetadata`, the door the two flattened overlay members are + // reached through. The same key pair on the view item RECORD is a separate + // family with its own conversion and its own D3 entry, disjoint from this one + // by `config`; the two share the prescription texts, not a conversion. + { + id: 'view-overlay-owner-hidden-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'view.owner / view.hidden on a flattened view overlay — the lean PUT /api/v1/meta/view/:name body ' + + 'with no config that the console saves for a view it personalizes', + replacement: + '(removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone ' + + 'who can read its object. A view that must not be listed is deleted, or no longer shipped from ' + + 'source; per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism.', + reason: + 'The D2 conversion `view-overlay-owner-hidden-removed` deletes both keys from every flattened ' + + 'overlay (a view body with no `config` and no container slot) — in `views` (stack sources, and ' + + 'every stored row, replayed on each read before it is served or badged) and in the ' + + 'assembled-manifest view item channel — and the delete is lossless: both switcher read paths ' + + 'filter on the view kind and object and sort on `order`, so an overlay saved with ' + + '`hidden: true` hid nothing and no scope ever read `owner`. The judgment is about exposure, ' + + 'the same one the view item record\'s retirement leaves. A view someone hid or marked as one ' + + 'user\'s through its overlay has always been listed to every user who can read the object. ' + + 'The delete is lossless but does not always close the row: an overlay row that held nothing ' + + 'but its identity and these keys is left identity-only, a body the view door refuses, so ' + + 'that row needs its author (see the acceptance criteria). ' + + 'Measured writers in this repository and its sibling UI: zero (no source, example or skill, ' + + 'and objectui at its pinned commit and at main writes neither key on an overlay; the HotCRM ' + + 'app writes neither). NOT MEASURED: clients outside this repository, and production stored ' + + 'rows — the write door accepted and stored both until this release, and no deployment store ' + + 'is reachable from here.', + acceptanceCriteria: + 'No flattened view overlay you save carries `owner` or `hidden`: the write door refuses either ' + + 'with 422 INVALID_METADATA, the issue located at the key and the retirement prescription as ' + + 'its message. A stored overlay row that held either is stripped of it on every read, and what ' + + 'follows depends on what else the row holds. (1) A row with any other view key (a column ' + + 'state, a sort, a default flag, an order) is served and badged valid without the keys, a GET ' + + 'then a PUT of the whole row answers 200 (if it was otherwise valid), and ' + + '`os migrate meta --stored --apply` rewrites it. (2) A hide-only row — nothing but its ' + + 'identity (name, object, viewKind, label) and `owner` / `hidden`, such as ' + + '`{ object, viewKind, hidden: true }` — is left with identity only, which the view door ' + + 'refuses ("only identity fields"): it is served badged invalid (it was badged valid before ' + + 'this release), a whole-row re-save or one that adds only identity answers 422 ' + + 'INVALID_METADATA, and `--apply` reports it `failed` and leaves it as stored. A write that ' + + 'adds a real view key, such as a toolbar toggle, saves. Resolve each such row: delete it (it ' + + 'never changed what anyone saw), or add the personalization setting its author meant and ' + + 'save that. For every view whose overlay had carried either key, its author has confirmed ' + + 'that the view may be listed to all readers of its object, or has deleted it. No switcher ' + + 'read path ever read either key, so which views the switcher lists does not change.', + }, // The display page size a view gets when it declares none moved from 25 to 50 // (maintainer ruling on objectui#9853). A default move reaches every silent // document with no parse error and nothing in the author's diff, so the @@ -19891,6 +19961,32 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `properties`, so no gate judges this row. // D2: `view-item-owner-hidden-removed`. 'ui/ViewItemWire:owner', + // #20230 (ADR-0049 enforce-or-remove; triage direction 「follow #20085's + // disposition for the same key pair」). The flattened view overlay's `hidden` — + // the lean personalization PUT with no `config`, members 3 and 4 of the `view` + // union `saveMetaItem` validates — was declared by `flattenedViewOverlayFields()` + // separately from the view item's, accepted, stored verbatim, and read by + // nothing: both switcher read paths filter on `viewKind` + `object` only, so a + // `hidden: true` overlay hid no view. No writer in this framework, its examples, + // objectui at its pin and at `main`, or the HotCRM app (cloud not reachable). + // Tombstoned with `retiredKey()` on both overlay members, with the view item's + // own prescription text; `.strip()` members, so a bare deletion would have + // dropped the key in silence. Registered under `ui/ViewMetadata`, the exported + // door the overlay members are reached through (the members themselves are not + // exported). ⚠️ No gate below can JUDGE this row: `ui/ViewMetadata` is in + // `unemitted-schemas.baseline.json` (its `config: z.undefined()` guards have no + // JSON Schema form), so `authorable-surface/` carries no `ui/ViewMetadata:*` + // line and check (b) never sees the tombstone — the row is declared, not + // checked. D2: `view-overlay-owner-hidden-removed`. + 'ui/ViewMetadata:hidden', + // #20230 — the overlay door's `owner`, the sibling of `ui/ViewMetadata:hidden` + // (see that row for the measurement and the registration's def key). It named + // a user nothing ever read: no per-user scope exists for a view (ADR-0017, + // parked), so an overlay marked as one user's changed nothing for anyone. + // Tombstoned with `retiredKey()` on both overlay members, with the view item's + // own prescription text. Same blind spot as its sibling: the def is unemitted, + // so no gate judges this row. D2: `view-overlay-owner-hidden-removed`. + 'ui/ViewMetadata:owner', // ], }; diff --git a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts index 0104a0ace01..6e06345acdf 100644 --- a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts +++ b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts @@ -19,9 +19,10 @@ * would be a silent strip (ADR-0104). Every door that carries a ViewItem * record therefore refuses, with the prescription. * 2. The flattened-overlay members declare their OWN `owner` / `hidden` - * (`flattenedViewOverlayFields()`) on a different door this retirement - * does not touch. Pinned as a BOUNDARY, so a later reader does not read - * the overlay's acceptance as a half-done retirement. + * (`flattenedViewOverlayFields()`) on a different door. [#20230] That + * door's pair is retired too, with these same texts, and its full pin set + * is `view-overlay-owner-hidden-retirement.test.ts`; the BOUNDARY pin + * below moved with it, from "still parses" to "refused, same text". * 3. D2 conversion `view-item-owner-hidden-removed` (step 18), scoped to the * record spelling, reaching both collections a record travels in: `views` * (stack sources, and the stored-row seam's `{ views: [row] }`) and the @@ -156,12 +157,18 @@ describe('view item owner/hidden retirement — the tombstones, at every door th } }); - it('BOUNDARY: a flattened overlay (no `config`) still declares its own `owner` / `hidden` — a different door', () => { - // Not a half-done retirement: the overlay members are a lean - // personalization PUT, declared separately (`flattenedViewOverlayFields()`), - // and outside this card. If they are retired later, this pin moves with them. - const overlay = { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'form', hidden: true, owner: 'usr_7' }; - expect(ViewMetadataSchema.safeParse(overlay).success).toBe(true); + it('BOUNDARY, moved: a flattened overlay (no `config`) is refused with the SAME prescription — the other door, retired too', () => { + // [#20230] The overlay members declared their own `owner` / `hidden` + // (`flattenedViewOverlayFields()`), a lean personalization PUT outside + // #20085. They are now tombstoned with this file's texts; the overlay + // door's full pin set lives in `view-overlay-owner-hidden-retirement.test.ts`. + for (const [key, value, prescription] of RETIRED) { + const overlay = { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'form', [key]: value }; + const r = ViewMetadataSchema.safeParse(overlay); + expect(r.success, `an overlay carrying \`${key}\``).toBe(false); + if (r.success) continue; + expect(r.error.issues[0]!.message).toMatch(prescription); + } }); it('fails tsc at the authoring site: the input type of both keys is `never`', () => { @@ -200,12 +207,13 @@ describe('view item owner/hidden retirement — the D2 conversion', () => { expect(ViewMetadataSchema.safeParse(rehydrated).success).toBe(true); }); - it('reaches the assembled-manifest `viewItems` channel, and leaves overlays and containers alone', () => { + it('reaches the assembled-manifest `viewItems` channel, leaves overlays to their own entry and containers alone', () => { const { stack, notices } = collectConversionNotices( { viewItems: [ { ...RECORD, hidden: false }, - // A flattened overlay in the same channel: its door still declares `hidden`. + // A flattened overlay in the same channel. [#20230] Its `hidden` is + // retired too, and stripped by the OVERLAY entry, never by this one. { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list', hidden: true }, ], // A container carries neither key and has no top-level `config`: untouched. @@ -213,12 +221,14 @@ describe('view item owner/hidden retirement — the D2 conversion', () => { }, { includeRetired: true }, ); - expect(notices.map((n) => n.path)).toEqual(['viewItems[0].hidden']); - expect(notices.every((n) => n.conversionId === 'view-item-owner-hidden-removed')).toBe(true); + expect(notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['view-item-owner-hidden-removed', 'viewItems[0].hidden'], + ['view-overlay-owner-hidden-removed', 'viewItems[1].hidden'], + ]); expect(stack).toEqual({ viewItems: [ RECORD, - { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list', hidden: true }, + { name: 'crm_lead.pipeline', object: 'crm_lead', viewKind: 'list' }, ], views: [{ object: 'crm_lead', list: { type: 'grid', columns: ['name'] } }], }); @@ -262,14 +272,15 @@ describe('view item owner/hidden retirement — ADR-0087 registration', () => { // ⭐ `owner` and `hidden` are among the commonest key names in this tree (field // `hidden`, app `hidden`, column `hidden`, record `owner` …), so a textual // matcher would be all noise. The matcher is STRUCTURAL instead: an offender is -// one object literal (or one YAML mapping) whose OWN keys include `viewKind`, -// `config` and `owner` or `hidden` — the ViewItem record spelling, and nothing -// else. A flattened overlay (no `config`) is the other door and is not matched. +// one object literal (or one YAML mapping) whose OWN keys include `viewKind` +// and `owner` or `hidden` — the ViewItem record spelling (with `config`) and, +// since #20230, the flattened overlay spelling (without it): both doors of the +// family. A container never carries `viewKind`, so it is not matched. // // The bound, stated: a record assembled by SPREAD (`{ ...record, hidden: true }`) // or computed keys is invisible to a text walk; `docs/**`, `.claude/**`, // `.github/**` and the repo-root files are outside the radius. -describe('tree-scoped absence: no ViewItem record inside the declared radius still carries owner/hidden', () => { +describe('tree-scoped absence: no ViewItem record or flattened overlay inside the declared radius still carries owner/hidden', () => { const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); @@ -288,11 +299,13 @@ describe('tree-scoped absence: no ViewItem record inside the declared radius sti * spell the retired keys on a record. */ const EXCLUDED = new Set([ - // The tombstone itself — and the flattened-overlay door's own shape, whose - // `config: z.undefined()` guard sits beside that door's `owner` / `hidden` - // (schema source, not an authoring; measured as the one hit before this - // exclusion). Its examples live in doc comments, which the lexer skips. + // The tombstones themselves — both doors' shapes (schema source, not an + // authoring; the overlay door's `config: z.undefined()` guard sits beside + // its tombstones, measured as the one hit before this exclusion). Its + // examples live in doc comments, which the lexer skips. 'packages/spec/src/ui/view.zod.ts', + // [#20230] The overlay door's own pins author the retired keys on purpose. + 'packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts', // This pin names the keys to assert their absence. THIS_FILE, ]); @@ -311,7 +324,7 @@ describe('tree-scoped absence: no ViewItem record inside the declared radius sti const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; const isOffendingKeySet = (keys: Set): boolean => - keys.has('viewKind') && keys.has('config') && RETIRED_KEYS.some((k) => keys.has(k)); + keys.has('viewKind') && RETIRED_KEYS.some((k) => keys.has(k)); /** * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame @@ -454,9 +467,12 @@ describe('tree-scoped absence: no ViewItem record inside the declared radius sti expect(offendersIn('.yaml', 'views:\n - name: a.b\n viewKind: list\n config:\n type: grid\n hidden: true\n')).toEqual([3]); expect(offendersIn('.md', "Prose.\n\n```ts\nsave({ viewKind: 'list', config: {}, owner: 'u1' });\n```\n")).toEqual([4]); expect(offendersIn('.md', 'Prose.\n\n```yaml\nviewKind: list\nconfig: {}\nhidden: true\n```\n')).toEqual([4]); + // [#20230] A flattened overlay — no `config`, the other door of the family, retired too. + expect(offendersIn('.ts', "put({ name: 'a.b', object: 'a', viewKind: 'list', hidden: true })")).toEqual([1]); + expect(offendersIn('.yaml', '- name: a.b\n object: a\n viewKind: form\n owner: u1\n')).toEqual([3]); // Neighbours that must NOT match. - // A flattened overlay — no `config`, the other door. - expect(offendersIn('.ts', "put({ name: 'a.b', object: 'a', viewKind: 'list', hidden: true })")).toEqual([]); + // A container: no `viewKind` of its own, and the keys on a nested slot. + expect(offendersIn('.ts', "defineView({ list: { type: 'grid', columns: [{ field: 'x', hidden: true }] } })")).toEqual([]); // A record without the keys; the keys on a NESTED object inside `config`. expect(offendersIn('.ts', "({ viewKind: 'list', config: { columns: [{ field: 'x', hidden: true }] } })")).toEqual([]); // A `sys_view_definition` row: `view_kind`, and `viewKind` only read off an object. @@ -479,7 +495,7 @@ describe('tree-scoped absence: no ViewItem record inside the declared radius sti expect(vanished.length).toBe(before + 1); }); - it('no ViewItem record carrying owner/hidden survives inside the declared radius', () => { + it('no ViewItem record or flattened overlay carrying owner/hidden survives inside the declared radius', () => { const offenders: string[] = []; let visited = 0; let recordBearing = 0; @@ -510,6 +526,6 @@ describe('tree-scoped absence: no ViewItem record inside the declared radius sti // record were really judged. expect(visited).toBeGreaterThan(1000); expect(recordBearing).toBeGreaterThan(50); - expect(offenders, 'a ViewItem record carrying `owner`/`hidden` means the retirement is being undone').toEqual([]); + expect(offenders, 'a view record or overlay carrying `owner`/`hidden` means the retirement is being undone').toEqual([]); }); }); diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts index 776bb4ac605..0dfa94d2b4c 100644 --- a/packages/spec/src/ui/view-metadata-schema.test.ts +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -272,13 +272,27 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha ['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 hide PUT', { hidden: true, object: 'crm_lead', viewKind: 'form' }], ['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) => { expect(ViewMetadataSchema.safeParse(body).success).toBe(true); }); + // [#20230] The hide PUT left the list above: the overlay's `hidden` is a + // retired key (ADR-0049), read by nothing and written by no platform + // surface. It is still DECLARED — a tombstone — so the precondition stays + // inert on it, and the refusal belongs to the overlay member, carrying the + // retirement prescription at the key. + it('REFUSES a hide PUT on a real view at the member, with the retirement prescription', () => { + const bound = { object: 'crm_lead', viewKind: 'form' } as const; + const r = ViewMetadataSchema.safeParse({ ...bound, hidden: true }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues[0]!.code).toBe('invalid_union'); + expect(r.error.issues[0]!.message).toMatch(/^`view\.hidden` was removed in @objectstack\/spec 17\.5\.0/); + expect(JSON.stringify(r.error.issues)).not.toContain('Not a `view` body'); + }); + // [#7741] The same shapes with NO baseline to inherit identity from are now // refused — that save would mint an unservable row badged `valid: true`, // the exact receipt the ruling forbids. What this block pins is the @@ -342,8 +356,12 @@ describe('ViewMetadataSchema — genuine validation across the three runtime sha it('…but identity PLUS any real view key is fine — leanness is not the bar', () => { // [#7741] "identity" here means the FULL binding pair: `object` alone or // `viewKind` alone is half a binding and the members refuse it now. - expect(ViewMetadataSchema.safeParse({ name: 'v', object: 'o', viewKind: 'form', hidden: true }).success).toBe(true); + expect(ViewMetadataSchema.safeParse({ name: 'v', object: 'o', viewKind: 'form', order: 2 }).success).toBe(true); expect(ViewMetadataSchema.safeParse({ name: 'v', object: 'o', viewKind: 'list', isPinned: true }).success).toBe(true); + // [#20230] …a RETIRED key is not a real view key: `hidden` used to stand + // in the first line above, and the same identity plus it is refused now. + const identity = { name: 'v', object: 'o', viewKind: 'form' } as const; + expect(ViewMetadataSchema.safeParse({ ...identity, hidden: true }).success).toBe(false); }); it('leaves non-objects to the union — it judges objects only', () => { diff --git a/packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts b/packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts new file mode 100644 index 00000000000..abfedb476a0 --- /dev/null +++ b/packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts @@ -0,0 +1,356 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The flattened view overlay's `owner` / `hidden` RETIRED (#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 — declared both keys in + * `flattenedViewOverlayFields()`, separately from the view item's pair that + * #20085 retired. It accepted them, the write door stored them verbatim, and + * nothing read either. Writer census before removal (recorded beside the + * prescriptions in `view.zod.ts`): none in this framework or its examples, in + * objectui at its pin and at `main`, or in the HotCRM app. + * + * Bookkeeping shapes, pinned below: + * 1. Both keys are `retiredKey()` tombstones on the two overlay members, with + * the view item's OWN prescription texts — one family, one text. Both + * members `.strip()`, so a bare deletion would drop the key in silence + * (ADR-0104); the tombstone makes every door that parses an overlay refuse. + * 2. D2 conversion `view-overlay-owner-hidden-removed` (step 18), scoped to + * the FLATTENED spelling and disjoint from the view item's entry by + * `config`, reaching `views` (stack sources, stored rows) and `viewItems` + * (the assembled-manifest channel). + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/ViewMetadata:*` — declared, not + * judged: `ui/ViewMetadata` is unemitted, so no build gate sees the rows. + * 4. The family's one D3 semantic entry, `view-overlay-owner-hidden-retired` + * (ruling B on #17152), naming its D2 conversion by id. The view item + * record's pair is a separate family with its own conversion and its own + * D3 entry; the two share the prescription texts, not a record. + * + * `defineView` is NOT an overlay door: it parses the strict container + * (`ViewSchema`), which declares neither key and refuses an overlay-shaped + * body as a container — pinned below as the control that no `define*` factory + * reaches the overlay members. The save door's ADR-0112 envelope (`code` + + * `status`) is pinned where it is produced, in `@objectstack/metadata-protocol` + * (`protocol.save-union-issues.test.ts`); a schema refusal here is a + * `ZodError`, whose issues carry `code` and `path` but no `status`. + */ + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { AssembledViewArtifactSchema } from './assembled-views.zod'; +import { + VIEW_METADATA_MEMBERS, + ViewItemWireSchema, + ViewMetadataSchema, + defineView, +} from './view.zod'; + +/** A bound flattened LIST overlay — the shape the console's toolbar saves, neither retired key. */ +const LIST_OVERLAY = { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list' } as const; +/** A bound flattened FORM overlay. */ +const FORM_OVERLAY = { name: 'crm_lead.edit', object: 'crm_lead', viewKind: 'form' } as const; +/** A well-formed ViewItem record — the other door of the same family. */ +const RECORD = { + name: 'crm_lead.my_hot_leads', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', columns: ['name'] }, +} as const; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const OWNER_PRESCRIPTION = /`view\.owner` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049.*Delete the key\..*`os migrate meta --from 17`/s; +const HIDDEN_PRESCRIPTION = /`view\.hidden` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049.*Delete the key;.*`os migrate meta --from 17`/s; + +const RETIRED = [ + ['owner', 'usr_7', OWNER_PRESCRIPTION], + ['hidden', true, HIDDEN_PRESCRIPTION], +] as const; + +const MEMBERS = [ + ['listOverlay', VIEW_METADATA_MEMBERS.listOverlay, LIST_OVERLAY], + ['formOverlay', VIEW_METADATA_MEMBERS.formOverlay, FORM_OVERLAY], +] as const; + +type Issue = { code: string; path: PropertyKey[]; message: string; errors?: Issue[][] }; + +describe('overlay owner/hidden retirement — the tombstones, at every door that parses an overlay', () => { + for (const [key, value, prescription] of RETIRED) { + for (const [member, schema, overlay] of MEMBERS) { + it(`the ${member} member refuses \`${key}\` at its path instead of stripping it`, () => { + // ⭐ The half a bare deletion would have lost: both members `.strip()`. + const r = schema.safeParse({ ...overlay, [key]: value }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === key); + expect(issue, `the refusal must name \`${key}\``).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual([key]); + expect(issue!.message).toMatch(prescription); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith(`\`view.${key}\` was removed`)).toBe(true); + }); + } + + it(`the overlay's \`${key}\` text IS the view item's — one family, one prescription`, () => { + const onOverlay = VIEW_METADATA_MEMBERS.listOverlay.safeParse({ ...LIST_OVERLAY, [key]: value }); + const onRecord = ViewItemWireSchema.safeParse({ ...RECORD, [key]: value }); + expect(onOverlay.success || onRecord.success).toBe(false); + if (onOverlay.success || onRecord.success) return; + const a = onOverlay.error.issues.find((i) => i.path[0] === key)!.message; + const b = onRecord.error.issues.find((i) => i.path[0] === key)!.message; + expect(a).toBe(b); + }); + + it(`the \`view\` write door (the registry binding) refuses a bound overlay carrying \`${key}\``, () => { + // `getMetadataTypeSchema('view')` is what `saveMetaItem` validates a + // `PUT /api/v1/meta/view/:name` body against. + const door = getMetadataTypeSchema('view'); + expect(door).toBe(ViewMetadataSchema); + for (const overlay of [LIST_OVERLAY, FORM_OVERLAY]) { + const r = door!.safeParse({ ...overlay, [key]: value }); + expect(r.success, `${overlay.viewKind} overlay`).toBe(false); + if (r.success) continue; + const top = r.error.issues[0] as unknown as Issue; + expect(top.code).toBe('invalid_union'); + // The claimed member's own message surfaces on the union… + expect(top.message).toMatch(prescription); + // …and that member's issue still locates the key. + const located = (top.errors ?? []).flat().find((i) => i.path[0] === key); + expect(located, 'the claimed overlay member must locate the key').toBeDefined(); + expect(located!.code).toBe('invalid_type'); + expect(located!.message).toMatch(prescription); + } + }); + + it(`the assembled-manifest channel refuses an overlay carrying \`${key}\``, () => { + // Built from the same members, so it refuses too — which is why the D2 + // conversion must reach `viewItems` (pinned below). + expect(AssembledViewArtifactSchema.safeParse({ ...LIST_OVERLAY, [key]: value }).success).toBe(false); + expect(AssembledViewArtifactSchema.safeParse({ ...FORM_OVERLAY, [key]: value }).success).toBe(false); + }); + } + + it('CONTROL: the same overlays without the keys pass every door, live round-trip keys intact', () => { + const live = { isDefault: true, order: 2, scope: 'shared', label: 'Leads' } as const; + for (const [label, schema, body] of [ + ['listOverlay member', VIEW_METADATA_MEMBERS.listOverlay, { ...LIST_OVERLAY, ...live }], + ['formOverlay member', VIEW_METADATA_MEMBERS.formOverlay, { ...FORM_OVERLAY, ...live }], + ['door (list)', ViewMetadataSchema, { ...LIST_OVERLAY, ...live }], + ['door (form)', ViewMetadataSchema, { ...FORM_OVERLAY, ...live }], + ['assembled (list)', AssembledViewArtifactSchema, { ...LIST_OVERLAY, ...live }], + ] as const) { + const r = schema.safeParse(body); + expect(r.success, `${label} must accept the overlay`).toBe(true); + if (!r.success) continue; + const data = r.data as Record; + // The strip path: absence stays absence. + expect(data, `${label} grows no \`owner\``).not.toHaveProperty('owner'); + expect(data, `${label} grows no \`hidden\``).not.toHaveProperty('hidden'); + // The live neighbours are untouched by the tombstones. + expect(data.isDefault, `${label} keeps \`isDefault\``).toBe(true); + expect(data.order, `${label} keeps \`order\``).toBe(2); + expect(data.scope, `${label} keeps \`scope\``).toBe('shared'); + } + }); + + it('CONTROL: the view item door refuses the pair too — the family is closed on both doors', () => { + for (const [key, value, prescription] of RETIRED) { + const r = ViewMetadataSchema.safeParse({ ...RECORD, [key]: value }); + expect(r.success).toBe(false); + if (r.success) continue; + expect(r.error.issues[0]!.message).toMatch(prescription); + } + expect(ViewMetadataSchema.safeParse(RECORD).success).toBe(true); + }); + + it('CONTROL: `defineView` is the container door, not an overlay door — it never reached these members', () => { + // An overlay-shaped body is not a container: the strict `ViewSchema` names + // `object`/`viewKind`/`hidden` as unknown keys. Pre-existing, and the reason + // no `define*` factory needed a tombstone for this retirement. + expect(() => defineView({ ...LIST_OVERLAY, hidden: true } as never)).toThrow(/hidden/); + expect(() => defineView({ ...LIST_OVERLAY } as never)).toThrow(); + }); +}); + +describe('overlay owner/hidden retirement — the D2 conversion', () => { + it('a STORED overlay row carrying the keys rehydrates clean, then parses at the door', () => { + // Every read of a stored `view` row replays the chain as `{ views: [row] }` + // before the row is served or badged (`convertStoredItem`). + const stored = { ...FORM_OVERLAY, label: 'Edit lead', isDefault: true, owner: 'usr_7', hidden: true }; + const notices: { conversionId?: string; path?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('view', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), + }) as Record; + + expect(notices.map((n) => n.conversionId)).toEqual([ + 'view-overlay-owner-hidden-removed', + 'view-overlay-owner-hidden-removed', + ]); + expect(notices.map((n) => n.path)).toEqual(['views[0].owner', 'views[0].hidden']); + expect(rehydrated).not.toHaveProperty('owner'); + expect(rehydrated).not.toHaveProperty('hidden'); + // CONTROL: the live round-trip keys on the same row survive. + expect(rehydrated.isDefault).toBe(true); + expect(rehydrated.label).toBe('Edit lead'); + // …and, because this row carries content (`isDefault`), the rehydrated row + // is what the write door accepts now, so a whole-row re-save of it saves. + // The row that carries NO content is the residue pinned below. + expect(ViewMetadataSchema.safeParse(rehydrated).success).toBe(true); + // CONTROL: the stored row itself, unconverted, is what the door now refuses. + expect(ViewMetadataSchema.safeParse(stored).success).toBe(false); + }); + + it('strips a flat row that has no `viewKind` yet — the write path heals that in, then would refuse the key', () => { + const { stack, notices } = collectConversionNotices( + { views: [{ name: 'crm_lead.all', type: 'grid', columns: ['name'], hidden: true }] }, + { includeRetired: true }, + ); + expect(notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['view-overlay-owner-hidden-removed', 'views[0].hidden'], + ]); + expect(stack).toEqual({ views: [{ name: 'crm_lead.all', type: 'grid', columns: ['name'] }] }); + }); + + it('reaches `viewItems`, is disjoint from the record entry by `config`, and leaves containers alone', () => { + const { stack, notices } = collectConversionNotices( + { + viewItems: [ + { ...LIST_OVERLAY, hidden: false }, + // A record in the same channel: the view item's entry strips it. + { ...RECORD, owner: 'usr_7' }, + ], + // A container carries neither key and has no top-level overlay body. + views: [{ object: 'crm_lead', list: { type: 'grid', columns: ['name'] } }], + }, + { includeRetired: true }, + ); + // Each door's key is stripped by that door's own entry — never by both. + expect(notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['view-item-owner-hidden-removed', 'viewItems[1].owner'], + ['view-overlay-owner-hidden-removed', 'viewItems[0].hidden'], + ]); + expect(stack).toEqual({ + viewItems: [LIST_OVERLAY, RECORD], + views: [{ object: 'crm_lead', list: { type: 'grid', columns: ['name'] } }], + }); + // Both converted entries parse through the assembled channel they travel in. + for (const entry of stack.viewItems as unknown[]) { + expect(AssembledViewArtifactSchema.safeParse(entry).success).toBe(true); + } + + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference (copy-on-write). + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const { stack, notices } = collectConversionNotices({ views: [{ ...LIST_OVERLAY, hidden: true }] }); + expect(notices).toHaveLength(0); + expect(stack).toEqual({ views: [{ ...LIST_OVERLAY, hidden: true }] }); + }); +}); + +/** + * The hide-only residue — the one stored class the strip cannot bring back + * into the accept set, stated in the D2 docblock, the D3 acceptance criteria + * and the changeset, and pinned here so those sentences stay true. + * + * The card's own measured shape, `{ object, viewKind, hidden: true }` (plus the + * `name` the write path stamps), holds nothing but identity and a retired key. + * The strip leaves IDENTITY ONLY, and the `view` door's identity precondition + * (#5599 / #7741 — `assertViewIdentity`) refuses a body that says which view it + * attaches to and nothing about what the view is. So the row is served badged + * invalid, a whole-row re-save is refused (the save-door half of this pin is + * in `@objectstack/metadata-protocol`'s `protocol.save-union-issues.test.ts`), + * and `os migrate meta --stored --apply` reports it `failed`. Remedy: delete + * the row, or add the personalization setting its author meant. + */ +describe('overlay owner/hidden retirement — the hide-only residue', () => { + const IDENTITY = { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list' } as const; + + for (const [label, retired] of [ + ['hidden', { hidden: true }], + ['owner', { owner: 'usr_7' }], + ['both', { hidden: true, owner: 'usr_7' }], + ] as const) { + it(`a stored hide-only row (${label}) strips to identity only, which the door refuses as "only identity fields"`, () => { + const stored = { ...IDENTITY, ...retired }; + const notices: { conversionId?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('view', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string }), + }); + // The strip itself is exact: identity, and nothing else. + expect(rehydrated).toEqual(IDENTITY); + expect(notices.length).toBe(Object.keys(retired).length); + expect(notices.every((n) => n.conversionId === 'view-overlay-owner-hidden-removed')).toBe(true); + + // …and identity alone is not a `view` body: the precondition's own words, + // one `custom` issue at the root — NOT the retirement prescription, which + // has nothing left to locate. + const r = ViewMetadataSchema.safeParse(rehydrated); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.code).toBe('custom'); + expect(r.error.issues[0]!.path).toEqual([]); + expect(r.error.issues[0]!.message).toContain('Not a `view` body'); + expect(r.error.issues[0]!.message).toContain('only identity fields'); + expect(r.error.issues[0]!.message).toContain('the write path stamps them itself'); + expect(r.error.issues[0]!.message).not.toMatch(/was removed in @objectstack\/spec/); + }); + } + + it('a rename adds only identity (`label`), so it is refused too', () => { + const r = ViewMetadataSchema.safeParse({ ...IDENTITY, label: 'All leads' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues[0]!.message).toContain('only identity fields'); + }); + + it('CONTROL: a write that adds a real view key (a toolbar toggle) saves — the residue is the identity-only body, not the row', () => { + for (const toggle of [{ isDefault: true }, { order: 2 }, { columnState: { widths: { name: 120 } } }]) { + expect(ViewMetadataSchema.safeParse({ ...IDENTITY, ...toggle }).success, JSON.stringify(toggle)).toBe(true); + } + }); +}); + +describe('overlay owner/hidden retirement — ADR-0087 registration', () => { + it('declares both keys on the overlay door under major 18, with the D2 entry in the step-18 chain', () => { + for (const key of ['ui/ViewMetadata:owner', 'ui/ViewMetadata:hidden']) { + expect(RETIRED_KEYS_BY_MAJOR[18], key).toContain(key); + } + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain('view-overlay-owner-hidden-removed'); + }); + + it('the family carries ONE D3 semantic entry, and it names the family\'s D2 conversion (ruling B on #17152)', () => { + const semantic = MIGRATIONS_BY_MAJOR[18]!.semantic; + const family = semantic.filter((s) => s.id === 'view-overlay-owner-hidden-retired'); + expect(family).toHaveLength(1); + const entry = family[0]!; + // Its door, its conversion, the measured zero and the unmeasured population. + expect(entry.surface).toContain('flattened view overlay'); + expect(entry.reason).toContain('`view-overlay-owner-hidden-removed`'); + expect(entry.reason).toContain('Measured writers in this repository and its sibling UI: zero'); + expect(entry.reason).toContain('NOT MEASURED'); + expect(entry.acceptanceCriteria.length).toBeGreaterThan(0); + // One family, one record. Another entry may NAME this conversion only as a + // cross-reference that points at this record (the view item family's D3 + // entry does, to say the overlay pair is a separate family) — never as a + // second record of its own. + const naming = semantic.filter((s) => JSON.stringify(s).includes('view-overlay-owner-hidden-removed')); + expect(naming.map((s) => s.id)).toContain('view-overlay-owner-hidden-retired'); + for (const other of naming.filter((s) => s.id !== 'view-overlay-owner-hidden-retired')) { + expect(JSON.stringify(other), other.id).toContain('view-overlay-owner-hidden-retired'); + } + }); +}); diff --git a/packages/spec/src/ui/view-union-diagnostics.test.ts b/packages/spec/src/ui/view-union-diagnostics.test.ts index 2bc7d3fe8a3..be98becbc72 100644 --- a/packages/spec/src/ui/view-union-diagnostics.test.ts +++ b/packages/spec/src/ui/view-union-diagnostics.test.ts @@ -280,8 +280,11 @@ describe('[#7025] the acceptance face of ViewMetadataSchema — as re-ruled by # { object: 'crm_lead', formViews: { my: { type: 'simple', sections: [SECTION] } } }], ['overlay.list.columns', { columns: ['name', 'stage'], ...BOUND_LIST }, { type: 'grid', columns: ['name', 'stage'], ...BOUND_LIST }], - ['overlay.list.aux', { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false, ...BOUND_LIST }, - { type: 'grid', columns: ['name'], isDefault: true, order: 2, hidden: false, ...BOUND_LIST }], + // [#20230] `hidden` left this row: the overlay's `owner` / `hidden` are + // retired (ADR-0049), so the aux keys that remain are the live ones, and the + // same row WITH `hidden` is now in REFUSED as `overlay.list.aux.retiredHidden`. + ['overlay.list.aux', { type: 'grid', columns: ['name'], isDefault: true, order: 2, ...BOUND_LIST }, + { type: 'grid', columns: ['name'], isDefault: true, order: 2, ...BOUND_LIST }], ['overlay.form.min', { type: 'simple', ...BOUND_FORM }, { type: 'simple', ...BOUND_FORM }], ['overlay.form.sections', { sections: [{ label: 'Main', fields: ['name'] }], ...BOUND_FORM }, { type: 'simple', sections: [SECTION], ...BOUND_FORM }], @@ -299,7 +302,6 @@ describe('[#7025] the acceptance face of ViewMetadataSchema — as re-ruled by # // it, with every list key stripped unread. ['put.isPinned', { isPinned: true, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], ['put.sortOrder', { sortOrder: 3, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], - ['put.hidden', { hidden: true, ...BOUND_FORM }, { type: 'simple', hidden: true, ...BOUND_FORM }], ['put.pinAndOrder', { isPinned: true, sortOrder: 3, ...BOUND_LIST }, { type: 'grid', ...BOUND_LIST }], ]; @@ -316,6 +318,12 @@ describe('[#7025] the acceptance face of ViewMetadataSchema — as re-ruled by # ['overlay.badType', { type: 'sideways' }, ['invalid_union']], ['overlay.badColumns', { type: 'grid', columns: 'not-an-array' }, ['invalid_union']], ['overlay.emptyState.badKey', { type: 'grid', emptyState: { title: 'None', notAnEmptyStateKey: 1 } }, ['invalid_union']], + // [#20230] Formerly ACCEPTED (`overlay.list.aux` with `hidden: false`, and + // `put.hidden`): the overlay's `hidden` / `owner` are retired, and a bound + // overlay carrying either is refused at the key with the prescription. + ['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']], // [#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 @@ -367,6 +375,25 @@ describe('[#7025] the acceptance face of ViewMetadataSchema — as re-ruled by # }); } + // [#20230] The three retired-key rows above are refused for the RETIREMENT, + // not for some other defect a codes-only pin would also read as a refusal: + // the claimed overlay member's message is the prescription, located at the key. + it('the retired-key overlay rows are refused BY the tombstone, with its prescription', () => { + const rows = REFUSED.filter(([label]) => ['overlay.list.aux.retiredHidden', 'put.hidden', 'put.owner'].includes(label)); + expect(rows).toHaveLength(3); + for (const [label, body] of rows) { + const r = ViewMetadataSchema.safeParse(body); + expect(r.success, label).toBe(false); + if (r.success) continue; + const key = label === 'put.owner' ? 'owner' : 'hidden'; + expect(r.error.issues[0]!.message, label).toMatch(new RegExp(`^\`view\\.${key}\` was removed in @objectstack/spec`)); + const located = ((r.error.issues[0] as unknown as { errors?: { path: PropertyKey[]; code: string }[][] }).errors ?? []) + .flat() + .find((i) => i.path[0] === key); + expect(located?.code, label).toBe('invalid_type'); + } + }); + // The JSON-Schema face the `/api/v1/meta/types/view` endpoint serves is // pinned in `view-metadata-schema.test.ts`; re-asserted here in the one // dimension this change could have moved — the member COUNT. diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 25d7ad851ed..2609367870c 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4770,6 +4770,17 @@ export const ViewKindSchema = z * and no effect, and a view marked as one user's was shown to everyone: the * `owner` half is a visibility claim, the security shape ADR-0049 is about. * + * [#20230] The SAME two constants tombstone the flattened overlay's own + * `owner` / `hidden` ({@link flattenedViewOverlayFields}) — one family, one + * text, triage direction 「follow #20085's disposition for the same key + * pair」. The overlay door (a lean personalization PUT with no `config`) + * declared both names separately, accepted and stored them, and nothing read + * either. Writer census before removal: none in this framework or its + * examples, in objectui at its pin and at `main` (the toolbar writes only + * `rowHeight` / `sort` / `hiddenFields` / `columnState` / `inlineEdit`, the + * switcher only `label` / `isPinned` / `isDefault` / `sortOrder`), or in the + * HotCRM app; cloud was not reachable from that census. + * * Declared ABOVE {@link viewItemBaseShape} on purpose: under * `OS_EAGER_SCHEMAS=1` every `lazySchema` factory runs at module init in file * order, and a `const` below its first eager reader is a TDZ error (the @@ -4833,11 +4844,12 @@ function viewItemBaseShape() { * {@link viewItemArmShape} exists for: `tsc` types the key `never` on * `defineViewItem`'s input, and every parse raises the prescription. * - * ⚠️ The flattened-overlay members declare their OWN `owner` / `hidden` + * The flattened-overlay members declare their OWN `owner` / `hidden` * (`flattenedViewOverlayFields()`) — a different door, a lean - * personalization PUT with no `config`, and deliberately untouched here. - * The D2 conversion `view-item-owner-hidden-removed` is scoped to the - * record spelling for the same reason. + * personalization PUT with no `config`. [#20230] They are tombstoned + * there too, with these same two texts. The D2 conversion + * `view-item-owner-hidden-removed` stays scoped to the record spelling; + * `view-overlay-owner-hidden-removed` strips the overlay spelling. */ owner: retiredKey(VIEW_ITEM_OWNER_RETIRED), hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED), @@ -5281,8 +5293,18 @@ function flattenedViewOverlayFields(kind: 'list' | 'form') { isDefault: z.boolean().optional(), order: z.number().int().optional(), scope: ViewScopeSchema.optional(), - owner: z.string().optional(), - hidden: z.boolean().optional(), + // [#20230] RETIRED — ADR-0049 enforce-or-remove, the view item's pair + // (#20085) on this door. Declared here, accepted by the write door, stored + // verbatim, and read by nothing: both switcher read paths filter on + // `viewKind` + `object` and sort on `order`, so `hidden: true` hid no + // view and `owner` scoped none. Tombstoned, never deleted: both members + // `.strip()`, so a bare deletion would drop the key in silence (ADR-0104) + // — the no-effect save this retirement ends. The texts are the view + // item's own ({@link VIEW_ITEM_OWNER_RETIRED} / + // {@link VIEW_ITEM_HIDDEN_RETIRED}); stored overlay rows are stripped by + // the D2 conversion `view-overlay-owner-hidden-removed`. + owner: retiredKey(VIEW_ITEM_OWNER_RETIRED), + hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED), protection: ProtectionSchema.optional(), ...MetadataProtectionFields, // Structural guards — a flattened overlay is neither a record nor a container. @@ -5402,13 +5424,16 @@ function speaksViewVocabulary(body: unknown): boolean { * it rejects bodies that are **not a view at all** (`{ nope: 1 }`, `{}`, * `{ id: 'x' }`, and identity with no content) while making no judgement about * whether the view is *complete* — which is what keeps it compatible with every - * lean shape the platform round-trips. A pin PUT (`{ isPinned: true }`), a hide - * PUT (`{ hidden: true }`), a reorder (`{ sortOrder: 3 }`) and a column-sort PUT - * all carry declared non-identity keys and are unaffected *by this precondition* - * ([#7741] the UNION may still refuse the baseline-less ones — an overlay with - * no shadowed entry to inherit `object`/`viewKind` from now fails the members' - * binding requirement, which is the ruled behaviour, and the refusal is the - * members' located guidance rather than this precondition's). + * lean shape the platform round-trips. A pin PUT (`{ isPinned: true }`), a + * reorder (`{ sortOrder: 3 }`) and a column-sort PUT all carry declared + * non-identity keys and are unaffected *by this precondition* ([#7741] the + * UNION may still refuse the baseline-less ones — an overlay with no shadowed + * entry to inherit `object`/`viewKind` from now fails the members' binding + * requirement, which is the ruled behaviour, and the refusal is the members' + * located guidance rather than this precondition's). [#20230] A hide body + * (`{ hidden: true }`) is no longer a platform write: `hidden` is a retired + * key, still in the vocabulary as a tombstone, so it passes this precondition + * and the members refuse it with the retirement prescription. * * "Complete" is deliberately NOT the bar, and the distinction is the whole * reason this is safe: `{ isPinned: true }` is not a renderable view either, but