fix(metadata-protocol): GET /meta/types stops publishing properties no instance can satisfy - #18231
Conversation
… 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>
📓 Docs Drift Check9 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
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.
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: And the generators that build ⇒ the zero is a real absence, not a dead pattern. No generated page is falsified by this diff and no regeneration is owed here.
|
…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>
🔴
|
| 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.
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); 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
|
Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report: the reviewer's transcript carries
Contract reviewServed-tier: ① Derived judgmentsDiff = 7 files, +599/−8, merge base J1 — The accept-set change of the served document, measured, is: none. J2 — Public surface: unchanged. J3 — The predicate is right; the WALK is not. FAIL finding.
This is a WIDENING of a live node — the module header's own "the one thing it must not do" ( J4 — The rewritten pin (
J5 — Stop condition (#1), re-derived independently. Emitter output for a J6 — The shipped changeset's consumer census is false. FAIL finding. J7 — Ratchet (#5). J8 — Assertions diff-wide (#6). 8 deleted lines total: protocol.ts 4 ( J9 — Do the new tests fail on the base (#4). J10 — Docs divergence (#10). ② Semver levelDeclared: Convention ( Plain semver: also ③ Boundary flags
Implemented-by: VERDICT: FAIL Two defects, both invisible to every gate, both cheap:
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>
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.
What I did wrongTwo separate breaches of the dual-carrier rule (maintainer, 2026-08-22, 「两边都挂好」), both mine, both readable in the label event stream:
The second row is the one that matters. A gate clears on a PASS. The adopted record at comment 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 The machine read that caught it⛔ Not recall. The checker distinguishes 「被剥」 from 「从未挂过」 only because the second carrier exists. Had I hung it correctly at Repair, read backThe rule I am writing down for myself, in the spelling that would have stopped thisAdopting 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 This is the same shape as the lesson from comment The rework for the 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>
⛔ 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.
What I asserted, and what the authority actually saysI wrote that the rule "binds clearing to the other verdict, and only to it", citing:
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 (「席内复核的适用面、载体纪律、资格与归属、降档保险丝见
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 — ⇒ 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 itMy 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:
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 isSurvives — one real error, and it is still mine. At Current state is correct, for the reason I got wrong. The re-hang I made at The lesson, stated against myselfEarlier 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 Generated by Claude Code |
Contract reviewServed-tier: Re-review of the rework after the FAIL on ① Derived judgmentsSize and 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 J2 — The three reproductions are fixed, and both halves of claim 1 hold. Re-run by me against the head's exported 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 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 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. J6 — The consumer class and its three mechanisms exist at the pinned objectui. J7 — Gates, exact commands, exits, on the head tree. Local: J8 — Assertions diff-wide. ② Semver levelDeclared Prose, sentence by sentence against measurements: the tombstone shape, the ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Seat adoption of the at-tier record
|
| 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>
⛔ 更正本席记录里的一句注解 —— dev 逮到的,它是对的:「(file absent on base)」是我的 shell 造出来的假因
我写了什么dev 量到的事实文件在 base 上,一直都在。 这是一次修改,不是新增。 假因是怎么来的 —— 值得记进方法学我跑的实际是:
⇒ 这与本班另外两条读数纪律同源:
⇒ 补一条:任何 ⭐ 结论不受影响,而且更硬
「文件本来就在、里面本来没有这句、这句是本 PR 加的」比「文件是新增的」更能说明它是本 PR 的责任。 ⭐ dev 的处置是对的它照做了编辑(两条命令的输出都与令一致,裁定不受影响),同时把注解的假摆出来,而不是静默照抄、也不是因为一句注解错了就停手。这正是停手条件该被用的方式 —— 本班第五次 dev 把冲突摆上台面,五次都对。 派发令里那句同样的注解一并作废。⛔ 落地历史不改写,更正以本条为准。 Generated by Claude Code |
Contract reviewServed-tier: Third at-tier review of this PR, fenced to the prose fix-up ① Derived judgmentsHead and shape. J1 — Nothing else moved; the fence held. J2 — The accept set of the served payload is unchanged from J3 — B4 is corrected, and the new sentence is derived TRUE. From the tree, not from the comment: J4 — B1: the completeness claim is gone and nothing new is claimed. "asks the one question that makes a removal legal": 1 hit at 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 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); J7 — STALE TREE, judged. ② Semver levelUnchanged: ③ Boundary flags
Implemented-by: 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>
|
…peater-row-tombstone-columns
…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>
Contract reviewServed-tier: Fourth at-tier review of this PR, on the whole diff at this head, after the merge of ① Derived judgmentsHead, base, merge. 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 J2 — Public surface: unchanged. J3 — J4 — The over-drop guard's ledger, as edited this round: tightened to the new truth, not loosened. The only assertion edited is the J5 — Did the merge change any served accept set beyond what #17751 and #18303 changed? No. J6 — The degenerate-derivation pin on the merged tree. All 13 J7 — os-regen hygiene, measured against the tree rather than taken from the report. J8 — Gates on the head tree, exact commands and exits. ② Semver levelDeclared Does the changeset state what it does? Yes. Every mechanism sentence was verified in reviews 1–3 and is byte-identical since The re-anchoring decision, judged: ENDORSED. Re-anchoring to ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…tack-ai#18243) Fixes objectstack-ai#18229 ## What changed `scripts/pm/check-half-states.mjs` only. H31 compares the two carriers of the `needs:contract-review` gate, and it took `prDeliversCard` — the wide body-first/branch-fallback delivery relation — as its population. That relation counts a `Part of #N` line, which declares MEMBERSHIP in what #N tracks rather than a delivery of it, so an epic tracker whose sub-PRs are gated correctly drew a row saying the gate was missing from the card half of a dual carrier. The row's remedy text is action-shaped, so acting on it hangs the gate on a card nothing will ever close — where the stroke that clears gates never arrives. Three edits: 1. **`bindingClosesCard(pr, n)`** — a new exported predicate, one read of `deliveryEvidence`'s existing grading, never a second keyword parser. It answers "does this PR CLOSE #n", which is the question the gate's clearing stroke rides. `part-of`, `part-of-inline` and `branch-name` all answer false. 2. **H31's own population filter** — the carrier comparison runs over the PRs `bindingClosesCard` accepts. Those rows are byte-identical to before. 3. **A DECLINED row for every other binding** — a weak-bound delivering PR whose carrier differs from the card's still produces a row, one that names both carriers and the binding it read, states that it declined to judge, and prescribes no write. A weak-bound PR whose carrier agrees produces nothing, exactly as before. `TRACKING_ANCHOR_LABEL` is extracted so the ruling-anchor state has one spelling across H13's exemption list and H31's new clause. ## Why this seam, and how the shared invariant survives The shared relation is NOT narrowed. `prDeliversCard`'s docblock forbids it (「⛔ Do not narrow it here to serve H8: that would make the live half invisible to the rows that exist to see it」), and H8's open side, H35's sibling resolver, `claimDelivery`, H53 and the `check-clause2-carriers` pairing all still read it wide. The narrowing is H31's own filter over the population that relation hands it, so the rows still agree about which PR delivers which card and differ only about which binding makes a CARRIER PAIR — a question only H31 asks. Both docblocks that state the invariant were updated to say so rather than left to imply the old thing. Silently dropping the weak bindings would have been objectstack-ai#4690 in this row's own uniform: a split that was never judged renders identically to a board whose two carriers agree, and this is the one row that can tell 「被剥」 from 「从未挂过」. Hence the declined row, and hence its scope: it speaks only where the carriers actually differ, because a standing row per tracker per sweep is the disease, not the cure. The declined row is deliberately NOT marked `UNJUDGED_MARKER` — that marker buys trim priority ahead of judged rows, and a decline must never sort ahead of a real carrier split in the same `gate` band. ## Measured on the live board Anchor objectstack-ai#9857's sweep at 2026-09-15T01:57Z, commit `b3b43b6`, run 34919049964, carries exactly one H31 row: objectstack-ai#14122 (`tracking`), naming open PR objectstack-ai#18212 (draft, via a `Part of` declaration). Replayed offline against the 9 open PRs and the 9 open cards those PRs name (the exact H31 candidate set — a card no open PR names can never reach the comparison): | card | `tracking` | delivering evidence | row before | row after | |---|---|---|---|---| | objectstack-ai#18122 | no | objectstack-ai#18238 closing-keyword | none | none | | objectstack-ai#17502 | no | objectstack-ai#18231 closing-keyword | none | none | | objectstack-ai#17598 | no | objectstack-ai#18230 closing-keyword | none | none | | objectstack-ai#15410 | no | objectstack-ai#18227 closing-keyword | none | none | | objectstack-ai#18202 | no | objectstack-ai#18212 closing-keyword | none | none | | objectstack-ai#14122 | **yes** | objectstack-ai#18212 **part-of** | action-shaped finding | **DECLINED row** | | objectstack-ai#17396 | no | objectstack-ai#18198 closing-keyword | none | none | | objectstack-ai#17356 | no | objectstack-ai#18131 closing-keyword | none | none | | objectstack-ai#17097 | no | objectstack-ai#18096 closing-keyword | none | none | One row changes, and it is the expected one. Every other delivering binding on the live board is a closing keyword, so nothing else moves. ## Tests `pnpm check:pm-half-states` (= `node scripts/pm/check-half-states.mjs --self-test`): **4042 cases pass** before, **4075 cases pass** after, exit 0 — 33 new cases. The pin and the control both land, per the card's own remedies: - pin: the `Part of`-only tracker still produces a row (⛔ not a silent skip), the row says `DECLINES to judge`, names the binding it read, explains why a ruling anchor can never clear a gate, and prescribes NOTHING; it is asserted to be neither the old action-shaped sentence nor a LOUD row nor an UNJUDGED-ranked one. - control: a closing-keyword binding whose card lacks the gate — the row STANDS, with the unchanged action-shaped sentence. - plus: agreement on a weak binding stays clean both ways; a weak-bound card without `tracking` still reports the split but without the ruling-anchor clause; an adjudicable split outranks a decline and names only the closing-bound PR. **Ablation** (one-off, restored; run from the committed fix). Mutating `bindingClosesCard` back to the pre-fix wide population (`deliveryEvidence(pr, n) !== null`) turns the self-test RED: ``` == anchor counts BEFORE == old-text occurrences: 1 / new-text occurrences: 0 mutation written == anchor counts AFTER == old-text occurrences: 0 / new-text occurrences: 1 HEAD blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 MUT blob=906ac10ab7837ef5dc746f1bb3ccda8740e88c75 VERDICT ablation-exit=1 ✗ check-half-states self-test: 11 of 4075 case(s) failed. == restore leg == REST blob=dca4e1dbff5d6a46d10c5ac53a527b888e5f0811 old-text occurrences after restore: 1 / new-text occurrences after restore: 0 git diff HEAD bytes: 0 ``` The mutation is proved on disk by the blob hash moving off the HEAD blob and by the two anchor occurrence counts flipping; the restore leg is proved by the blob hash returning to the HEAD blob and by an empty `git diff HEAD`, not by an exit code. There is no build step and no `dist/` for a repo-root `.mjs`, so the rebuild half of the ablation preflight does not apply here. **Gates** — derived with no paths, at head `e0a4efa`: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 41 command(s) node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD_FILE -> Run reconciliation — 41 derived, 41 run, 0 NOT-MEASURED, 0 UNRUN. EXIT CODES — all 41 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them. ``` All 41 exited 0, each captured by redirect before any pipe. The gate script's own wiring is inside that set: `pnpm check:pm-half-states` is the package.json step lint.yml runs, and it is the script's `--self-test`. **Lint** — a declared narrowing, not the farm. `eslint scripts/pm/check-half-states.mjs --no-inline-config --format json` at head `e0a4efa`: **1 file linted, 0 errors, 0 warnings**, exit 0. The population is read from eslint's own config, not guessed — an `ESLint` instance over `git ls-files` reports `tracked=8674 in-scope=6755 ignored=1919 no-rules=0`. Invariance: this repo runs one `eslint.config.mjs` which enables type-aware linting for no file at all (every `parserOptions` in it is exactly `{ ecmaVersion: 'latest', sourceType: 'module' }`; zero `project`/`projectService` hits; the config says so itself at its `QUERY_OPTIONS_TEST_GLOBS` note), and that config is not in this diff — so no rule's verdict on a file this PR did not touch can move because of it. The repo-wide `pnpm lint` run is CI's. `skip-changeset`: `scripts/pm/**` is PM tooling and ships in no package's `files[]`, so nothing published moves. ## Acceptance notes - `scripts/pm/check-half-states.mjs` is non-governed PM tooling, so this is in-seat review. `.github/workflows/half-state-patrol.yml` and anchor objectstack-ai#9857 are untouched: the workflow runs the script from `main`, so the fix reaches the anchor at the first scheduled sweep after landing. - objectstack-ai#18214 is the sibling shape one file over — the clause-② dual carrier that `scripts/pm/check-clause2-carriers.mjs --pair` demands on an epic tracker reached through a `Part of` line. It is out of scope here and remains open. `bindingClosesCard` is exported for it: that file already imports `deliveryEvidence` and `prDeliversCard` from this one, so the fix there is an import rather than a second copy of the predicate. - noted, not filed: the sibling install of this script in `objectui` (`scripts/pm/check-half-states.mjs`) has already drifted from this copy (different md5 at `b3b43b6`), while this file's header describes the pair as copied VERBATIM. No gate can hold two repos equal, so the wording is an aspiration rather than a claim — carrier: the next dev dispatched on a cross-repo patrol card. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… gates read it (objectstack-ai#18346) Fixes objectstack-ai#16421 Clause-②: no A gate strengthens; no published runtime contract moves. Stated by the ruling, and re-derived here: the declaration's accept set only grows (the arm is optional and every existing spelling reads byte-identically), and the three gates that read it become stricter, never looser. ## The defect A **narrowing** — "these spellings stop being accepted" — could ship to customers with the release notes saying nothing, and every gate green. `check-adr-0087-registration.mjs` decided breaking-ness from an author's hand-written `**BREAKING**` prose banner; an author who did not type it was classified non-breaking and was asked for no ADR-0087 disposition. Measured, not hypothesised: **objectstack-ai#16296** narrowed `sys_job.timezone` / `sys_report_schedule.timezone` to the IANA value domain and shipped with no banner, no `major` and no `!`. **objectstack-ai#14238** did the same one surface earlier. ## The ruling being executed Director summon objectstack-ai#17, decision batch objectstack-ai#2 item 1, **option B**, maintainer verbatim 「同意」 (comment `5572145955`, 2026-09-07T14:32:32Z). Options A (keep the prose banner) and C (a static accept-set differ) are refused there and are not reopened here. ## What landed | # | deliverable | where | | --- | --- | --- | | 1 | the closed two-arm enumeration in the declaration format | `AGENTS.md` item 3 | | 2 | `readClause2Line` reads the arm — **once**, no second parser | `scripts/pm/check-clause2-carriers.mjs` | | 3 | signal (4): a declared `narrowing` is breaking | `scripts/check-adr-0087-registration.mjs` | | 4 | the level axis reads the arm | `scripts/check-changeset-no-major.mjs` | | 5 | **ONE** ADR-0087 ledger row, recording objectstack-ai#16296 | `packages/spec/src/migrations/entries/semantic/` | >⚠️ **Row 5 is deliberately one row, not the two the ruling names — corrected by the `domain:devx` seat (objectstack-ai#6023) on the dev's measurement.** > The ruling lists objectstack-ai#14238 and objectstack-ai#16296 as the two already-shipped narrowings. **objectstack-ai#14238 is not a narrowing**: its diff is 535 insertions and 0 deletions across 11 files (two nullable columns ADDED, carrying `valueDomain: 'iana_time_zone'` from their first release), and its own shipped changeset says so in writing — *"A NON-BREAKING ADDITION, registered here in writing because ADR-0087's registries have no additive entry kind"*. > A row claiming it narrowed something would be false data in the one ledger this mechanism keeps true, which ADR-0087's objectstack-ai#13080 addendum refuses by name. ⇒ One row is written, the second is **not fabricated**, and the falsified premise is carried to the maintainer rather than silently dropped. >⚠️ **The citation above was wrong in the dispatch order and is corrected here.** The seat's dispatch word cited comment `5573372199`, which answers **HTTP 404**; the real ruling is `5572145955` (HTTP 200, verified by both the dev and the seat). The quoted ruling text itself is unchanged — only the provenance id moved. | 6 | pins for both arms, in both directions | the three gates' self-tests | ### The shape | spelling | reading | | --- | --- | | `Clause-②: yes` | a widening, declared through the value — unchanged | | `Clause-②: yes (widening)` | the same, said out loud | | `Clause-②: yes (narrowing)` | widens one surface, narrows another; both facts are read | | `Clause-②: no (narrowing)` | NOT a widening, but BREAKING — the point of the arm | | `Clause-②: no (widening)` | MALFORMED: the value and the arm contradict each other | (The rows are table cells on purpose: a line-initial second declaration in this body would be a second answer to the same question.) The arm is **optional**, and that is a measurement rather than a kindness: all five declarations on the open board the day this landed carry no parenthetical arm, and all five read byte-identically afterwards (proof below). Two shapes fail **closed** rather than reading as an absent arm, because reading them as absent is the direction a declared narrowing silently disappears in: a near-arm spelling (`(narrowed)`, `(Narrowing)`, `(widen)`) and the unfilled template `(widening|narrowing)`. Both land in `malformed`, the state this reader already owns for "the slot holds something nobody can grade". ## Measurements ### Back-compat — the five in-flight declarations Read from the live GitHub payload, before and after the change, comparing the whole reading object: | PR | reading | arm | | --- | --- | --- | | objectstack-ai#18344 | `declared no` — IDENTICAL | none | | objectstack-ai#18268 | `declared no` (em-dash reasoning, parenthesised identifiers) — IDENTICAL | none | | objectstack-ai#18231 | `near-miss inline-key` — IDENTICAL | n/a | | objectstack-ai#18227 | `declared no` — IDENTICAL | none | | objectstack-ai#18096 | `declared no` — IDENTICAL | none | **5/5 byte-identical** apart from the additive `arm: null` field. Nothing in flight loses its reading. ### `AGENTS.md` — both ratchet axes Its line ratchet has **zero headroom** (1075 lines, ceiling 1075), so the format is carried at **net zero lines**, paid for by deleting content rather than by re-wrapping: - deleted: the widening-rationale clause (`the widening it declares is what makes it more than a patch…`), 93 bytes; - deleted: the CI step name the gate already prints in its own failure, 78 bytes. | axis | before | after | | --- | --- | --- | | lines | 1075 / ceiling 1075 | **1075** — headroom 0, unchanged | | widest line | ≤ 120 bytes | edited lines measure 119 / 118 / 120 / 119 / 81 bytes | | widest table row | 768 / pin 768 | **768**, untouched | `check:pm-skill-ratchet` self-test and run both exit 0. ### Both arms, both directions Every arm pin is a **pair**, because one direction alone cannot tell a reading from a constant — a gate that classified both arms as breaking would satisfy a `narrowing` test while telling a consumer nothing: - `check-adr-0087-registration`: `narrowing` is in `MUST_MATCH_BREAKING`, `widening` is in `MUST_NOT_MATCH_BREAKING`, both on a banner-free `minor` changeset — the exact shape objectstack-ai#16296 shipped in. - `check-changeset-no-major`: same tree, same levels, only the declaration moves — `no (narrowing)` reaches `enforce`, bare `no` reaches `not-declared`. - `check-clause2-carriers`: both arms read; the contradiction, the near-arm family and the unfilled menu are all `malformed`; and three CONTROL rows pin the arm-less spellings unmoved. ## Two judgement calls, declared rather than made quietly 1. **The `**BREAKING**` banner signal is KEPT.** The dispatch word asked for breaking-ness to stop being read from the prose banner. Removing signal (2) would un-declare the **52 changesets in stock** that carry it and nothing else — the gate's own docblock has refused that direction ("narrowing to any one of them would drop real declarations") since it was written, and the ruling's own text asks only that the gate *read the arm*. Signal (4) is added beside it, so breaking-ness no longer **depends** on prose. If the maintainer wants (2) retired, that is a second, deliberate card with its own stock count. 2. **`check-adr-0087-registration` reads the arm from the CHANGESET body, not the PR body.** It has no pull-request payload by construction — `cut-rc.yml` runs it on a `workflow_dispatch` over a whole snapshot range with no PR at all. All three of its existing signals are changeset-local, and the fourth is too. ## Acceptance notes - noted, not filed: `readClause2Correction` does not carry the arm. A correction comment can change a declaration's *value* but not its *direction*. No half-state results today — the arm's three readers are the changeset body (ADR-0087), the PR body (no-major) and this file's own rows, and none of them reads corrections — so this is recorded rather than filed. Carrier: `scripts/pm/check-clause2-carriers.mjs`, the next card that touches `readClause2Correction`. - noted, not filed: the I1/I2 fixture in `check-adr-0087-registration.mjs` staged its import siblings from a hand-written two-name list. Signal (4) added an edge into a nine-module closure including `pm/dispatch-gates.mjs`, so the list is now **derived** by walking real import statements. That is a guard extension inside this card's own defect class, not a drive-by: without it, the next import added over in `pm/` would kill this gate's fixture with an error about neither file. ## 维护者速读(草稿) **改了什么** —「本卡是否放宽契约」这条申报,现在可以再带一个方向:`(widening)` 或 `(narrowing)`,只有这两种拼法。声明 `(narrowing)` 的改动会被三个门禁当作破坏性变更处理:必须写 ADR-0087 处置、必须给到 `minor` 档。`AGENTS.md` 记下格式,行数一行没多。 **为什么改** — 一次「以后不再接受某些写法」的收紧,过去可以在发布说明里一个字都不提就发给客户,所有门禁照样亮绿:门禁是从作者手写的 `**BREAKING**` 横幅判断破坏性的,忘了写就当没有。objectstack-ai#16296 和 objectstack-ai#14238 就是这么发出去的。改完之后,判据是一个封闭的词,不是一段散文。 **风险与代价(含回滚)** — 风险面是「在飞的申报会不会一夜失效」。方向词是可选的,板上 5 个在飞 PR 的申报实测全部逐字不变,已列在上面的表里。代价是作者多写一个词,并且要把同一行抄进 changeset 正文。两条已申报的判断:`**BREAKING**` 横幅这条旧信号保留(库存里 52 个 changeset 只靠它),以及 ADR-0087 门禁从 changeset 正文读方向(它跑 RC 切版时根本没有 PR)。回滚是单笔 revert:三个门禁各自的自测都是新增用例,没有既有用例被改写。 **席位意见** — (留空,复核席定稿) **你要做的** — 确认两件事:① 旧的 `**BREAKING**` 横幅信号保留,是否合你意(裁定原文只说「读分肢」,派发词的转述是「不再从横幅判断」);② 两条 ADR-0087 账本行记的是平台对象列的取值域收紧 —— 该账本服务的是元数据升级者,请确认这两行落在那里是你要的记录位置。 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: claude[bot] <noreply@anthropic.com>
Fixes #17502
GET /meta/typespublished everyretiredKey()tombstone as a property node beside the live keys.z.toJSONSchemarenders a tombstone as a node carrying a[REMOVED]description andnot: {}— truthful to a consumer that reads the subschema, invisible to one that reads the KEY SET. Studio builds a repeater's column headers fromitems.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.toJsonSchemaSafenow 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 isrequiredis 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 sessionhttps://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 aproperties/$defsmap. A property literally namedpropertiestherefore had its own keywords read as property subschemas, and any keyword valued{ not: {} }under it was deleted.additionalProperties,itemsandpropertyNamesall 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):After the fix (
a8958b9f1c), same script, same inputs:The fix. The walk now splits by position.
walkSchemais the only place a property may be dropped, because it is the only position where the decidingrequiredarray is a sibling.properties,patternProperties,dependentSchemas,$defsanddefinitionsare walked bywalkSchemaMap, which hands every VALUE back towalkSchemaand drops nothing — their keys are author-chosen names, not keywords, and neitherpatternPropertiesnor$defshas arequiredarray that could license a drop (a$defsentry 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
requiredordefaultbought its whole subtree an exemption from the walk —NON_SCHEMA_KEYSskipped 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
propertiesor$defsmap has an entry namedpropertiestoday, and the newover-drop guardbelow 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.tsnow carries three: the two pure-zod position cases, the$defscase, 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 andexpected [ '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 isstripUnauthorableProperties(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 pinningdashboard'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:
Note what the run also shows:
protocol.meta-types-degenerate-derivation.test.tsstayed 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-shain this worktree reads53ded82bf7a494f54e344e19099dbf00854b8694— read here, from the file, not from a local checkout. The earlier round measured a local objectui atff1d5ea, which is not the pin and is not an ancestor of it in either direction. Every objectui statement below is at53ded82b.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.mdis false at the next.objectui-shabump. 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:*.form.tslayout: its field list IS the servedpropertiesmap, and a nested object renders recursively, so a tombstone at any depth becomes a field with the[REMOVED]prescription as its help text;items.properties[k].title ?? k;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 base1bdbf82cb5; #17751 landed in between and retiredChartConfigSchema.aria, takingdashboard8 → 9 andreport0 → 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
toJsonSchemaSafeemits for aretiredKey()node, measured over the whole served registry: exactly two keys — adescriptionbeginning[REMOVED], andnot: {}— on all 80 nodes, with notitleon 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 80required.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'sauthorable-surface/ratchet, and the generated reference pages, which print the full prescription on anever-typed row.Consumer-side, corrected, at the pinned objectui
53ded82bmetadata-admin/widgets.tsxRowCell)descriptionbranch at all;notis never consulted. A repeater column loses no TEXT — the removal only withdraws the offer.METADATA_FORM_REGISTRYlayouts)SchemaForm.tsxFieldRow)schema.descriptionas help text for a layout-less type.api.cacheTtl,job.timeout.SchemaForm, so a depth-1 tombstone renders the same way:job.retryPolicy.retryDelayMs. The earlier round intersected TOP-level tombstones only and missed it.derivePropertyNames(items)hands a layout-less repeater its columns:book.groups[].translationsis a sixth offered column.mergeServerFieldsgraftingdashboard.refreshIntervalandpage.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 pagesAll 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, unchangedNothing authorable is removed or renamed, and no valid document changes shape, because every dropped node was unsatisfiable — so this is not
majorand carries no ADR-0087 disposition. It is more than a patch because it narrows a published payload.check:changeset-no-major,check:empty-changesetandcheck:adr-0087-registrationall 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 carriesClause-②: yesat the start of its own line — that comment is what the enqueue gate reads, and theneeds:contract-reviewlabel is on the PR. This body carries noClause-②:line and does not need to; noted because the review noted its absence.Scope fence
Clean. Nothing under
packages/spec/src/stack.zod.ts, anystack-*.test.ts,packages/spec/src/data/analytics.zod.ts,packages/spec/src/ui/view.zod.ts,packages/spec/src/api/discovery.zod.tsorpackages/spec/src/meta-spelling/. Noobjectuifile is touched — it was read only, at the pin53ded82b. The review's other measurements (theacceptsNothingpredicate, theengine-double-contractratchet rows, the changeset LEVEL, the docs divergence withcontent/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.
21edb645e9pnpm --filter '@objectstack/metadata-protocol^...' buildthen the package buildpnpm --filter @objectstack/metadata-protocol typecheckpnpm --filter @objectstack/metadata-protocol test(full package suite)pnpm lint(eslint . --no-inline-config, whole repo, no narrowing)pnpm check:engine-double-contractpnpm check:nul-bytesplus an own control-character sweep of the changed filescheck:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage,check:type-check-debt,check:doc-authoring,check:objectui-changesetcheck:empty-changeset,check:changeset-no-major,check:adr-0087-registration(all--base origin/main)agent.properties.model.properties.maxTokens; restored,git diff HEADemptyThe 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,bookand layouts. Four of the six named sites exist because the type carries no*.form.tslayout 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.requiredguard. The drop is vetoed by the parent'srequiredarray. JSON Schema'sdependentRequiredcan require a key conditionally, and a drop is a widening there too. Zod emits nodependentRequiredand none appears anywhere in the served registry, so there is nothing to fix today; recorded rather than filed.Generated by Claude Code