Repository navigation
Commit 3cb84d0
feat(spec)!: retire the view item's owner and hidden keys (ADR-0049) (#20227)
Fixes #20085
Clause-②: no
## What this does
Retires the view item's `owner` and `hidden` keys under ADR-0049
enforce-or-remove, following the triage direction on the card (comment
5826969296, verbatim: 「retire both keys」) and the
`spec-property-retirement` playbook.
Both keys sat on the view-item identity layer (`viewItemBaseShape()` in
`packages/spec/src/ui/view.zod.ts`). They were accepted by the strict
authoring door and by the wire member that the `PUT /api/v1/meta/view`
door validates, and `saveMetaItem` stored them verbatim. Nothing read or
wrote either one. An author (often an AI) who wrote `hidden: true` or
`owner: 'u1'` got a clean save and no effect, and a view marked as one
user's was listed for everyone. After this PR, every door that carries a
ViewItem record refuses both keys with a prescription.
## Premise, re-measured first (execution note 1)
Each reading is taken against the named tree, with a lit control on the
same ref. The premise held, so the retirement proceeds.
| where | ref | `owner` / `hidden` on a view item | lit control |
|---|---|---|---|
| objectstack `packages/**`, `examples/**` | `e7f69dbb` | no reader, no
writer. Both switcher read paths filter on `viewKind` + `object` and
sort on `order`: `GET /meta/view?object=` (`rest-server.ts` ~6594-6607)
and `getViewsByObject` (`metadata-manager.ts:1727`). Examples author
zero ViewItem records (`viewKind:` count 0); the 5 `owner:` hits in
example view files are field-label translations. | `order` is read by
both paths; `label:` authored 109 times in the same example files |
| objectui at the pin `.objectui-sha` | `f8a9d0fb` | view-receiver
`.owner` reads: 0. The 13 view-receiver-shaped `.hidden` hits are all
fields, menu items or grid columns (read one by one). All 19 view write
calls carry no `hidden` / `owner` patch; the only hidden-like patch key
is ListView `hiddenFields`. Zero `.createView(` callers. Zero
`sys_view_definition` references. | `isDefault` view reads: 10;
`isPinned` 56; `sys_metadata` 124 |
| objectui `main` | `25c7d584` | same readings (0 / 13 non-view / 18
write calls, none carrying the keys / 0 / 0) | `isDefault` 10;
`isPinned` 56; `sys_metadata` 129 |
| cloud `main` | `48d70663` | no code reader or writer. One test double
pins a lean `{hidden:true}` personalization PUT as accepted; that is the
flattened-overlay door (below), which this PR leaves alone. |
`sys_metadata` 160 |
Zone 2 assumption 3 (no personal-view feature reads `owner` through
another path): confirmed. `sys_view_definition` has `owner` / `hidden`
columns and a docblock claiming the switcher merges its rows
client-side. objectui references the table 0 times at both refs, and no
framework code reads its rows. ADR-0131 D13 (2026-09-04) already records
that table and its runbook as inert and retired, with execution paused,
and records per-user view scope as a parked v18 direction. The table and
the runbook are left to that ADR's execution.
Zone 2 assumption 2 (stored data): no producer was measured, but the
`PUT /meta/view` door accepted and persisted both keys until now, so a
stored row can hold them. The D2 conversion below is therefore owed. An
upgrading deployment's stored row carrying either key is stripped at
rehydration (`database-loader.ts:825` replays the chain over every
stored row as `{ views: [row] }`), then parses clean. Behaviour does not
change, because neither key ever had an effect.
## Scope boundary, measured as the order asked
- The `owner` / `hidden` at `view.zod.ts:5186-5187`
(`flattenedViewOverlayFields()`) are **not** the same key on the same
door. They are the flattened-overlay members' own declarations: a lean
personalization PUT with no `config`, a different door. They are not
retired here. A bound `{ object, viewKind, hidden: true }` overlay still
saves, and that is pinned as a boundary. The conversion leaves overlays
alone for the same reason.
- Not the column `hidden` (`:1060`), not `Hidden override` (`:3193`),
not `scope`, not the flattened list-overlay region (`:5433` onward, PR
#20183).
## The route, and why (the open choices settled here, on the four axes)
**1. `retiredKey()` on the SHARED shape, not strict deletion plus a
`guidance` entry.** `viewItemBaseShape()` feeds two doors. The strict
authoring door (`ViewItemSchema`) would refuse a deletion, but the wire
member (`ViewItemWireSchema`, `.strip()`) is member 1 of the union that
`saveMetaItem`, the `view` registry binding and the assembled-manifest
`viewItems` channel all run, and a bare deletion there would be a silent
strip (ADR-0104).
- Business need: the wire door is where a real PUT lands.
- Long-term soundness: one declaration serves both doors
(derive-by-reference, the rule `viewItemArmShape()` exists for), with no
second copy to drift.
- AI-error prevention: `tsc` types the key `never` on `defineViewItem`'s
input, and every parse carries the prescription instead of a strip.
- Startup-stage restraint: no new mechanism; `retiredKey()` inside
`strictObject` shapes is established precedent (`TursoConfig.timeout`,
`ListViewSchema.pageName` in this same file).
**2. The D2 conversion reaches `viewItems` as well as `views`.** Package
export and environment artifacts carry standalone ViewItem records in
the assembled-manifest `viewItems` channel.
`applyArtifactForwardConversions` replays the chain over that channel,
and then the registration loop parses each entry against
`AssembledViewArtifactSchema`, which now refuses the keys. Walking
`views` alone would have left an artifact assembled before this release
failing registration with a 422 over two keys that never did anything.
This is the minimal reach that keeps the new refusal from breaking an
upgrade; it adds no new surface.
**3. The flattened-overlay copies stay.** The order asked that a
separate door be retired only if it is the same key on the same door. It
is not, so those copies are reported under Acceptance notes rather than
removed.
## Contract changes, quoted verbatim
`view.owner` prescription (a `retiredKey()`, issue code `invalid_type`,
path `["owner"]`):
> `view.owner` was removed in @objectstack/spec 17.5.0 (ADR-0049
enforce-or-remove) — it named the user a `personal` view item belonged
to, and nothing ever read it: the view switcher (`GET
/meta/view?object=`) serves every item bound to the object without
looking at `owner`, so a view marked as one user's was listed for every
user who can read the object. Delete the key. Nothing restricts a view
item to one user today — per-user view scoping is a parked direction
(ADR-0017), not a shipped mechanism — so a view item is visible to
everyone who can read its object. Run `os migrate meta --from 17` to
list the mechanical edits for existing sources; apply them by hand.
`view.hidden` prescription (issue code `invalid_type`, path
`["hidden"]`):
> `view.hidden` was removed in @objectstack/spec 17.5.0 (ADR-0049
enforce-or-remove) — it promised to hide a view item from the switcher,
and nothing ever read it: `GET /meta/view?object=` and the console's
view switcher list every item bound to the object, `hidden: true`
included. Delete the key; to take a view out of the switcher, delete the
view item itself (or stop shipping it from source). Run `os migrate meta
--from 17` to list the mechanical edits for existing sources; apply them
by hand.
Removed `.describe` texts (the keys now describe themselves as
`[REMOVED]` plus the prescription above in the generated reference):
> Owner user id — set when `scope` is `personal`.
> Hidden from the switcher (per-user / per-org declutter).
The `ViewScopeSchema` TSDoc no longer calls the package layer "hideable
from the switcher" or says `personal` is "scoped to `owner`". It now
states that per-user scoping is parked and that nothing restricts a
`personal` item to one user.
## The retirement kit (the playbook's surface list)
| surface | this PR |
|---|---|
| schema | `retiredKey()` ×2 on `viewItemBaseShape()`, with an in-schema
comment on what was removed and why |
| D2 conversion | `view-item-owner-hidden-removed` (`toMajor: 18`,
`retiredFromLoadPath: true`), record spelling only, `views` +
`viewItems`, lossless `stripKeys`, fixture with 3 notices (two on a
`views` record, one on a `viewItems` record), a flattened-overlay
neighbour kept |
| D3 chain | id added to `MIGRATIONS_BY_MAJOR[18].conversionIds`, and
the step rationale extended |
| `RETIRED_KEYS_BY_MAJOR[18]` | `ui/ViewItem:owner`,
`ui/ViewItem:hidden`, `ui/ViewItemWire:owner`, `ui/ViewItemWire:hidden`
(four entry files plus `gen:migration-registry`) |
| liveness ledger | no row, because a row would be an ORPHAN: the walk
stops at the `view` union's container arm (Acceptance notes) |
| generated artifacts | `check:generated --fix` proved
`content/docs/references/**` stale, and nothing else.
`authorable-surface/`, `json-schema.manifest/`, `api-surface/` and the
signatures are **byte-identical, as expected on this route**: all four
read a def's top-level `properties` or exports, and `ViewItem` /
`ViewItemWire` are discriminated unions with no top-level `properties`.
`spec-changes.json` and the upgrade guide are unchanged too, because
they project up to the current protocol major (17), so no major-18
sibling appears in them either (measured:
`object-tenancy-organization-field-removed` 0 hits,
`action-inert-keys-removed` 2) |
| forms / i18n | no form offers either key (`view.form.ts:54` is the
column `hidden`) |
| CLI advisory lint | ledger-driven; no row, so nothing changes |
| examples / skills / hand-written docs | zero authorings (measured);
`tsc` and the tree-scoped pin below hold that |
| reconciliation ledger | the `metadata-form-zod-reconciliation.test.ts`
comment block now records both keys as retired |
| changeset | `@objectstack/spec: minor`, `**BREAKING**`, FROM → TO, the
one-line fix, and the ADR-0087 disposition `registered
view-item-owner-hidden-removed` |
## Pins
(`packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts`)
- Every door that carries a record refuses both keys:
- the strict authoring door and the `.strip()` wire member: issue code
`invalid_type`, path, and prescription;
- the `view` registry binding, which is what `saveMetaItem` runs:
`invalid_union` whose message is the prescription, and whose
viewItem-branch issue locates the key;
- the assembled channel.
- `defineViewItem` throws, and an `@ts-expect-error` proves the input
type is `never` (the file is compiled by `check:test-typecheck`).
- CONTROL: the same record without the keys passes all four doors, with
`scope` / `isDefault` / `order` intact and no `owner` / `hidden` grown.
- BOUNDARY: a flattened overlay's own keys still parse.
- Conversion: a stored row rehydrates clean through
`applyConversionsToStoredItem` and then parses at the door; `viewItems`
is reached and overlays and containers are left alone; the second replay
produces 0 notices and returns the same reference; on the load path, a
live author is refused rather than rewritten.
- Registration: the four `RETIRED_KEYS_BY_MAJOR[18]` rows and the chain
id.
- **Tree-scoped structural absence** over the declared
`@objectstack/spec` radius (`packages`, `examples` non-code, `skills`,
`content`, `scripts`, all already in
`scripts/cross-package-test-inputs.mjs`).
- `owner` / `hidden` are among the commonest key names in this tree, so
the matcher is structural rather than textual. It flags one object
literal, or one YAML mapping, whose own keys include `viewKind`,
`config` and a retired key.
- Anti-vacuity cases cover each syntax the walk reads, plus the
neighbours that must not match: an overlay, a nested column `hidden`,
the `sys_view_definition` row shape, prose, a quoted string, and a
sibling YAML item.
- Excluded, with stated reasons: the conversion fixture, the gitignored
`json-schema/` output, release notes, the pin itself, and `view.zod.ts`.
`view.zod.ts` was the one measured hit, and it was the overlay door's
own shape (a `config: z.undefined()` guard beside that door's keys).
## Verification (final head `b0b5b381`, after merging `origin/main` at
`ce70876e`, which carried #20183)
Heavy runs went through `scripts/pm/os-verify-lock.sh`; every exit code
was written to disk before its log was read. The box was shared (lock
queue waits of 9–20 min), so wall-clock figures are contended readings.
| run | head | reading |
|---|---|---|
| metadata-protocol dependency closure build (includes
`@objectstack/spec` build + `gen:schema`) | `b0b5b381` | exit 0 |
| `pnpm --filter @objectstack/spec check:generated` | `b0b5b381` | exit
0, all 15 artifacts current. Earlier, `--fix` regenerated only
`content/docs/references/**`, the one artifact it proved stale |
| spec `--project local`, full | `b0b5b381` | 544 files / 16035 tests
passed |
| spec `--project repo`, full (this pin now lives here) | `b0b5b381` |
33 files / 604 tests passed (roster length 33, including this pin) |
| spec `typecheck` (`tsc` + `check:scripts-typecheck` +
`check:test-typecheck`, so the `@ts-expect-error` is compiled) |
`b0b5b381` | exit 0 |
| consumer: metadata-protocol, the 22 files that spell `viewKind`
(#20183's new tests included) | `b0b5b381` | 22 / 372 passed |
| consumer: objectql, the 24 `viewKind` files (registration loop,
assembled `viewItems`) | `60eec227` | 24 / 380 passed |
| consumer: lint (vitest ran the whole suite) | `60eec227` | 109 / 4232
passed |
| consumer typecheck: `@objectstack/lint`,
`@objectstack/metadata-protocol` | `60eec227` | exit 0, exit 0 |
| spec source audits `check:liveness`, `check:empty-state`,
`check:exported-any`, `check:dual-source-exports`, `check:variant-docs`
| `4cb0f252` | all exit 0 |
**Reverse verification (cross-package type, one-shot, nothing left
behind).** A temp `packages/lint/src/zz-issue20085-dts-probe.ts` called
`defineViewItem({ …, hidden: true })` against the REBUILT spec `.d.ts`.
`@objectstack/lint` `tsc --noEmit` exited 1 with
`src/zz-issue20085-dts-probe.ts(9,3): error TS2322: Type 'true' is not
assignable to type 'undefined'.` — the probe's `hidden: true` line.
After the probe was removed (verified absent), `@objectstack/lint
typecheck` exited 0. Predicted direction: red. Observed: red.
**Door measurement (one-shot, not committed).** The existing
`protocol.save-union-issues.test.ts` harness drives the real
`saveMetaItem` write path over its stub engine. With a probe block
appended, in a temp file, deleted after the run:
| body saved | result |
|---|---|
| record with `owner` | `INVALID_METADATA` / `422` / 0 rows persisted,
prescription in `issues` |
| record with `hidden` | the same |
| CONTROL: the same record without either key | saves, 1 row |
| BOUNDARY: a bound lean `{ hidden: true, object, viewKind }` overlay |
saves, 1 row |
**Ablation of the new pin** (`scripts/ablation-replace.mjs`, on
committed state). The mutation swapped `owner:
retiredKey(VIEW_ITEM_OWNER_RETIRED),` for `owner:
z.string().optional(),`: anchor count 1 → 0, blob `9d5445c7` →
`fc09981a`. The pin then read 4 failed / 14 passed, exactly the four
`owner` door pins (strict, wire, `view` door, assembled). The restore
brought the blob back to HEAD `9d5445c7`, with `git diff HEAD` at 0
bytes and `git status --porcelain` at 0 lines. Predicted direction: red.
Observed: red.
**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` was re-derived on the ACTUAL changed paths
at `b0b5b381` (111 commands; the dispatch-time lead had 72). Every line
was run with its exit code captured before any pipe, and the record was
reconciled with `--ran`: **111 derived, 107 run, 4 NOT-MEASURED, 0
UNRUN**. All 107 measured commands exited 0: 40 `node scripts/…` and 67
`pnpm …`, including `check:adr-0087-registration` (`registered
view-item-owner-hidden-removed (new here …)`),
`check:changeset-no-major`, `check:empty-changeset`,
`check:cross-package-test-inputs`, `check:engine-double-contract`,
`check:nul-bytes` and the spec `check:*` family.
NOT MEASURED — exit 3, `PREREQUISITE NOT MET`. Each reads built output
of packages outside the closure built here (client-react; the whole
workspace), and the verify lock was queued 9–20 min per attempt, so they
were not built locally. This diff touches no package entry point,
export, client SDK or tsconfig. CI's build lanes measure them:
- `pnpm --filter @objectstack/spec run check:skill-examples`
- `pnpm check:dual-build-cjs-loads`
- `pnpm check:lean-entry-closure`
- `pnpm check:type-check-debt`
Also not measured locally, and owned by CI: the rest (closure of 26
packages) and client-react (35) consumer suites, `pnpm lint`, and the
CI-only job and type-check lanes that `dispatch-gates` lists as having
no local invocation.
## Acceptance notes (observed, not fixed here)
1. **The liveness walk cannot see a view-item key** (execution note 3 on
the card). `check:liveness`'s view walk stops at the `view` union's
container arm: its `shapeOf` takes the first object member, and the
`viewItem` arm is a discriminated union. So no view-item key can hold a
ledger row, and a row reads as ORPHAN. That is why this retirement has
no `dead` row to keep. Not claimed here. Carrier: none; triage routed it
to the `domain:spec` lane.
2. **Same family: the authorable-surface ratchet cannot see a view-item
key either.** `build-schemas.ts` collects authorable keys from a def's
top-level `properties` only. `ui/ViewItem` and `ui/ViewItemWire` are
discriminated unions with none, so `authorable-surface/` has no
`ui/ViewItem:*` line, and check (b) never sees these tombstones. The
four `RETIRED_KEYS_BY_MAJOR[18]` rows are declared rather than judged;
this PR's own pin holds them. Carrier: none; same family as note 1.
3. **The flattened-overlay door keeps its own `owner` / `hidden`, and
they are just as inert.** `flattenedViewOverlayFields()` declares both
for the lean personalization PUT. The premise table found no reader of
either key on any door, and the one-shot door probe saved a bound `{
hidden: true, object, viewKind }` overlay (1 row). Same family as this
card; left for the seat to judge, because the order scoped this card to
the ViewItem door. Carrier: none.
4. **The assembled channel's refusal loses the branch diagnostics.**
`AssembledViewArtifactSchema` is a plain `z.union`. A refused
`viewItems:` entry therefore reaches the registration loop's 422 as
"First issue: Invalid input", while the `view` door's union surfaces the
prescription (measured on a record carrying `hidden`). Pre-existing.
Carrier: none.
5. **The earlier view-family conversions do not reach `viewItems`.**
`mapViewPayloads` walks `stack.views` only, so, for example,
`view-page-mount-removed` never visits a ViewItem record's `config`
inside an assembled artifact's `viewItems:` channel. This is read from
`conversions/walk.ts`, not exercised end-to-end. This PR's own
conversion walks both collections. Carrier: none.
6. **A bound flattened LIST overlay without `columns` parses through the
FORM member.** On `b0b5b381`, `{ name, object, viewKind: 'list', hidden:
true }` is claimed as `listOverlay` by `selectViewMetadataBranch`, yet
the union accepts it through the form overlay (output stamped `type:
'simple'`); adding `columns` gives `type: 'grid'`. `saveMetaItem` stores
the original body, so no stored effect was measured. It sits in #20051's
region. Carrier: none.
7. **`sys_view_definition` and its migration runbook describe a switcher
merge that does not exist** (objectui has 0 references at its pin and at
`main`). ADR-0131 D13 already rules both inert and retired, with
execution paused. Nothing to do here.
## ADR interplay
ADR-0017 §2 describes `personal` views as "visible only to its `owner`".
Its 2026-09-04 amendment (ADR-0131 D13) retired the §3.4 store as inert
and parks per-user view scope as a v18 direction. This PR removes the
unenforced item-level declaration of that parked direction and leaves
the scope model itself alone (`scope` is untouched). If the direction is
revived, `owner` returns together with its reader. No ADR text is edited
here, which keeps this diff off the governed surfaces.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 66e266c commit 3cb84d0
12 files changed
Lines changed: 929 additions & 25 deletions
File tree
- .changeset
- content/docs/references/ui
- packages/spec
- src
- conversions
- migrations
- entries/retired-keys
- system
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2104 | 2104 | | |
2105 | 2105 | | |
2106 | 2106 | | |
2107 | | - | |
2108 | | - | |
| 2107 | + | |
| 2108 | + | |
2109 | 2109 | | |
2110 | 2110 | | |
2111 | 2111 | | |
| |||
2194 | 2194 | | |
2195 | 2195 | | |
2196 | 2196 | | |
2197 | | - | |
2198 | | - | |
| 2197 | + | |
| 2198 | + | |
2199 | 2199 | | |
2200 | 2200 | | |
2201 | 2201 | | |
| |||
2277 | 2277 | | |
2278 | 2278 | | |
2279 | 2279 | | |
2280 | | - | |
2281 | | - | |
| 2280 | + | |
| 2281 | + | |
2282 | 2282 | | |
2283 | 2283 | | |
2284 | 2284 | | |
| |||
2377 | 2377 | | |
2378 | 2378 | | |
2379 | 2379 | | |
2380 | | - | |
2381 | | - | |
| 2380 | + | |
| 2381 | + | |
2382 | 2382 | | |
2383 | 2383 | | |
2384 | 2384 | | |
| |||
0 commit comments