Skip to content

test(spec): give the metadata-form reconciliation ledger a root coordinate and an ADR-0010 overlay skip - #19639

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-19329-ledger-root-coordinate
Sep 22, 2026
Merged

os-justin merged 1 commit into
mainfrom
claude/issue-19329-ledger-root-coordinate

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #19329

Clause-②: no

Two instruments the top-level zodOnly direction (#19188) needs before it can be wired at
all, both landing in the gate itself —
packages/spec/src/system/metadata-form-zod-reconciliation.test.ts. Neither wires that
direction, and neither moves a verdict this gate reads today (before/after comparison below).

① The ledger's root coordinate — spelling: an explicit sentinel, (root)

ROOT_PATH = '(root)', plus one function that knows both coordinate spellings.
resolveCoordinate(form, root, path) returns the pair a coordinate resolves to — the keys the
form offers there, and the schema node they are judged against — and the
「every ledger entry still resolves on both sides」 test now goes through it. At the root that
pair is topLevelFields(form) against the type's own root schema; at every other coordinate it
is the nestedLists entry's offered against subSchemaAt(root, path), i.e. the same two
values the test read before.

Why a sentinel and not '' — the card left the spelling open, so this is the decision and
its reasons:

  1. '' is falsy, and path ? … : … is the load-bearing spelling in this very file
    (nestedLists' own prefix test). Any reader written that way silently reads the root
    coordinate as "no path given" — the one confusion a coordinate must not have.
  2. An unfilled path then cannot masquerade as a deliberate root row. Measured:
    resolveCoordinate(form, schema, '') is undefined, so an empty path fails the resolve
    test loudly instead of quietly excusing a top-level key. Ablation A below shows that pin go
    red the moment ROOT_PATH becomes ''.
  3. The dotted algebra has no zero-segment element: ''.split('.') is [''] — one empty
    segment, not none — so subSchemaAt(root, '') walks a segment no schema declares and
    resolves to undefined. '' would need a guard wherever a path is walked; a sentinel needs
    one at the single resolve step, and it is visible at the call site.
  4. Parentheses cannot occur in a form's field: name, so the sentinel cannot collide with a
    real dotted path — asserted over the live registry (0 collisions across all 17 forms),
    not assumed.
  5. It reads unambiguously in a failure label: object.(root).apiMethods, not
    object..apiMethods.

② The framework-field skip — derived from the ADR-0010 shape, applied at the root only

FRAMEWORK_FIELDS mirrors the liveness gate's precedent (FRAMEWORK_FIELDS in
packages/spec/scripts/liveness/check-liveness.mts — located by symbol; read-only here, not
edited), and offerableKeysAt(sub, path) is where it applies.

Derived, not hand-copied. The seven _-prefixed keys are
Object.keys(MetadataProtectionFields) — the one exported raw shape every metadata schema
spreads — so a key added to or dropped from the envelope moves this skip in the same commit. A
second copy of the liveness gate's eight literal names would have been the hand-copied-list
shape this whole file exists to abolish, and that const is module-local to a .mts script,
so there is nothing to import from it either way: the shape is the better source for both.
protection is the one name written out — the author-facing block the loader translates INTO
that envelope, spliced under that name by each schema rather than carried in a shape of its own
— and it is pinned against what it resolves to on every live type that declares it: 13 of 17,
each ["docsUrl","lock","reason"], equal to keysOf(ProtectionSchema).

The skip stops at the root, deliberately, and that is a measurement rather than a
preference. The overlay is spread into nested shapes too (three times in view.zod.ts alone),
and exactly one hand-written nested list resolves to a sub-schema carrying all seven
_-prefixed keys: object.fields, whose subset entry is judged today on how much of that
schema the quick-add grid does NOT cover. Skipping the overlay there would move a number an
asserting leg reads. Pinned as a dark control over both a synthetic probe and that live
instance; ablation B below shows it go red when the skip is applied at every coordinate.

The fence: before/after verdict comparison

Same command on both sides, exit code captured before any pipe, through
scripts/pm/os-verify-lock.sh (slot issue-19329):

pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 \
  --reporter=verbose src/system/metadata-form-zod-reconciliation.test.ts
unmodified origin/main (744a0a3) this branch (7c1b59a)
wrapper verdict VERDICT command-exit 0 VERDICT command-exit 0
test files 1 passed (1) 1 passed (1)
tests 46 passed (46) 53 passed (53)
non-pass marks none none
verdict-set sha256 ad70459ed4c08edeaed52b8e8a7f9a64fa46a0b194c6a345f32407dbd547dcfd 3b801fc825039fa32cb9d080a864993e2d2fc511fd46006247c65c981099ecfd

Exact set difference over the normalised verdict lines (status mark + full test path, per-test
durations stripped), not a count:

  • Missing from after: 0. All 46 origin/main verdicts are present on the branch,
    byte-identical, and all 46 still pass. No rename, no removal, no status flip — so formOnly
    and offered-but-retired keep asserting exactly what they assert today.
  • Added: 7, all of them the new pins in one new describe block:
    • the root coordinate resolves to the top-level pair, which no nested path can
    • the empty string is not the coordinate — an unfilled path fails loudly instead
    • no live form has a hand-written list at the sentinel, so it cannot be shadowed
    • the overlay set IS the ADR-0010 shape, not a second hand-copied list
    • positive control: the overlay drops out of the offerable keys at the root, and nothing
      else does
    • dark control: the skip does not reach a nested coordinate, live instance included
    • a top-level omission is recordable, and a row naming the overlay is not

No existing verdict changed, so the fork the card reserved for that outcome was not reached.

Ablation — both pins can fail, and both legs restored

Through scripts/ablation-replace.mjs (anchor must hit; the write is verified against the
disk; restore proven by blob equality with HEAD plus an empty git diff HEAD), each wrapping
the same locked vitest run. No dist leg: the subject is reached by relative src imports
inside its own package, so nothing resolves through exports.

leg mutation on-disk evidence result
A const ROOT_PATH = '(root)'; → const ROOT_PATH = ''; anchor 1 → 0, blob b72a5edfcd72 → d138333efcbf 1 failed | 52 passed — exactly 「the empty string is not the coordinate」
B offerableKeysAt's path === ROOT_PATH ? … : keys → skip applied unconditionally anchor 1 → 0, blob b72a5edfcd72 → 9ec64f2272af 1 failed | 52 passed — exactly 「dark control: the skip does not reach a nested coordinate」

Both legs: ok restored: blob == HEAD (b72a5edfcd72) and git diff HEAD is empty. Direction was
predicted before each run and matched (turn red, one named test each).

Census, re-derived on this tree

⚠️ These are my readings on base 744a0a3, not the card's figures restated. Probe: a
temporary .mts script (deleted; untracked, never committed) that imports
METADATA_FORM_REGISTRY, getMetadataTypeSchema and MetadataProtectionFields, and slices
the gate's own helper block verbatim — lines 151-340 of the unmodified file, sha256
d07ca156491f3dec — so nothing is re-implemented by grep. Run as
OS_EAGER_SCHEMAS=1 tsx, with a lit control and a dark control asserted in the same pass.

reading value card
registered forms / types 17 17
top-level keys in shape, summed 524 —
top-level authorable keys (tombstones dropped), summed 495 —
top-level keys the forms DO offer, summed 222 —
top-level authorable keys no form offers (the population) 274 274
of those, the ADR-0010 overlay 132 132
— the seven _-prefixed keys × 17 forms 119 119
— protection 13 13
the population minus the overlay 142 145
formOnly at top level 0 0
offered-but-retired at top level 0 0
lit control: name, offered by N of 17 forms 17 17
dark control: a fabricated key 0 0
dead ledger rows across these 17 liveness/*.json 27 27
— of those, inside the 274 population 0 0

One figure does not reproduce: the keys needing a recorded reason are 142, not 145.
274 − 132 = 142 exactly, on this tree and by that arithmetic, so the two published numbers
cannot both be readings of this population. Everything else matches, including both "what is
NOT wrong here" claims: no dead verdict touches the population, and the two directions the
gate does assert are clean.

Verification

Final commit 7c1b59a8e; the tree was clean for every run below and git status --porcelain
is empty.

  • Derived gate families — node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 77 commands from the actual diff (1 path, +291/−16).
    Every one was run, each exit code written to a file before any pipe, then reconciled:
    ✓ dispatch-gates --ran: 77 derived famil(ies) accounted for — 74 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3). — 0 UNRUN.
  • 74 exit 0. Includes check:nul-bytes, check:cross-package-test-inputs,
    check:test-source-alias, check:type-check-coverage, check:published-files,
    check:doc-authoring, check:issue-citations, and the spec artifact set
    (check:api-surface, check:authorable-surface, check:docs, check:liveness,
    check:empty-state, check:strictness-ledger, …) after
    pnpm --filter @objectstack/spec build (green; no generated artifact moved).
  • 3 NOT MEASURED, reported as exactly that — check:dual-build-cjs-loads,
    check:lean-entry-closure, check:type-check-debt each exited 3, PREREQUISITE NOT MET:
    they read built output for 87 / 1 / 30 packages that have no dist in this checkout, which
    is the whole-monorepo build CI does before those steps. Not a pass and not a finding — they
    measured nothing. A fourth, check:doc-formula-expressions, also exited 3 and was
    cleared: turbo run build --filter=@objectstack/formula --filter=@objectstack/lint, re-run,
    exit 0.
  • pnpm --filter @objectstack/spec test — VERDICT command-exit 0; 510 files,
    14919 passed | 1 todo.
  • pnpm --filter @objectstack/spec typecheck — VERDICT command-exit 0, including
    check:test-typecheck: 53 files / 257 errors / 142 pinned signatures held, i.e. the
    shrink-only test-layer ledger is unmoved by the new code.
  • pnpm lint (eslint . --no-inline-config, the repo-wide union, not a narrowed run) —
    VERDICT command-exit 0, no output.
  • Dependency closure — packages/spec has no workspace dependencies, so there is no ^...
    closure to build; its own dist was rebuilt before the gates that read it.

Changeset: skip-changeset, measured

@objectstack/spec publishes files: [dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json]. The landing file is
src/system/metadata-form-zod-reconciliation.test.ts, which no entry matches. Measured after
the build by grepping every published path (including all 202 tracked src/**/*.zod.ts) for
each symbol this PR introduces:

