Skip to content

Commit 736c63a

Browse files
test(spec): record why fourteen top-level keys are never offered by a metadata form (#20064)
Part of #19333 Clause-②: no This PR gives the top-level keys that no metadata form may offer a recorded reason. The reasons live in the metadata-form reconciliation ledger, at the root coordinate PR #19639 added. It does **not** switch on the top-level `zodOnly` assertion. After this PR, 40 top-level keys on the 16 object-rooted types still have neither a form row nor a recorded reason, so the population does not close. What stays open under #19333: the one residue key that fits none of the card's buckets (`field.format`), and the open questions on the seven `view` keys folded in from #19334, which is no longer open. The other 39 residue keys are the structured-control bucket, and they are carded on #19332. One file changes: `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts`. It gets 14 ledger rows and a comment block. No schema, form, `describe()`, liveness row or generated artifact changes. ## The population, re-derived on this tree ⛔ No number is carried over from the card or the thread. I copied the reconciliation gate's own helper block byte for byte, from the top of the file down to the first `describe(`, into a throwaway probe test next to it. The probe runs the same `resolveCoordinate(form, root, ROOT_PATH)` / `offerableKeysAt` / `omittedAt(LEDGER, …)` calls the gate runs. It was deleted afterwards and is not in the diff. Final slice: bytes 0..34234, sha256 `7b97432d8408…`, prefix verified byte-identical on disk. The controls are asserted inside the probe: | control | reading | |:--|:--| | LIT: `name` | offered by **17 of 17** forms | | DARK: a fabricated key, form side | offered by **0** forms | | DARK: the same key, schema side | declared by **0** schemas | | stage (17 registered forms) | before (base `7e6ca1787a`) | after (head `39590d226c`, merged with main `980bc05e5b`) | |:--|--:|--:| | top-level zod-only keys, overlay included | 229 | 229 | | of those, the ADR-0010 overlay (skipped at the root since PR #19639) | 132 | 132 | | non-overlay keys with no offer and no recorded reason | **97** | **83** | **Arithmetic.** 229 = 274 − 45. The census round measured 274 at `596090efbe7`, and PR #19673 has since landed 45 scalar form rows. 97 = 229 − 132, which is also 142 − 45. 83 = 97 − 14, and 14 is the number of rows this PR adds. **The card's 145 and the thread's 142 count two different sets.** Neither is a misreading. - 145 = 132 overlay + 13 keys in the card's own three other sub-buckets (4 + 4 + 5). The card's table adds up to it. - 142 = 274 − 132, every non-overlay key, including the 47 scalar, 39 structured and 36 + 7 `view` keys that belong to other cards. - On this tree the matching figures are **146** (132 + 14; the extra key is `field.system`, see below) and **97**. ## The 229, by sub-bucket, measured I assigned each bucket from the key's own `describe()` and its row in `packages/spec/liveness/TYPE.json`. None was decided here: | sub-bucket | criterion | keys | disposition | |:--|:--|--:|:--| | ADR-0010 provenance / lock overlay | in `FRAMEWORK_FIELDS` (the 7 `MetadataProtectionFields` keys on all 17 forms, 119, plus `protection` on 13) | 132 | **one reason, already in place**: the `FRAMEWORK_FIELDS` skip. No rows, and a root row naming an overlay key is refused by the resolve test | | platform-written, never authored | describe says not authored / never authored / machine-managed / auto-injected | 5 | a root `omit` row each | | deprecated or legacy alias | describe carries `[DEPRECATED …]` or `[LEGACY ALIAS …]` | 4 | a root `omit` row each, the shape of the `page.interfaceConfig.sourceView` precedent | | declared, not enforced yet | liveness verdict `planned` / `experimental`, or every child of the row is | 5 | a root `omit` row each, saying the key is out of this gate until enforced | | the seven `view` keys from #19334 | no liveness verdict at any coordinate | 7 | measured, **no row** (see below) | | residue, object-rooted | fits no bucket above | 40 | 39 structured (#19332) + `field.format` | | residue, `view` | per-arm keys | 36 | outside the top-level direction until the per-arm forms exist, per the #19330 ruling (letter A) | 132 + 5 + 4 + 5 + 7 + 40 + 36 = **229**. ### The 14 rows - **Platform-written:** `app._unpublished`, `field.system`, `view.columnState`, `view.isPinned`, `view.sortOrder`. - **Deprecated / legacy alias:** `object.displayNameField`, `object.titleFormat`, `view.drawerWidth`, `view.groups`. - **Not enforced yet:** `object.externalSharingModel` (planned), `field.useGrouping` (planned), `page.requires` (planned), `agent.structuredOutput` (experimental), `action.onSuccess` (both children `navigate` and `openIn` planned). Why the five `view` rows are safe while `view` is outside the direction: each of these reasons holds on every arm. None of the rows can excuse a key that a future per-arm form ought to offer. `field.system` is the one key not in the card's 145. PR #19673 held it out of the scalar bucket, and the census round routed it here. It meets the platform-written criterion on its own `describe()` (`Auto-injected system/audit field`, set against `author-declared business fields`). Every writer is platform code: `packages/spec/src/data/injected-system-column-provenance.ts`, `packages/objectql/src/search-companion.ts`, `packages/metadata-core/src/audit-field-governance.ts`. The record validator skips its required and multi-value checks for a flagged column (`packages/objectql/src/validation/record-validator.ts`), so a control would let an author turn those checks off by claiming a false provenance. ### `app._unpublished`: what it is It is the ADR-0045 §3 publish gate, amended to its own key. The AI materialization path writes it, `POST /packages/:id/publish-drafts` clears it, and `filterAppForUser` reads it. It shares the ADR-0010 envelope's `_` naming convention but is **not** a member of that envelope: it is not in `MetadataProtectionFields`, and only `AppSchema` declares it. So its absence from `FRAMEWORK_FIELDS` is correct, not a gap, and nothing here widens that set. It now has its own root row, which gives a machine a place to read its machine-written status. ## The seven `view` keys **The planned mechanism does not work.** The plan was to give them verdicts in `packages/spec/liveness/view.json`. That ledger's walk stops at the `container` arm of the `view` union: the gate's `shapeOf` takes the first OBJECT member, and the `viewItem` arm is a discriminated union, so it is passed over. `--dump view` walks only name, label, object, list, form, listViews, formViews and the overlay. A row for any of the seven is therefore an ORPHAN. Measured by planting a `config` row: `check:liveness` exited 1 with `✗ 1 ORPHAN ledger row(s) … view/config`. The file was then restored, blob `21c15486454c` equal to HEAD. **What was measured instead.** Readings at framework `7e6ca1787a` and objectui `62597c588`, re-read at framework `980bc05e5b` and objectui `f8a9d0fb0596` (the console pin on that main), with the same results: | key | writers | readers | reading | |:--|:--|:--|:--| | `config` | `defineViewItem`; the console's `viewEnvelope` (Save as view); `expandViewContainer` | `MetadataManager.getViewsByObject` serves it; the console's View editor edits `draft.config` | **authored**, live | | `viewKind` | `defineViewItem`; `viewEnvelope`; `expandViewContainer`, the server's `viewIdentityPatch` and the console's `buildPersistedViewBody` also stamp it | `getViewsByObject` filters on it; the console's `listViews` drops the form family on it | **authored** discriminator, live | | `order` | `expandViewContainer`; the authoring door's own guidance names it "the authored default" beside the per-user `sortOrder` | `getViewsByObject` sorts on it | **authored**, live | | `isDefault` | declared on the strict authoring door; the console's set-default (`setDefaultViewPatches`) | the console's switcher | **authored**, and also console-written as shared state | | `scope` | `expandViewContainer` stamps `package`; nothing writes `shared` or `personal` | the generic metadata list's `scope` filter | platform-stamped; no runtime writer | | `owner` | none in either repo | none in either repo | inert | | `hidden` | none in either repo | none in either repo | inert | None of the seven gets a row. The four authored keys would be excused by a row, and whether an arm form should offer them is a question for the first per-arm form, not for this ledger. The other three have no live writer, so there is no platform-written state to give as the reason. The readings are kept as a comment at the end of the ledger. They are not in `view.json`, because `liveness/` is in `@objectstack/spec`'s published `files[]` and a note there would change the tarball. ## Why the `zodOnly` check is not wired The direction #19188 needs covers the 16 object-rooted types (per the #19330 ruling, letter A). 40 of their keys still carry neither an offer nor a reason. Wiring the check now would turn those 40 into red lines, which is the shape the census round refused. `view` stays outside the direction until its first arm form is registered. ## Ablation Every leg went through `scripts/ablation-replace.mjs`. The anchor had to hit, the mutation was verified on disk by marker count and blob change, and the restore was proven by blob hash plus an empty `git diff HEAD`. Every leg restored to `efc4637425b6` (the file at `9c63b38770`). The subject is imported from `src` by relative path, so no build or `dist/` sits between the mutation and the run. 1. **Remove the overlay reason.** `offerableKeysAt`'s root filter became `return keys;`. The gate went red: 2 failed of 54, and `_lock is overlay and must not be offerable at the root` names the keys. The census went 83 → **215**, exactly +132, all overlay keys. 2. **Delete one recorded reason** (the `app._unpublished` row). The census went 83 → **84** and names `app._unpublished`. The gate stayed **green**, 54 of 54. That is the measured statement of what "unwired" means: while the `zodOnly` direction has no assertion, no gate notices a row going missing. 3. **Point a row at a key the form offers** (`displayNameField` → `nameField`). The resolve test went red: `object.(root).nameField: the form offers it now — drop the ledger entry`. The new rows cannot outlive the omission they excuse. An earlier attempt at the `view.json` probe was a no-op: the replacement contained its own anchor, so the tool refused before running anything. It was re-run with a non-self-matching anchor, and that run is the reading quoted above. ## Verification (head `39590d226c`) - `@objectstack/spec` build: exit 0. Tree clean afterwards (no `authorable-surface.base.json` or other artifact movement). - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: **534 files, 15708 passed, 2 todo**. Pre-merge at `9c63b38770`: 533 files, 15681 passed. - `pnpm --filter @objectstack/spec typecheck` (`tsc --noEmit` + `check:scripts-typecheck` + `check:test-typecheck`): exit 0. The test layer holds its identity-pinned debt with no new signature. - The reconciliation gate plus the probe: 2 files, 54 tests passed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 77 commands, each run with its exit code captured before any pipe. `--ran` first reported `77 derived, 73 run, 4 NOT-MEASURED, 0 UNRUN`: the four exit-3 families `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` were `PREREQUISITE NOT MET` on other packages' `dist/` while the whole-closure build waited for the shared lock. After that build ran under the lock (turbo 72/72, verdict 0), all four were re-run and exit 0, and `--ran` reports `77 derived famil(ies) accounted for — 77 run, 0 NOT-MEASURED`. (Updated by the seat from the dev's report `5825062779`.) - `check:liveness`: green, and `state-counts.md is current`. - Lint, narrowed and measured: `eslint --no-inline-config --format json` on the one changed file gives 1 file, 0 errors, 0 warnings. The population comes from eslint's own `--print-config` (the file is linted, not ignored). The config has no `parserOptions.project`, no `projectService` and 0 typed rules. It is therefore not type-aware, and a test-file edit cannot move any other file's verdict. ## Changeset None. The only changed path is not published: `npm pack --dry-run` of `@objectstack/spec` lists 2034 files, with 0 `*.test.ts` among them, while the positive controls `src/ui/view.zod.ts` and `liveness/view.json` are present. The documented no-release route is the `skip-changeset` label (AGENTS.md, Post-Task Checklist step 3). This PR's author does not write labels, so the seat applies it. ## Acceptance notes - **The five not-enforced rows hold only while the verdict does.** No leg re-reads the liveness verdict, so when one of these keys becomes enforced its row goes stale silently. The ledger comment says to delete the row at that point. Carrier: whichever PR enforces each key. - **`object.actions` sits on the edge of the platform-written criterion.** Its describe says "auto-populated from top-level actions via objectName", but 8+ platform objects author `actions: […]` inline, so it stays with the structured bucket (#19332). - **The liveness ledger covers only one arm of the `view` union** (`container`). The keys of the other arms can hold no verdict there at all. This was noted by the census round, and it goes to whoever registers the first per-arm form. The two inert keys above are the part of it that is a finding, handed to the filing seat. - The `274 … 132 of them this overlay` figures in the file's existing comments are dated readings from the census tree and were left as written. --- _Generated by [Claude Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60fdaa9 commit 736c63a

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,160 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
227227
key: 'onlyWhen',
228228
why: "the mirror of `retention.onlyWhen` — one shape by design (`lifecycleOnlyWhenSchema`, object.zod.ts) — with the same boundary: a row-filter map with no scalar rendering among the ttl block's text inputs, and its one writer today is the code-declared sys_session object (`revoked_at: { $null: true }`). Offering it needs a structured control, a form-face addition rather than a reconciliation",
229229
},
230+
// ── The root coordinate (#19333): top-level keys no form may offer ──
231+
//
232+
// Three reasons, each read off the key's own `describe()` or its liveness
233+
// verdict (`packages/spec/liveness/TYPE.json`) rather than decided here:
234+
// platform-written state, a deprecated or legacy alias, and a key declared
235+
// but not enforced yet. A fourth reason covers far more keys and has no row
236+
// at all: the ADR-0010 provenance/lock overlay, whose one reason is
237+
// `FRAMEWORK_FIELDS` above — a root row naming one of its keys is refused by
238+
// the resolve test below.
239+
//
240+
// `view` rows are here although `view` is outside the top-level direction
241+
// until its per-arm forms exist: each reason holds on every arm, so none of
242+
// these rows can excuse a key a future arm form ought to offer. Seven other
243+
// `view` keys carry no row; the block at the end of this ledger records
244+
// what was measured for each of them, and why none gets a row.
245+
//
246+
// The not-enforced-yet rows hold only while the verdict does. Once a key is
247+
// enforced its row is stale: delete it and decide the offer then — that
248+
// decision belongs to the enforcement, not to this gate.
249+
250+
// Platform-written, never authored.
251+
{
252+
kind: 'omit',
253+
type: 'app',
254+
path: ROOT_PATH,
255+
key: '_unpublished',
256+
why: "platform-written, never authored — the schema's own words: `Machine-managed publish gate (ADR-0045 §3) … Written by AI materialization, cleared by publish-drafts. Never authored`. The `_` prefix is the tooling-stamped channel ADR-0010's envelope also uses, but the key is not in that envelope (`MetadataProtectionFields`), so the overlay skip does not reach it and this row does. A control would let an author publish or re-gate an app by hand",
257+
},
258+
{
259+
kind: 'omit',
260+
type: 'field',
261+
path: ROOT_PATH,
262+
key: 'system',
263+
why: "platform-written, never authored — the schema calls it the `Auto-injected system/audit field` marker, kept apart from `author-declared business fields`, and every writer is platform code (the injected-column provenance table, the search companion, audit-field governance). The record validator reads it on the write path and skips its required and multi-value checks for a flagged column, so a control would let an author claim a false provenance that silently switches those checks off",
264+
},
265+
{
266+
kind: 'omit',
267+
type: 'view',
268+
path: ROOT_PATH,
269+
key: 'columnState',
270+
why: "platform-written, never authored — `Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored)`. Declared on the wire members only so the console's own write parses; the authoring door (`ViewItemSchema`) rejects the key by name",
271+
},
272+
{
273+
kind: 'omit',
274+
type: 'view',
275+
path: ROOT_PATH,
276+
key: 'isPinned',
277+
why: "platform-written, never authored — `Studio round-trip: view pinned in the switcher (per-user state, written by the console — not authored)`; the authoring door (`ViewItemSchema`) rejects the key by name",
278+
},
279+
{
280+
kind: 'omit',
281+
type: 'view',
282+
path: ROOT_PATH,
283+
key: 'sortOrder',
284+
why: "platform-written, never authored — `Studio round-trip: position within the switcher (per-user state, written by the console — not authored)`; the authoring door (`ViewItemSchema`) rejects the key by name and points the author at `order`, the authored default",
285+
},
286+
287+
// Deprecated or legacy alias — deliberately not offered to new authors, the
288+
// `page.interfaceConfig.sourceView` precedent at the top of this ledger.
289+
{
290+
kind: 'omit',
291+
type: 'object',
292+
path: ROOT_PATH,
293+
key: 'displayNameField',
294+
why: "`[DEPRECATED → nameField]` alias, accepted on read for back-compat and deliberately not offered to new authors: this form offers the canonical `nameField` (ADR-0079), and a second control beside it would teach the retired spelling",
295+
},
296+
{
297+
kind: 'omit',
298+
type: 'object',
299+
path: ROOT_PATH,
300+
key: 'titleFormat',
301+
why: "`[DEPRECATED → nameField (ADR-0079)]` render-only title template the server cannot return or query, deliberately not offered to new authors; its own describe prescribes the migration — a single-field title to `nameField`, a composite to a formula field designated as `nameField` — and both targets are authorable",
302+
},
303+
{
304+
kind: 'omit',
305+
type: 'view',
306+
path: ROOT_PATH,
307+
key: 'drawerWidth',
308+
why: '`[DEPRECATED → size buckets]` pixel drawer width, deliberately not offered to new authors: a pixel width cannot be chosen without knowing the client viewport, so the renderer derives it from the size bucket',
309+
},
310+
{
311+
kind: 'omit',
312+
type: 'view',
313+
path: ROOT_PATH,
314+
key: 'groups',
315+
why: '`[LEGACY ALIAS → sections]` accepted for back-compat and folded onto `sections` at parse (`sections` wins when both are present), deliberately not offered to new authors',
316+
},
317+
318+
// Declared, not enforced yet — no offer until it is enforced.
319+
{
320+
kind: 'omit',
321+
type: 'object',
322+
path: ROOT_PATH,
323+
key: 'externalSharingModel',
324+
why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0090 D11: validated at authoring time only; the audience-aware evaluator branch that would honour it is scheduled, not built). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
325+
},
326+
{
327+
kind: 'omit',
328+
type: 'field',
329+
path: ROOT_PATH,
330+
key: 'useGrouping',
331+
why: 'declared, not enforced yet — liveness verdict `planned` (the renderer read side that maps it onto `Intl.NumberFormat` is not landed). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
332+
},
333+
{
334+
kind: 'omit',
335+
type: 'page',
336+
path: ROOT_PATH,
337+
key: 'requires',
338+
why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0080: inferred at compile time; save/load enforcement of plugin presence is deferred). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
339+
},
340+
{
341+
kind: 'omit',
342+
type: 'agent',
343+
path: ROOT_PATH,
344+
key: 'structuredOutput',
345+
why: 'declared, not enforced yet — `[EXPERIMENTAL — not enforced]` in its own describe and `experimental` in the liveness ledger: parsed, no runtime consumer. No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
346+
},
347+
{
348+
kind: 'omit',
349+
type: 'action',
350+
path: ROOT_PATH,
351+
key: 'onSuccess',
352+
why: 'declared, not enforced yet — both of its children (`navigate`, `openIn`) carry the liveness verdict `planned`: no console consumer reads the block yet. No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
353+
},
354+
355+
// Measured, and deliberately NOT recorded: seven `view` keys with no
356+
// liveness verdict at any coordinate. `liveness/view.json` cannot hold them:
357+
// the liveness walk stops at the union's `container` arm (its `shapeOf`
358+
// takes the first OBJECT member, and the `viewItem` arm is a discriminated
359+
// union), so a row for any of the seven is an ORPHAN — planting `config`
360+
// there failed `check:liveness`. Read at framework 7e6ca1787a and objectui
361+
// 62597c588, then re-read at framework 980bc05e5b and objectui f8a9d0fb0596
362+
// (the console pin on that main) with the same readings:
363+
//
364+
// config the viewItem arm's REQUIRED body. Authored (`defineViewItem`,
365+
// the console's `viewEnvelope`); `getViewsByObject` serves it.
366+
// viewKind that arm's discriminator. Authored, and also stamped by
367+
// `expandViewContainer`, `viewIdentityPatch` and the console's
368+
// `buildPersistedViewBody`; `getViewsByObject` filters on it.
369+
// order authored: the authoring door's own guidance names it the
370+
// authored default beside the per-user `sortOrder`.
371+
// `getViewsByObject` sorts on it.
372+
// isDefault declared on the strict authoring door, AND written by the
373+
// console's set-default (`setDefaultViewPatches`); the
374+
// console's switcher reads it.
375+
// scope stamped `package` by `expandViewContainer`; nothing writes
376+
// `shared` or `personal`.
377+
// owner no writer and no reader of the view key in either repo.
378+
// hidden no writer and no reader of the view key in either repo.
379+
//
380+
// None of them gets a row. The first four are authored, so a row would
381+
// excuse a key a per-arm form may owe an offer, and that is a question for
382+
// the first arm form rather than for this ledger. The last three have no
383+
// live writer, so there is no platform-written state to give as the reason.
230384
];
231385

232386
// ────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)