Skip to content

Commit eea8787

Browse files
feat(spec)!: retire the flattened view overlay's owner and hidden keys (ADR-0049) (#20286)
Fixes #20230 Clause-②: no (narrowing) ## What this does Retires the flattened view overlay's `owner` and `hidden` keys under ADR-0049 enforce-or-remove. Triage direction on the card (comment 5856621469), verbatim: 「follow #20085's disposition for the same key pair」. PR #20227 retired the same pair on the view item record; this PR retires it on the other door, with the same prescription texts. The overlay door is the lean `PUT /api/v1/meta/view/:name` body with no `config`: members 3 and 4 of the `view` union (`VIEW_METADATA_MEMBERS.listOverlay` / `.formOverlay`), built from `flattenedViewOverlayFields()` in `packages/spec/src/ui/view.zod.ts`. It declared both keys, the write door accepted them, and `saveMetaItem` stored them verbatim. Nothing read either one. After this PR, every door that parses an overlay refuses both keys with the prescription. A stored overlay row that holds either one is stripped on read: - a row with other view keys is valid again and re-saves; - a hide-only row (`{ object, viewKind, hidden: true }`) is left identity-only, which the door refuses. 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. The D2 docblock, the D3 entry and the changeset all state this, and it is pinned. ## Stop valve: the writer census, taken first Taken before any edit, each reading with a lit control on the same ref. No real writer was found, so the retirement proceeds. | where | ref | writers of overlay `owner` / `hidden` | lit control | |---|---|---|---| | objectui at the `.objectui-sha` pin | `f8a9d0fb` | 0. All 12 view write call sites were read one by one (the `persistViewPatch` toolbar path, `updateView` / `updateViewConfig` / `createView` in the data adapter, `viewEnvelope` saves, and the two `PublicFormsPage` saves). None writes either key. The toolbar's overlay keys are `VIEW_OVERLAY_OWNED_KEYS` = `rowHeight`, `sort`, `hiddenFields`, `columnState`, `inlineEdit`. The switcher writes `label` and `isPinned`. | 8 `persistViewPatch` call sites writing the owned keys; 2 `updateView` row-key writes | | objectui `main` | `6cf5999` | 0 (same 12 call sites, same reading) | 7 `persistViewPatch` call sites; 2 row-key writes | | objectstack `packages/**`, `examples/**` | `e46218674` | 0. Examples author no `viewKind` at all, and no view-level `hidden` / `owner` in 10 view files. No framework source writes an overlay body with either key. | `label:` 88 times in the same 10 example view files | | HotCRM | `2f7b2326` (= its remote `main`) | 0. No view write call, and no view-level `hidden` / `owner` in 14 view files. | `label:` 159 times in those files | | cloud | not reachable | NOT MEASURED. REST read answered 403 and `add_repo` was refused for this session. #20227's census at cloud `48d70663` recorded no code writer, and one test double that pins a lean `{hidden:true}` PUT as accepted. That is a test fixture, not a writer. It goes red on cloud's next spec bump only if it parses through the spec schema. | — | Readers, re-checked: `.hidden` / `.owner` reads on a view in `rest-server.ts` = 0/0 and in `metadata-manager.ts` = 0/0. The 5 `.hidden` reads in `metadata-protocol/src/protocol.ts` are all field-level. Control: `.order` is read 2 / 1 / 2 times in the same three files. ## Dispatch assumptions, measured 1. The two keys were at `view.zod.ts:5284-5285` on `e46218674`, and `flattenedViewOverlayFields(kind)` takes a `kind` argument. **Held.** Only those two keys move. 2. The `retiredKey()` tombstone applies. **Held.** Both overlay members `.strip()`, and a `z.never()` member refuses loudly instead of stripping: the pins below assert issue code `invalid_type` at path `[key]`, carrying the prescription. 3. A D2 conversion is owed. **Held, and the view-item entry does not cover it.** `view-item-owner-hidden-removed` skips any body without a `config` dict, and its own fixture pinned an overlay's `hidden: true` as kept. This PR adds a separate entry, disjoint by `config`. 4. Other `view.zod.ts` regions were not touched: no edit in `FormViewSchema.layout`, `ViewMetadataParsed` or `diagnoseViewMetadata`. ## The route - **Tombstones.** `owner: retiredKey(VIEW_ITEM_OWNER_RETIRED)` and `hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED)` in `flattenedViewOverlayFields()`. These are the view item's own constants, so both doors answer with the same text, as the order asked. A pin asserts the overlay's issue message is byte-equal to the view item's. - **D2 `view-overlay-owner-hidden-removed`** (`toMajor: 18`, `retiredFromLoadPath: true`, lossless `stripKeys`). Scope: the flattened spelling, meaning a body with no `config` and no container slot. It walks `views` (stack sources, and every stored row, which `convertStoredItem` replays before serving or badging) and `viewItems` (the assembled channel). It does NOT require `viewKind`: a flat row stored before the #7741 binding has none until the write path heals it in, and then the save would refuse the key it still held. It is wired into `MIGRATIONS_BY_MAJOR[18]`, and the step rationale is extended. - **Why the D2 matters at runtime, and what it cannot do.** objectui's `updateView` is a read-merge-write, and `buildPersistedViewBody` re-sends a saved view whole. A stored row served WITH `hidden` would make the next toolbar toggle a 422, so the read path strips first. - For a content-bearing row, that is the whole story. - For a hide-only row, the strip leaves identity only, and the door refuses that (the identity precondition: only identity fields). The badge turns invalid, a whole-row re-save or a rename (`label` is identity) answers 422, and `--apply` reports `failed` and leaves the row as stored. A toggle that adds a real key saves. - Remedy: delete the row, or add the setting its author meant. - **D3 `view-overlay-owner-hidden-retired`** (ruling B on #17152). It names its conversion by id in `reason`, which is the shape #20255's census pin reads. That pin is now live on `main` and green here. Its `acceptanceCriteria` state both classes, the hide-only row included. The view item's pair is a separate family with its own D2 and its own D3 (`18.view-item-owner-hidden-retired.ts`, from #20255). This PR corrects that entry's one stale sentence (amendment `5859181450`). - **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewMetadata:owner` and `ui/ViewMetadata:hidden`. The overlay members are not exported. `ui/ViewMetadata` is the exported door they are reached through, and it is listed in `unemitted-schemas.baseline.json`, so these rows are declared, not judged. The retirement test pins them. - **No liveness row.** The `view` ledger walks the container keys only (`name`, `label`, `object`, `list`, `form`, `listViews`, `formViews`), so a row would be an ORPHAN. `check:liveness` is green without one. - **Generated artefacts.** `check:generated`: all 15 were current, and there was nothing to regenerate. The four surface ratchets are byte-identical, which is expected on this route: the def is unemitted. `spec-changes.json` and the upgrade guide project up to protocol 17, so no major-18 entry shows there either (the same reading as PR #20227). - **Forms / examples / skills / docs.** No form offers either key. There are zero authorings in `examples/`, `skills/` and `content/docs/`. The tree-scoped pin below holds that. - **Changeset.** `@objectstack/spec: minor`, `**BREAKING**`, FROM → TO, the one-line fix, `Clause-②: no (narrowing)`, ADR-0087 disposition `registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired`. ## Pins The new file is `packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts` (in-package, local project): - Both overlay members refuse each key at its path: `invalid_type`, the path, and the prescription. The `view` door (`getMetadataTypeSchema('view')`) refuses with `invalid_union`, the prescription surfaces as the union's message, and the claimed member locates the key. The assembled channel refuses too. - CONTROL: the same overlays without the keys pass every door, with `isDefault` / `order` / `scope` intact and no key grown. The view item door refuses the pair as well, so the family is closed on both doors. `defineView` is the container door, not an overlay door. - D2: a stored row rehydrates clean and then parses at the door, while the unconverted row is refused. A `viewKind`-less flat row is stripped. The `viewItems` channel is reached, with each door's key stripped by its own entry. Containers are left alone. Idempotence: the second replay has 0 notices and returns the same reference. Load path: a live author is refused, not rewritten. - Registration: the two keys, the chain id, and one D3 record for the conversion. Any other entry naming the conversion must also name `view-overlay-owner-hidden-retired`, so it is a pointer, never a second record. - **Hide-only residue** (patch round 1): - A stored `hidden`, `owner` or both row strips to identity only. The door refuses it with the identity precondition's own text, as one custom issue at the root rather than the prescription. - A rename (`label`) is refused. - Controls `isDefault` / `order` / `columnState` save. The ADR-0112 envelope is pinned at the door that produces it. `packages/metadata-protocol/src/protocol.save-union-issues.test.ts` adds a describe block over the existing stub-engine harness (no new double). For each key and each family, `saveMetaItem` rejects with `code` `INVALID_METADATA` and `status` `422`, persists 0 rows, and carries an issue located at the key with the prescription. CONTROL: the same bound overlays without the keys save, 1 row each. Patch round 1 adds two pins here: - **Save door:** a whole-row PUT of the stripped hide-only row answers `INVALID_METADATA` / 422, with 0 rows and "only identity fields". The same row plus `isDefault` saves. - **Read path:** `getMetaItem` over a seeded row serves a stored overlay without `owner` / `hidden`. `_diagnostics` is valid when the row carries content and invalid when it is hide-only. The existing harness gains an optional seed; its default is unchanged. ## Flipped pins: repo-wide sweep, each one load-bearing The sweep grepped every test file that spells `viewKind` beside `hidden` / `owner`, in all packages. | pin | before | after | |---|---|---| | `spec/ui/view-item-owner-hidden-retirement.test.ts` BOUNDARY | an overlay with the keys parses | refused, with the SAME prescription | | same file, conversion test | overlays left alone | the overlay key is stripped by `view-overlay-owner-hidden-removed`, the record key by the view-item entry (asserted as pairs) | | same file, tree-scoped matcher | record spelling only | both spellings (`viewKind` + a retired key); anti-vacuity cases for an overlay (TS, YAML) and a container | | `spec/conversions/registry.ts` view-item fixture | overlay neighbour kept `hidden: true` | the neighbour carries neither key, which keeps the fixtures disjoint once the overlay entry replays | | `spec/ui/view-metadata-schema.test.ts` | `a hide PUT` accepted; `identity + hidden` accepted | the hide PUT is refused at the member with the prescription (not by the precondition); identity + a live key is accepted, identity + `hidden` refused | | `spec/ui/view-union-diagnostics.test.ts` | `overlay.list.aux` (with `hidden`) and `put.hidden` ACCEPTED | moved to REFUSED, plus `put.owner`; a new test asserts those rows are refused BY the tombstone (the prescription, `invalid_type` at the key) | | `spec/conversions/view-spelling-walk.test.ts` | the overlay's `owner` survives conversion | `owner` stripped, with the notice under the overlay entry; every binding key still survives | | `metadata-protocol/src/metadata-diagnostics.union-issues.test.ts` | `{hidden, object, viewKind}` badged `valid: true` | badged invalid, with the prescription at `hidden`; a live key is badged valid | ## Verification **Patch round 1, final head `a05b32f8b`**, merged with `origin/main` at `4e0f72e8d`, which carries #20238, #20255 and #20244 (dev report `5860055944`): - spec `--project local`, full: 553 files / 16341 tests. - The touched pins plus `migrations.test.ts`, with the census pin shown verbosely: 6 / 530. - The repo view-item pin: 18/18. - `turbo build rest^...`: 24/24. - metadata-protocol save-door + diagnostics: 2 / 40. - Typecheck spec + metadata-protocol: exit 0. - `check:generated`: 15/15 current. - Gates: 88 derived, 86 run and exit 0, 2 NOT-MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`: exit 3, PREREQUISITE NOT MET), 0 UNRUN. - Ablation (round 1, at `e38a8027b`): the overlay strip replaced by `return view` → 6 red (the residue, stored-row, `viewKind`-less and `viewItems` pins) / 18 green. The restore was proven by blob == HEAD and an empty `git diff HEAD`. The round-0 readings below are at `2a40c104c`. Round 0: final head **`2a40c104c`**. That is after merging `origin/main` at `17bd3187`, which carried #19920's `view.zod.ts` / `assembled-views.zod.ts` type change. Heavy runs went through `scripts/pm/os-verify-lock.sh`, and every exit code was written to disk before its log was read. The box was shared, with lock waits of 3–9 min, so wall-clock readings are contended. | run | head | reading | |---|---|---| | `turbo run build --filter='@objectstack/rest^...'` (spec + the consumer closure) | `2a40c104c` | exit 0, 24/24 tasks | | `pnpm --filter @objectstack/spec check:generated` | `2a40c104c` | exit 0, all 15 artifacts current; nothing regenerated | | spec `--project local`, full | `2a40c104c` | 553 files / 16275 tests passed | | spec `--project repo`, `view-item-owner-hidden-retirement.test.ts` (tree-scoped pin) | `2a40c104c` | 18/18 passed | | metadata-protocol, the edited files + `view-write-path-identity.test.ts` | `2a40c104c` | 3 files / 41 tests passed | | typecheck: spec (`tsc` + scripts + `check:test-typecheck`), lint, metadata-protocol | `2a40c104c` | exit 0 ×3 | | consumers, full: metadata-protocol / lint / metadata; objectql and rest (their 24 / 13 view files) | `cbc81c574` | 189 files / 2720 tests (3 skipped) · 110 / 4262 · 54 / 821 · 24 / 380 · 13 / 191, all exit 0 | **Reverse verification** (a one-shot probe removed by an EXIT trap, verified absent afterwards): `packages/lint/src/zz-issue20230-dts-probe.ts` typed `{ object, viewKind: 'list', hidden: true }` as `ViewMetadata`, against the REBUILT spec `.d.ts`. `@objectstack/lint` `tsc --noEmit` exited 2: `src/zz-issue20230-dts-probe.ts(2,14): error TS2322: Type '{ object: string; viewKind: "list"; hidden: boolean; }' is not assignable to type 'ViewMetadata'.` With the probe removed, `git status` showed 0 lines and `lint typecheck` exited 0. Predicted direction: red. Observed: red. **Ablation** (`scripts/ablation-replace.mjs`, on committed state, wrap mode). The mutation swapped the overlay's `hidden: retiredKey(VIEW_ITEM_HIDDEN_RETIRED),` for `hidden: z.boolean().optional(),`: anchor 1 → 0, blob `1f93b520` → `e9ad3dec`. Three spec files then read 10 failed / 139 passed, and the 10 are exactly the overlay `hidden` pins: both members, the same-text pin, the door, the assembled channel, the hide-PUT refusal, the identity pin, and the three union-diagnostics rows. The `owner` pins stayed green, as they should. The restore brought the blob back to HEAD `1f93b520`, with `git diff HEAD` at 0 bytes and `git status --porcelain` at 0 lines. Predicted direction: red. Observed: red. (The metadata-protocol save-door pins resolve spec through `dist/`, so they were not part of this ablation.) **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `2a40c104c` derived 88 commands. All 88 ran with the exit code captured before any pipe, and were reconciled with `--ran`: **88 derived, 86 run, 2 NOT-MEASURED, 0 UNRUN**. All 86 measured commands exited 0. That includes `check-adr-0087-registration` (`registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired (new here …)`, `[BREAKING+bang+clause-②-narrowing]`), `check-changeset-no-major`, `check-empty-changeset`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:doc-authoring`, and the spec `check:*` family (`check:authorable-surface`, `check:liveness`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:api-surface`, `check:docs`). NOT MEASURED (exit 3, `PREREQUISITE NOT MET`; each reads built output of the whole workspace, which was not built locally): `pnpm check:dual-build-cjs-loads`, `pnpm check:type-check-debt`. This diff touches no package entry point, export or tsconfig. CI's build lanes measure both. Also owned by CI: `pnpm lint`, the remaining objectql / rest suites, and the lanes `dispatch-gates` lists outside the derived total. The CLI `integration` tier does not apply (no `packages/cli` change). ## Acceptance notes (observed, not fixed here) _The seat updated this body at 2026-09-27T21:40Z after patch round 1, per dev report `5860055944`. Reviews: `5859174998` (FAIL at `2a40c104c`)._ 1. **One family or two for D3, and the overlap with PR #20255.** PR #20255 (#20201, not merged when this opened) adds `18.view-item-owner-hidden-retired.ts` as the view item family's D3 entry, and a census pin requiring every major-18 conversion to be named by a D3 entry of its step. This PR's conversion is separate, disjoint by `config`, so it carries its own D3 entry naming it. That keeps one record per conversion and no second file under #20255's filename, which would be an add/add collision. #20255 has since landed (`f415bcf18`), and the census pin is green here at `a05b32f8b`. Its sentence 「A flattened view overlay keeps its own `owner` and `hidden` …」 is replaced in this PR (amendment `5859181450`): the overlay pair is a separate family, with its own D2 `view-overlay-owner-hidden-removed` and D3 `view-overlay-owner-hidden-retired`. 2. **This PR supersedes one sentence of #20227's pending changeset.** `.changeset/view-item-owner-hidden-retired.md` says an overlay "still parses". It is left as landed, because `check-empty-changeset` refuses an edit to another PR's release note. This PR's changeset states the supersession instead. The release compiler should read the two together. 3. **Cloud is NOT MEASURED** (above). If its mock-protocol double parses `{hidden:true}` through the spec, it goes red at cloud's spec bump. That is a fixture edit there. Carrier: cloud, at its next `@objectstack/spec` bump. 4. **The assembled channel's refusal loses the branch diagnostics** (#20227's acceptance note 4, pre-existing): `AssembledViewArtifactSchema` is a plain `z.union`. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10ea9eb commit eea8787

