Skip to content

fix(metadata-protocol): GET /meta/types stops publishing properties no instance can satisfy - #18231

Merged
os-warren merged 9 commits into
mainfrom
claude/issue-17502-repeater-row-tombstone-columns
Sep 16, 2026
Merged

os-warren merged 9 commits into
mainfrom
claude/issue-17502-repeater-row-tombstone-columns

Conversation

@os-warren

@os-warren os-warren commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17502

GET /meta/types published every retiredKey() tombstone as a property node beside the live keys. z.toJSONSchema renders a tombstone as a node carrying a [REMOVED] description and not: {} — truthful to a consumer that reads the subschema, invisible to one that reads the KEY SET. Studio builds a repeater's column headers from items.properties[k].title ?? k, so a tombstone inside a row shape became a column an author was invited to fill and the publish door then refused.

toJsonSchemaSafe now drops every property whose subschema admits no instance, before it serves or caches the document. The predicate is structural — it asks the JSON Schema question "does this admit any instance at all" — never a [REMOVED] prefix match, which would put a second hand-written spelling of "this is a tombstone" into a consumer. A property that admits nothing and is required is kept: dropping it would turn "this object admits nothing" into "this object admits anything".

This PR has been through an at-tier contract review that returned FAIL on two findings. Both are fixed below. The review record is comment 5672966288; this body was written from it, in session https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6.

Rework round — the two FAIL findings

FAIL 1 — the walk was not position-aware and widened a live node

walk() applied the properties-map logic at every object node it visited, including a node that IS a properties / $defs map. A property literally named properties therefore had its own keywords read as property subschemas, and any keyword valued { not: {} } under it was deleted. additionalProperties, items and propertyNames all use { not: {} } to say "and nothing more", so deleting one widens a live node — the one thing the module header says it must not do.

All three reproductions were re-derived here against the branch's own exported function before any edit, and re-run after. Two of them are pure zod with no hand-written input.

Before the fix (branch tip 9eaf3c08a5):

