Skip to content

feat(spec): refuse bare element:filter / element:form nodes by name, and stop suggesting retired component types - #17592

Merged
os-bill merged 4 commits into
mainfrom
claude/issue-15110-retired-element-node-refusal
Sep 11, 2026
Merged

os-bill merged 4 commits into
mainfrom
claude/issue-15110-retired-element-node-refusal

Conversation

@os-bill

@os-bill os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15110

Clause-②: yes — half ① narrows a published accept set: two element: types that parse clean today are refused by name. needs:contract-review hangs on both carriers; nothing lands until an at-tier review returns.

The card is three pieces of very different natures. All three ship here.

what nature
① element:filter / element:form join RETIRED_PAGE_COMPONENT_TYPES accept-set narrowing
② KNOWN_COMPONENT_TYPE_CANDIDATES stops offering retired keys plain bug fix, no ruling
③ the vocabulary docblock's "superset … by exactly the" sentence was one member short one clause

The fence, cleared per member before either was added

Triage's fence, quoted rather than paraphrased:

⚠️ #14159's ruling scope is the one member user:profile. Extending refusal to further members narrows a published accept set for each one. ⇒ That is fine only where the member's own docblock already records the gap as unintended — which is exactly what the two element-grain retirements do, and why this card is a queue card. ⛔ For any member where no such documented intent exists, stop and report: narrowing there is a contract change and the maintainer's, not the dev's.

Each member's own docblock, quoted from packages/spec/src/ui/component.zod.ts at merge base ef180302f5:

  • ElementFilterPropsSchema (:2052–2055) — "A bare node with empty properties parses clean (the open type union accepts any string, so a node-level refusal is not expressible here); the migration strips the keys and leaves exactly that bare, inert node."
  • ElementFormPropsSchema (:2102–2105) — the same sentence, verbatim, one element over.

"Not expressible here" is a capability statement, and it sits between two sentences that make its intent unambiguous: the same docblock says deleting the ComponentPropsMap row would turn "a loud retirement back into a silent no-op", which is the outcome it is written to avoid. The landed ruling names those two sentences as a gap in its own words — page.zod.ts (:66–68), shipped by #14159: "the gap the element:filter / element:form retirements recorded as 'a node-level refusal is not expressible here'. It is expressible one level up." Fence cleared for both members. Nothing here reasons from family resemblance, and no member outside the two was touched.

What changed

① Two entries in RETIRED_PAGE_COMPONENT_TYPES. No new prose was authored: each node prescription is the element-grain tail of that element's own retiredKey tombstones with its per-key property KEYNAME clause dropped, so the node door and the props door carry one text. A pin in component.test.ts holds them equal byte-for-byte, which is how the "one prescription, three doors, no drift" shape reaches a type whose row cannot be z.never.

The rows stay as they are. user:profile is retiredComponentProps because it never had an authorable key; these two carry six tombstoned keys each, where a per-key prescription says more than one whole-bag refusal could. Both halves are pinned.

② The candidate pool is now the known set minus whatever the vocabulary retired — derived from the retirement map, not restated beside it, so a type retired tomorrow leaves the pool the day it lands. isKnownComponentType is unchanged: known and writable are different questions, and keeping the retired names known is what makes their refusal a located prescription instead of an unregistered-custom-string skip.

③ user:profile added to the row-superset sentence.

Prose this change falsified, corrected in the same diff. The two D2 conversions' customer-facing summary strings and the protocol-18 upgrade note said the stripped node "stays, inert as it always was" — 28 occurrences across conversions/registry.ts, migrations/registry.ts and the twelve retired-key entries, now zero (the registry mirror regenerated with gen:migration-registry). The conversions' behaviour is untouched: they still strip the keys and still leave the node, because deleting authored page nodes is a layout decision a mechanical conversion must not make. What changed is that the node they leave is refused by name, so the prose says that instead.

Behavioural proof, both directions, through the real doors

Measured with a probe driving PageComponentSchema and validateComponentTypes (the component-type-unknown rule itself, never the candidate array).

Node parse — before → after:

element:filter     ACCEPTED  ->  REFUSED  custom@[type] "`element:filter` was removed in @objectstack/spec 17 (ADR-0049) — …"
element:form       ACCEPTED  ->  REFUSED  custom@[type] "`element:form` was removed in @objectstack/spec 17 (ADR-0049) — …"
user:profile       REFUSED   ->  REFUSED   (lit control: the landed member, unchanged)
element:button     ACCEPTED  ->  ACCEPTED  (lit control)
element:text       ACCEPTED  ->  ACCEPTED  (lit control)
object-grid        ACCEPTED  ->  ACCEPTED  (lit control: the open arm)
mcp:connect-agent  ACCEPTED  ->  ACCEPTED  (lit control: the open arm)

Suggester — before → after, and the reverse check:

element:fitler  "Rename `element:fitler` → `element:filter`."  ->  "Use a declared component type … give it its own namespace …"
element:frm     "Rename `element:frm` → `element:form`."      ->  "Use a declared component type … give it its own namespace …"
global:serch    "Rename … → `global:search`."                  ->  unchanged   (lit control: a LIVE type is still proposed)
record:detials  "Rename … → `record:details`."                 ->  unchanged   (lit control)

The reverse check the card asked for: a typo that used to resolve to a retired name now proposes nothing and falls back to the own-namespace prescription. It does not propose something worse — no STOP. A pin asserts the stronger property directly: whatever the rule proposes must itself pass the rule. Candidate pool 46 → 43.

Ablation

Two mutations, each proven on disk before its colour was read, each restored by state.