symbol published paths containing it
ROOT_PATH 0
FRAMEWORK_FIELDS (this file's) 0
isFrameworkField 0
resolveCoordinate 0
offerableKeysAt 0
MetadataProtectionFields (positive control) 71

Zero hits with the positive control hitting ⇒ nothing publishes ⇒ no changeset is owed, and
inventing a published-package changeset for an instrument change would be the wrong record.

Acceptance notes

维护者速读(草稿)

改了什么 — 只动一个测试文件:元数据表单 ↔ Zod 对账门禁。给它的「故意不提供」台账补了一个
顶层坐标(哨兵 (root)),并加了一条跳过规则:ADR-0010 的来源/锁定覆盖层(7 个下划线键加
protection)不是作者编写面,顶层对账时不算缺口。新增 7 条钉子测试。

为什么改 — 这两件都是 #19188 顶层方向的前置件。台账每条都用点分路径作键,而路径只来自嵌套
清单,所以顶层的第一条「故意不提供」根本记不下来;覆盖层则占了 274 个顶层未提供键中的 132 个
(48%),不跳过的话新方向一半输出是噪音。本 PR 只把工具做出来,⛔ 没有打开那个方向。

风险与代价(含回滚) — 风险点是「给门禁加跳过」等于削弱门禁。已按卡上的围栏逐条证明没有:
门禁今天在断言的两条腿(formOnly、offered-but-retired)46 条判决前后逐字相同、全绿,只多出 7 条
新钉子;跳过规则只在根坐标生效,嵌套坐标有暗控测试守着(含 object.fields 这个真实实例)。两次
消融各证明一条新钉子真能变红。不发布任何包(已实测),回滚 = 还原这一个文件,无迁移、无下游。

席位意见 —

你要做的 — 确认哨兵拼法 (root) 是你想要的(卡上留给实现者决定;备选是空字符串,正文列了
五条不选它的理由)。确认「工具先落地、断言留给 #19188」这个分工。其余无需动作:草稿 PR,⛔ 未翻
ready、⛔ 未挂 auto-merge。


Generated by Claude Code

…inate and an ADR-0010 overlay skip

The ledger is keyed by a dotted `nestedLists` path, and those are nested by
construction, so a deliberate omission at a form's TOP level could not be
recorded at all: the resolve leg looks its coordinate up among the hand-written
lists and finds nothing, and `subSchemaAt(root, '')` walks one empty segment
because `''.split('.')` is `['']`, not `[]`.

Two instruments, neither wired to an assertion:

- `ROOT_PATH` — an explicit sentinel rather than `''`, because `''` is falsy and
  `path ? … : …` is this file's own load-bearing spelling, so an unfilled `path`
  would otherwise masquerade as a deliberate root row. `resolveCoordinate` is
  the one place that knows both spellings; the resolve leg goes through it.
- `FRAMEWORK_FIELDS` / `offerableKeysAt` — the ADR-0010 provenance/lock overlay
  is system-stamped, never authored, and is 132 of the 274 top-level keys no
  form offers. Derived from `MetadataProtectionFields` rather than copied from
  the liveness gate's names, so the set cannot drift from the envelope. Skipped
  at the root coordinate ONLY: `object.fields` resolves to a sub-schema carrying
  the whole envelope, and its `subset` entry is judged today on what the
  quick-add grid does not cover.

Seven new pins, all synthetic or dark controls over the live registry. The
top-level zod-only direction itself stays unwired.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@os-justin os-justin added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 22, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6ffccc51e2f36c24cbabea0a5242d0001a8d00f3 → packageMentionDocs.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7c1b59a8e1739c9285cab9f05dcd808ff3d5d1ec

In-seat, per C6's own remedy 「the lane seat reviews at tier -- in-seat when its served tier is the constant」. This seat's served model was read from the session record, ⛔ not assumed: last_served_model = the constant's value. Review taken 2026-09-22T02:49Z. Every judgment below is read off the branch source and the API, ⛔ never off the dev's report narrative.

① Derived judgments

No published surface moves. The diff is ONE path — packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, +291/−16 — and @objectstack/spec publishes files[dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json], read from origin/main:packages/spec/package.json. A .test.ts matches no entry, src/**/*.zod.ts least of all. ⇒ nothing in this diff ships to a consumer; no accept/reject result changes for any author.

The one gate-weakening risk is root-scoped in source. A framework-field skip added to a gate is one spelling of 门禁削弱, which is maintainer-floor, so the dispatch fence required it to serve only the not-yet-wired top-level direction. Read at branch line 468:

return path === ROOT_PATH ? keys.filter((k) => !isFrameworkField(k)) : keys;

⇒ the filter is reachable only when path === ROOT_PATH; every nested coordinate gets the unfiltered set. The dev's ablation B (skip applied unconditionally) reddens exactly one pin named 「dark control: the skip does not reach a nested coordinate, live instance included」 — a dark control for this property existing at all is the right shape.

The skip set is DERIVED, not hand-copied — line 164 spreads ...Object.keys(MetadataProtectionFields) (imported at 108) ⇒ it cannot drift from the envelope it mirrors. That is strictly better than the check-liveness.mts precedent it was told to follow, which still spells its names out.

Scope respected: the top-level direction is NOT wired. The file says so at 848–850 — 「What is deliberately NOT here: an assertion that the top-level zod-only set is empty … wiring that direction is #19188's work」 — and no such assertion exists in the diff. ⇒ this card made the direction possible, which is what it was for.

The fence's before/after, re-derived by this seat rather than accepted. The dev reports verdict-set sha256s and MISSING=0 / ADDED=7. Independent corroboration on the two readings a reviewer can take without the suite:

reading origin/main branch 7c1b59a8e1
formOnly occurrences in the file 7 7 — unchanged
it(/test( declarations 12 19 — +7, exactly the new pins, none removed

And the 16 deletions read in full: they are the inline body of the resolve-on-both-sides test (nestedLists(...), lists.find(...), subSchemaAt(root, entry.path), authorableKeysOf(sub), list!.offered) plus comment lines — the computation resolveCoordinate now performs. ⛔ No expect was dropped without an equivalent: the two deleted guards become a single resolveCoordinate that returns undefined, which the retained expect(...).toBeTruthy() at 605 still fails loudly on. ⇒ no existing verdict changed; the fork the fence reserved was not reached.

Root-coordinate spelling. ROOT_PATH = '(root)' (line 139) over ''. The reasoning is sound and the decisive leg is mechanical, not aesthetic: '' is falsy and path ? … : … is load-bearing in this very file, so a root coordinate spelled '' reads as 「no path given」 to any reader written that way — and an unfilled path could then masquerade as a deliberate root row. Parenthesis-collision was asserted over the live registry (0 across 17 forms), ⛔ not assumed.

② Semver level

None — no changeset owed. skip-changeset is correct, and the routing was verified against files[] rather than taken on the category claim: the landing path matches no published entry. ⛔ Not judged from the dev's grep of published paths, which is its reading, not mine.

③ Boundary flags

  1. ⚠️ A card-text defect, surfaced rather than absorbed. The body says 「145 keys of the 274 need a recorded reason」; the dev re-derived 142, and 274 − 132 = 142 exactly ⇒ the card's three figures cannot all be readings of one population. The dev reported it instead of quietly matching the card — correct behaviour. This is a defect in the card, ⛔ not in the diff, and does not gate this PR. The card is corrected separately.
  2. ⚠️ 3 of 77 derived gate families NOT MEASURED (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt), each exit 3 PREREQUISITE NOT MET for want of a whole-monorepo dist. Declared, not omitted. ⛔ Neither a pass nor a finding — they measured nothing. CI builds that closure before those steps.
  3. ⚠️ CI was still converging when this review was taken: 36 check runs — 13 success, 7 skipped, 16 in_progress, and zero non-green. This is an honest in-flight reading, ⛔ not an all-green claim. This PASS is a verdict on the DIFF; the enqueue decision still waits on green.
  4. ℹ️ Observation, deliberately not filed. packages/spec/scripts/liveness/check-liveness.mts still hand-spells the overlay names this gate now derives. The dev left it in Acceptance notes with a named carrier and a reason it is not a card: a key added to the envelope stops being skipped there, falls through to unclassified and reds check:liveness loudly ⇒ no reproducible defect, no declared-contract violation, no metadata-authoring trap. Agreed — it clears none of the three filing classes, so ⛔ no card.

mcp_calls: 0 — no denied tool was touched; every read and write went through the REST proxy or scripts/pm/*.

Implemented-by: claude/issue-19329-ledger-root-coordinate
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 22, 2026 03:15
@os-justin
os-justin added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 3a9b07f Sep 22, 2026
41 checks passed
@os-justin
os-justin deleted the claude/issue-19329-ledger-root-coordinate branch September 22, 2026 03:38
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… metadata form (objectstack-ai#20064)

Part of objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#19334, which is no longer open. The other 39
residue keys are the structured-control bucket, and they are carded on
objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#19334 | no liveness verdict at any
coordinate | 7 | measured, **no row** (see below) |
| residue, object-rooted | fits no bucket above | 40 | 39 structured
(objectstack-ai#19332) + `field.format` |
| residue, `view` | per-arm keys | 36 | outside the top-level direction
until the per-arm forms exist, per the objectstack-ai#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 objectstack-ai#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 objectstack-ai#19188 needs covers the 16 object-rooted types (per the
objectstack-ai#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 (objectstack-ai#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

protocol:system size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants