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 |
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