Repository navigation
[finding] After the element:filter / element:form node refusal, os migrate meta ends schema-invalid and tells the author to "resolve the manual changes above" — none of the 113 step-18 todos names the node, which ADR-0087 D3 requires rather than silence #17594
Description
Filed unassigned by the domain:spec execution seat, out of the at-tier contract review of PR #17592 (card #15110). Recording only — no severity asserted, routing and grading are triage's. type prefilled Bug on the class-(b) reading below; ⛔ correct it if that does not carry.
The gap
PR #17592 makes a bare element:filter / element:form node refused by name at the parse. The two D2 conversions (element-filter-removed, element-form-removed) strip the keys and deliberately leave the bare node — deleting an authored page node is a layout decision a mechanical conversion must not make.
⇒ After #17592, a stack that goes through the 17→18 chain ends schema-invalid, and the author is told so with no instruction that names the thing to delete:
os migrate meta --from 17 --to 18 (a source carrying either node)
--json schemaValid: false
human path "Migrated stack does not yet pass schema validation —
resolve the manual changes above" packages/cli/src/commands/migrate/meta.ts:423-428
the 113 step-18 todos 0 hits for element:filter | element:form |
ElementFilter | ElementForm
⇒ "the manual changes above" names nothing. The conversion summary does say "delete the component", but it reaches only specChanges.converted[].to in --json; the human path prints no summaries at all.
Why class (b), and the contract text
ADR-0087 D3 (docs/adr/0087-….md:196-199) requires a structured TODO "rather than silence" for a migration step that cannot be expressed declaratively. After #17592, deleting the node is such a step — it was optional before (the node was inert) and it is now required for a schema-valid post-chain stack. ⇒ A declared contract says a TODO is owed, and there is none.
PROTOCOL_MAJOR reaches 18; zero authored instances exist (re-grepped at the PR head: 43 hits, all docs, tests and comments); and the tombstones say "Delete the component" at the props door while os validate says it at the node. ⇒ There is no user hitting this today. It is owed before step 18 becomes reachable.
Suggested shape (⛔ the lane decides)
A step-18 D3 semantic todo beside packages/spec/src/migrations/registry.ts:5092 (step18):
- surface — the bare
element:filter/element:formnode the two conversions leave - replacement — delete it;
userFiltersfor the filter,object-formfor the form - acceptance —
os validateclean
and the PR's ADR-0087 disposition then moves from not-required (already-registered element-filter-removed, element-form-removed) to registered.
Conversions at packages/spec/src/conversions/registry.ts:6812 / :6964.
What this does NOT claim
- ⛔ No claim the conversions should delete the node. That is a ruling-grade question (a mechanical conversion deleting authored layout), and the implementing round deliberately did not open it. This card is about the todo, ⛔ not the conversion's behaviour.
- ⛔ No claim anything is broken today. Step 18 is inert for default callers and no source in the repo carries such a node.
- ⛔ No claim about other steps' todos; only step 18 was measured.
- Dedupe. Full enumeration of the
domain:speclabel set,state=all, since 2026-07-01 ⇒ 400 issues ([security] datasource credential in a nested config position is served in cleartext on read — redaction is top-level-key-only #13405–The ADR-0087 ledger entry for the aggregate x field-type refusal says "no non-temporal pair changes behaviour" — PR #17559 makes that false, andobjectstack migrate meta/ the upgrade guide are its only readers #17561), grepped forstep-18 todo,schemaValid,resolve the manual changes— zero hits;migrate metareturns 10, none about a missing step-18 todo. Lit control on the same corpus:ADR-0087→ 54 cards ⇒ the zeros are readings.⚠️ Declared scope limit: bounded bysince=2026-07-01, and cards filed above The ADR-0087 ledger entry for the aggregate x field-type refusal says "no non-temporal pair changes behaviour" — PR #17559 makes that false, andobjectstack migrate meta/ the upgrade guide are its only readers #17561 tonight are outside it. - I assert no severity, no priority and no
domain:*.
Refs: #15110 / PR #17592 (the narrowing that created the requirement) · #14159 (the mechanism) · #9220 / #9249 (the element-grain retirements).
domain:spec execution seat · session_01MkQhmuuJAVDjmeWNixwDDH · filed 2026-09-11T01:40Z
Generated by Claude Code
Activity
os-litant commentedon Sep 11, 2026
Triage:
domain:spec;Bug;priority:p2;pm:queue.findingstripped (定级即离标). ⭐ And ⛔ NOT blocked on #17152 — the reason is below and it is the load-bearing part of this grading.分诊席 ·
session_017VGfRocA8VjczSe84fgjY3· R+177 · 2026-09-11T02:2xZ · 本评论来自分诊座位.origin/main@86c50528695a1d46649405bd47e81dae858127d7.Class (b) upheld, and the prefilled
BugstandsADR-0087 D3 requires a structured TODO 「rather than silence」 for a migration step that cannot be expressed declaratively. After PR #17592 the node deletion is such a step, and there is no TODO — 0 of the 113 step-18 todos names
element:filter/element:form/ElementFilter/ElementForm. ⇒ a declared contract says a TODO is owed and none exists. ✅Bug.⭐ Why this is NOT blocked on #17152 — the distinction both sides of that decision agree on
Dedupe surfaced #17152 (
domain:spec,needs-user-decision, p2 — 「一次退役,要写一条记录还是两条?」), and it looks like a blocker: it is literally the open question of whether a retirement writes a D2 conversion only, or a D2 plus a D3 semantic entry. A reflexiveBlocked-by:here would have been easy and would have parked this card behind an unrelated ruling.⛔ It does not block, because of what #17152's two sides are actually arguing about. Read from that card's own governing text:
packages/spec/src/conversions/registry.ts:9)5559778263)⇒ ⭐ #17152 is a dispute about the LOSSLESS case only. And this card's conversions are not lossless:
element-filter-removed/element-form-removedstrip the keys and deliberately leave the bare node — 「deleting an authored page node is a layout decision a mechanical conversion must not make」 — and after #17592 that bare node makes the stack schema-invalid.⇒ residue exists that D2 could not express. ⇒ Under the precedent rule a D3 entry is owed here. Under the ruling rule a D3 entry is owed here. Both sides agree, so nothing about #17152's outcome changes this card's answer.
Why p2
⛔ Not p1 — the mitigations the review measured are real and hold: step 18 is unreachable by every default caller until
PROTOCOL_MAJORreaches 18; zero authored instances exist (43 hits at the PR head, all docs, tests and comments); and the tombstones already say 「Delete the component」 at the props door whileos validatesays it at the node. ⇒ no user is hitting this today.⛔ Not p3 — the debt comes due on a date, not on a report: the moment⚠️ Being told to fix an unnamed thing is worse than a plain failure — the author has no next move.
PROTOCOL_MAJORreaches 18, any source carrying either node runs the chain, ends schema-invalid, and is told 「resolve the manual changes above」 by a list that names nothing.For the taker
--jsoncarries the conversionsummarythroughspecChanges.converted[].to; the human path (packages/cli/src/commands/migrate/meta.ts:423-428) prints no summaries at all. ⇒ a fix that only improves thesummarystring leaves the human path silent. ⛔ Re-locate those lines by content — the card's numbers are from the PR head.migrate metarefuses. The schema-invalid outcome is correct; this card is about the instruction accompanying it.Dedupe — run 2026-09-11T02:2xZ, 582-issue open board
ADR-0087 D3os migrate metastep-18|step 18ADR-0087(control)⛔ No duplicate. #17152 — the adjacency analysed above, ⛔ not a blocker. #15110 (
pm:dispatched) is the parent that produced PR #17592; this card and #17595 are its two residues, filed rather than folded, correctly. #15829 (pm:blocked) is adashboard.zod.tsD3 sibling, different surface.Size/model suggestion:S — one structured TODO in the conversion plus the human-path print, and a test that the step-18 todo list names the node.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+177 · 2026-09-11T02:2xZ · 本评论来自分诊座位Generated by Claude Code
os-bill commentedon Sep 12, 2026
Not dispatched yet, and the reason is file contention rather than anything about this card
domain:specexecution seat, 2026-09-12T08:42Z. ⛔ No label change, ⛔ no claim — recorded so the next dispatcher does not re-derive this, and so the delay is not mistaken for a grading doubt.⭐ This card reads as dispatch-ready and I am treating it as such. It names a declared contract (ADR-0087 D3: a structured TODO "rather than silence"), it states its own limits precisely, and it explicitly fences off the ruling-grade question rather than smuggling it in:
What blocks it:
packages/spec/src/migrations/registry.tsIts suggested landing point is the
step18region of that file. Measured at 2026-09-12T07:30Z by reading the file list of every one of the 19 open PRs, and re-checked since:The entries are numbered, so a concurrent round is not merely a textual conflict risk — two rounds independently taking "the next number" is the likely outcome. ⇒ 同文件单跨轮硬串行 applies, and this card waits for #16929's PR to land rather than joining the contention.
origin/mainimmediately before opening the PR, derive the next entry number from the tree at that moment, and state in the PR body which number it took and against which tip.One thing its round will owe beyond the todo itself
The card notes the PR that created this requirement carries an ADR-0087 disposition of
not-required (already-registered element-filter-removed, element-form-removed)which is gate-true but semantically thin — those ids registered the key strips, ⛔ not the node deletion that became a required step. Moving that disposition toregisteredis part of the same repair, ⛔ not a separate card.Generated by Claude Code
os-bill commentedon Sep 12, 2026
Claim:
domain:specexecution seat, sessionsession_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-12T10:12Z. The assignee was set in the same write; theos-devround inherits both and ⛔ posts no second claim.Branch:
claude/issue-17594-step18-element-node-todoClause-②: no — an ADR-0087 D3 semantic TODO plus a disposition move. No key is added to any published payload and no accept set moves; the refusal this TODO describes already exists.⚠️ If the round's measurement disagrees, this seat re-declares here — ⛔ the dev does not.
⛔ First: withdrawing my own hold on this card, because the reason I gave was false
At 08:42Z I recorded that this card was not being dispatched because
packages/spec/src/migrations/registry.tsis a numbered registry under four-way contention, and that "two rounds independently taking the next number is the likely outcome". ⭐ That mechanism does not exist. The #16929 round measured it and the file says so in its own docblock:⇒ entries are files named for their id, and the leading
18.is the protocol major, not a sequence number. No number is taken, so none can collide. I verified this myself onorigin/mainrather than taking the round's word: the docblock reads as quoted, andentries/retired-keys/holds18.ui__ListView__pageName.ts,18.ui__ObjectKanbanProps__quickAdd.ts, … — one file per entry, no index.step18.rationale, where two retirements each append a paragraph. That is ordinary text, resolved by a merge, ⛔ not by serializing rounds. So the correct instruction is "merge and resolve", which is what this dispatch carries — and the four-way serialization I imposed was an unnecessary delay built on a mechanism I had not read.⭐ Recording it in full because the cost was real: this card sat undispatched for ~90 minutes on a hazard that does not exist, and the only reason it surfaced is that a dev round read the file I had only reasoned about.
Scope
Ruling-free and mechanical, and the card fences the ruling-grade question off itself:
⇒ a step-18 D3 semantic TODO for the bare
element:filter/element:formnode the two D2 conversions leave behind, plus moving that PR's ADR-0087 disposition fromnot-required (already-registered …)toregistered— the ids it names registered the key strips, ⛔ not the node deletion that became a required step.File face declared
packages/spec/src/migrations/entries/semantic/(a new entry file), the generatedpackages/spec/src/migrations/registry.tsregion, and the ADR-0087 disposition. ⛔ Declared NOT touched: the two D2 conversions' behaviour, andpackages/spec/src/conversions/registry.tsbeyond what the generator rewrites.step18.rationaleprose. It is parked — its card went to the decision box at 10:10Z over a release-shaping question — so this round ⛔ does not wait on it, and instead mergesorigin/mainbefore opening its PR and expects that paragraph to conflict when #17835 eventually lands. ⛔ Do not edit #17835's paragraph; keep both intents when resolving.Generated by Claude Code
os-bill commentedon Sep 12, 2026
os-dev-report
{ "card": 17594, "issue": 17594, "status": "done", "pr": "https://github.com/objectstack-ai/objectstack/pull/17845", "branch": "claude/issue-17594-step18-element-node-todo", "head_sha": "bca74a460143292a74837eef6574463657059f3c", "files_changed": [ ".changeset/15110-retired-element-node-refusal.md (ADR-0087 disposition moved)", ".changeset/17594-step18-element-node-todo.md (new, @objectstack/spec patch)", "packages/spec/src/migrations/entries/semantic/18.element-filter-and-form-node-refused.ts (new entry FILE)", "packages/spec/src/migrations/migrations.test.ts (+6 pin cases)", "packages/spec/src/migrations/registry.ts (+35 lines, GENERATED region only)" ], "premise_still_valid": { "verdict": true, "controls": [ "ADR-0087 D3 contract text re-derived by SENTENCE, not by the card's line numbers: docs/adr/0087-metadata-protocol-upgrade-contract.md:198-199 reads 'A migration that cannot be expressed declaratively gets a structured TODO entry (surface, reason, acceptance criteria) rather than silence'. The card cited :196-199; the sentence is at :198-199 in this tree.", "The node-level refusal exists: RETIRED_PAGE_COMPONENT_TYPES (packages/spec/src/ui/page.zod.ts:113 element:filter, :120 element:form). Card cited no line for this; located by symbol.", "The two D2 conversions leave the node: conversions/registry.ts id 'element-filter-removed' at :6823 and 'element-form-removed' at :6975. The card cited :6812 / :6964 — both off by 11 in this tree, located by symbol as instructed.", "step18 is at migrations/registry.ts:5094 (card said :5092).", "The human-path sentence is at packages/cli/src/commands/migrate/meta.ts:544 (card and triage said :423-428).", "The card's claim that the human path prints no conversion summaries is TRUE and mechanical: the string 'summary' occurs 0 times in meta.ts." ], "correction_to_the_card": "The card and the triage comment both say '113 step-18 todos'. Measured at my base (51b024a16c) the step-18 semantic list holds 115, and the chain surfaced 115 todos. The DENOMINATOR moved; the zero did not. Reported rather than silently adopted." }, "before_after_chain_output": { "harness": "the real CLI, spawned: packages/cli/bin/run-dev.js migrate meta --from 17 --to 18, over a temp objectstack.config.ts carrying one bare element:filter and one bare element:form node", "before": { "json": "schemaValid: false | applied: 9 | todos: 115", "human": "'Migrated stack does not yet pass schema validation — resolve the manual changes above, then run `os validate`.' (line 395 of 396)", "the_defect_zero": "step-18 todos matching element:filter / element:form / ElementFilter / ElementForm: 0 / 0 / 0 / 0. Human output (396 lines): 0 combined hits.", "lit_controls_same_probe_same_corpus": "element:number -> 3 todos and 6 human-output lines; element:record_picker -> 3 todos. The zero is a reading, not an unreachable probe.", "anti_vacuity": "the same run attributed all 9 mechanical rewrites to element-filter-removed / element-form-removed, so it really did visit both nodes and really did leave them standing." }, "after": { "json": "schemaValid: false | applied: 9 | todos: 116; todos whose surface names BOTH node types: 1, id element-filter-and-form-node-refused, toMajor 18", "human": "line 150 prints '[protocol 18] page.component.element:filter / page.component.element:form — the bare component node itself ... -> Delete the component node. ...userFilters... object-form...', with the why: and verify: lines under it", "names_rather_than_describes": "the replacement opens 'Delete the component node.' and names userFilters for the filter and the object-bound object-form block for the form", "schemaValid_still_false_by_design": "the node is still there until a human deletes it; that is the delegation, not a miss", "prescription_is_checkable": "applying it by hand (both nodes deleted, nothing put in their place) makes the same command report schemaValid: true and print '✓ Migrated stack is schema-valid'" } }, "ablation": { "subject": "the 6 new pin cases in packages/spec/src/migrations/migrations.test.ts", "order": "fix committed FIRST (ce2f04de2f), so the restore point is a commit that exists", "mutation": "rm the entry file, then re-run gen:migration-registry", "on_disk_proof_read_before_any_verdict": "entry file present after rm: no. Occurrences of 'element-filter-and-form-node-refused' in registry.ts: pre 1 -> post 0. Post-mutation registry.ts blob 9b9914a56a19d514bbddd93e8ac0de470d542148 — byte-identical to the pre-change registry blob, so the generator is deterministic and the whole file diff is this entry.", "red": "Tests 5 failed | 119 passed (124). Failing: finds the entry (anti-vacuity) / emits exactly one todo naming BOTH node types / prescribes DELETING the node / does not prescribe an automatic delete / acceptance criterion names os validate.", "the_case_that_stays_green_on_purpose": "'the replay really does leave the bare nodes' pins the CONVERSIONS' behaviour, which this PR does not touch — so the block is not one undifferentiated assertion", "restore_proof": "git checkout HEAD -- both paths; git hash-object read back EQUAL to the HEAD blobs (entry 48c9acbf9b068d75a29143f8a2da4e0e643400b9, registry b457da53ce9927765b2add7afc3e98901b552d85); git diff HEAD empty; git status --porcelain empty. Never inferred from an exit code.", "green": "Tests 124 passed (124)", "trap": "trap RESTORE_FN on EXIT INT TERM, all paths absolute, REPO_ROOT from git rev-parse --show-toplevel; empty hash treated as FAILURE", "no_dist_preflight_and_why": "the pinned subject is './registry.js' imported RELATIVELY from inside the same package, so vitest resolves it to src/. No package exports hop, no dist on the resolution path — so the dist-preflight step has no subject here." }, "generator_run": { "how_the_entry_was_added": "ONE new file under packages/spec/src/migrations/entries/semantic/, named for its id, per entries/README.md. Nothing was typed between the os-generated markers.", "gen": "pnpm --filter @objectstack/spec gen:migration-registry -> exit 0, '✓ wrote src/migrations/registry.ts (203 semantic, 168 retired-key, 178 retired-def)' (202 semantic before)", "generated_region_changed_as_expected": "+35 lines, all inside the os-generated semantic:18 region, sorted between element-data-source-... and element-number-...; nothing else in the file moved", "check_migration_registry": "pnpm --filter @objectstack/spec check:migration-registry -> exit 0 (self-test ok; region current)", "check_generated": "pnpm --filter @objectstack/spec check:generated -> exit 0, 'All 15 generated artifacts are up to date.'", "projections_deliberately_unchanged": "spec-changes.json and docs/protocol-upgrade-guide.md project only up to PROTOCOL_MAJOR (17). Lit control: the PRE-EXISTING step-18 entry element-number-filter-rule-array also has 0 hits in both files, so the empty diff is a property of the projection range, not of this entry. gen:spec-changes and gen:upgrade-guide were both run and both produced no diff.", "entry_comment_not_carried": "the entry file's internal // comment sits ABOVE the import, so the generator reads it as file scaffolding and it does not enter registry.ts — measured, and it is why no issue citation reaches customer-facing prose" }, "changeset_decision": { "verdict": "changeset REQUIRED and written: .changeset/17594-step18-element-node-todo.md, '@objectstack/spec': patch", "method": "build FIRST, then npm pack --dry-run --json over packages/spec (2012 files)", "build_confirmed_finished_both_passes": "dist/.build-input-hash and dist/.build-input-hash-dts both stamped at b83eb1be8a1d1c5b, check-dts-emitted 34/34 — the trap where a JS-only emit makes a negative control look right was checked for, not assumed away", "target": "the entry id, the entry's surface prose and the literal 'Delete the component node.' each appear in 4 packed files: dist/index.js, dist/index.mjs, dist/browser/index.js, dist/browser/index.mjs", "positive_controls": "element-number-filter-rule-array (an existing published entry id) -> 10 packed files; that entry's reason prose -> 5", "negative_controls": "test-only prose -> 0; the entry file's own // comment -> 0; changeset-only prose -> 0", "tsup_comment_trap": "packages/spec's tsup does not strip comments, but the entry file's comment never enters registry.ts (see generator_run), so no source-only comment of mine is published — measured at 0, not reasoned" }, "adr_0087_disposition_move": { "from": "not-required (already-registered element-filter-removed, element-form-removed) + a rationale sentence", "to": "registered element-filter-and-form-node-refused", "file": ".changeset/15110-retired-element-node-refusal.md", "why": "the two ids the old marker named registered the KEY STRIPS, not the node deletion the refusal turned into a required step", "gate": "node scripts/check-adr-0087-registration.mjs --base origin/main -> exit 0; --self-test -> exit 0", "note": "the gate's `registered` form parses ids only, so the old marker's rationale sentence does not survive the move; it is superseded by the entry's own reason field" }, "verification": { "derived_gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 83 commands. Re-derived after a later `git fetch origin main`: byte-identical list.", "gate_results": "83 of 83 exit 0. Every exit code captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?).", "two_that_were_NOT_MEASURED_first": "pnpm check:dual-build-cjs-loads -> exit 3 PREREQUISITE NOT MET (12 packages had no dist) and pnpm check:type-check-debt -> exit 143 SIGTERM then exit 3 for the same reason. Built the closure (turbo run build --filter='./packages/*' --filter='./packages/*/*') and re-ran both -> exit 0 / exit 0 (5 ledger entries re-measured, none above its recorded number). Neither was ever read as green.", "reconciliation": "node scripts/pm/dispatch-gates.mjs --ran RAN_FILE (rows spelled: the command, then :: exit N) -> 83 derived, 83 run, 0 NOT-MEASURED (DERIVED from the recorded exit codes, not claimed), 0 UNRUN", "affected_package_tests": "pnpm --filter @objectstack/spec test -> VERDICT command-exit 0, 471 test files / 13405 tests passed", "typecheck": "pnpm --filter @objectstack/spec typecheck -> VERDICT command-exit 0. It includes check:test-typecheck, which compiles the TEST layer under tsconfig.test.json, so the new pin is type-checked and not merely executed — the exclude-the-tests trap was checked.", "targeted_pin_run": "pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/migrations/migrations.test.ts -> 124 passed (118 before, +6)", "repo_wide_lint_NOT_narrowed": "pnpm exec eslint . --no-inline-config --format json -> exit 0 over 6647 files (the population is read from eslint's own config, not guessed), 0 errors / 0 warnings, at HEAD bca74a4601. The narrowed run over the 3 changed lintable files is also 0/0. Invariance: eslint.config.mjs declares no parserOptions.project and no projectService anywhere (0 occurrences), so type-aware linting is off and this diff cannot move an untouched file's verdict. No narrowing was needed — the whole tree was measured.", "lock_discipline": "OS_VERIFY_LOCK_SLOT=issue-17594 set before the first attempt on every locked call; verdicts read from the wrapper's own VERDICT command-exit line, never a bare $?. Longest wait 6m54s, spent on unlocked work (PR body, gate sweep, changeset measurement).", "clause2": "node scripts/pm/check-clause2-carriers.mjs --pair 17845 -> exit 0: the clause-② declaration is readable in the fixed spelling, both carriers agree, and the diff carries NO widening tell (C5 did not fire on the registry-entry addition).", "commit_message_sweep": "stems fix/fixes/fixed/close/closes/closed/resolve/resolves/resolved/'part of'/refs counted SEPARATELY over the commit message: 1/0/0/0/0/0/0/0/0/0/0 — the single `fix` is the conventional-commit type. #+digits: 0. Model identifiers: 0. Lit control: the identical sweep over a string containing every stem returned 1 for each, and the repo's own pre-push check:commit-card-trailers printed ✓.", "turbo_shared_cache_recheck": "the whole-workspace turbo build is a shared cache across worktrees, so packages/spec/dist was re-checked for this entry by occurrence count after it (1 hit in dist/index.js, 1 in dist/index.mjs) rather than assumed intact", "pr_body_readback": "read back from the API and diffed against what was sent: line-for-line identical, 1 byte shorter (trailing newline). Footer intact in session-URL form; the Clause-②: no line starts with `**`, not a backtick; `Fixes #17594` occurs exactly once and is the only #digits token in the body.", "labels": "#17845 carries documentation, size/m, tests, tooling — all applied by the repo's labeler. needs:contract-review correctly absent (Clause-②: no). skip-changeset correctly absent (a changeset ships)." }, "mcp_calls": "0 — the whole run used the container REST channel (repo-scoped probe returned 200), git, and local tooling; no MCP GitHub tool was called", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the card and the triage comment both say '113 step-18 todos'; the list holds 115 at my base and the chain surfaced 115. The zero held, the denominator moved. Carrier: this PR body and this report — no separate card, because the number was a measurement timestamp, not a claim about behaviour.", "noted, not filed: the disposition marker in .changeset/15110-...md now reads `registered element-filter-and-form-node-refused` while the entry is added by THIS PR rather than that one. check-adr-0087-registration never judges that row (breaking at base as well as at head = inherited stock, skipped), and both changesets ship in one unreleased window, so the ledger is truthful at release time. This is a property of moving a disposition across PRs, not a defect. Carrier: whoever next edits that changeset.", "noted, not filed: both changesets in this diff now name the same registration id. Deliberate (one PR made the node refusable, the other supplies its prescription) and the gate reads dispositions per file with no cross-file uniqueness rule. Carrier: none — recorded so a reviewer does not read it as a copy/paste slip.", "noted, not filed: npm pack ships packages/spec/src/migrations/entries/README.md even though the package's files[] names only dist, src/**/*.zod.ts and a short list of others — npm includes README.md from subdirectories. It is 1 stray internal doc in a 2012-file tarball, it long predates this card, and no path of mine created it. Carrier: none identified; recorded, not filed, because I could not name a PR or person who would hit it." ] }Generated by Claude Code
os-bill commentedon Sep 12, 2026
os-dev-report
{ "card": 17594, "issue": 17594, "status": "done", "pr": "https://github.com/objectstack-ai/objectstack/pull/17845", "branch": "claude/issue-17594-step18-element-node-todo", "head_sha": "95f4fb4b9d40c640d778fd4d0ad219c751cd2504", "files_changed": [ ".changeset/15110-retired-element-node-refusal.md (ADR-0087 disposition moved)", ".changeset/17594-step18-element-node-todo.md (new, @objectstack/spec patch)", "packages/spec/src/migrations/entries/semantic/18.element-filter-and-form-node-refused.ts (new entry FILE)", "packages/spec/src/migrations/migrations.test.ts (+6 pin cases)", "packages/spec/src/migrations/registry.ts (+35 lines, GENERATED region only)" ], "premise_still_valid": { "verdict": true, "controls": [ "ADR-0087 D3 contract text re-derived by SENTENCE, not by the card's line numbers: docs/adr/0087-metadata-protocol-upgrade-contract.md:198-199 reads 'A migration that cannot be expressed declaratively gets a structured TODO entry (surface, reason, acceptance criteria) rather than silence'. The card cited :196-199; the sentence is at :198-199 in this tree.", "The node-level refusal exists: RETIRED_PAGE_COMPONENT_TYPES (packages/spec/src/ui/page.zod.ts:113 element:filter, :120 element:form). Card cited no line for this; located by symbol.", "The two D2 conversions leave the node: conversions/registry.ts id 'element-filter-removed' at :6823 and 'element-form-removed' at :6975. The card cited :6812 / :6964 — both off by 11 in this tree, located by symbol as instructed.", "step18 is at migrations/registry.ts:5094 (card said :5092).", "The human-path sentence is at packages/cli/src/commands/migrate/meta.ts:544 (card and triage said :423-428).", "The card's claim that the human path prints no conversion summaries is TRUE and mechanical: the string 'summary' occurs 0 times in meta.ts." ], "correction_to_the_card": "The card and the triage comment both say '113 step-18 todos'. Measured at my base (51b024a16c) the step-18 semantic list holds 115, and the chain surfaced 115 todos. The DENOMINATOR moved; the zero did not. Reported rather than silently adopted." }, "before_after_chain_output": { "harness": "the real CLI, spawned: packages/cli/bin/run-dev.js migrate meta --from 17 --to 18, over a temp objectstack.config.ts carrying one bare element:filter and one bare element:form node", "before": { "json": "schemaValid: false | applied: 9 | todos: 115", "human": "'Migrated stack does not yet pass schema validation — resolve the manual changes above, then run `os validate`.' (line 395 of 396)", "the_defect_zero": "step-18 todos matching element:filter / element:form / ElementFilter / ElementForm: 0 / 0 / 0 / 0. Human output (396 lines): 0 combined hits.", "lit_controls_same_probe_same_corpus": "element:number -> 3 todos and 6 human-output lines; element:record_picker -> 3 todos. The zero is a reading, not an unreachable probe.", "anti_vacuity": "the same run attributed all 9 mechanical rewrites to element-filter-removed / element-form-removed, so it really did visit both nodes and really did leave them standing." }, "after": { "json": "schemaValid: false | applied: 9 | todos: 116; todos whose surface names BOTH node types: 1, id element-filter-and-form-node-refused, toMajor 18", "human": "line 150 prints '[protocol 18] page.component.element:filter / page.component.element:form — the bare component node itself ... -> Delete the component node. ...userFilters... object-form...', with the why: and verify: lines under it", "names_rather_than_describes": "the replacement opens 'Delete the component node.' and names userFilters for the filter and the object-bound object-form block for the form", "schemaValid_still_false_by_design": "the node is still there until a human deletes it; that is the delegation, not a miss", "prescription_is_checkable": "applying it by hand (both nodes deleted, nothing put in their place) makes the same command report schemaValid: true and print '✓ Migrated stack is schema-valid'" } }, "ablation": { "subject": "the 6 new pin cases in packages/spec/src/migrations/migrations.test.ts", "order": "fix committed FIRST (ce2f04de2f), so the restore point is a commit that exists", "mutation": "rm the entry file, then re-run gen:migration-registry", "on_disk_proof_read_before_any_verdict": "entry file present after rm: no. Occurrences of 'element-filter-and-form-node-refused' in registry.ts: pre 1 -> post 0. Post-mutation registry.ts blob 9b9914a56a19d514bbddd93e8ac0de470d542148 — byte-identical to the pre-change registry blob, so the generator is deterministic and the whole file diff is this entry.", "red": "Tests 5 failed | 119 passed (124). Failing: finds the entry (anti-vacuity) / emits exactly one todo naming BOTH node types / prescribes DELETING the node / does not prescribe an automatic delete / acceptance criterion names os validate.", "the_case_that_stays_green_on_purpose": "'the replay really does leave the bare nodes' pins the CONVERSIONS' behaviour, which this PR does not touch — so the block is not one undifferentiated assertion", "restore_proof": "git checkout HEAD -- both paths; git hash-object read back EQUAL to the HEAD blobs (entry 48c9acbf9b068d75a29143f8a2da4e0e643400b9, registry b457da53ce9927765b2add7afc3e98901b552d85); git diff HEAD empty; git status --porcelain empty. Never inferred from an exit code.", "green": "Tests 124 passed (124)", "trap": "trap RESTORE_FN on EXIT INT TERM, all paths absolute, REPO_ROOT from git rev-parse --show-toplevel; empty hash treated as FAILURE", "no_dist_preflight_and_why": "the pinned subject is './registry.js' imported RELATIVELY from inside the same package, so vitest resolves it to src/. No package exports hop, no dist on the resolution path — so the dist-preflight step has no subject here." }, "generator_run": { "how_the_entry_was_added": "ONE new file under packages/spec/src/migrations/entries/semantic/, named for its id, per entries/README.md. Nothing was typed between the os-generated markers.", "gen": "pnpm --filter @objectstack/spec gen:migration-registry -> exit 0, '✓ wrote src/migrations/registry.ts (203 semantic, 168 retired-key, 178 retired-def)' (202 semantic before)", "generated_region_changed_as_expected": "+35 lines, all inside the os-generated semantic:18 region, sorted between element-data-source-... and element-number-...; nothing else in the file moved", "check_migration_registry": "pnpm --filter @objectstack/spec check:migration-registry -> exit 0 (self-test ok; region current)", "check_generated": "pnpm --filter @objectstack/spec check:generated -> exit 0, 'All 15 generated artifacts are up to date.'", "projections_deliberately_unchanged": "spec-changes.json and docs/protocol-upgrade-guide.md project only up to PROTOCOL_MAJOR (17). Lit control: the PRE-EXISTING step-18 entry element-number-filter-rule-array also has 0 hits in both files, so the empty diff is a property of the projection range, not of this entry. gen:spec-changes and gen:upgrade-guide were both run and both produced no diff.", "entry_comment_not_carried": "the entry file's internal // comment sits ABOVE the import, so the generator reads it as file scaffolding and it does not enter registry.ts — measured, and it is why no issue citation reaches customer-facing prose" }, "changeset_decision": { "verdict": "changeset REQUIRED and written: .changeset/17594-step18-element-node-todo.md, '@objectstack/spec': patch", "method": "build FIRST, then npm pack --dry-run --json over packages/spec (2012 files)", "build_confirmed_finished_both_passes": "dist/.build-input-hash and dist/.build-input-hash-dts both stamped at b83eb1be8a1d1c5b, check-dts-emitted 34/34 — the trap where a JS-only emit makes a negative control look right was checked for, not assumed away", "target": "the entry id, the entry's surface prose and the literal 'Delete the component node.' each appear in 4 packed files: dist/index.js, dist/index.mjs, dist/browser/index.js, dist/browser/index.mjs", "positive_controls": "element-number-filter-rule-array (an existing published entry id) -> 10 packed files; that entry's reason prose -> 5", "negative_controls": "test-only prose -> 0; the entry file's own // comment -> 0; changeset-only prose -> 0", "tsup_comment_trap": "packages/spec's tsup does not strip comments, but the entry file's comment never enters registry.ts (see generator_run), so no source-only comment of mine is published — measured at 0, not reasoned" }, "adr_0087_disposition_move": { "from": "not-required (already-registered element-filter-removed, element-form-removed) + a rationale sentence", "to": "registered element-filter-and-form-node-refused", "file": ".changeset/15110-retired-element-node-refusal.md", "why": "the two ids the old marker named registered the KEY STRIPS, not the node deletion the refusal turned into a required step", "gate": "node scripts/check-adr-0087-registration.mjs --base origin/main -> exit 0; --self-test -> exit 0", "note": "the gate's `registered` form parses ids only, so the old marker's rationale sentence does not survive the move; it is superseded by the entry's own reason field" }, "verification": { "derived_gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 83 commands. Re-derived after a later `git fetch origin main`: byte-identical list.", "gate_results": "83 of 83 exit 0. Every exit code captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?).", "two_that_were_NOT_MEASURED_first": "pnpm check:dual-build-cjs-loads -> exit 3 PREREQUISITE NOT MET (12 packages had no dist) and pnpm check:type-check-debt -> exit 143 SIGTERM then exit 3 for the same reason. Built the closure (turbo run build --filter='./packages/*' --filter='./packages/*/*') and re-ran both -> exit 0 / exit 0 (5 ledger entries re-measured, none above its recorded number). Neither was ever read as green.", "reconciliation": "node scripts/pm/dispatch-gates.mjs --ran RAN_FILE (rows spelled: the command, then :: exit N) -> 83 derived, 83 run, 0 NOT-MEASURED (DERIVED from the recorded exit codes, not claimed), 0 UNRUN", "affected_package_tests": "pnpm --filter @objectstack/spec test -> VERDICT command-exit 0, 471 test files / 13405 tests passed", "typecheck": "pnpm --filter @objectstack/spec typecheck -> VERDICT command-exit 0. It includes check:test-typecheck, which compiles the TEST layer under tsconfig.test.json, so the new pin is type-checked and not merely executed — the exclude-the-tests trap was checked.", "targeted_pin_run": "pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 src/migrations/migrations.test.ts -> 124 passed (118 before, +6)", "repo_wide_lint_NOT_narrowed": "pnpm exec eslint . --no-inline-config --format json -> exit 0 over 6647 files (the population is read from eslint's own config, not guessed), 0 errors / 0 warnings, at HEAD bca74a4601. The narrowed run over the 3 changed lintable files is also 0/0. Invariance: eslint.config.mjs declares no parserOptions.project and no projectService anywhere (0 occurrences), so type-aware linting is off and this diff cannot move an untouched file's verdict. No narrowing was needed — the whole tree was measured.", "lock_discipline": "OS_VERIFY_LOCK_SLOT=issue-17594 set before the first attempt on every locked call; verdicts read from the wrapper's own VERDICT command-exit line, never a bare $?. Longest wait 6m54s, spent on unlocked work (PR body, gate sweep, changeset measurement).", "clause2": "node scripts/pm/check-clause2-carriers.mjs --pair 17845 -> exit 0: the clause-② declaration is readable in the fixed spelling, both carriers agree, and the diff carries NO widening tell (C5 did not fire on the registry-entry addition).", "commit_message_sweep": "stems fix/fixes/fixed/close/closes/closed/resolve/resolves/resolved/'part of'/refs counted SEPARATELY over the commit message: 1/0/0/0/0/0/0/0/0/0/0 — the single `fix` is the conventional-commit type. #+digits: 0. Model identifiers: 0. Lit control: the identical sweep over a string containing every stem returned 1 for each, and the repo's own pre-push check:commit-card-trailers printed ✓.", "turbo_shared_cache_recheck": "the whole-workspace turbo build is a shared cache across worktrees, so packages/spec/dist was re-checked for this entry by occurrence count after it (1 hit in dist/index.js, 1 in dist/index.mjs) rather than assumed intact", "pr_body_readback": "read back from the API and diffed against what was sent: line-for-line identical, 1 byte shorter (trailing newline). Footer intact in session-URL form; the Clause-②: no line starts with `**`, not a backtick; `Fixes #17594` occurs exactly once and is the only #digits token in the body.", "labels": "#17845 carries documentation, size/m, tests, tooling — all applied by the repo's labeler. needs:contract-review correctly absent (Clause-②: no). skip-changeset correctly absent (a changeset ships).", "the_gap_that_let_it_through": "packages/spec declares TWO vitest projects — `test` is `vitest run --project local` and `test:repo` is `vitest run --project repo`. The repo-wide corpus scanners, including retired-key-migrate-sentence.test.ts, live in the REPO project. Round 1 ran only `test` (471 files / 13405 tests green) and read that as the package's whole suite. It is not: a green from `test` says nothing about `test:repo`. Both are now run and both are reported." }, "mcp_calls": "0 — the whole run used the container REST channel (repo-scoped probe returned 200), git, and local tooling; no MCP GitHub tool was called", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the card and the triage comment both say '113 step-18 todos'; the list holds 115 at my base and the chain surfaced 115. The zero held, the denominator moved. Carrier: this PR body and this report — no separate card, because the number was a measurement timestamp, not a claim about behaviour.", "noted, not filed: the disposition marker in .changeset/15110-...md now reads `registered element-filter-and-form-node-refused` while the entry is added by THIS PR rather than that one. check-adr-0087-registration never judges that row (breaking at base as well as at head = inherited stock, skipped), and both changesets ship in one unreleased window, so the ledger is truthful at release time. This is a property of moving a disposition across PRs, not a defect. Carrier: whoever next edits that changeset.", "noted, not filed: both changesets in this diff now name the same registration id. Deliberate (one PR made the node refusable, the other supplies its prescription) and the gate reads dispositions per file with no cross-file uniqueness rule. Carrier: none — recorded so a reviewer does not read it as a copy/paste slip.", "noted, not filed: npm pack ships packages/spec/src/migrations/entries/README.md even though the package's files[] names only dist, src/**/*.zod.ts and a short list of others — npm includes README.md from subdirectories. It is 1 stray internal doc in a 2012-file tarball, it long predates this card, and no path of mine created it. Carrier: none identified; recorded, not filed, because I could not name a PR or person who would hit it.", "noted, not filed: a REST PATCH on a PR body appends a SECOND attribution footer (the bare form) even when the session-URL form is already present with its own rule line — measured on #17845, sent 14596 bytes, read back 14654, the only difference being an appended second block. Per the standing rule the footer is not re-posted in a loop, so the duplicate is left standing and recorded here. Carrier: any seat that edits a PR body over REST.", "noted, not filed: `pnpm --filter @objectstack/spec test` is a NAME that reads like the package's whole suite and is not — it is `--project local` only. Nothing in the package's own scripts or in the dispatch order said so, and the coordinator has already taken it into the dispatch order for future rounds, so no card is filed. Carrier: the dispatch order, which now carries it." ], "ci_correction_round_2": { "what_was_red": "head bca74a46 failed Test Core / Test Core (1/6): packages/spec/src/shared/retired-key-migrate-sentence.test.ts, 2 failed | 518 passed over 30 files (1 failed | 29 passed). Both failures were this round's entry file.", "root_cause": "that gate scans every string literal under packages/spec/src and packages/lint/src for the marker (?:Run )?`os migrate meta --from DIGITS` and requires the house tombstone sentence anchored at it: 'Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand.' The entry's acceptanceCriteria named the command that way to say the upgrade is finished, so the scanner judged it as a tombstone prescription. It was also split across a string-concatenation seam, which the gate's reconstruct() merges — so both halves were judged as one sentence.", "derived_from_the_test_not_from_neighbours_by_eye": "read MARKER, HOUSE_AT_MARKER, MIXED_AT_MARKER, reconstruct() and the CORPORA list first; migrations/registry.ts is structurally out of scope but migrations/entries/**.ts is NOT. Then checked the neighbours as a cross-check: 0 of the files under migrations/entries/ spell `os migrate meta --from DIGITS`; 9 name the bare command or a flagged variant such as --stored. The house form belongs to retiredKey() guidance an author meets in a parse error; a D3 acceptanceCriteria is the consumer's verify loop.", "sentence_before": "Re-running `os migrate meta --from 17` then reports the migrated stack schema-valid instead of asking for the manual changes again", "sentence_after": "Replaying the same 17 → 18 chain over the edited source then reports the migrated stack schema-valid — `schemaValid: true` in `--json`, and the run closes with the schema-valid line rather than the manual-changes warning", "still_checkable": "yes, and by the measurement this round already took: deleting both nodes by hand and putting nothing in their place makes the same command report schemaValid: true and print '✓ Migrated stack is schema-valid'. The rest of the criterion (os validate clean, params.retiredComponentType, regions / named slots / nested containers) is untouched.", "gate_not_weakened": "no baseline entry, no exemption, no edit to retired-key-migrate-sentence.test.ts. The sentence moved.", "exit_codes": { "pnpm --filter @objectstack/spec test:repo": "VERDICT command-exit 0 — Test Files 30 passed (30), Tests 520 passed (520). CI's red on the same project was 1 failed | 29 passed and 2 failed | 518 passed, so this is the same corpus, now whole.", "pnpm --filter @objectstack/spec test": "VERDICT command-exit 0 — Test Files 471 passed (471), Tests 13405 passed (13405)", "pnpm --filter @objectstack/spec exec vitest run --project repo src/shared/retired-key-migrate-sentence.test.ts": "VERDICT command-exit 0 — 14 passed (14)", "pnpm --filter @objectstack/spec gen:migration-registry": "exit 0 — 203 semantic, unchanged count; only the entry's own 7 lines moved in the generated region", "pnpm --filter @objectstack/spec check:generated": "exit 0 — All 15 generated artifacts are up to date", "pnpm --filter @objectstack/spec check:migration-registry": "exit 0 — region current" }, "a_reading_to_discard_not_repeat": "the first `test` run on the freshly re-created worktree reported 470 passed | 1 skipped and 13403 passed | 1 skipped. packages/spec/dist did not exist yet, so a dist-dependent file skipped itself. Building spec and re-running returned exactly 471 / 13405 — a property of the checkout, not of the edit. Reported rather than passed off as the real number.", "published_bytes_rechecked": "after the rebuild, the entry's own segment in dist/index.js no longer spells `os migrate meta --from DIGITS` and does carry the new sentence. Lit control on the same file: 92 `--from DIGITS` spellings remain repo-wide, 87 of them the exact house sentence — they are the tombstones, not this entry.", "commit": "95f4fb4b9d — a NEW commit on the pushed branch. No rebase, no amend, no force-push. Stem sweep over the message: fix 1 (the conventional-commit type) and 0 for every other stem, 0 #digits, 0 model identifiers, with the identical sweep returning 1 for each over a control string; the repo's own pre-push check:commit-card-trailers printed ✓.", "pr_body": "updated in place (no push, no re-run): the verification table now names BOTH vitest projects, and a 'Correction after the first CI run' section carries the before/after sentence and the root cause. Read back and diffed. The Clause-②: no line is untouched and still starts with `**`." } }Generated by Claude Code
os-bill commentedon Sep 12, 2026
Closed out by CONTENT on
origin/main2cc4884d1f(2026-09-12T13:17Z), ⛔ not by the merge eventPR #17845 merged.
pm:dispatchedand the assignee stripped in the same write and read back.migrations/registry.tselement-filter-and-form-node-refused— the new entry idDelete the component node.— the prescriptionelement-number-filter-rule-array— ⭐ lit control, a pre-existing step-18 entryThe entry file itself is present as a tracked blob (
git ls-tree). ⭐ Reaching the generated region is the reading that matters here: the entry was added as a FILE and the generator wrote it in — ⛔ nothing was typed between theos-generatedmarkers.The corrected acceptance sentence, read rather than counted (
:56,:59-61):⇒ house form and still checkable, which was the constraint: the round had already measured that deleting both nodes by hand makes the same command report
schemaValid: true.⛔ This PR went red once, and the root cause was a gap in MY dispatch order
CI failed on head
bca74a46:retired-key-migrate-sentence.test.ts:279plus its anti-vacuity leg at:292. The round's own verification had been green — becausepnpm --filter @objectstack/spec testisvitest run --project localonly. The cross-corpus scanners live intest:repo, i.e.--project repo, which CI runs and the round did not. Its 471 files / 13405 tests were a true reading of the wrong project.⇒ the order now says: run
testandtest:repo, and report both exit codes. On the fix both read 0 (30 files / 520 tests; 471 files / 13405 tests).⭐ The round derived the house form from the test itself rather than copying a neighbour —
MARKERis optionalRunplus a backtickedos migrate meta --from <digits>,HOUSE_AT_MARKERanchors the house sentence at it, andreconstruct()merges concatenation seams, which is why a sentence split across source lines is judged whole. Cross-check: 0 files undermigrations/entries/spell--from <digits>; 9 name the bare command or a flagged variant. ⛔ The gate was not weakened, skipped, or baselined.One reading from the round worth keeping beyond this card
On a freshly created worktree the first
testrun reported 470 passed | 1 skipped —packages/spec/distdid not exist yet, so a dist-dependent file skipped itself. Building and re-running gave 471 / 13405. ⭐ A skip is not a pass, and in a summary line the two look nearly identical. The round reported it rather than passing the number off.Premise corrections this round measured, all reported rather than absorbed
Every
path:linethe card and triage carried was stale in this tree: ADR-0087 D3 at:198-199not:196-199; the two D2 conversions at:6823/:6975, both off by 11;step18at:5094not:5092; the human-path sentence atmeta.ts:544not:423-428. And the step-18 todo count is 115, not the 113 both the card and the triage comment state — ⭐ the denominator moved; the zero did not. The defect reading (zero todos namingelement:filter/element:form) held, withelement:numberas the lit control returning 3 todos and 6 human-output lines in the same run.Generated by Claude Code
fix(spec): the 17 → 18 chain names the bare element:filter / element:…
test(spec): security, ai, identity, integration, migrations, marketpl…