R1  z.toJSONSchema(z.object({ properties: z.record(z.string(), z.never()) }))
    in   ... "properties":{"properties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"not":{}}}} ...
    out  ... "properties":{"properties":{"type":"object","propertyNames":{"type":"string"}}} ...
    changed(byRef): true   changed(byValue): true     -> admitted only {}, now admits any object

R2  z.toJSONSchema(z.object({ properties: z.array(z.never()) }))
    in   ... "properties":{"properties":{"type":"array","items":{"not":{}}}} ...
    out  ... "properties":{"properties":{"type":"array"}} ...
    changed(byRef): true   changed(byValue): true     -> admitted only [], now admits any array

R3  { "$defs": { "properties": { "type": "object", "additionalProperties": { "not": {} } } } }
    out  { "$defs": { "properties": { "type": "object" } } }
    changed(byRef): true   changed(byValue): true     -> same deletion inside $defs

After the fix (a8958b9f1c), same script, same inputs:

R1  changed(byRef): false   changed(byValue): false   (returned by reference, byte-identical)
R2  changed(byRef): false   changed(byValue): false
R3  changed(byRef): false   changed(byValue): false

The fix. The walk now splits by position. walkSchema is the only place a property may be dropped, because it is the only position where the deciding required array is a sibling. properties, patternProperties, dependentSchemas, $defs and definitions are walked by walkSchemaMap, which hands every VALUE back to walkSchema and drops nothing — their keys are author-chosen names, not keywords, and neither patternProperties nor $defs has a required array that could license a drop (a $defs entry may also be the target of a $ref).

The mirror defect, fixed by the same change. Because the map was read as a node, a property NAMED required or default bought its whole subtree an exemption from the walk — NON_SCHEMA_KEYS skipped it. That is a missed strip rather than a widening, and it is pinned in the same commit.

Served exposure is zero, before and after. No served properties or $defs map has an entry named properties today, and the new over-drop guard below proves every one of the 27 served documents is still exactly its own derivation minus unsatisfiable nodes.

The unit pin that was missing. unauthorable-nodes.test.ts now carries three: the two pure-zod position cases, the $defs case, and the keyword-name-collision mirror. Proven red against the broken walk first — 3 failed / 6 passed, expected undefined to deeply equal { not: {} } on R1 and R3 and expected [ 'dead', 'live' ] to deeply equal [ 'live' ] on the mirror — then green after the fix.

FAIL 1b — the over-drop blind spot the rewritten pin gained

The review's mutation mut4b — an over-eager strip dropping a nested live key outside dashboard.widgets — ran green. Since #17502 the blast-radius baseline is stripUnauthorableProperties(preFixDerivation(type)), so a strip defect sits on both sides of that comparison and cancels itself out.

Closed by a new assertion in this card's own pin file, over-drop guard: the served payload is its derivation MINUS unsatisfiable nodes, nothing else. It reads the removals off the served payload and its derivation by a parallel walk of the two documents — deliberately not a second implementation of the strip, so the defect cannot appear on both sides again. Three verdicts: every served document is still a pure DELETION of one of its two derivation arms (nothing added, nothing rewritten); every removed node must admit no instance; and a non-vacuity control pinning dashboard's eight removals at both depths (the five repeater-row columns plus three top-level tombstones), sorted so key order is not what is pinned.

Proven by re-running mut4b's own mutation, under a trap, with the blob hash checked both ways:

== BASELINE ==
HEAD blob            : 9ec8cfe07c7bbfbac8154607094ececa45783f99
on-disk blob         : 9ec8cfe07c7bbfbac8154607094ececa45783f99
occurrences OLD      : 1          occurrences maxTokens: 0
== MUTATED (|| key === 'maxTokens' added to the drop condition) ==
on-disk blob         : 928e48116e77a4f6b203228107fa13f952e1873b
occurrences OLD      : 0   (expect 0)   NEW: 1   maxTokens: 1
== RUN under mut4b ==
VITEST_EXIT=1
  x over-drop guard: the served payload is its derivation MINUS unsatisfiable nodes, nothing else
  AssertionError: expected [ Array(1) ] to deeply equal []
  + [ "agent.properties.model.properties.maxTokens" ]
  Test Files  1 failed | 2 passed (3)      Tests  1 failed | 34 passed (35)
== RESTORED ==
on-disk blob         : 9ec8cfe07c7bbfbac8154607094ececa45783f99
occurrences maxTokens: 0   (expect 0)
git diff HEAD        : []          git status --porcelain: []

Note what the run also shows: protocol.meta-types-degenerate-derivation.test.ts stayed green under the same mutation. The blind spot is real, it is where the review said it was, and the new guard is the thing that closes it — naming the exact live node that was dropped.

FAIL 2 — the shipped changeset's consumer census was false

The changeset said "5 reachable as repeater columns" and "exactly two". Both are false.

The sha. .objectui-sha in this worktree reads 53ded82bf7a494f54e344e19099dbf00854b8694 — read here, from the file, not from a local checkout. The earlier round measured a local objectui at ff1d5ea, which is not the pin and is not an ancestor of it in either direction. Every objectui statement below is at 53ded82b.

The fix is not a bigger number. Per the seat's ruling, the census is renderer-dependent and pin-dependent, so any count written into CHANGELOG.md is false at the next .objectui-sha bump. The changeset now states the class — a tombstone arriving as help text, or as a repeater column, under an editable input the publish door refuses — and names the three mechanisms that put one in front of an author:

  • the flat, schema-driven fallback for a served type carrying no *.form.ts layout: its field list IS the served properties map, and a nested object renders recursively, so a tombstone at any depth becomes a field with the [REMOVED] prescription as its help text;
  • repeater rows, whose column headers are items.properties[k].title ?? k;
  • server-field grafting, where an inspector merges the server's top-level properties into a trailing "More fields" section, so a key the UI's own bundled spec predates is offered precisely because the served document is the only place it is known from.

The producer-side census — 80 nodes across 16 types at 74eaab8614, this PR's merge base, measured over this repo's own served registry — is repo-local and stays. (It read 77 across 15 at the earlier merge base 1bdbf82cb5; #17751 landed in between and retired ChartConfigSchema.aria, taking dashboard 8 → 9 and report 0 → 2. A reading taken at a tree, not a standing invariant.)

The same false clause in protocol.meta-types-unauthorable-columns.test.ts's header is corrected to match. The five widget columns stay named there as the row that file pins, no longer as the whole reachable set.

The stop condition, re-derived independently

The dispatch made one question a stop condition: if dropping the node destroys a live prescription channel, stop with an empty diff. The review re-derived this independently and reached the same answer, and it is restated here as the seat asked: no retiredKey() prescription channel is destroyed, and the served schema was never one. The strip is the right remedy. The FAIL findings are about how the strip walks and about what the changeset claims, not about whether to strip.

What toJsonSchemaSafe emits for a retiredKey() node, measured over the whole served registry: exactly two keys — a description beginning [REMOVED] , and not: {} — on all 80 nodes, with no title on a single one (which is what makes the defect invisible to the renderer: the header falls back to the humanized key), identical in both derivations, and 0 of the 80 required.

Channels that carry the prescription and are untouched: tsc (property of the Zod shape), the parse (pinned in this PR — the refusal carries the FROM/TO prescription byte for byte), packages/spec's authorable-surface/ ratchet, and the generated reference pages, which print the full prescription on a never-typed row.

Consumer-side, corrected, at the pinned objectui 53ded82b

Consumer Verdict at the pin
Repeater row cells (metadata-admin/widgets.tsx RowCell) No description branch at all; not is never consulted. A repeater column loses no TEXT — the removal only withdraws the offer.
Sectioned property panel (METADATA_FORM_REGISTRY layouts) Zero layouts declare a retired key (the #5280 fix holds). Nothing rendered, nothing lost.
Flat fallback, field face (SchemaForm.tsx FieldRow) Renders schema.description as help text for a layout-less type. api.cacheTtl, job.timeout.
Flat fallback, nested-form face Recursive SchemaForm, so a depth-1 tombstone renders the same way: job.retryPolicy.retryDelayMs. The earlier round intersected TOP-level tombstones only and missed it.
Flat fallback, object-rows face derivePropertyNames(items) hands a layout-less repeater its columns: book.groups[].translations is a sixth offered column.
mergeServerFields grafting Merges server-only top-level properties into a trailing "More fields" section rendered with the description as help: dashboard.refreshInterval and page.assignedProfiles, both retired in a spec newer than the one the pinned objectui bundles. A consumer the earlier round's table never checked.
tsc, the parse, authorable-surface/, reference pages Untouched.

All of these are the same defect class — an offer the door refuses — so the stop condition's answer is unchanged. What changed is that the changeset no longer claims a number for them.

Semver — minor, unchanged

Nothing authorable is removed or renamed, and no valid document changes shape, because every dropped node was unsatisfiable — so this is not major and carries no ADR-0087 disposition. It is more than a patch because it narrows a published payload. check:changeset-no-major, check:empty-changeset and check:adr-0087-registration all pass. The review agreed and this round changes nothing about the level.

On the clause-② carrier, since this body is being edited: the declaration lives on the claim comment 5670996882, which carries Clause-②: yes at the start of its own line — that comment is what the enqueue gate reads, and the needs:contract-review label is on the PR. This body carries no Clause-②: line and does not need to; noted because the review noted its absence.

Scope fence

Clean. Nothing under packages/spec/src/stack.zod.ts, any stack-*.test.ts, packages/spec/src/data/analytics.zod.ts, packages/spec/src/ui/view.zod.ts, packages/spec/src/api/discovery.zod.ts or packages/spec/src/meta-spelling/. No objectui file is touched — it was read only, at the pin 53ded82b. The review's other measurements (the acceptsNothing predicate, the engine-double-contract ratchet rows, the changeset LEVEL, the docs divergence with content/docs/references/**) were all judged correct and are untouched by this round.

Verification

Exit codes captured before any pipe; gate verdicts read from the gate's own printed line.

Run, at 21edb645e9 Exit
pnpm --filter '@objectstack/metadata-protocol^...' build then the package build 0
pnpm --filter @objectstack/metadata-protocol typecheck 0
pnpm --filter @objectstack/metadata-protocol test (full package suite) 0 — 178 files passed, 3 skipped; 2548 tests passed, 19 skipped
pnpm lint (eslint . --no-inline-config, whole repo, no narrowing) 0
pnpm check:engine-double-contract 0
pnpm check:nul-bytes plus an own control-character sweep of the changed files 0, clean
check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:type-check-debt, check:doc-authoring, check:objectui-changeset 0
check:empty-changeset, check:changeset-no-major, check:adr-0087-registration (all --base origin/main) 0
Unit pins against the BROKEN walk, before the fix 1 — 3 failed / 6 passed, exactly the three new pins
Ablation: mut4b re-run against the new over-drop guard 1 — 1 failed / 34 passed, naming agent.properties.model.properties.maxTokens; restored, git diff HEAD empty

The full-suite delta is +4 tests over the review's reading of the previous head (2544 passed), which is the three new unit pins plus the over-drop guard.

Open questions

  • api, job, book and layouts. Four of the six named sites exist because the type carries no *.form.ts layout and the panel falls through to the flat schema-driven list. Whether those types should carry layouts is a separate question from this card and was not touched.
  • A conditional cousin of the required guard. The drop is vetoed by the parent's required array. JSON Schema's dependentRequired can require a key conditionally, and a drop is a widening there too. Zod emits no dependentRequired and none appears anywhere in the served registry, so there is nothing to fix today; recorded rather than filed.

Generated by Claude Code

… JSON Schema

`GET /meta/types` published every `retiredKey()` tombstone as a property node
next to the live keys. `z.toJSONSchema` renders the tombstone as
`{ "description": "[REMOVED] <prescription>", "not": {} }` — correct for a
consumer that reads the subschema, invisible to one that reads the key set.
Studio builds a repeater's column headers from `items.properties[k].title ?? k`,
so a tombstone in a row shape became a column an author was invited to fill and
the publish door then refused.

`toJsonSchemaSafe` now strips every property whose subschema admits no instance
before serving or caching. The predicate is structural (`{ not: {} }` admits
nothing), never the `[REMOVED] ` description prefix — a prefix match would put a
second hand-written spelling of the tombstone in a consumer, which is the shape
this change removes. A property that admits nothing AND is `required` is kept:
dropping it would widen "admits nothing" into "admits anything".

Measured over the served registry: 77 such nodes across 14 types, 5 of them
reachable as repeater columns (`dashboard.widgets[]`).

Every prescription channel survives — the change is a property of one emitter:
`tsc` still types the key `never`, the parse still refuses it with the guidance
byte for byte, `authorable-surface/` still lists each key `[RETIRED]`, and the
generated reference pages still print the prescription on a `never`-typed row.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
… new engine doubles

Adversarial re-verification of the round that produced the strip stage found
two things the implementation got right and two the prose got wrong.

The census figure. `77 nodes across 14 types` conflated two derivations. The
SERVED payload carries 77 across 15 types: `toJsonSchemaSafe` falls through to
the `io: 'input'` retry arm for `action` alone, and that arm contributes
`execute` / `shortcut` / `bulkEnabled` which the default (output) derivation
cannot see. The default derivation alone is 74 across 14. The control test in
`protocol.meta-types-unauthorable-columns.test.ts` computes the 74 figure and
was titled with the 77 one; it now says which arm it measures and why it does
not re-spell `isDegenerateDerivation` (the emitter owns the only copy).

The `no prescription is lost` claim. Measured consumer-side rather than
asserted: of the 77 nodes exactly two -- `api.cacheTtl` and `job.timeout` --
reach a renderer that puts the tombstone's `description` in front of an author,
because those two served types carry no `*.form.ts` layout and the property
panel falls through to a flat schema-driven field list whose rows render
`description` as help text. Both keep the full prescription on their generated
reference page. The five repeater columns lose nothing: the row-cell renderer
has no `description` branch at all, so the column was an offer with no
prescription attached. The changeset now states the bounded exception instead
of a blanket claim.

`check:engine-double-contract` was red on the new test file: its fake engine
pins delete/findOne/update doubles the ledger did not record. Registered with
`--write`; 6 seam rows, 0 lost.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 10 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 500c1b56956537dd13dc6b2129dd2fb428da351cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 86cd6ac839ec14a1b790cad34f8f368651da8ef1 — the merge of head 0be466359c29b8019c129be3a02e958ccc8768c8 into base 500c1b56956537dd13dc6b2129dd2fb428da351c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 86cd6ac839ec14a1b790cad34f8f368651da8ef1 && git checkout 86cd6ac839ec14a1b790cad34f8f368651da8ef1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 500c1b56956537dd13dc6b2129dd2fb428da351c 0be466359c29b8019c129be3a02e958ccc8768c8 && git checkout -B drift-repro 500c1b56956537dd13dc6b2129dd2fb428da351c && git merge --no-ff 0be466359c29b8019c129be3a02e958ccc8768c8

node scripts/docs-audit/affected-docs.mjs --json 500c1b56956537dd13dc6b2129dd2fb428da351c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

Seat note — the drift check's own blind spot, re-read by hand. Result: ⛔ no docs regeneration owed — and one consequence named for the contract review.

domain:spec execution PM seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-14T22:5xZ.

The run above lists nothing and says so honestly: 「not a clean bill of health … This check sees only pages that NAME a derived anchor」. That leaves a manual re-read owed, and this PR is a case where it matters — it changes what a published door serves, and a page describing that door would not necessarily name any changed symbol.

① Does this change reach the generated reference pages? No. Measured.

The function this PR changes has exactly one non-test consumer in the tree:

git grep -ln 'toJsonSchemaSafe' origin/main -- 'packages/**/src/**' | grep -v '.test.'
  -> packages/metadata-protocol/src/protocol.ts        (and nothing else)

And the generators that build content/docs/references/** do not go through it — they call z.toJSONSchema directly:

git grep -ln 'toJsonSchemaSafe' origin/main -- 'scripts/**' 'packages/spec/scripts/**' 'apps/docs/**'
  -> 0 hits
control, same paths, same instrument: 'toJSONSchema'
  -> packages/spec/scripts/build-openapi.ts · build-react-blocks-contract.ts · build-schemas.ts
     check-duration-unit-keys.ts · check-react-blocks-declaration-parity.ts

⇒ the zero is a real absence, not a dead pattern. No generated page is falsified by this diff and no regeneration is owed here.

⚠️ ② But that same reading names a consequence, and it belongs in the review rather than in this note's verdict

Those two paths now disagree on purpose:

  • /meta/types (this PR) will stop publishing a retiredKey() tombstone as a property node.
  • content/docs/references/** is generated by the spec-side z.toJSONSchema path, which is untouched, so those pages keep rendering the [REMOVED] … descriptions.

That may well be right — a human reading docs wants to know a key was retired and what replaced it, while a machine reading the served schema should not be offered a column its parse door refuses. That is exactly the distinction this card is about.

This seat is not ruling it. It is handed to the at-tier clause-② review as a named input: is the divergence between the two published surfaces intended, and is it stated anywhere an author would look? If the answer is "intended", it deserves a sentence in the PR body; if it is not, the diff is incomplete on a surface neither CI nor the drift check can see.

⚠️ And the premise to falsify first: this note reads origin/main 1bdbf82cb5. If the diff has since added a second consumer of toJsonSchemaSafe, the one-consumer reading above is stale — re-run the grep rather than trusting it.

⛔ Nothing here is a verdict on the diff. The at-tier review has not run.


Generated by Claude Code

…e retry, not the strip

The full package suite caught what the previous round's two-file run could not:
`protocol.meta-types-degenerate-derivation.test.ts` went red in 11 places
because the strip moves 15 served payloads away from their raw derivation, and
that pin compares the served document against exactly that raw derivation.

Left alone, the pin is red for a reason that is not its own AND blind to the
reason it exists for -- a later blanket widening to `io: 'input'` would land
inside an assertion already failing for unrelated reasons.

So the baseline carries the same strip, applied through the emitter's own
`stripUnauthorableProperties` rather than a second spelling, and what is left
between the two sides is exactly the degeneracy retry's blast radius. The
assertion keeps its strength: widen the retry to every type and 24 types move
instead of one.

The property-count controls keep the card's original numbers as their
authority -- 48 for `action`, 26 for `agent`, 30 for `app` and the rest -- and
add back what the strip removed, derived per type via `retiredTopLevelCount`
rather than a second hand-maintained table. A live property that appears or
disappears is still red. `retiredTopLevelCount` reads whichever derivation has
properties, so `action`'s three tombstones are counted on the `io: 'input'`
retry arm where they are the only place they exist.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

🔴 Test Core (2/6) is red on 587c4cf730, and it is this PR's — ruled out as a base failure, measured

domain:spec execution PM seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-14T23:0xZ. Posting the blocker per the drive-to-green rule; the implementing round is live on this branch and has been handed the diagnosis.

What is failing. Check run 104184297136, Test Core (2/6)failure. Its only two annotations:

command (/home/runner/work/objectstack/objectstack/packages/metadata-protocol) pnpm run test exited (1)
Process completed with exit code 1.

packages/metadata-protocol's own test script — the package this PR changes (protocol.ts +11/−4, plus two new suites).

Ruled out before anything else, so nobody re-litigates it:

candidate measured
red on the base too No. Test Core (2/6) is success on base 1bdbf82cb5 on every run of it
a shard-wide or infra failure No. Sibling PR #18230, same base, different diff, has not gone red on that shard
a flake worth a re-run No. 「Flake」 is not a root cause, and none of the three re-run conditions holds — the failure is inside the changed package's own suite, not a pre-test-body death, and it has not passed on this exact commit

⇒ this PR's to root-cause. ⛔ No re-run spent.

⚠️ A measurement this seat could NOT take, stated as unread rather than clean: the Actions job-logs endpoint is blocked by this environment's egress proxy (curl: (56) CONNECT tunnel failed, response 403), so the exact failing assertion is not readable from CI here. The diagnosis above rests on the step-level check-run annotations plus the base/sibling comparison — ⛔ it does not name a test, and this note does not claim to.

Handed to the round with the branch, with three constraints restated: run the package's whole test script rather than only the two new suites (the annotation names the script, so a pre-existing suite broken by the protocol.ts change is squarely in range); ⚠️ this diff also moves scripts/engine-double-contract.pinned.json (+15) — a moved ratchet count must be justified, ⛔ never re-pinned to make a check pass; and ⛔ no assertion is weakened, skipped, loosened or deleted to reach green. If the failure shows that dropping tombstone nodes from the served schema breaks something real, that is a finding to report, ⛔ not a thing to force past.

⛔ The at-tier clause-② review has not run on this PR and nothing here is a verdict on the diff.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the domain:spec seat (session_01KB5PFtxuy1x3dcR5gxudx6), 2026-09-15T00:5xZ. ⛔ Not rewritten, not summarised.

Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report: the reviewer's transcript carries "model":"claude-fable-5-1" 222 times and no other value — zero fallback evidence. Controls, same instrument, two os-dev transcripts from this seat: "model":"claude-opus-5" ×147 and ×160.

⚠️ This PR had never been reviewed before. The implementing round was killed by a container restart before it filed an os-dev-report, so this record is the first and only adversarial read of the diff.


Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9eaf3c08a5fa22e627041bc8529ea590c3f6ff1f

① Derived judgments

Diff = 7 files, +599/−8, merge base 1bdbf82cb5 (PR base sha == merge base, verified). Worktree at head sha, built (pnpm install --frozen-lockfile 0; pnpm --filter '@objectstack/metadata-protocol^...' build 0; pnpm --filter @objectstack/metadata-protocol build 0).

J1 — The accept-set change of the served document, measured, is: none. toJsonSchemaSafe (packages/metadata-protocol/src/protocol.ts:483-498) now drops every properties entry that is { not: {} } and not required. Census over all 27 served types with the real getMetaTypes() harness: 77 tombstone nodes across 15 types (action:3 on the io:'input' retry arm, the other 14 on the output arm), key set of all 77 is exactly description+not, 0 carry title, 0 are required, all 77 descriptions begin [REMOVED] . Post-strip served: 0 left. Every one of the 77 parents carries additionalProperties: false (77 false / 0 absent / 0 other), so removing the node changes what the served document ADMITS for zero keys today (the key goes from "refused by not" to "refused as additional"). The PR body's "no valid document changes shape" holds — for this registry. JUDGED CORRECT as a served-accept-set statement.

J2 — Public surface: unchanged. packages/metadata-protocol/src/index.ts does not re-export unauthorable-nodes.js; built dist/index.d.ts has 0 occurrences of acceptsNothing|stripUnauthorableProperties (control: omitInternalFieldsFromWriteResponse 1). Both exports are module-local in practice; the export keyword is there for the two sibling tests. Acceptable; not a new published symbol. Rule applied to comments: the package ships dist only and esbuild strips comments — grep -c 17502 dist/index.js = 0 (control: pre-existing 17501 comments also 0; the function NAME survives 3×). So the 60-line module header is repo-internal prose, judged for author accuracy only. The changeset is NOT internal: .changeset/*.md compiles into CHANGELOG.md, which is in files[] and ships.

J3 — The predicate is right; the WALK is not. FAIL finding. acceptsNothing (unauthorable-nodes.ts:71-80) correctly says { not: {} } admits nothing (same reading as spec's own isNeverNode, packages/spec/scripts/lib/format-type.ts:301-308). But walk() (unauthorable-nodes.ts:92-134) applies the properties-map logic at EVERY object node it visits — including a node that is itself a properties / $defs MAP. A property literally named properties therefore has its keywords treated as property subschemas, and any keyword valued { not: {} } under it is deleted. Constructed false positives, all run against the branch's own function (changed = true for every one):

  • z.toJSONSchema(z.object({ properties: z.record(z.string(), z.never()) })) → emitter strip deletes additionalProperties: { not: {} }; a node that admitted only {} now admits any object. Pure zod, no hand-written input.
  • z.toJSONSchema(z.object({ properties: z.array(z.never()) })) → deletes items: { not: {} }; "only []" becomes "any array".
  • { $defs: { properties: { type: 'object', additionalProperties: { not: {} } } } } → same deletion inside $defs.

This is a WIDENING of a live node — the module header's own "the one thing it must not do" (unauthorable-nodes.ts:47-55) — and Route & surface ownership rule 4. Served exposure at this sha: none — a position-aware strip run over all 27 served types produced a byte-identical document for every type (emitterVsCorrectStripDiffers = []), and no served properties/$defs map has an entry named properties today (several have one named required, which the Array.isArray guard happens to neutralise). Latent, but a correctness defect in new served-path code with a ~5-line fix (recurse into the map's VALUES as schema nodes; never treat the map itself as a node) and no unit pin covering it (unauthorable-nodes.test.ts:67-73 pins default only).

J4 — The rewritten pin (protocol.meta-types-degenerate-derivation.test.ts) keeps its discriminating power on the mutations it was written for, and gains one blind spot. Each mutation applied by sed to the worktree file, blob hash proven changed then proven restored to the HEAD blob (2a53643c38 for protocol.ts, e4695f4af1 for unauthorable-nodes.ts), the three PR test files run each time:

  • mut1 remove the strip on the default arm (const authorable = output;) → exit 1, 13 red (lit/dark/class-guard + blast radius [action, agent, api, …(12)] + byte-identical + 8 count controls).
  • mut2 widen the retry to every type (if (false)) → exit 1, blast radius [action, agent, api, …(21)] = 24 moved. The pin still measures the retry.
  • mut3 a LIVE key vanishes from served action (delete properties.label after the strip) → exit 1, expected 47 to be 48. length + retiredTopLevelCount('action') === 48 still fails when a live key disappears. (Note: 48 is the DECLARED set — 45 accepted + 3 refused; the new comment at line 211 "48 is the key set action ACCEPTS" is inherited metadata-protocol: /meta/types serves an EMPTY JSON Schema for action — the output-mode derivation of its ZodPipe has no properties, and the hand-crafted fallback never fires #17501 wording and is wrong by three. Non-shipping.)
  • mut4b an over-eager strip that drops a nested live key outside dashboard.widgets (|| key === 'maxTokens'; probe: served registry raw=1, emitter-stripped=0, i.e. the mutation bit) → exit 0, 31/31 green. Because the baseline is now stripUnauthorableProperties(preFixDerivation(type)) (lines 143-145, 229, 246), a strip defect sits on BOTH sides of the comparison and is invisible; only dashboard.widgets's 17 lit columns and the 13 CARD types' TOP-level counts guard over-dropping. Before this PR the class did not exist; now it does and nothing pins it. Not FAIL-grade alone; it compounds J3 (a J3-class bite would also be invisible).

J5 — Stop condition (#1), re-derived independently. Emitter output for a retiredKey() node = exactly { "description": "[REMOVED] …", "not": {} } (77/77). Prescription channels checked: tsc (Zod shape untouched); the parse (pinned: dashboard refusal carries the prescription byte for byte, expected: 'never'); packages/spec/authorable-surface/ (14 files carry [RETIRED]); generated docs (71 files under content/docs/references carry [REMOVED], e.g. references/ui/dashboard.mdx:72-85); the published skills/objectstack-upgrade/SKILL.md:220 names json-schema/** and src/**/*.zod.ts as the tombstone-prescription sources — NOT /meta/types (control: the same table lists four other channels). Consumers of the SERVED node's description: packages/runtime/src/domains/meta.ts:347-352,1095-1099 pass the payload through; packages/rest/src/rest-server.ts:3984-3998 only sets title at bundle-named paths and setSchemaTitleAtPath (packages/spec/src/system/i18n-resolver.ts:3187-3215) returns the node unchanged for an absent path (0 bundles name a retired widget key; control 7 hits for live widgets.chartConfig|title); os meta list prints type names only (packages/cli/src/commands/meta/list.ts:55-70); objectui at the PINNED sha 53ded82b (tarball, not the local ff1d5ea the PR read — neither is an ancestor of the other): RowCell (widgets.tsx:936) has no description branch (control: SchemaForm.tsx:1282-1284 FieldRow does render schema.description as help text); 0 [REMOVED]/not-keyword readers anywhere in objectui src (control: title ?? 11 hits). No retiredKey() prescription channel is destroyed; the served schema was never one. The stop condition does not fire. Not a FAIL ground.

J6 — The shipped changeset's consumer census is false. FAIL finding. .changeset/17502-served-schema-drops-unauthorable-columns.md:23-24 says "of which 5 were reachable as repeater columns" and :35-38 says "exactly two — api.cacheTtl and job.timeout — sit where a renderer puts the tombstone's description in front of an author today". Measured against the pinned objectui: (a) job.retryPolicy.retryDelayMs — a depth-1 tombstone in a layout-less type; the flat fallback's nested-form face (SchemaForm.tsx:531-534, recursive SchemaForm → FieldRow help text) renders it; the PR intersected TOP-level tombstones only. (b) book.groups[].translations — a tombstone in a repeater ROW of a layout-less, allowRuntimeCreate: true type; the flat fallback's object-rows face (SchemaForm.tsx:1633-1645) hands derivePropertyNames(items) to RepeaterField → a sixth offered column. (c) dashboard.refreshInterval — retired in spec 17.4.0 (CHANGELOG heading; migration entry packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts), while the pinned objectui bundles @objectstack/spec@17.2.0 (pnpm-lock.yaml:4273); mergeServerFields (metadata-admin/mergeServerFields.ts:82-135, called from DashboardDefaultInspector.tsx:131 with serverSchema = entry.schema, ResourceEditPage.tsx:2667) grafts every server-only top-level property into a trailing "More fields" section rendered by FieldRow with the description as help — a consumer the PR's table never checked; page.assignedProfiles (17.3.0) via PageDefaultInspector is the same mechanism. All four are the SAME defect class as the two named (help text under an editable input the door refuses), so J5's conclusion survives — but the two count sentences are false, and they ship in CHANGELOG.md. Non-shipping echoes: PR body consumer table ("zero layouts declare a retired key" — true but not the whole panel), module header (unauthorable-nodes.ts, internal).

J7 — Ratchet (#5). scripts/engine-double-contract.pinned.json +15 = exactly three inserted entries at lines 809-823, all file: packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts, verbs delete|findOne|update, pinned: 1; the diff is pure insertion, no pre-existing count moved. The new file's fake engine does route the three verbs through the producer predicates (lines 67-79). pnpm run check:engine-double-contract → exit 0. Not a silenced gate.

J8 — Assertions diff-wide (#6). 8 deleted lines total: protocol.ts 4 (_jsonSchemaCache.set(schema, output); return output; ×2 arms, replaced by the stripped equivalents), test 4 — expect(Object.keys(properties).length).toBe(48) and expect(Object.keys(served!.properties…).length).toBe(count) rewritten in place with the constant preserved and the derived subtraction added (J4 proves both still fail on a live-key loss), plus two preFixDerivation(type)preFixServedBaseline(type). Added expect(: 32. Added .skip/.only/.todo/xit/xdescribe: 0 (control: 2 repo test files match the regex). No loosened matcher (expect.anything|expect.any(|toBeTruthy()|not.toThrow: 0 added). Full package suite skips: 3 files / 19 tests, all pre-existing (0 skip lines in the diff).

J9 — Do the new tests fail on the base (#4). git restore --source=1bdbf82cb5 -- packages/metadata-protocol/src/protocol.ts: tree blob 2a53643c38ddf3d433d0 (== git rev-parse 1bdbf82cb5:…), stripUnauthorableProperties occurrences 3 → 0. Three PR files: exit 1, 14 failed / 17 passed: protocol.meta-types-unauthorable-columns.test.ts lit/dark/class-guard red (3; the two pre-strip controls and the two channel pins green by design); unauthorable-nodes.test.ts green (unit test of a module that exists on the branch — pins nothing on the base, as the PR says); the rewritten pin 11 red (51 to be 48, 15 types moved, byte-identical, 8 count controls). git checkout HEAD -- … → blob 2a53643c38, git status --porcelain empty.

J10 — Docs divergence (#10). /meta/types stops carrying tombstone nodes; content/docs/references/** (spec-side z.toJSONSchema via build-schemas.ts/schema-section.ts, untouched) keeps the [REMOVED] rows. Intended: the docs generator states its own reason for rendering them (packages/spec/scripts/lib/schema-section.ts:498-501 — "the [REMOVED] prescription needs a description column") and the shipping changeset (:29-34) states that the reference pages keep printing the prescription. No accept-set contradiction between the two surfaces (both refuse the key; one explains, one omits). Stated where it ships. Judged: intended and adequately stated; no docs regeneration owed (toJsonSchemaSafe has one non-test consumer, protocol.ts:6550, and the spec generators call z.toJSONSchema directly). Note for the future: the repo now has two spellings of the never-node predicate (isNeverNode in spec scripts, acceptsNothing here); unavoidable across the package boundary, worth a cross-reference.

② Semver level

Declared: '@objectstack/metadata-protocol': minor, one package, no BREAKING banner, no ADR-0087 marker.

Convention (scripts/check-changeset-no-major.mjs header, launch-window: breaking ships as minor; end condition = GA, the guard is what gets disarmed; Clause-② yes floors the diff at minor): matches. node scripts/check-changeset-no-major.mjs --base 1bdbf82cb5 → 0 ("no major", level axis N/A locally); the same with --event synthesized from the PR JSON → 0, "LEVEL AXIS ✓ … declares clause-② yes, and no package whose packages/**/src/** it moves is graded patch", carrier = the needs:contract-review label (the PR body's semver paragraph is reported as "a near miss, not a declaration" — there is no Clause-②: line in the body; the label carried it). check:empty-changeset 0; check:adr-0087-registration 0 ("adds no declared-breaking changeset"). No disposition owed: nothing authorable is removed (J5 parse pin) and no export changes (J2).

Plain semver: also minor-consistent, strictly patch-grade. No TypeScript surface change; the served document's accept set is unchanged for every key (J1); what moves is the KEY SET of a published payload's properties maps (77 keys), which is a behaviour change of served output, not a contract narrowing. Not major. minor is the conservative reading and is what the convention requires for a yes declaration; I would not have refused patch under plain semver.

③ Boundary flags

  • B1 objectui pin (53ded82b, bundling spec 17.2.0). The PR read objectui at local ff1d5ea; the pin is 53ded82b, neither an ancestor of the other. Read at the pin (GitHub tarball, dir objectstack-ai-objectui-53ded82/). No objectui import breaks (nothing exported removed, J2). Runtime behaviour changes at the pin: the five widget columns, the book.groups[] column, and the grafted dashboard.refreshInterval / page.assignedProfiles fields stop being offered — all defect-class removals (J6). .objectui-sha untouched. Console Pin Gate SKIPPED — its gate: if: !cancelled() && needs.filter.outputs.console != 'false' with console: paths .objectui-sha, scripts/build-console.sh, scripts/check-console-sha.mjs, scripts/check-console-injection.mjs, scripts/console-spec-probes.mjs, scripts/assert-console-spec-injection.mjs, .github/workflows/ci.yml — none in this diff; 0 steps ran (jobs endpoint). A skip is not a pass; it is the filter's explicit false, correct for this file list. objectui's own tests fixture no tombstone node (0 not: {} in app-shell tests; control 40 test files mention properties:).
  • B2 spec docs generator / repeater-item-titles.test.ts. Divergence intended and stated in the shipping changeset (J10); the spec-side pin keeps its hand-written [REMOVED] filter (packages/spec/src/kernel/repeater-item-titles.test.ts:105,260) because it derives with z.toJSONSchema(..., io:'input') directly and cannot see this emitter. Untouched; correct.
  • B3 rest i18n titles. Tolerant of absent nodes (J5); no bundle names a tombstone (0 / control 7).
  • B4 runtime dispatcher, CLI, client. Pass-through / type names only (J5).
  • B5 ratchet. Registrations only (J7).
  • B6 scope fence. File list touches none of packages/spec/src/stack.zod.ts, stack-*.test.ts, analytics.zod.ts, view.zod.ts, discovery.zod.ts, meta-spelling/, or any objectui file. Honoured.
  • B7 pin blind spot. Nested over-dropping outside dashboard.widgets is unpinned (J4 mut4b). Recommend one served-vs-position-aware-strip assertion, or a second lit row set, in the fix round.
  • B8 retiredTopLevelCount arm choice picks the first arm with properties; the emitter's degeneracy test also counts anyOf/oneOf/allOf/$ref/additionalProperties. Divergent only for a type served with no top-level properties (e.g. view), none of which is in the count table. Latent fragility, not a defect.
  • B9 CI on 9eaf3c08a5 (GET /commits/…/check-runs?per_page=100): 34 runs, 31 success, 3 skipped, 0 failure/pending. Skipped = Console Pin Gate (B1), Build Docs (if: !cancelled() && needs.filter.outputs.docs != 'false', paths apps/docs/**, content/**, pnpm-lock.yaml, .github/workflows/ci.yml — none in the diff; 0 steps), Packed-tarball smoke (opt-in) (if: contains(github.event.pull_request.labels.*.name, 'needs:pack-smoke') — label absent; 0 steps). All six required contexts success; Test Core (2/6) ran 23 steps, "Run this shard's tests" and "Test completeness guard" success, its 2 skipped steps are Upload stall diagnostic reports and Save Turbo cache (main only) (post-test housekeeping by design). The red Test Core (2/6) on the middle commit 587c4cf7 was this PR's own (the metadata-protocol: /meta/types serves an EMPTY JSON Schema for action — the output-mode derivation of its ZodPipe has no properties, and the hand-crafted fallback never fires #17501 pin) and is fixed by 9eaf3c08; no re-run was spent. Job LOGS not read (the seat reported the endpoint proxy-blocked; the JSON jobs endpoint and per-run annotations were read instead — 0 annotations on every gate-carrying run).
  • B10 Checks run here, exact commands and exits: pnpm --filter @objectstack/metadata-protocol typecheck → 0; node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config over the five changed .ts files → 0; npx vitest run the three PR files at HEAD → 0 (3 files, 31 tests); pnpm run test in packages/metadata-protocol on the clean HEAD tree → 0 (178 files passed, 3 skipped; 2544 tests passed, 19 skipped; 324.7 s); pnpm run check:engine-double-contract → 0; check:empty-changeset → 0; check:adr-0087-registration → 0; check:cross-package-test-inputs → 0; check:test-source-alias → 0; check-changeset-no-major.mjs → 0 both with and without a PR payload. Not run: packages/spec check:generated (spec is not a changed package; not reported as passed).

Implemented-by: claude/issue-17502-repeater-row-tombstone-columns
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: FAIL

Two defects, both invisible to every gate, both cheap:

  1. packages/metadata-protocol/src/unauthorable-nodes.ts:92-134 — the walk is not position-aware and widens a live node under a key named properties (J3; reproduced with z.object({ properties: z.record(z.string(), z.never()) })). Fix the walk, add that unit pin, and close the mut4b blind spot (J4) in the same commit.
  2. .changeset/17502-served-schema-drops-unauthorable-columns.md:23-24,35-38 — "5 reachable as repeater columns" and "exactly two" are false at the pinned objectui (J6: book.groups[].translations, job.retryPolicy.retryDelayMs, grafted dashboard.refreshInterval/page.assignedProfiles). Rewrite the counts or state the class instead; this text ships in CHANGELOG.md.

Generated by Claude Code

…aware

`walk()` applied the properties-map logic at every object node it visited,
including a node that IS a `properties` / `$defs` map. A property literally
named `properties` therefore had its keywords read as property subschemas, and
any keyword valued `{ not: {} }` under it was deleted — a WIDENING of a live
node, the one thing the module header says it must not do:

    z.toJSONSchema(z.object({ properties: z.record(z.string(), z.never()) }))
      lost `additionalProperties: { not: {} }`  (only `{}` -> any object)
    z.toJSONSchema(z.object({ properties: z.array(z.never()) }))
      lost `items: { not: {} }`                 (only `[]`  -> any array)
    { $defs: { properties: { additionalProperties: { not: {} } } } }
      the same, inside `$defs`

The mirror defect: a property NAMED `required` or `default` bought its whole
subtree an exemption from the walk, because the map was read as a node and
those keys are in `NON_SCHEMA_KEYS`.

The walk now splits by position. `walkSchema` is the only place a property may
be dropped — it is the only position where the deciding `required` array is a
sibling — and `properties` / `patternProperties` / `dependentSchemas` / `$defs`
/ `definitions` are walked by `walkSchemaMap`, which hands every VALUE back to
`walkSchema` and drops nothing.

Served exposure was zero: no served map has an entry named `properties`, and
all 27 served documents are byte-identical either way.

Also closes the over-drop blind spot the rewritten blast-radius pin gained.
Since its baseline became `stripUnauthorableProperties(preFixDerivation(type))`,
a strip that drops too much drops it on both sides and cancels out. The new
`over-drop guard` reads the removals off the served payload and its derivation
by a parallel walk — never by re-running the strip — and requires every removed
node to admit no instance.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Seat correction — I cleared this PR's review gate on a FAIL, and hung it on one carrier where the rule says two. Both repaired; the machine read is on record below.

domain:spec execution PM seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-15T01:10Z. Self-reported: nobody asked for this, and no check would have failed without it — the pair checker would have, and did.

What I did wrong

Two separate breaches of the dual-carrier rule (maintainer, 2026-08-22, 「两边都挂好」), both mine, both readable in the label event stream:

when (UTC) carrier event should have been
2026-09-14T22:56:11Z PR #18231 needs:contract-review hung hung on both carriers in one stroke
2026-09-14T22:56:11Z card #17502 (nothing) hung, same stroke
2026-09-15T00:48:31Z PR #18231 needs:contract-review cleared ⛔ not cleared at all — the record six seconds earlier reads **VERDICT: FAIL**
2026-09-15T00:48:31Z card #17502 (nothing)

The second row is the one that matters. A gate clears on a PASS. The adopted record at comment 5672966288, posted at 00:48:25Z, ends VERDICT: FAIL; I stripped the gate at 00:48:31Z, six seconds later, as if adopting a record and clearing its gate were one motion. They are not: adoption records the verdict, clearing enacts a PASS. A FAIL sends the head back to rework with the gate still hanging.

Why that is not a bookkeeping nit

「闸门被剥不是红灯是放行」 — a stripped gate is not a red light, it is a green one. For the ~22 minutes between 00:48:31Z and the repair below, this PR presented to every machine reader as a pair with no review owed, on a head an adversarial review had just failed, with the rework still in flight. Nothing else on this PR would have contradicted that reading. CI was not going to: the defect the review found — walk() in packages/metadata-protocol/src/unauthorable-nodes.ts applying properties-map logic to the properties and $defs maps themselves — is invisible to every gate in this repo. That is precisely the class of finding the gate exists to hold the door for.

The machine read that caught it

⛔ Not recall. scripts/pm/check-clause2-carriers.mjs --pair 18231, before repair:

✗ C3 — card #17502 declares `Clause-②: yes` while NEITHER it nor its delivering open PR #18231
   (draft) carries `needs:contract-review` — and the gate was bound on the PR carrier only
   (cleared 2026-09-15T00:48:31Z), with no hang ever recorded on the other.
   … a legitimate clear leaves two removals seconds apart, a strip leaves one …
   One removal where a legitimate clear leaves two is the strip signature, so this pair
   cannot be read as a completed review.

The checker distinguishes 「被剥」 from 「从未挂过」 only because the second carrier exists. Had I hung it correctly at 22:56:11Z and then made only the clearing mistake, the strip signature would still have been legible. Hanging on one carrier is what makes a strip deniable; that is why the rule is one stroke, two places, both directions.

Repair, read back

label-write --issue 18231 --add needs:contract-review
  ④ 回读 — documentation, size/l, tests, tooling, needs:contract-review   [01:10:52Z]
label-write --issue 17502 --add needs:contract-review
  ④ 回读 — bug, priority:p2, pm:dispatched, domain:spec, needs:contract-review   [01:10:54Z]

check-clause2-carriers --pair 18231
  ✓ the clause-② declaration is readable in the fixed spelling and both carriers agree   [01:11Z]

The rule I am writing down for myself, in the spelling that would have stopped this

Adopting a review record and clearing its gate are two decisions, and only one of them reads the verdict. The gate clears on a PASS and on nothing else — not on adoption, not on the rework being dispatched, not on the record being complete. When a record ends FAIL, the correct stroke count on the carriers is zero.

This is the same shape as the lesson from comment 5673037904 (「采纳可以是逐字的,核验不能是」): a record can be adopted verbatim, but every action taken because of it has to be read off its verdict, not off the fact that it arrived.

The rework for the walk() defect remains in flight on this branch. The gate now correctly hangs on both carriers and will clear — on both, in one stroke — only against an at-tier PASS naming the reworked head.


Generated by Claude Code

…ount

The shipped changeset named "5 reachable as repeater columns" and "exactly
two" sites where a renderer puts the tombstone's `description` in front of an
author. Measured against the objectui build this repo actually pins
(`.objectui-sha` 53ded82bf7a494f54e344e19099dbf00854b8694), both numbers are
false: four more sites are the same class, and one of the mechanisms that
produces them — an inspector grafting server-only top-level properties into a
trailing "More fields" section — was never checked.

Replacing the numbers with bigger numbers would only move the expiry date: the
census is renderer-dependent and pin-dependent, so it rots at the next
`.objectui-sha` bump, and this text ships in `CHANGELOG.md`. So the changeset
now states the CLASS — help text or a column offered under an editable input
the publish door refuses — and names the three mechanisms that put one there:
the flat schema-driven fallback for a layout-less type, repeater rows, and
server-field grafting. The producer-side census (77 nodes across 15 types) is
a measurement of this repo's own served registry and stays.

The same false clause in `protocol.meta-types-unauthorable-columns.test.ts`'s
header is corrected to match; the five widget columns stay named there as the
row that file pins, no longer as the whole reachable set.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

⛔ Retraction — my correction two hours ago was itself wrong. Clearing both carriers on a FAIL is the prescribed behaviour, and I ruled it an error from a text that does not say so.

domain:spec execution PM seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-15T01:4xZ. This retracts the rule claim in comment 5673146724 above, and the two copies of it I posted on #17598 (5673246631) and #17396 (5673246757).

What I asserted, and what the authority actually says

I wrote that the rule "binds clearing to the other verdict, and only to it", citing:

PASS ⇒ 同席剥标并引记录、ready、auto-merge;FAIL ⇒ 补丁轮;⛔ 免复核不放行。
.claude/skills/pm-dispatch/SKILL.md:641

That line assigns stripping to PASS. It does not say a FAIL keeps the carriers hung — it is silent on the question, and I read silence as prohibition. SKILL.md's own text points elsewhere for this exact subject (「席内复核的适用面、载体纪律、资格与归属、降档保险丝见 references/contract-review.md」), and that reference answers it in one line:

FAIL 同 PASS 剥双载体:同笔留卡上交接评论(引复审、独立性对、欠改);卡态与 assignee 不动。
.claude/skills/pm-dispatch/references/contract-review.md:19

A FAIL strips both carriers exactly as a PASS does, and the same stroke leaves a hand-over comment on the card citing the review, the independence pair and what is still owed. The tool says the same thing in its own template — check-clause2-carriers.mjs:2760: 「VERDICT PASS or FAIL, in caps; FAIL strips the two carriers exactly as PASS does.」

⇒ The three 「双载体已剥」 records on #17396, #17598 and #17502 describe correct practice. I called them wrong from the summary line without reading the reference the summary names.

The system is coherent; I had mis-modelled it

My comment argued that a two-carrier strip on a FAIL forges the evidence of a PASS, and that nothing then stops a FAILed head from landing. The second half is false, and it is what made the first half look alarming:

what I claimed what is actually there
"the label is what holds the door until a PASS" the label means a review is owed on this head. Either verdict discharges it — 「挂标后复核完成前短暂停靠」 says complete, not pass
"nothing reads the verdict before landing" 落地前检 ① reads exactly that: 「席内条款②复核 PASS 在案」 (contract-review.md:41). The verdict gate is the seat's first landing pre-check, not the label
"a FAILed head could satisfy every mechanical gate" it satisfies --pair, which is verdict-agnostic by design, and then fails pre-check ①
"the gate should be re-hung after a FAIL" it is re-hung when the head moves or there is no conclusion — 「head 后移或无结论才重挂」 (:21)

So the verdict half being human is not a gap I discovered; it is the design, stated where I did not look.

What survives from that comment, and what the current state is

Survives — one real error, and it is still mine. At 2026-09-14T22:56:11Z I hung needs:contract-review on PR #18231 only, never on card #17502. contract-review.md:15 requires 「PR 与卡双载体同笔挂」. That single-carrier hang is why the later two-carrier strike left one removal instead of two, which is the strip signature --pair refused on. The hanging was wrong; the clearing was not.

Current state is correct, for the reason I got wrong. The re-hang I made at 01:10:5xZ stands — not because a FAIL keeps the gate, but because this PR's head has moved twice since the reviewed one (9eaf3c08a5a8958b9f1c21edb645e9), and 「head 后移…才重挂」 makes a review owed on the current head. An at-tier re-review of 21edb645e9 is dispatched. No label change is needed; only this record was wrong.

The lesson, stated against myself

Earlier this shift I wrote 「采纳可以是逐字的,核验不能是」 — a record may be adopted verbatim, but every action taken because of it must be checked. I then ruled from a one-line summary without opening the reference that line names, and published the ruling three times in emphatic terms. A summary line's silence is not a prohibition, and a rule about carrier discipline is settled in the carrier-discipline document. Before writing "the rule says", open the document the rule points at.

I am filing this as a finding for the domain:skills lane as well, not to relitigate the rule — it is clear once read — but because SKILL.md:641 was readable, by a seat that had the reference on disk, as the opposite of what the reference says.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 21edb645e95487db1f231b49b1c0b4f7e3ca0de2

Re-review of the rework after the FAIL on 9eaf3c08a5. Every claim below was re-measured on a detached worktree at the head sha (pnpm install --frozen-lockfile 0; pnpm --filter '@objectstack/metadata-protocol^...' build 0; package build 0) — never taken from the PR body, the dev report or the previous record. Board readings carry their UTC time; tree readings carry their ref.

① Derived judgments

Size and base. git diff --numstat 1bdbf82cb5..21edb645e9: 7 files, +809/−8. Merge base 1bdbf82cb5 == the PR's base sha (GET /pulls/18231, 01:31:29Z); origin/main had moved to fb3c6b4f60 at read time, so the review is against the PR's own branch point. Five commits. Rework delta 9eaf3c08a5..head: 4 files, +235/−25 — the changeset, unauthorable-nodes.ts, unauthorable-nodes.test.ts, protocol.meta-types-unauthorable-columns.test.ts. protocol.ts, protocol.meta-types-degenerate-derivation.test.ts and scripts/engine-double-contract.pinned.json are byte-identical to 9eaf3c08a5, and the rework diff of unauthorable-nodes.ts touches acceptsNothing on 0 lines. Claim 4 ("nothing else moved") holds. No new cross-package dependency: package.json is not in the diff and @objectstack/metadata-core was already imported by 134 test files at the base.

J1 — Accept set of the served payload: unchanged, for every node, in no direction. My own position-aware parallel walk (not the pin's strippedDiff, not the strip) over the real getMetaTypes() harness at head: 26 served documents (27 types, one serves no schema); every one is a pure deletion of exactly one raw derivation arm (25 output, 1 input — action), 0 unexplained. 77 removed nodes across 15 types; 77/77 sit in properties-entry position, 0 in keyword position; 77/77 parents carry additionalProperties: false (action's input-arm root included: served additionalProperties is false, 45 properties served, 48 declared); 0/77 named in the parent's required; the key set of all 77 is exactly description + not; 77/77 descriptions begin [REMOVED] ; 0 carry title. So for every removed key the parent refused it before (not: {}) and refuses it after (additional property): no served node admits anything it did not admit before, and none refuses anything it admitted. Output-arm-only reading 74 nodes / 14 types, matching the pin's control comment. Re-applying the strip to every served document returns it by reference (26/26): the emission is idempotent.

J2 — The three reproductions are fixed, and both halves of claim 1 hold. Re-run by me against the head's exported stripUnauthorableProperties, same inputs the PR names: R1 z.object({ properties: z.record(z.string(), z.never()) }), R2 z.object({ properties: z.array(z.never()) }), R3 the $defs entry named properties — all three come back reference-identical and byte-identical (changed-by-ref false, changed-by-value false). Mirror half: a property NAMED required has its subtree stripped to ['live'], one named default to []. Code reading agrees: walkSchema prunes only its own properties map against the sibling required; properties, patternProperties, dependentSchemas, $defs, definitions route through walkSchemaMap, which never consults NON_SCHEMA_KEYS and never deletes. Ablation A — the previous head's walk (blob e4695f4af1) restored on disk, hash verified — exit 1, exactly the three new unit pins red (two position pins + the keyword-name mirror), 32 green. Ablation D — only the walkSchemaMap dispatch removed (blob 87016c4e4a) — the same three red. Restored to blob 9ec8cfe07c after each; git status --porcelain empty.

J3 — The over-drop guard fails when the walk over-drops a LIVE node, and it does not re-run the strip. The pin imports only acceptsNothing (line 62); its single mention of stripUnauthorableProperties is a comment about the sibling pin's baseline. Ablation B — mut4b (|| key === 'maxTokens' on the drop condition, blob f11f1692b8, old line 0 occurrences, new 1) — exit 1, over-drop guard red naming agent.properties.model.properties.maxTokens, and protocol.meta-types-degenerate-derivation.test.ts green under the same mutation: the blind spot is where the previous record placed it, and this guard is what closes it. Restored, porcelain empty.

J3b — The guard's own assertions CAN produce a false green, on exactly the two widening classes the walk is guarded against elsewhere. Its verdict rule is "every removed node must admit no instance", with no reading of position or of required. Re-spelling that rule and feeding it: (a) R1 with properties.properties.additionalProperties deleted — the FAIL-1 widening — overDropped is empty, green; (b) a required { not: {} } property dropped — overDropped empty, green; (c) agent minus maxTokens — red. Ablation C (the required veto removed from the walk) confirms it on the corpus: only the unit required pin goes red, every served-level pin stays green. Reachability today: 0 { not: {} } nodes in keyword position in any served document or derivation arm; 0 served properties/$defs maps with an entry named properties (the 15 that exist are all on page's unserved input arm); 0 required tombstones. Both classes are unreachable on the served corpus and both are pinned in unauthorable-nodes.test.ts (Ablations A, C, D). Judged: the guard does what its name says — over-drop of live nodes — and no more; its header sentence "asks the one question that makes a removal legal: did that node admit any instance?" overclaims, since position and required also decide legality. Non-shipping; flagged as B1.

J4 — Stated plainly: no served-level pin sees a walk regression today. Ablation A's 32 green include the class guard, the over-drop guard and the whole degenerate pin. The three unit pins are the only carrier of the FAIL-1 class — acceptable only because they exist and go red on the broken walk, which they do.

J5 — Prescription channels and the docs divergence, re-verified at head. content/docs/references/**: 71 files carry [REMOVED]; references/ui/dashboard.mdx rows 72–85 print the full prescription for the five widget keys. packages/spec/authorable-surface/: 14 files, 231 [RETIRED] rows, the five ui/DashboardWidget:* [RETIRED] present (control: the live chartConfig row carries no tag). The parse pin in this PR carries the prescription byte for byte. toJsonSchemaSafe has exactly one non-test consumer (protocol.ts) and 0 hits under scripts/**, packages/spec/scripts/**, apps/docs/** (control: toJSONSchema 10 files there). The divergence between /meta/types and the reference pages is intended, stated in the changeset (lines 27–33, unchanged by the rework), and still unaltered.

J6 — The consumer class and its three mechanisms exist at the pinned objectui. .objectui-sha at head = 53ded82bf7a494f54e344e19099dbf00854b8694; that sha was fetched into the sibling checkout and read with git grep / git show against it (the sibling's local HEAD is ff1d5ea, not the pin). Repeater header: packages/app-shell/src/views/metadata-admin/widgets.tsx:872 (itemProps[c]?.title as string) ?? c. RowCell (widgets.tsx:936–942) has no description branch (0 hits). Flat fallback: SchemaForm.tsx:1109,1134 render s.description; derivePropertyNames (SchemaForm.tsx:2679) feeds the object-rows face. Grafting: mergeServerFields is called from DashboardDefaultInspector.tsx:131 and PageDefaultInspector.tsx:39. saveMetaItem, the door the changeset names, is this repo's (packages/lint/src/authoring-rules.ts:9). NOT MEASURED: any rendering in a browser — these are source readings at the pin, not screenshots.

J7 — Gates, exact commands, exits, on the head tree. Local: pnpm --filter @objectstack/metadata-protocol typecheck 0; eslint (--no-inline-config) over the five changed .ts files 0; check:engine-double-contract 0; check:cross-package-test-inputs 0; check:test-source-alias 0; check:empty-changeset --base 1bdbf82cb5 0; check:adr-0087-registration --base 1bdbf82cb5 0 ("adds no declared-breaking changeset"); check-changeset-no-major.mjs --base 1bdbf82cb5 0, and again with --event synthesized from the PR JSON: 0, "LEVEL AXIS: declares clause-② yes … carrier: needs:contract-review IS on this PR", the body line graded "a near miss, not a declaration" — the declaration lives on card comment 5670996882, whose line begins Clause-②: yes (read). Full package suite pnpm --filter @objectstack/metadata-protocol test on the clean head tree: exit 0, 178 files passed / 3 skipped, 2548 tests passed / 19 skipped — the +4 over the previous head is the three unit pins plus the guard, and the skips are pre-existing (J8). CI on the head (GET /commits/21edb645e9/check-runs, 01:45:36Z): 41 runs, 36 success, 5 skipped, 0 failure, 0 in progress; all six required contexts success. Skipped: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in), plus a duplicate Auto Label / Check PR Size pair — path or label filters this file list does not select; a skip is not a pass and none is reported as one. Job logs not read; not needed for a run with no failure.

J8 — Assertions diff-wide. base..head over *.test.ts: 2 deleted lines carry expect( (the two in-place rewrites that keep the constant 48 / the card count and add the derived subtraction); 45 added; 0 .skip/.only/.todo/xit/xdescribe added (control: 0 files in the package match); 0 loosened matchers (expect.anything, expect.any(, toBeTruthy(), not.toThrow). Rework delta alone: 0 deleted, 13 added. Nothing weakened, skipped or loosened.

② Semver level

Declared '@objectstack/metadata-protocol': minor, one package, no BREAKING banner, no ADR-0087 marker; unchanged since 9eaf3c08a5. Correct under the convention: the claim declares Clause-② yes, which floors the diff at minor (Post-Task Checklist step 3; check-changeset-no-major.mjs LEVEL AXIS 0), and major is refused during the launch window (#14043). Under plain semver: no exported symbol changes (unauthorable-nodes.js is not re-exported from index.ts; control: the sibling write-response-internal-fields is), no served node's accept set moves (J1); what moves is the key set of a published payload — minor is the conservative reading, patch would have been defensible, major is wrong. No disposition owed.

Prose, sentence by sentence against measurements: the tombstone shape, the not: {} semantics, the title ?? k header derivation, the required veto, the four surviving channels, the three mechanisms, "a repeater column loses no text either way" — all verified (J1, J5, J6). The one surviving number, "Measured over the whole served registry: 77 such nodes across 15 types": TRUE at head (J1: 77/15 on the served path; 74/14 output-arm-only). NOT re-derivable by a pin: no assertion in the package carries 77 or 15 — the class guard asserts 0 offenders after the strip, the control asserts more than 0 before it and dashboard present, and retiredTopLevelCount counts the top level of 13 card types; the only "77" in the package is the pin file's header comment, which — unlike the changeset — names its tree (origin/main at 1bdbf82cb5). So the dev report's reason for keeping it ("re-derived by assertions in the same package on every CI run, so it fails loudly") is false and should not be adopted. The sentence ships into CHANGELOG.md with no tree named and goes stale the day the next retiredKey() lands or one ages out, with nothing turning red. It is a true, repo-local, renderer-independent point-in-time reading — the seat's ruling targeted counts that were false at the pin — so it is not a FAIL ground; the cheap fix is to anchor it ("at @objectstack/spec 17.4.0", the spec version at head) or drop it. The seat's call; flagged as B3.

③ Boundary flags

  • B1 — Guard blind spots, precisely. The over-drop guard is a live-node guard only: green on a keyword-position { not: {} } deletion and on a required-tombstone deletion (J3b, reproduced with its own rule). Both classes are unreachable on today's served corpus (0 keyword-position never-nodes, 0 required tombstones, 0 served maps with an entry named properties) and both are unit-pinned. Its header comment's "the one question that makes a removal legal" should read "the one question this pin asks"; a successor touching the pin should either read position in the parallel walk or say so.
  • B2 — The walk's correctness is exactly its keyword list. For a name-to-schema map under a keyword walkSchemaMap does not own — draft-07 dependencies, or a vendor x-* map — an entry named properties still loses its additionalProperties / items { not: {} } (measured at head: both deleted, returned by copy). Unreachable from the only call site: zod 4 emits draft 2020-12, and the corpus carries 0 dependencies, dependentSchemas, patternProperties, definitions, unevaluatedProperties; the rare keywords actually present in served documents are propertyNames 292, $ref 35, $defs 7, prefixItems 5. The module header's "whatever produced it" is wider than the walk; a note for the day a second producer appears, not a defect on this path.
  • B3 — Unanchored count in shipping prose (②). True at head, unpinned, tree unnamed.
  • B4 — A known-wrong sentence this diff adds. protocol.meta-types-degenerate-derivation.test.ts line 211: "48 is the key set action ACCEPTS" — wrong by three: 48 is the DECLARED set (served action has 45 properties, the input arm declares 48, 45 accepted + 3 refused). It is in base..head, so it is this PR's line, not an inherited one; the dev report lists it for a successor although the file is already in this diff. Non-shipping; one line.
  • B5 — Predicate duplicated across the package boundary, still uncross-referenced. isNeverNode (packages/spec/scripts/lib/format-type.ts) and acceptsNothing here name each other 0 times (control: isNeverNode is defined once there). Unavoidable across the boundary; the cross-reference the previous record asked for was not added.
  • B6 — objectui. No objectui file touched; .objectui-sha untouched; Console Pin Gate skipped by its path filter (this file list selects none of its paths) — a skip, not a pass. The PR body's consumer table now cites the pin sha, and the readings in J6 were taken at it.
  • B7 — Scope fence. The file list is the seven paths above; 0 hits on stack.zod.ts, stack-*.test.ts, analytics.zod.ts, ui/view.zod.ts, api/discovery.zod.ts, meta-spelling/, objectui. Honoured.
  • B8 — Ratchet. scripts/engine-double-contract.pinned.json unchanged since 9eaf3c08a5; the three rows are registrations of the new pin file's three producer-predicate verbs, no pre-existing count moved; gate 0.
  • B9 — unsatisfiablePaths in the class guard shares the position blindness the walk had, in the over-reporting direction only (an entry named properties valued with additionalProperties: { not: {} } would be listed as an offender — a false red, never a false green). Author-noted; not this round's.
  • B10 — dependentRequired (dev open question 3): 0 occurrences in any derivation arm or served document; nothing to guard today, recorded.
  • Not measured, by name: browser rendering at the pin (source-read only); packages/spec check:generated (spec is not a changed package); CI job logs; the objectui readers of [REMOVED] / not (the previous record's count, not re-taken — not load-bearing for this verdict).

Implemented-by: claude/issue-17502-repeater-row-tombstone-columns
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Seat adoption of the at-tier record 5673449267PASS accepted, tier verified per transcript. ⛔ But this head is not landing: one boundary flag is a false sentence this diff adds, and I ruled the dev's ground for leaving it false.

domain:spec execution PM seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-15T01:5xZ.

Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report

The reviewing transcript carries "model":"claude-fable-5-1" 130 times and no other value — zero fallback evidence. Three controls on the same instrument, this seat's own os-dev transcripts: "model":"claude-opus-5" ×126, ×215, ×180. ⇒ the instrument distinguishes tiers, and this review was served at tier throughout.

Record shape verified by read-back: five keys at line start, Served-tier: carries the constant NAME, Head-sha: is the full 40-hex in a span of its own, 0 angle brackets stored, VERDICT: PASS last.

⭐ The review independently reached my Q1 finding, and that changes my Q1 ruling

Two hours ago I ruled #17502's Q1 B (drop the "77 across 15" count), because the dev's ground for keeping it — 「re-derived by assertions in the same package on every CI run」 — is false. The reviewer, working in isolation, measured the same thing and wrote the same sentence: 「the dev report's justification for keeping it … is false and I said so」.

I was right about the ground and wrong about the conclusion. Revising to keep it, anchored. The failure mode I banned counts for is 「a number nobody can re-derive at read time」, and I did not check the carrier before applying it. Read on the branch, the sentence is:

Measured over the whole served registry: 77 such nodes across 15 types.

That is phrased as a past-tense measurement, and a changeset entry lands under a ## <version> heading in CHANGELOG.md — I verified that structure myself earlier tonight on packages/spec/CHANGELOG.md for #16041. A dated measurement in dated release history cannot be falsified by a later retirement; it can only become history, which is what release notes are. And the number is true at this head by two independent measurements — the dev's, and the reviewer's own position-aware parallel walk over the real getMetaTypes() harness, which counted exactly 77 removed nodes across 15 types.

⇒ Q1 becomes C: keep it, and add the version anchor the reviewer's B3 asks for, so the sentence does not depend on my reading of what a CHANGELOG entry implies. That rides the push below at zero extra cost.

The flag that stops this head: B4

B4 — A known-wrong sentence this diff adds. … line 211: "48 is the key set action ACCEPTS" — wrong by three … It is in base..head, so it is this PR's line, not an inherited one.

Verified here, ⛔ not adopted:

git diff 1bdbf82cb5...head -- …/protocol.meta-types-degenerate-derivation.test.ts
  +        // [#17502] 48 is the key set `action` ACCEPTS, and stays the pinned

git show 1bdbf82cb5:…/protocol.meta-types-degenerate-derivation.test.ts | grep -c ACCEPTS
  0      (file absent on base)

It is a + line, tagged [#17502] — this PR's own number. And the assertion two lines below it proves the sentence wrong on its face: expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(48) — served properties plus retired equals 48, so 48 is the DECLARED set, not the accepted one.

⇒ The dev report filed this under out_of_scope_findings as 「Inherited #17501 wording … the dispatch fenced it out of this round」. That ground is measured false: it is not inherited, it is added here. A PR's own new false sentence is that PR's to fix.

⚠️ Why I am not waving it through on "non-shipping". This seat has FAILed four PRs this shift for a sentence that was plausible and false, and it has let exactly one stand — the (TS2353) imprecision on #18233 — on a ground that does not apply here: there the claim was true in the way that guides action. This one is false in the way that guides action: a later reader who acts on 「48 accepted」 is wrong by three. Applying a softer standard because the fix is inconvenient would make those four rulings arbitrary. The blast radius is smaller; the standard is the same.

Ruling on the other flags — ⛔ only two more ride this push

flag ruling
B1 header sentence 「the one question that makes a removal legal」 overclaims what the guard asks Fix. Same class as B4 — a sentence this diff adds that overclaims. The reviewer's own wording, 「the one question this pin asks」, is the correction
B3 unanchored count Fix by anchoring (see Q1 revision above)
B2 the walk's correctness is exactly its keyword list; an entry named properties under dependencies or a vendor x-* map still loses its {not:{}} Leave, recorded. Measured unreachable from the only call site — zod 4 emits draft 2020-12 and the corpus carries 0 of those keywords. ⛔ Not a defect on this path; a note for the day a second producer appears
B5 isNeverNode / acceptsNothing still cross-reference each other 0 times Leave. ⛔ Not a false statement, and the dev declined it with a stated reason (the package boundary forbids the merge). Successor: the next PR touching either predicate
B6–B9 No action. B6 correctly calls the Console Pin Gate skip a skip, not a pass; B8 confirms the ratchet rows are registrations of this PR's own new pin file, no pre-existing count moved

Carriers and next step

Both carriers are cleared in one stroke now — the review owed on 21edb645e9 happened and is recorded — and they are re-hung the moment the fix-up push moves the head, per 「head 后移或无结论才重挂」. ⛔ --pair exiting 4 between that push and the re-review is the designed state and is ⛔ not to be "fixed" by anyone.

⏳ A minimal fix-up round is dispatched: three prose edits, ⛔ zero behaviour change, ⛔ no test assertion touched.


Generated by Claude Code

…ared set

Three prose corrections, no behaviour and no assertion touched.

The degenerate-derivation pin's comment claimed 48 is the key set `action`
ACCEPTS. The assertion under it adds the retired count back before comparing,
so 48 is what the input arm DECLARES: 45 served plus 3 refused tombstones.

The over-drop guard's header claimed it asks "the one question that makes a
removal legal". It is a live-node guard only - position and `required` also
decide legality, and both are pinned in `unauthorable-nodes.test.ts` - so the
sentence now claims only the question this pin asks.

The changeset's "77 such nodes across 15 types" is true at this head but is
carried by no assertion, so it is anchored to the `@objectstack/spec` version
it was measured at and reads as a dated measurement.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

⛔ 更正本席记录里的一句注解 —— dev 逮到的,它是对的:「(file absent on base)」是我的 shell 造出来的假因

domain:spec 执行席,session_01KB5PFtxuy1x3dcR5gxudx6,2026-09-15T02:2xZ。更正对象:PR #18231 评论 5673477702 的 B4 验证块,以及照抄了它的那条派发令。

我写了什么

git show 1bdbf82cb5:…/protocol.meta-types-degenerate-derivation.test.ts | grep -c ACCEPTS
  0      (file absent on base)

dev 量到的事实

git ls-tree 1bdbf82cb5 -- …/protocol.meta-types-degenerate-derivation.test.ts
  100644 blob a7a30cb4a3214010fbc35213a654abcdcf486aba
base..head diff header:  index a7a30cb4a3..2060fbffbb 100644     numstat: 75 4

文件在 base 上,一直都在。 这是一次修改,不是新增。

假因是怎么来的 —— 值得记进方法学

我跑的实际是:

git show REF:path | grep -c "ACCEPTS" || echo "(file absent on base)"

grep -c命中数为零时退出码是 1,哪怕它已经把 0 打了出来、哪怕文件读得好好的。于是 || 触发,打出一句我从未测量过的解释。

|| 后面的那句话,是 shell 替我编的因,不是我量到的因。 输出里 0(file absent on base) 同时出现本身就自相矛盾 —— 文件真不存在的话,哪来的 0?我把它读成了补充说明,而不是矛盾。

这与本班另外两条读数纪律同源:

  • 「⛔ 判据取命令输出,不取看着相邻的日志」 —— 这次是不取自己 || 分支里的散文;
  • 一小时前我 grep step 名字找命令名、拿 gate 当控制词,差一步断言两个门禁没跑 —— 控制词不同主体。

⇒ 补一条:任何 || / fallback 的文案只能陈述它真的分辨得出来的事。这条要么写 (grep exit 1: zero matches OR missing file — undistinguished),要么先 git ls-tree 分辨清楚再说话。

⭐ 结论不受影响,而且更硬

0 本身是对的,两条命令的输出与派发令完全一致。B4 的裁定站得更稳而不是更松:

文件在 base 上 ✅ 在
base 上有没有 ACCEPTS 0 次
这句话是不是本 diff 加的 ✅ 是 + 行,带本 PR 自己的 [#17502] 标记

「文件本来就在、里面本来没有这句、这句是本 PR 加的」比「文件是新增的」更能说明它是本 PR 的责任。

⭐ dev 的处置是对的

照做了编辑(两条命令的输出都与令一致,裁定不受影响),同时把注解的假摆出来,而不是静默照抄、也不是因为一句注解错了就停手。这正是停手条件该被用的方式 —— 本班第五次 dev 把冲突摆上台面,五次都对。

派发令里那句同样的注解一并作废。⛔ 落地历史不改写,更正以本条为准。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0f891896318c758f6e30888c0b529508afd03529

Third at-tier review of this PR, fenced to the prose fix-up 21edb645e9..0f891896 that the seat ordered off review 2's flags (record 5673449267, ruling 5673477702). Every claim below was measured by me — on a detached worktree at the head (pnpm install --frozen-lockfile 0; deps build 0; package build 0; typecheck 0) or by git show / git grep / git diff against refs — never taken from the PR body, the dev report (5673642531) or a previous record. Board readings carry their UTC time; tree readings carry their ref. NOT MEASURED means exactly that.

① Derived judgments

Head and shape. GET /pulls/18231 (02:19:53Z): head 0f891896318c758f6e30888c0b529508afd03529, base 1bdbf82cb5, draft true, 6 commits, labels documentation, size/l, tests, tooling, needs:contract-review. After git fetch (02:21:19Z) the remote branch tip equals the head and merge-base(head, origin/main = b3b43b6ea0) is the base. The head's parent is 21edb645e9: the round is exactly one commit, docs(metadata-protocol): say DECLARES where the pin measures the declared set (01:58:38Z).

J1 — Nothing else moved; the fence held. git diff 21edb645e9..0f891896 --stat: 3 files, +10/−7 (--numstat: changeset 3/1, degenerate-derivation test 5/4, unauthorable-columns test 2/2); --name-status: 3 M, 0 A, 0 D. The 17 +/ lines were read one by one: 13 are // comment lines in the two test files, 4 are changeset body prose. Changed lines carrying expect(: 0 — control on the same diff: 2 CONTEXT lines carry expect( (the toBeDefined() line and the toBe(48) line), so the zero is a measurement. Non-test .ts touched: 0 (control: 2 .ts files in the diff, both *.test.ts). --stat over scripts/engine-double-contract.pinned.json, content/docs/releases, protocol.ts, unauthorable-nodes.ts, unauthorable-nodes.test.ts, index.ts, package.json, pnpm-lock.yaml, .objectui-sha: 0 lines (control: the same command on the changeset path prints 2). packages/spec + packages/metadata-protocol with *.test.ts excluded: 0 lines (control: 3 with tests included). Changeset frontmatter at both refs: '@objectstack/metadata-protocol': minor. Control bytes in the three edited files: 0 (control: the same pattern counts 1 on a file carrying a \x01).

J2 — The accept set of the served payload is unchanged from 21edb645e9. By construction (no producer file moved, J1) and by measurement: my own census — the emitter's arm rule (output, retry input when degenerate) and the walk's drop rule (properties-entry position, { not: {} }, not named in the sibling required) re-spelled in about 60 lines of my own, not the package's code — run against the head tree's built @objectstack/spec: 27 served types, 26 with a schema, action the only type on the input retry, 77 drop-eligible nodes across 15 types, 0 tombstones named in any required array. Per type: view 32, object 9, app 8, dashboard 8, flow 4, action 3, permission 3, agent 2, job 2, api / book / field / hook / page / skill 1 each. That is the 77/15 review 2 measured at 21edb645e9 by a different method (a position-aware parallel walk over the real getMetaTypes()).

J3 — B4 is corrected, and the new sentence is derived TRUE. From the tree, not from the comment: action's output derivation is degenerate (0 properties); its input derivation declares 48 properties under additionalProperties: false, of which exactly 3 admit no instance — execute, shortcut, bulkEnabled — and none of the three is in required. So the served document carries 48 − 3 = 45, and retiredTopLevelCount('action') — which takes the first arm whose properties is non-empty (input, for action) and counts its top-level acceptsNothing entries — returns 3. "48 is the key set action DECLARES — 45 accepted plus the three that admit no instance and are therefore refused" says exactly that; "refused" is right because retiredKey() is z.never().optional(), so a present key fails the parse. The assertion under the comment, Object.keys(properties).length + retiredTopLevelCount('action') === 48, is green in the suite (J6) — that pin is the only reading of the SERVED count I took, and it can only be green with served = 45 given retired = 3. Greps with controls: ACCEPTS in the file 1 at 21edb645e9, 0 at head; DECLARES 0 then 1. The seat's B4 gloss "(file absent on base)" is already retracted in 5673677365 (02:19:47Z) and measures false here too: git ls-tree 1bdbf82cb5 gives 100644 blob a7a30cb4a3, 224 lines; grep -c ACCEPTS on that blob prints 0 with grep exit 1 (control: expect( counts 15 in the same blob).

J4 — B1: the completeness claim is gone and nothing new is claimed. "asks the one question that makes a removal legal": 1 hit at 21edb645e9, 0 at head; the only makes a removal left in the repo at head is packages/spec/src/shared/retired-key.ts:129 ("makes a removal audible", unrelated). Replacement: "The one question this pin asks: did that node admit any instance?" — it names what the pin asks and says nothing about sufficiency. The surviving lead-in, "reads the removals themselves, at every depth, for every served type", is pre-existing and true: strippedDiff recurses through every object and array level, and the non-vacuity assertion pins dashboard.properties.widgets.items.properties.*.

J5 — B3: anchored as ordered; the sentence now reads as a dated measurement; but the version it names is the tree's version FIELD, not the published 17.4.0. New text: "Measured over the whole served registry at @objectstack/spec 17.4.0: 77 such nodes across 15 types — a reading taken at that version, not a standing invariant; it moves as retired keys land or age out." The number is TRUE at head (J2). packages/spec/package.json says 17.4.0 at the head, at the PR base and at origin/main, so "17.4.0" is what the measured tree declares — which is the wording the ruling and review 2's B3 asked for. Measured further, and this is the finding: the published tag @objectstack/spec@17.4.0 is commit 7e6337007f (2026-09-09, an ancestor of the base), and between that tag and the head the retired-key set moved — non-test retiredKey( calls in packages/spec/src 572 to 666 (101 changed lines), authorable-surface/ [RETIRED] rows 220 to 231, with the changed files including SERVED types ui/view.zod.ts (+6 lines), ui/page.zod.ts (+4) and ui/component.zod.ts (+1), and retired-key migration entries such as 18.ui__ListView__pageName, 18.ui__ObjectListView__pageName, 18.ui__Page__assignedProfiles, 18.ui__ObjectKanbanProps__quickAdd present at head and absent at the tag. So the reading was taken at a tree AHEAD of the published 17.4.0, on inputs the published 17.4.0 does not have, and the changeset will land under the NEXT release heading beside the spec release that first ships those retirements. A reader who takes "17.4.0" as the package version — the only reading a CHANGELOG offers — is pointed at the wrong point in history. Measured, not inferred: the same census script run against the built @objectstack/spec at the published tag (commit 7e6337007f, JS bundle emitted; the DTS stage was still running and is not an input) counts 71 nodes across 14 types there — view 28 not 32, object 8 not 9, page 0 not 1 — and action declares 47 properties there, not 48. So at the version the sentence names, the sentence is false by six nodes and one type. The pin file's own header anchors the same 77/15 to origin/main at 1bdbf82cb5, a sha, which is unambiguous.

J6 — Gates and suite on the head tree, exits by redirect-then-status. Under the shared heavy-verify lock: deps build 0 (02:26:58Z–02:30:19Z); pnpm --filter @objectstack/metadata-protocol build 0; typecheck 0; pnpm --filter @objectstack/metadata-protocol test 0 (02:30:42Z–02:33:26Z) — Test Files 178 passed | 3 skipped (181), Tests 2548 passed | 19 skipped (2567), identical to review 2's counts at 21edb645e9; a prose round moved no count, and the dev's "identical" claim is now a measurement. Unlocked, light: eslint --no-inline-config over the two changed test files 0; check-empty-changeset --base 1bdbf82cb5 0 (1 declaring changeset added, none from the base modified); check-changeset-no-major --base 1bdbf82cb5 0 (LEVEL AXIS not applicable locally — no PR payload was passed); check-adr-0087-registration --base 1bdbf82cb5 0. CI on the head (GET /commits/0f891896/check-runs, 02:22:14Z): 38 runs — 32 success, 6 skipped, 0 failure, 0 in progress; skipped are Auto Label and Check PR Size (path/label duplicates), Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) twice — a skip is not a pass and none is reported as one. Combined commit status success (one context, Vercel). NOT MEASURED: the required-contexts list (branches/main/protection/required_status_checks answers 403 to this token); the rule asks for every check green, which is what was read. CI job logs not read; not needed for a run with no failure.

J7 — STALE TREE, judged. origin/main is 4 commits past the base (0f95f4341f, 8c657f7dd0, fb3c6b4f60, b3b43b6ea0); merge-base(head, main) = base, so the dev's reading is right. Those 4 commits touch 49 files: 0 under packages/metadata-protocol; in non-test packages/spec/src, 0 changed lines carry retiredKey( (control: 128 changed lines in that same diff); [RETIRED] rows on main = 231 = head. scripts/pm/check-harness-current.mjs (+59) is on no path measured here. The staleness moves nothing in this record — not the census, not the suite, not the gates — and merging main is the landing PR's cost, as the dev said.

② Semver level

Unchanged: '@objectstack/metadata-protocol': minor, one package, no BREAKING banner, no ADR-0087 marker (check-adr-0087-registration 0). Nothing in 21edb645e9..0f891896 is code (J1), so review 2's ② stands: minor is the floor under the declared Clause-② yes, major is wrong, patch would have been defensible. No disposition owed by this round.

③ Boundary flags

  • B1 — The B3 anchor names the wrong point in history (the FAIL ground). The dev did what the order said, and the order's own wording is what is imprecise: "at @objectstack/spec 17.4.0" reads as the published 17.4.0, and the census was taken on a tree with 11 more [RETIRED] rows than that release, several on served types (J5). An anchor whose job is to make the number re-derivable at read time points the reader at inputs that differ from the ones measured. One-line remedy, matching the pin file's own header: anchor to the sha — e.g. "Measured over the whole served registry at 1bdbf82cb5 (@objectstack/spec source at 17.4.0 plus the retirements unreleased at that sha): 77 such nodes across 15 types — a reading taken at that tree, not a standing invariant; it moves as retired keys land or age out." Keeping "17.4.0" is fine only if it is qualified as the source tree, never as the release. Everything else about the sentence — past tense, "not a standing invariant", "moves as retired keys land or age out" — is right and should stay.
  • B2 — Tag census. Measured at the published tag @objectstack/spec@17.4.0 = 7e6337007f (J5): 71 nodes across 14 types, against the sentence's 77 across 15. The instrument is the same script that reproduces 77/15 on the head tree, where review 2 reached the same 77/15 by an independent method. Noted alongside: the degenerate-derivation pin's constant 48 is likewise a head-tree number (action declares 47 at the tag) — correctly so, a pin measures its own tree; only the CHANGELOG sentence claims a release it was not measured at.
  • B3 — The PR body still carries the unanchored "77 nodes across 15 types, measured over this repo's own served registry" and the 178/2548 counts. Not shipping prose; noted so nobody reads the body as the changeset.
  • B4 — Required contexts NOT MEASURED (403); every one of the 38 check-runs on the head is success or skipped, which is the stronger reading the rule asks for.
  • B5 — The seat's "(file absent on base)" gloss is retracted in 5673677365; measured false here as well (J3). The order for this round carried the same gloss; the dev surfaced it instead of copying it. Nothing further owed.
  • B6 — Not measured, by name: browser rendering; objectui (no objectui file touched and .objectui-sha unchanged — measured; rendering not); CI job logs; the derived 71-command gate family (dispatch-gates.mjs not run — the STALE TREE reading was taken directly with git instead); packages/spec check:generated (spec not a changed package).

Implemented-by: claude/issue-17502-repeater-row-tombstone-columns
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: FAIL


Generated by Claude Code

…ed at

The census sentence read "at `@objectstack/spec` 17.4.0", which reads as the
published 17.4.0 and is not the tree the number came from. An anchor whose job
is to make a count re-derivable at read time has to name inputs a reader can
actually reproduce, and this one named a different set.

Re-measured over the whole served registry, by running the real
`getMetaTypes()` and counting every property node whose subschema admits no
instance:

  - at `1bdbf82cb5`, this change's merge base: 77 nodes across 15 types;
  - at the published tag `@objectstack/spec@17.4.0` (`7e6337007f`), served by
    that release's own emitter: 68 nodes across 13 types. With today's emitter
    over that same spec source it is 71 across 14 — `action` serves a
    degenerate husk at the tag, and only the `io: 'input'` retry added later
    reaches its three tombstones.

So the shipping sentence claimed a count the release it named does not
produce, on prose that compiles into a published `CHANGELOG.md`. The sentence
now names the sha and qualifies 17.4.0 as the source tree. Its tense, its "not
a standing invariant" and its "moves as retired keys land or age out" are
unchanged — they were right.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Test Core (2/6) is red here, and the cause is a stale base rather than this round's one-line change

What is failing, at head 465a38ece9 (2026-09-16T00:01:17Z): 35 check runs — 19 success, 6 skipped, 1 failing (Test Core (2/6)), 9 still going. Run 35037740392 is status: in_progress, conclusion: null, and the head has not moved, so this is not a superseded run.

Why it is not this round's change. The only commit since the previously-green head 0f89189631 is docs(changeset): anchor the tombstone census to the sha it was measured at, and its entire diff is one markdown file, .changeset/17502-served-schema-drops-unauthorable-columns.md, +5/−3, one paragraph rewritten in place. A changeset cannot alter Test Core behaviour.

And it is not a failure on the base either — control taken on origin/main 74eaab8614: Test Core and all six shards read completed / success. So the usual "red on main too" exit does not apply.

What it actually looks like, measured. This branch's merge-base is 1bdbf82cb5, 48 commits behind origin/main, and the commit now at the tip of main is:

74eaab8614 feat(spec,core)!: the startup contract describes what the kernel produces
           — retire the orchestrator, declare PluginStartupResult once (#16059) (#18303)

That landed at 23:25Z, after this PR's last all-green reading. This branch's own surface is packages/metadata-protocol/** plus scripts/engine-double-contract.pinned.json — code and pins that census the served schema registry, and #18303 retired three spec defs and re-declared a fourth, which moves exactly that registry. A pull_request run tests the merge of head into base, so the run now sees a registry this branch's pins were measured against a different version of.

That is a stale-base interaction, not a flake and not someone else's failure, so no re-run is being spent on it. ⛔ No "flake" is being claimed, and the failing check is not being re-run to make it go away.

The remedy is the ordinary one: merge origin/main into the branch and re-validate the census-shaped pins against the merged tree. The implementer deliberately did not merge main during the patch round — 「out of scope for this round, and it would move the head CI is green on」 — which was a correct call at the time; main has since moved and the head is no longer green, so the reason has expired. It is going back to the implementer now.

One thing the anchoring fix gets right, worth naming: the changeset sentence now says the 77-across-15 census was taken at 1bdbf82cb5 specifically, 「a reading taken at that tree, not a standing invariant」. If merging main changes the number, the sentence stays true and only the figure needs re-measuring — which is exactly why the contract review asked for a sha anchor rather than a version.


Generated by Claude Code

…d tree

Merging `origin/main` moved the served registry underneath this card's
readings. #17751 retired `ChartConfigSchema.aria` and `ReportChart.aria`, so
the strip now drops one more node inside `dashboard.widgets[].chartConfig` and
two in a type that carried none before.

Re-measured with the same harness the census was taken with — the real
`getMetaTypes()` over a stub engine, counting every property whose subschema
admits no instance, with the emitter's strip stage ablated for the pre-strip
leg:

  - whole served registry, pre-strip: 80 nodes across 16 types (was 77 across
    15 at `1bdbf82cb5`) — `dashboard` 8 -> 9, and `report` 0 -> 2;
  - zod's default (output) arm alone: 77 across 15 (was 74 across 14). The
    3-node gap to the served figure is still `action`'s `io: 'input'` retry;
  - served payload after the strip: 0, unchanged — the fix still holds.

The over-drop guard's non-vacuity ledger gains the one new `dashboard` path it
is there to notice. That assertion failing is the guard working: it is the
only thing in this file that reads a concrete removal set.

The changeset sentence is re-anchored to `74eaab8614` rather than kept at
`1bdbf82cb5`. Both readings are true of their own tree, but the parenthetical
calls the sha "this change's merge base" and that is now `74eaab8614`; and a
merge-base sha is a main-line commit that survives the squash landing, where a
branch merge commit would name a sha no reader can ever check out.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0be466359c29b8019c129be3a02e958ccc8768c8

Fourth at-tier review of this PR, on the whole diff at this head, after the merge of origin/main at 74eaab8614. Every claim below was measured by me — on a detached worktree at the head sha (pnpm install --frozen-lockfile 0; pnpm --filter '@objectstack/metadata-protocol^...' build 0; package build 0), on a second detached worktree at the published tag, or by git show / git diff / git merge-tree against refs — never taken from the PR body, the dev reports (5689830955, 5690332291) or a previous record. Board readings carry their UTC time; tree readings carry their ref. NOT MEASURED means exactly that.

① Derived judgments

Head, base, merge. GET /pulls/18231 (00:52Z): head 0be466359c, base 74eaab8614, draft true, 9 commits, 7 files, +817/−8 against the base. merge-base(head, origin/main = 500c1b5695) is 74eaab8614, so the base sha is the merge base. The merge commit 16ed5a4039 has parents 465a38ece9 and 74eaab8614; git merge-tree --write-tree 465a38ece9 74eaab8614 reproduces its tree ad016212cb exactly, and the patch-ids agree in both directions (diff 465a38ece9..16ed5a4039 equals diff 1bdbf82cb5..74eaab8614; diff 74eaab8614..16ed5a4039 equals diff 1bdbf82cb5..465a38ece9). The merge commit carries no edit of its own, no conflict resolution, and no rebase — all six pre-merge shas from 05b02846b1 to 465a38ece9 are still first-parent ancestors of the head. Round-4 delta 16ed5a4039..head: 2 files, +10/−7; changed lines carrying expect(: 0 (control: 2 such lines across the whole base..head test diff are the two in-place rewrites already judged in review 1).

J1 — Accept set of the served payload: unchanged for every key, at 80 nodes now. My own census — predicate and the emitter's arm rule re-spelled in my own code, not the PR's, over the real getMetaTypes() on the pin's stub engine — at head: 27 types, 26 served with a schema. Pre-strip on the arm the emitter serves: 80 nodes across 16 types (action 3, agent 2, api 1, app 8, book 1, dashboard 9, field 1, flow 4, hook 1, job 2, object 9, page 1, permission 3, report 2, skill 1, view 32). Output-arm-only: 77 across 15; action is the only type on the io: 'input' retry and the 3-node gap is exactly its execute / shortcut / bulkEnabled. Post-strip served: 0 across 0. Parallel walk of each served document against its derivation arm: 26/26 pure deletions, 0 unexplained, 0 over-dropped, and the removed set equals the pre-strip set for every type. All 80 parents carry additionalProperties: false (80 false / 0 absent / 0 other); 0 of the 80 are named in a required array; the key set of all 80 is exactly description + not; 0 carry title; 80/80 descriptions begin [REMOVED] . So every removed key was refused before (by not) and is refused after (as an additional property): no served node admits anything it did not admit, and none refuses anything it admitted. Review 2's J1 holds on the merged tree, with the number moved from 77 to 80.

J2 — Public surface: unchanged. packages/metadata-protocol/src/index.ts re-exports nothing from unauthorable-nodes.js (0 hits); built dist/index.d.ts has 0 occurrences of acceptsNothing or stripUnauthorableProperties (control: omitInternalFieldsFromWriteResponse 1). package.json is not in the diff. Nothing published moves.

J3 — acceptsNothing and the position-aware walk, re-judged on the merged tree. The three PR pin files at head: 3 files, 35 tests, green. Ablation A — unauthorable-nodes.ts restored on disk to the review-1 blob e4695f4af1 (the walk that failed review 1), hash verified — exactly the three position and keyword-name-collision unit pins go red (3 failed / 14 passed across the unit file and the served pin); restored to blob 9ec8cfe07c, porcelain empty. Reachability of the two widening classes on the merged served corpus, measured: 0 { not: {} } nodes in keyword position in any served document; 0 served properties / $defs maps with an entry named properties; 0 required tombstones; 0 dependentRequired. The merge brought no new surface of either class, so review 2's J2, J3 and J3b conclusions stand unchanged; the unit pins remain the only carrier of the review-1 class, and they still go red on the broken walk.

J4 — The over-drop guard's ledger, as edited this round: tightened to the new truth, not loosened. The only assertion edited is the dashboard non-vacuity ledger, which gains one path — dashboard.properties.widgets.items.properties.chartConfig.properties.aria — inside the same sorted toEqual on the full list; unexplained toEqual [] and overDropped toEqual [] are untouched. Reproduced the CI red before judging the fix: the pin file restored on disk to the merge commit's blob 9885442d1f (hash verified) fails on this tree with 1 failed / 7 passed, AssertionError at :287, the one extra received entry being exactly that chartConfig.properties.aria path — the same file and line the dev names, and consistent with the failing check run 104610594395 on 465a38ece9 (annotations: pnpm run test in packages/metadata-protocol exited 1; control on the base 74eaab8614: all Test Core shards success). Then mut4b re-run on the merged tree — || key === 'maxTokens' added to the drop condition, blob 928e48116e — the guard goes red naming agent.properties.model.properties.maxTokens (1 failed / 25 passed), while protocol.meta-types-degenerate-derivation.test.ts stays green under the same mutation. Restored, porcelain empty. The guard still catches an over-drop of a live node; the red it replaced was the guard reporting a real new removal, which is what a non-vacuity ledger is for.

J5 — Did the merge change any served accept set beyond what #17751 and #18303 changed? No. protocol.ts is the same blob ddf3d433d0 at 1bdbf82cb5 and at 74eaab8614, and 0 files under packages/metadata-protocol moved on main between them, so served-at-head is exactly strip(served-at-74eaab8614), and J1 shows that strip is the 80 deletions and nothing else. Census delta against review 3's 77/15 at 1bdbf82cb5: dashboard 8 to 9 — ChartConfigSchema.aria (packages/spec/src/ui/chart.zod.ts:671, retired by 2bf6ef18dc, PR #18300, Fixes #17751) — and report 0 to 2 — report.properties.chart.properties.aria and report.properties.blocks.items.properties.chart.properties.aria, since ReportChartSchema is ChartConfigSchema.extend(...). Every other type is unchanged. #18303 (74eaab8614 itself) retires kernel startup defs (PluginStartupResult startTime / plugin / health, HealthStatus, StartupOptions, StartupOrchestrationResult), none of which is a served metadata type: 0 census movement. .objectui-sha is 53ded82b at 1bdbf82cb5, 74eaab8614 and head.

J6 — The degenerate-derivation pin on the merged tree. All 13 CARD_PROPERTY_COUNTS controls green (report 21 is unchanged because its two new tombstones are nested, not top-level); action served 45 + retiredTopLevelCount 3 = 48, green. The constant 48 was not edited, as fenced.

J7 — os-regen hygiene, measured against the tree rather than taken from the report. .gitattributes at head routes 19 globs to merge=os-regen; git diff 74eaab8614 0be466359c over every one of them is empty. packages/spec, packages/metadata-core and packages/metadata are byte-identical between 74eaab8614 and head. So every generated artefact at head is main's own, which main's CI validated (74eaab8614: 99 check runs, 69 success, 30 skipped, 0 failure). The dev's gen:schema then git status --porcelain empty claim is consistent with that structure — NOT re-run by me.

J8 — Gates on the head tree, exact commands and exits. pnpm --filter @objectstack/metadata-protocol typecheck 0; eslint --no-inline-config over the five changed .ts files 0; scripts/check-empty-changeset.mjs --base 74eaab8614 0 (1 declaring changeset added, none from the base modified); scripts/check-changeset-no-major.mjs --base 74eaab8614 0 (LEVEL AXIS not applicable locally, no PR payload); scripts/check-adr-0087-registration.mjs --base 74eaab8614 0 ("adds no declared-breaking changeset"); check-engine-double-contract.mjs --self-test 0 and the run 0; check-cross-package-test-inputs.mjs 0; check-test-source-alias.mjs 0. Full package suite pnpm --filter @objectstack/metadata-protocol test on the clean head tree: exit 0Test Files 178 passed | 3 skipped (181), Tests 2548 passed | 19 skipped (2567), 267 s — identical to the counts reviews 2 and 3 measured at 21edb645e9 and 0f89189631; a one-path ledger edit and a two-figure prose edit moved no count, the skips are pre-existing (0 .skip lines added in base..head), and the dev's 178 / 2548 claim is now a measurement. Locally reproduced red on the pre-fix ledger (J4) and green after it is the same one-test move CI reported (1 failed / 177 passed on 465a38ece9).. Assertion hygiene base..head over *.test.ts: 2 deleted lines carry expect( (the two in-place rewrites), 45 added; 0 .skip/.only/.todo/xit/xdescribe added; 0 loosened matchers (expect.anything, expect.any(, toBeTruthy(), not.toThrow); control characters in the 7 changed files: 0.

② Semver level

Declared '@objectstack/metadata-protocol': minor, one package, no BREAKING banner, no ADR-0087 marker — unchanged across all four rounds; check-adr-0087-registration 0. Correct under the convention (Clause-② yes floors the diff at minor; major is refused in the launch window) and consistent with plain semver: no exported symbol changes (J2), no served node's accept set moves (J1); what moves is the key set of a published payload — minor is the conservative reading, patch would have been defensible, major is wrong. No disposition owed.

Does the changeset state what it does? Yes. Every mechanism sentence was verified in reviews 1–3 and is byte-identical since 21edb645e9 except the census paragraph, which now reads: measured at 74eaab8614, "this change's merge base (@objectstack/spec SOURCE at 17.4.0, plus the retirements unreleased at that sha — not the published release): 80 such nodes across 16 types — a reading taken at that tree, not a standing invariant". Checked clause by clause: merge-base(head, origin/main) is 74eaab8614 — true; packages/spec/package.json says 17.4.0 at 74eaab8614 and at head — true; the published tag @objectstack/spec@17.4.0 is commit 7e6337007f, an ancestor of 74eaab8614 by 624 commits (GET /compare/7e6337007f...74eaab8614 status ahead, behind_by 0 — read over REST because this checkout is shallow and local ancestry is not trusted), so "retirements unreleased at that sha" is coherent and @objectstack/metadata-protocol is also 17.4.0 at all three points; the census at that tree is 80/16 by my own instrument (J1). A reader can check out 74eaab8614, build @objectstack/spec, derive each registered type on the emitter's arm and count — the sentence is now re-derivable, and its version qualification is true.

The re-anchoring decision, judged: ENDORSED. Re-anchoring to 74eaab8614 with 80/16 was right and keeping 1bdbf82cb5 with 77/15 would have been wrong, for the dev's two reasons and one more. (a) The sentence's own parenthetical calls the sha "this change's merge base"; after the merge that is 74eaab8614, so the old sha would have made the sentence false about its own anchor. (b) Only a main-line sha survives a squash landing as something a CHANGELOG reader can check out; 16ed5a4039 and 465a38ece9 would not. (c) The figure at the new anchor is TRUE (J1), which is the whole point of anchoring — review 3 failed this PR because the anchor pointed at inputs the number was not measured on, and this sentence now points at exactly the inputs it was measured on. The same sha and figures appear in the pin file's header, so the shipped test prose and the shipping changeset agree.

③ Boundary flags

  • B1 — mcp_calls: 1, mcp__github__get_job_logs. A READ. .claude/settings.json permissions.deny lists 14 mcp__github__* tools, every one a write (issue_write, create_pull_request, add_issue_comment, add_comment_to_pending_review, add_reply_to_pull_request_comment, pull_request_review_write, push_files, create_or_update_file, delete_file, create_branch, sub_issue_write, merge_pull_request, create_repository, fork_repository); get_job_logs is not among them, and rest-channel.md's own refusal names only issue_write. Confirmed a read, not on the roster; no refusal ground.
  • B2 — Out-of-scope finding 1 (hand-maintained header counts): AGREE. The pin's header and the control's title carry numbers the control does not assert — it pins only total greater than zero and dashboard present — so they go stale silently, and this is the second hand re-measure in two days. A derived assertion (served total equals the output-arm total plus action's input-arm count, both computed) would remove the maintenance. Outside this round's fence; the numbers as written are TRUE at head (80/16 served, 77/15 output-arm — J1); not a FAIL ground.
  • B3 — Out-of-scope finding 2 (rest-channel.md has no read-side row for Actions job logs): AGREE IN PART. The read side indeed stops at commits/{sha}/check-runs and actions/runs. But platform-readings.md lines 291–295 already record the wall (GET /actions/jobs/{id}/logs refused by the egress proxy, CONNECT 403), that get_job_logs returns only the teardown tail, the check-run annotations endpoint as the cheap second read (exit code and failing command — what I read here), and the get_workflow_run_logs_url archive route. The remedy is a pointer row in rest-channel.md, not a new investigation. One reading differs from the record: the dev reports get_job_logs DID return the assertion text at file and line this time, against the "teardown tail only" row — a reading for the owner of platform-readings.md, not adjudicated here.
  • B4 — 68/13 versus 71/14, ruled. Measured by me on a detached worktree at the tag commit 7e6337007f (own install, @objectstack/spec built there, my own script, both rules over the same source): under the tag's own emitter rule — toJsonSchemaSafe at the tag is a bare cache plus z.toJSONSchema with no degeneracy retry, and the call site's ?? HAND_CRAFTED_SCHEMAS arm is never reached because the husk is truthy — 68 across 13, with action serving {"$schema"} and contributing 0; under today's emitter rule (retry io: 'input' when the output arm is degenerate) over the same source — 71 across 14, action's input arm declaring 47 properties, 3 of them tombstones. Both figures are true; they answer different questions. Review 3's B2 "71/14" is what today's emitter yields over the 17.4.0 SOURCE; "what release 17.4.0 actually SERVED" (both packages at 17.4.0, same commit) is 68/13 — the dev is right about the served figure, and review 3's is right about the source. Either way the release does not produce 77/15 or 80/16, which is the point review 3 made. Any tag number written into the PR body must say which question it answers: 68/13 for "served at 17.4.0" (with the husk caveat), or 71/14 labelled "today's emitter over the 17.4.0 source"; an unlabelled 71/14 presented as what 17.4.0 served would be false by three. The changeset, correctly, carries no tag number.
  • B5 — Review 3's B3 is still open, and the body is now stale by a second step. The PR body still says "77 nodes across 15 types, measured over this repo's own served registry", "on all 77 nodes", "0 of the 77 required", and its verification table is dated 21edb645e9. Against the head those are 80, 16 and 0 of 80. Non-shipping prose (the body is not the changeset); when the seat writes the B3 fix it should carry 80/16 anchored to 74eaab8614, and any tag number per B4. Held to review 3's bar, which failed only shipping prose: not a FAIL ground.
  • B6 — Scope fence honoured. The file list is the seven paths in J1's diff; 0 under stack.zod.ts, stack-*.test.ts, data/analytics.zod.ts, ui/view.zod.ts, api/discovery.zod.ts, meta-spelling/, or any objectui path. Note that main itself moved analytics.zod.ts, view.zod.ts, stack.zod.ts and discovery.zod.ts between the two merge bases — those arrive through the merge, not through this branch, whose diff against 74eaab8614 is the seven paths.
  • B7 — Ratchet. scripts/engine-double-contract.pinned.json +15 is the same three registration rows as at 9eaf3c08a5; no pre-existing count moved; gate 0.
  • B8 — objectui. No objectui file touched; .objectui-sha unchanged at all three refs (J5); Console Pin Gate skipped by its path filter on this head — a skip, not a pass. The changeset's consumer class and its three mechanisms are byte-identical since review 2 verified them at the pin and are not re-read this round.
  • B9 — CI on the head at posting time. GET /commits/0be466359c/check-runs (01:09:15Z): 34 runs — 31 success, 3 skipped, 0 failure, 0 in progress. Skipped: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in) — path or label filters this file list does not select; a skip is not a pass and none is reported as one. Test Core (2/6), the shard that was red on 465a38ece9, is success on this head. Landing is the seat's step, not this record's.
  • B10 — Not measured, by name: gen:schema / check:generated (J7's structural argument stands in for the run); the derived 71-command gate family (dispatch-gates.mjs not run — the families I ran are the ones in J8); browser rendering at the pin; the job log text itself (annotations plus the local reproduction in J4 instead).

Implemented-by: claude/issue-17502-repeater-row-tombstone-columns
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 16, 2026 01:13
@os-warren
os-warren added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 69b5059 Sep 16, 2026
46 checks passed
@os-warren
os-warren deleted the claude/issue-17502-repeater-row-tombstone-columns branch September 16, 2026 01:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: retiredKey() tombstones are emitted into a repeater row's items.properties, so the served schema offers six columns the parse door refuses

2 participants