MUT-A — drop both members from the map (source-only; packages/spec's own vitest reads src). Occurrence counts by grep -o | wc -l: 1 → 0 per member; blob 680ad6cc → e84d8357. Run: 12 tests failed in component.test.ts. Restore: blob back at 680ad6cc, git diff HEAD empty.

MUT-B — remove the retired-type subtraction (the subject resolves @objectstack/spec/ui through exports → dist, so every leg rebuilt and was preflighted). Guard 1 → 0 on disk, blob 1c1a40be → d8cadc83; ablation-dist-preflight --absent confirmed the marker gone from all 90 built files. Run: 2 tests failed, on exactly the historical strings — expected 'Rename \element:fitler` → `element:fi…' not to contain 'element:filter'. Restore leg: rebuilt, preflight found the marker back in dist/ui/index.jsanddist/ui/index.mjs, whole-tree git status --porcelain` clean, suite back to 29/29.

One honest note on MUT-B: the mutated build exited 1 at the DTS step only (TS6133, the import left unused by the mutation) — after the JS bundle was written. The preflight, not the build's exit code, is what licenses the reading, and it showed the mutated artifact in place.

Verification

At c712af03f8:

  • pnpm --filter @objectstack/spec test — 472 files / 13381 tests passed
  • pnpm --filter @objectstack/lint test — 103 files / 3747 tests passed (the 40 files that failed on first run were an unbuilt dependency closure; green after --filter '@objectstack/lint^...' build)
  • typecheck for both packages — green, test layers included
  • Gates run green: check:generated, check:migration-registry, check:spec-changes, check:upgrade-guide, check:docs, check:authorable-surface, check:api-surface, check:yaml-examples, check:liveness, check:strictness-ledger, check:adr-0087-registration, check:changeset-no-major, check:nul-bytes, check:doc-authoring, check:docs-spec-enumerations, check:quick-reference-counts, check:page-declaration-shape, check:published-files, check:type-check-coverage, check:pm-widening-tells, check:cross-package-test-inputs, check:test-source-alias, check:spec-docblock-symbol-anchors, check:comment-mask-adoption, check:comment-mask-corpus, check:doc-frontmatter, check:undeclared-dep-imports, check:closing-keyword-parity, docs-audit/check-affected-docs, check:docs-redirects, check:docs-audit-scope, check:changeset-gate-self-tests, check:docs-transcript-drift, check:corpus-claim-drift
  • check:type-check-debt — NOT MEASURED, exit 3: it needs the whole-workspace build closure CI builds before that step. Nothing was measured, so this says nothing in either direction.
  • Repo-wide pnpm lint is CI's run, not this round's.

Changeset

@objectstack/spec: minor, measured rather than asserted. The change moves published artefacts — page.zod.ts and component.zod.ts ship as source under files[]'s src/**/*.zod.ts, and both dist/ and json-schema/ carry the new prescriptions. The level is minor because check-changeset-no-major forbids major during the launch window and the repo's convention puts accept-set narrowings on minor releases (migrations/registry.ts: "the launch-window convention: accept-set narrowings ride minor releases"); #14159's own narrowing shipped the same way. The body carries the BREAKING banner and a not-required (already-registered …) ADR-0087 disposition naming the two pre-existing conversion ids.

Scope

packages/spec/src/shared/union-author-message-pins.test.ts, packages/spec/src/ui/view.zod.ts, packages/spec/src/contracts/** and packages/plugins/plugin-approvals/** are untouched. The dead reference at component.zod.ts:1179 (#17578) is untouched. Nothing under content/docs/releases/ and no CHANGELOG.md was edited.


Generated by Claude Code

os-bill and others added 4 commits September 11, 2026 00:36
Source half of #15110: the two elements join RETIRED_PAGE_COMPONENT_TYPES
with the element-grain tail of their own retiredKey tombstones, the typo
suggester stops offering retired keys, and the prose the node refusal
falsifies is corrected. Tests follow in the next commit.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 6 documentable anchor(s). ⚠️ 12 changed file(s) yielded no anchor (packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__aria.ts, packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__fields.ts, packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__layout.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectui/layout-dsl.mdx (via ComponentPropsMap (symbol, a top-level const object), PageComponentSchema (symbol, a top-level const))
  • content/docs/ui/pages.mdx (via PageComponentType (symbol, a top-level const object))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via PageComponentSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/releases/v17/17-3.mdx (via ComponentPropsMap (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ComponentPropsMap (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

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

Which tree this was computed on

This run read content/docs from 688df7b3ca547e80a0068567fa69f94b0ea9f9e2 — the merge of head c712af03f8803d717a3d30a4f6363d9c7f0cd565 into base abc4b83ce8b01d4e5af3560a0547421f6f5ca98f, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 688df7b3ca547e80a0068567fa69f94b0ea9f9e2 && git checkout 688df7b3ca547e80a0068567fa69f94b0ea9f9e2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin abc4b83ce8b01d4e5af3560a0547421f6f5ca98f c712af03f8803d717a3d30a4f6363d9c7f0cd565 && git checkout -B drift-repro abc4b83ce8b01d4e5af3560a0547421f6f5ca98f && git merge --no-ff c712af03f8803d717a3d30a4f6363d9c7f0cd565

node scripts/docs-audit/affected-docs.mjs --json abc4b83ce8b01d4e5af3560a0547421f6f5ca98f

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs abc4b83ce8b01d4e5af3560a0547421f6f5ca98f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Seat adoption record — adopted VERBATIM. PASS WITH FINDINGS, ⭐ must-fix: NONE. domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-11T01:35Z.

Tier verified from the transcript, ⛔ not self-report: 136 harness-stamped model fields, all claude-fable-5-1 = CONTRACT_REVIEW_TIER; lit control 114 assistant messages. ⚠️ In-seat at-tier, ⛔ not cross-seat. ⚠️ 2 transport-introduced entities restored to </>; ⛔ no word changed.

⭐ The fence held, and it was checked the hard way. This was the one thing that could have made the narrowing a contract change the lane had no authority for. The reviewer did not take the round's quotations on trust: it read both docblocks at the merge base and found the sentence byte-identical in each, read page.zod.ts:66-68 where #14159 names those very sentences, and then went further than anyone asked — it read #9220 and #9249 in full, bodies and all comments, looking for any ruling that the surviving bare node was intended. There is none; the only mention is a test control, "which is a statement about a pin, not about intent." ⇒ Fence cleared per member, and no third member was touched.

⭐ It also improved the seat's own reasoning rather than just accepting it. The order told it to test hard whether "a gate forbids major in the launch window" settles the level — because that is a statement about a gate, ⛔ not about what the change is. Its answer: minor is right, but "for a better reason than the PR gives" — the repo's written convention (check-changeset-no-major.mjs:47-68, pr-automation.yml:712-719, maintainer ruling 2026-09-04 batch #35) puts accept-set narrowings on minor and carries breaking-ness in the BREAKING banner plus the ADR-0087 disposition. "The gate forbids major is the weaker half of the argument; the convention stands without it." ⇒ Same verdict, sound reasoning instead of circular.

⭐ And it found the consequence the PR did not name, which is the kind of thing an adversarial reader exists for. The node refusal flips os migrate meta --from 17 --to 18 to schemaValid:false on a source carrying those nodes — "resolve the manual changes above" — while none of the 113 step-18 todos names the node. ADR-0087 D3 requires a structured TODO "rather than silence" for a step that cannot be expressed declaratively, and deleting the node is now such a step. It graded that a card, not a blocker, and said why: step 18 is unreachable by default callers until PROTOCOL_MAJOR reaches 18, and zero authored instances exist. ⇒ This seat agrees, and files it rather than leaving it in a verdict.

Landing pre-checks — three read, ④ outstanding

pre-check reading, 2026-09-11T01:32Z
① at-tier verdict for the CURRENT head PASS WITH FINDINGS, no must-fix, on c712af03 — this record
② carriers --pair 17592 → exit 0, both carriers agree
③ governed --test over the FINAL 23 paths → exit 0, NOT governed; lit control (same 23 + AGENTS.md) → exit 3, GOVERNED ⇒ the instrument discriminates
④ checks head c712af03f8: 34 check NAMES, 0 non-green, Lint & Repo Gates still running

⇒ needs:contract-review is cleared on both carriers now (one stroke each, seconds apart — ⚠️ a lone stroke is what H35 fires on). ⛔ The PR is flipped ready and enqueued only once ④ is green.

Disposition of the three findings, applying the same test in both directions rather than filing everything the verdict names:

  1. Step-18 D3 todo → filed. ADR-0087 D3 is a declared contract and the verdict quotes it; a migration step that cannot be expressed declaratively owes a structured TODO rather than silence. Class (b).
  2. component-type-unknown silent on an exact retired name → filed. Measured on both sides, user-reachable, and it applies to user:profile identically ⇒ a follow-up to Ruling needed: does user:profile get a renderer, or become explicitly not author-placeable? — the one #12183 sibling that ruling never covered (gates objectui#7135) #14159's shape, ⛔ not a defect of this diff.
  3. The tail pin's substring-vs-equality nit → ⛔ NOT filed, recorded here with 承接者:无. packages/spec/src/ui/component.test.ts ships in no files[], and a loose assertion in a test is none of the three fileable classes. ⚠️ Filing it would be consistency with the verdict's list instead of with the rule — the same call this seat made on feat(spec): declare continueRestoredRun on the IApprovalService contract #17567's test-comment copies an hour ago, and it has to cut the same way here.

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #17592 @ c712af03

  • Implemented-by: branch claude/issue-15110-retired-element-node-refusal
  • Reviewed-by: isolated subagent at CONTRACT_REVIEW_TIER, adopted by session_01MkQhmuuJAVDjmeWNixwDDH

Method: head c712af03f8 fetched by ref, merge base ef180302f5 (proven ancestor). Two detached worktrees in the scratchpad (head + base), deps installed, @objectstack/spec built on each side under os-verify-lock.sh (VERDICT command-exit 0 both; dist/ui/index.mjs present by ls). Every before/after reading below was taken on both sides with the same probe; the shared checkout was never touched; both worktrees removed afterwards. Note for the record: my first two build attempts exited 126 (Permission denied on the worktree's lock script) and I discarded those readings rather than the lint probe that followed them — the second pair of builds is what licensed the suggester reading.

① Derived judgments

1. The triage fence — cleared, per member, on my own read. At ef180302f5, packages/spec/src/ui/component.zod.ts carries the sentence "A bare node with empty properties parses clean (the open type union accepts any string, so a node-level refusal is not expressible here); the migration strips the keys and leaves exactly that bare, inert node." verbatim in the ElementFilterPropsSchema docblock (:2052–2055) and, byte-identical, in the ElementFormPropsSchema docblock (:2102–2105). page.zod.ts :66–68 at the same ref, landed by #14159, names those sentences "the gap the element:filter / element:form retirements recorded as 'a node-level refusal is not expressible here'. It is expressible one level up." I also read #9220 and #9249 (bodies and all comments) for any ruling that the surviving node was intended: there is none — the only mention is #9249's verification report using "bare-node control green" as a test control, which is a statement about a pin, not about intent. No third member touched (RETIRED_PAGE_COMPONENT_TYPES at head: user:profile, element:filter, element:form). Not a contract change the lane lacked authority for.

2. The prescriptions — equality measured, pin adequate with one nit. For all six keys of each element, keyMessage.replace('property \` ', '') === RETIRED_PAGE_COMPONENT_TYPES.get(type)istrue(12/12, head; the map has no such entries at base, so the base reading is the expectedfalse). So "no new prose" is a fact. The pin (component.test.ts, the '$type: the node prescription is the tombstones' own tail, byte for byte'case) is a **substring** check —toContain(tail)` after the first em dash plus a head regex — not an equality. It catches drift on either side except a node tail that shrinks to a substring of the key tail. The PR body's "byte-for-byte" describes the fact, not the pin. Nit.

3. The 28-occurrence sweep — every corrected statement is true; no behaviour moved; one consequence unstated. git grep -o "inert as it always was" | wc -l: 28 at base → 0 at head, lit control ADR-0049 in page.zod.ts 11 → 14. The two conversions' apply() bodies are untouched in the diff; my chain run 17→18 on a stack carrying both elements produced 4 applied edits on both sides, same paths, same {} leftover nodes; check:migration-registry reports the mirror current (200/165/178). The new sentence "the bare node the conversion leaves is refused by name at the parse" is true: ObjectStackDefinitionSchema.safeParse on the chain's output is success:true at base and success:false at head with exactly two code:'custom' issues at pages.0.regions.0.components.{0,1}.type.
That flip is the consequence the PR does not name: os migrate meta --from 17 --to 18 on such a source now ends with schemaValid:false (--json) and, on the human path, "Migrated stack does not yet pass schema validation — resolve the manual changes above" (packages/cli/src/commands/migrate/meta.ts:423–428) — while none of the 113 step-18 todos names the node (probe: 0 hits for element:filter|element:form|ElementFilter|ElementForm across result.todos). The conversion summary, which does say "delete the component", reaches only specChanges.converted[].to in --json; the human path prints no summaries. ADR-0087 D3 (docs/adr/0087…md:196–199) requires a structured TODO "rather than silence" for a migration that cannot be expressed declaratively — and after this PR, deleting the node is such a step. Mitigations, all real: step 18 is inert for every default caller until PROTOCOL_MAJOR reaches 18; zero authored instances exist (re-grepped at head: 43 hits, all docs/tests/comments); the tombstones say "Delete the component" at the props door and os validate says it at the node. Card, not blocker — see ③ for the disposition angle.

4. The suggester — measured through the rule against built dist, both sides, preflighted. Dist marker RETIRED_PAGE_COMPONENT_TYPES.has in dist/ui/index.mjs: 1 at head, 0 at base; lit control KNOWN_COMPONENT_TYPE_CANDIDATES 2 and 2. Through validateComponentTypes: element:fitler, element:frm, element:filte, element:forms → base "Rename … → element:filter/element:form", head → the own-namespace prescription, proposing nothing; lit controls global:serch, record:detials, element:butotn → the same rename on both sides. Pool 46 → 43. Nothing worse is proposed. One boundary the card's item 1 implied and this PR does not (and does not claim to) close: the exact strings element:filter / element:form draw no finding from the rule on either side, because isKnownComponentType was deliberately left true. A raw-stack lint caller therefore still gets silence on a retired name; the parse door (definePage(), os validate, os build, and os lint only via loadConfig evaluating define*) is the sole refusal. Consistent with the #14159 shape (user:profile behaves identically), so a card, not a defect of this diff.

5. The z.never vs per-key question. Per-key is the right shape here: {} still parses at the row (pinned) and the node door is what closes it; user:profile had nothing to say per key. The residual asymmetry is at validateComponentProps on { properties: {} } — user:profile gets a COMPONENT_PROPS_INVALID finding, the two elements get nothing — and it is confined to the same raw-stack lint callers as (4). Card-level at most.

6. The MUT-B honesty note. The reasoning holds: tsup writes the JS bundle before the DTS step, vitest resolves @objectstack/spec/ui through exports to dist/ui/index.mjs, and a marker preflight on the written artefact is a state proof; I watched the same ordering in my own builds (dist/ui/index.mjs present while DTS Build start was still running). Accept.

7. Carriers and gates, re-run. readClause2Line(body) → {kind:'declared', value:'yes'}. check-changeset-no-major --base ef180302f5 --head c712af03f8 → clean (level axis N/A locally by construction; CI reads the PR). check-adr-0087-registration --base ef180302f5 → green on not-required (already-registered element-filter-removed, element-form-removed). needs:contract-review present on both carriers by REST read. spec-changes.json (in files[]) carries only cut majors, so it is not stale.

② Semver grading

@objectstack/spec: minor — correct, and for a better reason than the PR gives. The change is an accept-set narrowing (breaking under strict semver). The repo's written convention — scripts/check-changeset-no-major.mjs:47–68 ("During the launch window we ship breaking changes as minor… until GA… an accept-set narrowing… grades major") and .github/workflows/pr-automation.yml:712–719 ("During the launch window major stays refused… breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level", maintainer ruling 2026-09-04 batch #35) — puts it on minor and moves the breaking signal to the banner + disposition. Both are present in .changeset/15110-retired-element-node-refusal.md. Precedent: #14159's identical narrowing shipped "@objectstack/spec": minor (.changeset/user-profile-not-placeable.md). "The gate forbids major" is the weaker half of the argument; the convention stands without it.

③ Boundary-flag disposition

  • Clause-②: yes — required and correctly declared; the line begins a line and parses as declared.
  • ADR-0087: not-required (already-registered …) — gate-true (both ids pre-exist at base) but semantically thin: the ids named registered the key strips; this PR turns node deletion from optional into a required manual step for a schema-valid post-chain stack, and that step has no D3 entry. The honest completion is a step-18 D3 semantic todo (surface: the bare element:filter / element:form node the conversions leave; replacement: delete it / userFilters / object-form; acceptance: os validate clean) beside packages/spec/src/migrations/registry.ts:5092 (step18), with the disposition flipped to registered. Given step 18 is unreachable by default callers and no source carries the nodes, I grade this a card, not a blocker.

Verdict: PASS WITH FINDINGS

  • Must-fix before landing: none.
  • Cards, not blockers:
    1. Step-18 D3 todo for the node the two conversions leave, so migrate meta's "resolve the manual changes above" points at something (packages/spec/src/migrations/registry.ts:5092; packages/cli/src/commands/migrate/meta.ts:423–428; conversions at packages/spec/src/conversions/registry.ts:6812 / :6964); disposition then becomes registered.
    2. component-type-unknown stays silent on an exact retired name for raw-stack callers (packages/lint/src/validate-component-types.ts); decide whether the rule should carry the RETIRED_PAGE_COMPONENT_TYPES prescription — the same question applies to user:profile, so it is a follow-up to Ruling needed: does user:profile get a renderer, or become explicitly not author-placeable? — the one #12183 sibling that ruling never covered (gates objectui#7135) #14159's shape, not to this diff.
    3. Nit: tighten the tail pin in packages/spec/src/ui/component.test.ts to equality (keyMsg === node.replace(' was removed', 'property' + key + ' was removed')) so "byte-for-byte" is what the pin asserts.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

1 participant