15 files changed

Lines changed: 1072 additions & 70 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire the flattened view overlay's `owner` and `hidden` keys — accepted at the save door, stored, and read by nothing (#20230)
6+
7+
**BREAKING** — `owner` and `hidden` are removed from the flattened view overlay:
8+
the lean `view` body with no `config` that `PUT /api/v1/meta/view/:name` (the
9+
Studio and MCP save) accepts, members 3 and 4 of the `view` metadata door, and
10+
the same members in the assembled-manifest `viewItems:` channel. ADR-0049
11+
enforce-or-remove; triage direction, verbatim: 「follow #20085's disposition for
12+
the same key pair」. This completes the family: the view item record's `owner` /
13+
`hidden` are retired in this same release by its own entry, with the same texts.
14+
15+
⚠️ **This supersedes one sentence of the view item retirement's note in this same
16+
release.** That note says the flattened overlay's own `owner` / `hidden` are
17+
untouched and that a `{ object, viewKind, hidden: true }` overlay still parses.
18+
True of that change alone; after this one, such an overlay is refused too. Read the
19+
two notes together: after this release, neither door accepts either key.
20+
21+
Clause-②: no (narrowing)
22+
23+
The overlay door declared both keys separately from the view item's pair. A bound
24+
overlay such as `{ object, viewKind, hidden: true }` saved clean and one row was
25+
stored with the key, and nothing ever read it. Both view-switcher read paths
26+
(`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` + `object`
27+
and sort on `order`, so `hidden: true` hid nothing, and a view with `owner` set
28+
was listed for every user who can read the object.
29+
30+
Writer census, taken before removal: no writer of either overlay key in this
31+
framework or its examples, in objectui at its pinned commit and at `main` (the
32+
toolbar writes only `rowHeight`, `sort`, `hiddenFields`, `columnState` and
33+
`inlineEdit`; the switcher only `label`, `isPinned`, `isDefault` and `sortOrder`),
34+
or in the HotCRM app. The cloud repository was not reachable from the census.
35+
36+
### FROM → TO
37+
38+
| removed | what to write instead |
39+
| --- | --- |
40+
| 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. |
41+
| flattened overlay `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). |
42+
43+
**The one-line fix: delete `owner:` and `hidden:` from every view body you save.**
44+
`os migrate meta --from 17` lists the mechanical edits for existing sources.
45+
46+
⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what
47+
a view showed or to whom. What changes is the answer an author gets: a save that
48+
carries either key is refused `422 INVALID_METADATA`, with the prescription
49+
located at the key, instead of being stored with no effect. The prescriptions are
50+
the view item's own texts, so the family answers with one voice on both doors.
51+
52+
### Stored rows
53+
54+
Every read of a stored `view` row replays the conversion chain before the row is
55+
served or badged, and the D2 conversion strips both keys there. What that leaves
56+
depends on what else the row holds:
57+
58+
- **A row with any other view key** (a column state, a sort, a default flag, an
59+
order): served and badged valid without the keys. A GET then a PUT of the whole
60+
row saves (if it was otherwise valid), so the console's next read-merge-write of
61+
it saves, and `os migrate meta --stored --apply` rewrites it.
62+
- **A hide-only row**, holding nothing but its identity (`name`, `object`,
63+
`viewKind`, `label`) and `owner` / `hidden`, such as
64+
`{ object, viewKind, hidden: true }`: the strip leaves identity only, which the
65+
`view` door refuses ("only identity fields"). The row is served badged invalid
66+
(it was badged valid before this release). A whole-row re-save, or one that adds
67+
only identity (a rename sets `label`), answers `422 INVALID_METADATA`.
68+
`--apply` reports it `failed` and leaves it as stored; every read strips it
69+
again. A write that adds a real view key, such as a toolbar toggle, saves.
70+
**Fix: delete the row** (it never changed what anyone saw), or add the
71+
personalization setting its author meant and save that.
72+
73+
### The retirement kit
74+
75+
- **Tombstones on both overlay members.** `retiredKey()` in
76+
`flattenedViewOverlayFields()`, with the view item's prescription texts. Both
77+
members `.strip()`, so a bare deletion would have dropped the key in silence
78+
(ADR-0104).
79+
- **D2 conversion `view-overlay-owner-hidden-removed`** (step 18, retired from the
80+
load path). A lossless delete from the flattened spelling (no `config`, no
81+
container slot) in `views` (stack sources and stored rows) and `viewItems`
82+
(assembled artifacts). It is disjoint from `view-item-owner-hidden-removed` by
83+
`config`, so no row is judged by both.
84+
- **D3 semantic entry `view-overlay-owner-hidden-retired`**: the family's one D3
85+
record, naming its D2 conversion. The view item record's pair is a separate
86+
family with its own conversion and its own D3 entry; the two share the
87+
prescription texts.
88+
- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewMetadata:owner`, `ui/ViewMetadata:hidden`.
89+
`ui/ViewMetadata` is unemitted (its `z.undefined()` guards have no JSON Schema
90+
form), so no build gate judges these rows and the four surface ratchets are
91+
byte-identical on this retirement. The rows are pinned by the retirement test.
92+
- **No liveness row**: the `view` ledger walks the container keys only.
93+
- **No deprecation window**, per the project's startup-stage posture.
94+
95+
⚠️ **The out-of-repo population is NOT MEASURED.** `@objectstack/spec` is published,
96+
and production `sys_metadata` rows are not reachable from the repository. Stored
97+
rows are stripped on read by the conversion above, and a hide-only row among them
98+
needs the fix above. A client that still sends either key is refused at its next
99+
save.
100+
101+
<!-- adr-0087: registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired -->

‎packages/metadata-protocol/src/metadata-diagnostics.union-issues.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,18 @@ describe('#5599 a stored `view` that is not a view is no longer badged valid', (
193193
// inherits `object`/`viewKind` from the shadowed entry (#2555), so a
194194
// stored lean overlay of a REAL view looks exactly like this.
195195
expect(computeMetadataDiagnostics('view', { isPinned: true, object: 'task', viewKind: 'list' })).toEqual({ valid: true });
196-
expect(computeMetadataDiagnostics('view', { hidden: true, object: 'task', viewKind: 'list' })).toEqual({ valid: true });
196+
expect(computeMetadataDiagnostics('view', { order: 2, object: 'task', viewKind: 'list' })).toEqual({ valid: true });
197+
// [#20230] `hidden` used to stand in the line above. The overlay's
198+
// `owner` / `hidden` are retired (ADR-0049): a body that still carries
199+
// one is badged invalid with the retirement prescription at the key.
200+
// A STORED row never reaches this badge with the key — the read path
201+
// replays the chain first (`convertStoredItem`), and
202+
// `view-overlay-owner-hidden-removed` strips it.
203+
const bound = { object: 'task', viewKind: 'list' } as const;
204+
const retired = computeMetadataDiagnostics('view', { ...bound, hidden: true });
205+
expect(retired?.valid).toBe(false);
206+
const atKey = retired?.errors?.find((e) => e.path === 'hidden');
207+
expect(atKey?.message).toMatch(/^`view\.hidden` was removed in @objectstack\/spec/);
197208
// …while a stored row with NO binding is a row no object-bound read
198209
// path can serve — the #7741 dead row — and is badged invalid now.
199210
expect(computeMetadataDiagnostics('view', { isPinned: true })?.valid).toBe(false);

‎packages/metadata-protocol/src/protocol.save-union-issues.test.ts‎

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { describe, expect, it } from 'vitest';
3131
// of this package's (file, verb) pairs sat in the gate's DEBT ledger until
3232
// #5619 sank the two predicates into a package both sides already depend on.
3333
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
34+
import { applyConversionsToStoredItem } from '@objectstack/spec';
3435
import { getMetadataTypeSchema } from '@objectstack/spec/kernel';
3536
import { ObjectStackProtocolImplementation, zodIssuesToMetadataIssues } from './protocol.js';
3637

@@ -46,8 +47,16 @@ interface Row {
4647
const keyOf = (w: Record<string, unknown>) =>
4748
`${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`;
4849

49-
/** The engine surface the repository write path touches. */
50-
function makeProtocol() {
50+
/**
51+
* The engine surface the repository write path touches.
52+
*
53+
* [#20230] `seed` (optional; default none, so every other test's double is
54+
* unchanged) makes `findOne` answer the READ a `getMetaItem` performs against
55+
* `sys_metadata` with a stored row, and gives the registry the two read verbs
56+
* that path consults — answering nothing, so the served item is the stored row
57+
* after the rehydration seam and nothing else.
58+
*/
59+
function makeProtocol(seed: Array<{ type: string; name: string; metadata: Record<string, unknown> }> = []) {
5160
// ⚠️ Keyed BY TABLE. `find`/`findOne` below answer nothing, so this harness
5261
// cannot serve a `sys_metadata_history` row as a `sys_metadata` row the way
5362
// #16223 measured — but one flat map still made `rows.size` the total of
@@ -65,7 +74,15 @@ function makeProtocol() {
6574
let nextId = 0;
6675
const engine: any = {
6776
async findOne(object: string, query?: EngineFindOneQueryInput) {
68-
assertEngineFindOnePredicate(object, query); return null; },
77+
assertEngineFindOnePredicate(object, query);
78+
if (object !== 'sys_metadata' || seed.length === 0) return null;
79+
const where = ((query as { where?: Record<string, unknown> } | undefined)?.where ?? {});
80+
const hit = seed.find((r) => r.type === where.type && r.name === where.name
81+
&& (where.state ?? 'active') === 'active' && (where.organization_id ?? null) === null);
82+
return hit
83+
? { id: `seed_${hit.name}`, type: hit.type, name: hit.name, organization_id: null, state: 'active', metadata: JSON.stringify(hit.metadata) }
84+
: null;
85+
},
6986
async find() { return []; },
7087
async insert(table: string, data: Record<string, unknown>) {
7188
nextId += 1;
@@ -81,7 +98,9 @@ function makeProtocol() {
8198
assertEngineDeleteDispatch(opts);
8299
return { deleted: 0 };
83100
},
84-
registry: { registerItem: () => {}, registerObject: () => {} },
101+
registry: seed.length === 0
102+
? { registerItem: () => {}, registerObject: () => {} }
103+
: { registerItem: () => {}, registerObject: () => {}, getItem: () => undefined, getObject: () => undefined },
85104
};
86105
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
87106
return { protocol, rows };
@@ -363,3 +382,107 @@ describe('#5364 zodIssuesToMetadataIssues — the shared ranking, verbatim', ()
363382
expect(zodIssuesToMetadataIssues(null)).toEqual([]);
364383
});
365384
});
385+
386+
/**
387+
* #20230 — the flattened overlay's retired `owner` / `hidden`, at the door the
388+
* retirement exists for: `saveMetaItem`, the `PUT /api/v1/meta/view/:name` write
389+
* path the console and an MCP author reach.
390+
*
391+
* Before: a bound lean overlay `{ object, viewKind, hidden: true }` saved, one
392+
* row persisted with the key, and nothing ever read it — measured on this same
393+
* harness by the #20085 dev. After: refused with the ADR-0112 envelope, nothing
394+
* persisted, and the retirement prescription located at the key. Pinned HERE
395+
* and not only in spec because this envelope is what Studio and an MCP caller
396+
* receive — a spec tombstone that did not reach it would be a refusal nobody sees.
397+
*/
398+
describe('#20230 a flattened overlay carrying a retired owner/hidden is refused at the save door', () => {
399+
// Spread, not a literal: the spec's tree-scoped absence pin reads object
400+
// literals, and these bodies are refusals, not authorings.
401+
const BOUND_LIST = { object: 'task', viewKind: 'list' } as const;
402+
const BOUND_FORM = { object: 'task', viewKind: 'form' } as const;
403+
const PRESCRIPTION: Record<'owner' | 'hidden', RegExp> = {
404+
owner: /^`view\.owner` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049/,
405+
hidden: /^`view\.hidden` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049/,
406+
};
407+
408+
for (const [key, value] of [['owner', 'usr_7'], ['hidden', true]] as const) {
409+
for (const [family, bound] of [['list', BOUND_LIST], ['form', BOUND_FORM]] as const) {
410+
it(`a bound ${family} overlay with \`${key}\` answers 422 INVALID_METADATA and persists nothing`, async () => {
411+
const { protocol, rows } = makeProtocol();
412+
413+
const err = await rejection(save(protocol, { name: 'task_list', ...bound, [key]: value }));
414+
415+
expect(err.code).toBe('INVALID_METADATA');
416+
expect(err.status).toBe(422);
417+
expect(rows.size).toBe(0);
418+
// The prescription, located at the key the author sent.
419+
const atKey = err.issues.find((i: any) => i.path === key);
420+
expect(atKey, `an issue located at \`${key}\``).toBeDefined();
421+
expect(atKey.code).toBe('invalid_type');
422+
expect(atKey.message).toMatch(PRESCRIPTION[key]);
423+
expect(err.message).toContain(`\`view.${key}\` was removed`);
424+
});
425+
}
426+
}
427+
428+
/**
429+
* The hide-only residue, at the door it is refused by. The card's measured
430+
* stored shape `{ object, viewKind, hidden: true }` (plus the stamped
431+
* `name`) is served stripped by the rehydration seam — identity only — and
432+
* a whole-row PUT of what was served is refused by the identity
433+
* precondition. Stated in the D2 docblock, the D3 acceptance criteria and
434+
* the changeset; the remedy is to delete the row or add the setting its
435+
* author meant.
436+
*/
437+
it('RESIDUE: a whole-row PUT of a stripped hide-only row answers 422 INVALID_METADATA ("only identity fields")', async () => {
438+
const stored = { name: 'task_list', ...BOUND_LIST, hidden: true };
439+
const served = applyConversionsToStoredItem('view', stored) as Record<string, unknown>;
440+
expect(served).toEqual({ name: 'task_list', ...BOUND_LIST });
441+
442+
const { protocol, rows } = makeProtocol();
443+
const err = await rejection(save(protocol, served));
444+
445+
expect(err.code).toBe('INVALID_METADATA');
446+
expect(err.status).toBe(422);
447+
expect(rows.size).toBe(0);
448+
expect(err.message).toContain('only identity fields');
449+
// Not the retirement prescription: the key is already gone.
450+
expect(err.message).not.toContain('was removed in @objectstack/spec');
451+
});
452+
453+
it('RESIDUE CONTROL: the same stripped row plus a real view key (a toolbar toggle) saves', async () => {
454+
const served = applyConversionsToStoredItem('view', { name: 'task_list', ...BOUND_LIST, hidden: true }) as Record<string, unknown>;
455+
const { protocol, rows } = makeProtocol();
456+
const result = await save(protocol, { ...served, isDefault: true });
457+
expect(result.success).toBe(true);
458+
expect(rows.size).toBe(1);
459+
});
460+
461+
it('READ PATH: `getMetaItem` serves a stored overlay without `owner` / `hidden` — valid with content, invalid when hide-only', async () => {
462+
const { protocol } = makeProtocol([
463+
{ type: 'view', name: 'task_list', metadata: { name: 'task_list', ...BOUND_LIST, isDefault: true, order: 2, owner: 'usr_7', hidden: true } },
464+
{ type: 'view', name: 'task_hidden', metadata: { name: 'task_hidden', ...BOUND_FORM, hidden: true } },
465+
]);
466+
467+
const content = (await protocol.getMetaItem({ type: 'view', name: 'task_list' })).item;
468+
expect(content).not.toHaveProperty('owner');
469+
expect(content).not.toHaveProperty('hidden');
470+
expect(content.isDefault).toBe(true);
471+
expect(content.order).toBe(2);
472+
expect(content._diagnostics).toEqual({ valid: true });
473+
474+
const hideOnly = (await protocol.getMetaItem({ type: 'view', name: 'task_hidden' })).item;
475+
expect(hideOnly).not.toHaveProperty('hidden');
476+
expect(hideOnly._diagnostics?.valid).toBe(false);
477+
expect(JSON.stringify(hideOnly._diagnostics)).toContain('only identity fields');
478+
});
479+
480+
it('CONTROL: the same bound overlays without the keys still save, one row each', async () => {
481+
for (const bound of [BOUND_LIST, BOUND_FORM]) {
482+
const { protocol, rows } = makeProtocol();
483+
const result = await save(protocol, { name: 'task_list', ...bound, isDefault: true, order: 2 });
484+
expect(result.success).toBe(true);
485+
expect(rows.size).toBe(1);
486+
}
487+
});
488+
});

0 commit comments

Comments
 (0)