Skip to content

fix(core): os migrate resume completes a recorded-by run that committed a chunk or used a non-default --chunk-size (#21528) - #21554

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21528-resume-plan-identity
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21528-resume-plan-identity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21528

Clause-②: no

What changed

runMigrationJournal (packages/core/src/utils/migration-journal.ts) recomputed a resumed run's chunk plan from the rows load() returns at resume time, at the resumed plan's chunk size, and refused PLAN_CHANGED when that plan's hash differed from the one run_started recorded. A resume now reads the chunk plan back from run_started, which has carried it since the runner's first commit (ADR-0119 D2 item 2: "carrying the plan hash and chunk plan"):

  • Identity, the one place the journal hashes. hashMigrationPlan is unchanged. On a resume it hashes the RECORDED chunk boundaries with the plan's declared id and step names, so it compares the plan against what the run started over. The run's chunk size comes back from the journal. A plan whose id or steps changed still refuses PLAN_CHANGED.
  • Rows. Per step, with N rows started over and K of them in committed chunks: a load() that returns N rows binds positionally, as before. One that returns exactly N − K rows (it selects only the remaining work, as recorded-by's does) binds those rows, in order, to the chunks not yet committed. Any other count refuses PLAN_CHANGED and names the step. Every resume that passed before binds exactly as before (N rows, the same boundaries).
  • Unwind after such a resume. A chunk an earlier process committed, whose rows load() no longer returns, cannot be compensated. The unwind compensates this process's chunks newest-first, then halts with run_failed at that chunk with a reason. It does not hand compensate() other rows and journal a clean unwind.
  • A run_started with no recorded chunk plan (only a hand-written journal) keeps today's check: reproduce the recorded hash from the current rows.

No published member is added. MigrationPlan, MigrationPlanStep, MigrationJournalEvent and the run_started payload keep their shapes. MigrationPlanStep.load and RunMigrationJournalOptions.chunkSize gain TSDoc for what a resume does with them. The plan (recorded-by-sentinel.ts), os migrate resume's source and the list mode's resumable are untouched. No new error code: both new refusals are PLAN_CHANGED, the code the same inputs drew before.

The public door, before and after

packages/cli/src/commands/migrate/resume.recorded-by.integration.test.ts now makes three more interrupted runs the way a crash does (a child process runs the recorded-by plan under the real runner and is SIGKILLed inside a chunk's transaction) and drives the real os migrate resume --json:

  • (a) 203 sentinel rows, killed in chunk 1 after chunk 0 committed. The list says resumable: true, committedChunks: [0], unknownChunks: [1]. Before (core built from 1ac7308d7a): --run RUN_ID --yes exited 1 with Refused (PLAN_CHANGED): ... plan hash 037ebfcc70ee54096b8eb2a6aed8cd49 does not match the journal's f36863e5fee4bb4e40093c66a0b3096f. After: exit 0, completed, 2 of 2 chunks, no row left holding the sentinel, chunk_started indices [0, 1, 1] (chunk 0 is not run again).
  • (b) 3 rows started at chunk size 2, killed in chunk 0. The list says resumable: true. Before: exit 1, PLAN_CHANGED. After: exit 0, completed, chunksTotal: 2 (the journal's size; the registered plan's default 200 would make one chunk).
  • (c) The control. A run started by a plan whose step had another name: exit 1, Refused (PLAN_CHANGED), before and after. The sentinel rows and the journal are untouched.

Before the fix that file read 2 failed / 7 passed (the two acts above); after, 9 passed.

Tests (at 14e5287fa8, this branch's head)

  • packages/core/src/utils/migration-journal.test.ts: 29 passed (23 before + 6 new: a shrinking load resumes after a committed chunk; a non-shrinking load resumes positionally from a runner-written journal; the journal's chunk size wins over the plan's; the changed-plan control, three ways (step renamed, plan id changed, step added), each refused with code: 'PLAN_CHANGED' and zero journal writes; a row count that is neither N nor N − K is refused, naming the step; the unwind halt). The crash helper runs the REAL runner and stops a forward inside its chunk, so every resume reads a journal the runner wrote.
  • packages/metadata-protocol/src/migrations/recorded-by-sentinel.test.ts: 8 passed (1 new: the real plan, started at size 2 and killed after chunk 0 committed, resumes with the plan the owner registers at its default size, 2 of 2 chunks).
  • The door file above: 9 passed (--project integration, run locally because this diff edits that file).
  • Typecheck: @objectstack/core (with check:test-typecheck: 4 files / 4 errors held, unchanged), @objectstack/metadata-protocol, @objectstack/cli (test layer: 3 files / 28 errors held, unchanged), all exit 0.
  • Full suites on ae27f00812 (this diff before the merge of main, which touched none of these files): @objectstack/core 79 files / 2220 tests passed; @objectstack/metadata-protocol 205 files passed, 3 skipped / 3152 tests passed, 19 skipped. @objectstack/cli's unit tier: only the tier-partition pin (test/vitest-tiers-partition.test.ts, 22 passed); this diff changes no CLI source.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands on 14e5287fa8 derived 67 commands; all 67 ran, reconciled with --ran (each line carrying its exit code): 0 NOT MEASURED, 66 exit 0, and one exit 1, node scripts/check-empty-changeset.mjs --base origin/main, explained in the next section. Four roster gates the derivation flags as sharing a directory with these paths also ran green: check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity.
  • Lint, narrowed: the population is the 4 changed .ts files, none ignored by eslint.config.mjs. eslint --no-inline-config --format json read 4 files, 0 errors, 0 warnings. That config never enables type-aware linting (no parserOptions.project), so this diff cannot move the verdict of any file it does not touch. The repo-wide pnpm lint is CI's.

Reverse verification and ablation

  • Reverse verification, with the fix committed: migration-journal.ts restored to 1ac7308d7a in the working tree only, blob df8d8d5009 checked equal to the base's, then core rebuilt and node scripts/ablation-dist-preflight.mjs @objectstack/core planResumedRun --absent passed. Red as predicted: core unit 4 failed / 25 passed (the four resume pins; the control and the positional-resume pin stay green, as they should on both trees), the plan pin 1 failed / 7 passed, the door 2 failed / 7 passed (a and b; the control green). Restored with git checkout HEAD -- under an EXIT/INT/TERM trap, proven by the blob (361d75e7ee == HEAD) and an empty git diff HEAD, then rebuilt, with the preflight in default mode showing the marker back in 4 built files and a clean tree.
  • Ablation of the unwind guard, which reverse verification cannot isolate (the old runner refuses before reaching it): node scripts/ablation-replace.mjs planted the naive positional fallback (rowsByChunk.get(c.index) ?? rowsByStep[...].slice(offset, offset + length)). The landing was shown by the anchor count 1 → 0 and the blob change. The unwind pin went red: expected 'compensated' to be 'failed', which is the run handing chunk 0 other rows and journalling a clean unwind. Restored by the tool: blob == HEAD, git diff HEAD empty. The core unit suite imports the runner by relative path, so no build leg applies.

The pending #21498 changeset: a correction to confirm

This PR changes .changeset/21498-cli-compose-migration-recovery.md, which it did not add. That note's "Still refused" bullet said the runner refuses these two kinds of run with PLAN_CHANGED. This change makes that false, and both notes are still pending, so they would ship in one release. The bullet now says these runs reach the runner too and points to the @objectstack/core entry for #21528. check-empty-changeset stays red on this by design: it is the gate's DELIBERATE CORRECTION class, and its remedy is to say so here and get the correction confirmed. Restoring the old bullet would publish a sentence this PR makes false. Please confirm the correction.

The new changeset (.changeset/21528-core-resume-started-over-plan.md) is an @objectstack/core patch with Clause-②: no. No member is added to a published contract. Resume now accepts the runs its list mode already advertises as resumable, as ADR-0119 D2 item 5 declares.

Acceptance notes

  • The list's resumable is plan presence only (resume.ts: Boolean(plans?.get(r.planId))). So a run whose plan genuinely changed, or whose rows moved, is still listed resumable: true and then refused. The triage ruling keeps the list's wording out of this card. I read this from source; I did not measure the list for the control run.
  • A forward resume skips compensated chunks. The forward loop skips every chunk with a chunk_done, and a chunk that was committed and then compensated has one. So resuming forward a run whose in-run unwind failed partway would skip the chunks that unwind had undone. For recorded-by (a shrinking load()), that run's row count matches neither binding, so it is refused PLAN_CHANGED, as it was before. Only a plan whose load() does not shrink would take the skip, and no such plan is registered on this tree. I read this from source and did not measure it. Carrier: none.

Generated by Claude Code

claude added 7 commits October 3, 2026 05:20
…a committed chunk, or started at another chunk size

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
… it started over

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…ing load, the journal's chunk size, the changed-plan control

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…mmitted chunk at a non-default chunk size

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…e() over no rows

Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3
Co-authored-by: Claude <noreply@anthropic.com>
…till-refused bullet it makes false

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

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

16 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 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 fd5a1cd5973983bf8b1ad69a10148a85c74c9137 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 25fe097a8545c75f097741156a34d2954e88f932 — the merge of head 14e5287fa80db1806b55bcf4923f14c8bd18de56 into base fd5a1cd5973983bf8b1ad69a10148a85c74c9137, 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 25fe097a8545c75f097741156a34d2954e88f932 && git checkout 25fe097a8545c75f097741156a34d2954e88f932
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd5a1cd5973983bf8b1ad69a10148a85c74c9137 14e5287fa80db1806b55bcf4923f14c8bd18de56 && git checkout -B drift-repro fd5a1cd5973983bf8b1ad69a10148a85c74c9137 && git merge --no-ff 14e5287fa80db1806b55bcf4923f14c8bd18de56

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 14e5287fa80db1806b55bcf4923f14c8bd18de56
Local-runs: none

PR #21554 for card #21528, branch claude/issue-21528-resume-plan-identity, read at 2026-10-03T06:37Z. Net diff against main (merge base 24dc7c1134): 6 files, +647 / -79 — one source file (packages/core/src/utils/migration-journal.ts), three test files, one new changeset, one rewritten bullet in a pending changeset. Inputs: the card body and its four comments (triage 5965275807, unlock 5965394174, claim 5965820808, os-dev-report 5966314554); the PR body, its file list and the net diff; the check-runs on the head, plus the Check Changeset job's steps and its failed step's log. Judged against triage 5965275807 as unlocked by 5965394174. Nothing was built, run or re-run locally.

① Derived judgments

Public surface of @objectstack/core (src/index.ts re-exports utils/migration-journal.js): no exported type, signature, class member or error code moves. The exported declarations the diff touches change in TSDoc only — MigrationPlanStep.load (what a resume does with its return), RunMigrationJournalOptions.chunkSize (ignored on a resume whose journal carries a chunk plan), hashMigrationPlan and runMigrationJournal. MigrationJournalRefusal.code stays string; both new refusal messages carry PLAN_CHANGED, the code this site already drew. MigrationJournalEvent and the run_started payload (detail.chunks of {i, step, offset, length}, which the runner has written unchanged since its first commit) keep their shape — the fix reads back a field the runner already writes. Everything else is module-private: LoadedPlan.rowsByStep becomes rowsOf; loadPlan splits into loadRows, planNewRun, planResumedRun; recordedChunks, sliceOf and UnwindArgs.rowsOf are new or retyped but unexported. Judged RIGHT; Clause-②: no survives this diff.

Accept-set of runMigrationJournal / resumeMigrationJournal on a resume, each change named and judged:

  1. Identity. The resume hashes plan.id, the step names and the chunk boundaries run_started recorded; recordedChunks rebuilds each chunk's stepIndex from its recorded step name, so an unchanged plan reproduces the stored hash exactly and a renamed step maps to -1 and cannot. hashMigrationPlan is byte-unchanged; the one place that fed it on a resume (loadPlan) is the one place changed — the ruling's "measures where the journal hashes today and changes that one place". RIGHT, within "compares what the run started over, not what load() returns now … its declared plan identity and parameters, not its current rows".
  2. Chunk size. A resume runs the recorded chunk plan, so the size comes back from the journal; options.chunkSize and the registered plan's own chunkSize are ignored on such a resume, and the TSDoc says so. resume.ts passes no option and is untouched. RIGHT, within "the run's chunk size comes back from the journal, ⛔ not the current default".
  3. A changed plan. Plan id changed, a step renamed, a step added: PLAN_CHANGED with zero journal writes (core control three ways; door control). RIGHT, within "a genuinely changed plan still refuses PLAN_CHANGED".
  4. Row binding — the new rule. Per step, N is the rows its recorded chunks cover and K the rows in its chunk_done chunks. load() returning N rows binds positionally, so every resume that passed before binds exactly as before; returning exactly N − K binds those rows, in order, to the uncommitted chunks (the shrinking shape recorded-by has); any other count refuses PLAN_CHANGED naming the step, and a recorded chunk of a step the plan does not declare is refused too. Judged WITHIN the ruling and RIGHT: identity is decided before and apart from the rows (rows 1–3); the binding is the mechanism "make resume work" needs for a load() that shrinks by design; it is one rule for every plan with no new member, which is the ruling's stop condition; and every count it refuses was refused at the base too (a count other than N recomputes different boundaries, so the old hash mismatched; a count of N was and is positional). Two residual ambiguities, named, neither introduced by this diff: (4a) a shrinking load() that gained exactly K rows under the interrupted run reads as N and binds positionally — pre-existing, since the same count gave the same recomputed hash at the base; (4b) a non-shrinking load() that lost exactly K rows now reads as N − K and binds in order, with rows of committed chunks possibly forwarded again at attempt: 1, where the base refused by hash accident. No non-shrinking plan is registered on this tree (recorded-by, the only registered plan, shrinks), the new load TSDoc states both readings to a plan author, and telling the two apart needs a plan-declared load shape — a published member, the 强制条款② work the ruling told the dev to stop at. Escalated in ③; not a FAIL.
  5. Fallback. A run_started with no readable recorded chunk plan (a journal this runner did not write, or a malformed detail) keeps the base behaviour: recompute from the current rows and compare the hash. RIGHT; conservative.
  6. The unwind stop. After an N − K-bound resume, rowsOf answers undefined for a chunk an earlier process committed. The unwind compensates this process's chunks newest-first with their own rows, then halts at that chunk with run_failed carrying {phase: 'compensate', reason, step, cause} — the event and detail shape the existing no-compensate() halt already uses — and returns failed. Judged WITHIN the ruling and RIGHT: no new event kind, field or code; ADR-0119 D2 item 4 ("a compensation failure journals and halts loudly; it is never swallowed"); the dev's ablation of the naive positional fallback shows the alternative hands compensate() other rows and journals a clean unwind, the exact harm. Consequence named: such a run stays listed interrupted (one committed chunk outstanding) and a later resume of it is refused PLAN_CHANGED, its count being neither N nor N − K — the aftermath the no-compensate() halt already had, with --apply as the recovery. For an onCrash: 'compensate' plan (none registered; recorded-by declares resume) the same halt replaces the base's false PLAN_CHANGED.
  7. Untouched, as ruled. recorded-by-sentinel.ts (its test only), resume.ts source, the list mode's resumable. RIGHT, "make resume work, not the list quieter".

The ruling's three pins are present and run the real runner: migration-journal.test.ts +6 (shrinking resume after a committed chunk; non-shrinking positional resume; the journal's size over the plan's; the control three ways with zero writes; the neither-count refusal naming its step; the unwind halt with a reason and no error); recorded-by-sentinel.test.ts +1 (the real plan started at size 2, killed after chunk 0, resumes with the owner's default-size plan, 2 of 2 chunks); the door resume.recorded-by.integration.test.ts +3 runs driven through the real os migrate resume --json (203 rows killed in chunk 1 resumes to completed with chunk_started [0, 1, 1] and no sentinel left; 3 rows at size 2 resumes with chunksTotal: 2; the renamed-step control exits 1 Refused (PLAN_CHANGED) with rows and journal untouched). The base header's "this file does not pin around it" note is replaced by those pins. Their verdicts belong to the head's Test Core shards, pending at my read (③).

② Semver level

  • .changeset/21528-core-resume-started-over-plan.md — '@objectstack/core': patch, Clause-②: no. RIGHT: a bug fix in a released package takes patch (never none, never skip-changeset); no export, member or error code is added. The PR body's Clause-②: no line is present and the gate read it (check-changeset-no-major: "this PR declares clause-② no"). The no is the pull-back-to-the-declared-contract kind and cites its text: ADR-0119 D2 item 5 ("resume forward from the first chunk lacking chunk_done") and item 2 (run_started "carrying the plan hash and chunk plan"). The refusal removed is the mis-refusal of an unchanged plan; the deliberate refusal of a changed plan is kept — no, not yes. The changeset body's four claims (identity; the size from the journal; rows bound as N or N − K, else refused naming the step; the unwind halting failed) each match the diff.
  • packages/cli and packages/metadata-protocol — test files only; they publish nothing and owe no changeset. RIGHT.
  • Clause-②: no — confirmed on the diff (① surface row).

The DELIBERATE CORRECTION red — Check Changeset, job 111145179943, on this head:

  • The job's steps: 1–8, 11, 13, 14 and 15 success; 9 and 10 skipped; step 12, "Reject an empty-frontmatter changeset added by this PR", is the only red step. Its log: rule 1 passed ("No empty-frontmatter changeset introduced by this diff"); the exit 1 is rule 2, the foreign-changeset refusal of scripts/check-empty-changeset.mjs, naming exactly .changeset/21498-cli-compose-migration-recovery.md as "present on the merge base and CHANGED by this PR" and printing the COLLISION / DELIBERATE CORRECTION two-class text. Step 13 ("2 non-breaking changeset(s)") and step 15 (no major; clause-② read) are green.
  • The corrected note: .changeset/21498-cli-compose-migration-recovery.md, an @objectstack/cli patch, pending, not added by this PR. One bullet is rewritten; every other byte of the note is identical to the merge base.
  • The base bullet, sentence by sentence against this diff. (B1) "Still refused: a recorded-by run that had committed a chunk before it was interrupted, or that was started with a non-default --chunk-size." — FALSE once this diff lands: door runs (a) and (b) resume to completed. (B2) "resume now reaches the runner for these runs, and the runner refuses them with PLAN_CHANGED." — the first clause stays true; the second is FALSE under this diff. (B3) "Re-running os migrate recorded-by --apply converts whatever rows still hold the sentinel." — still true as a fact, no longer the recovery this note needs; dropping it publishes nothing false.
  • The head bullet, sentence by sentence. (H1) "A run that had committed a chunk, or that was started with a non-default --chunk-size, reaches the runner too." — TRUE: the reach is PR fix(cli,metadata-protocol): os migrate resume completes an interrupted recorded-by run, and os serve reports interrupted migration runs at boot #21527's composition, untouched here, and the door drives both run kinds through the real command to the runner. (H2) "The runner fix that lets it resume is in the @objectstack/core entry for [finding] os migrate resume lists an interrupted recorded-by run as resumable: true, then refuses it PLAN_CHANGED when the run had committed a chunk or used a non-default --chunk-size #21528." — TRUE: that entry is .changeset/21528-core-resume-started-over-plan.md, an @objectstack/core patch naming [finding] os migrate resume lists an interrupted recorded-by run as resumable: true, then refuses it PLAN_CHANGED when the run had committed a chunk or used a non-default --chunk-size #21528, and the only source file in this diff is packages/core/src/utils/migration-journal.ts.
  • Both notes are pending and ship in one release. The correction is confirmed. The red is by design on this head — its cause is the one step and the one file named above — and the gate itself is not changed.

③ Boundary flags

  • open_questions[0] — A, B or C on the 21498 bullet: A. The rewritten bullet is confirmed above sentence by sentence. B would republish B1 and B2, which this diff makes false. C loses the cross-reference a reader of the cli CHANGELOG needs to find the core entry.
  • out_of_scope[0] — the list's resumable is plan presence only (resume.ts: Boolean(plans?.get(r.planId))), so a run whose plan genuinely changed is still listed resumable: true and then refused. ANSWERED: out of this card by the ruling ("not the list quieter"), and the control shows the act refusing loudly, so nothing is silent. ESCALATED as a follow-up candidate for the engine lane, not a condition on this head: with the chunk plan now read back from run_started, the list could compare the registered plan's identity against the journal without a load(); recordedChunks is module-private, so that is a surface question for triage, not a rider here. Carrier on this PR: none.
  • out_of_scope[1] — the forward loop skips every chunk with a chunk_done, a compensated-then-resumed chunk included, so a non-shrinking plan whose in-run unwind failed partway would skip undone work. ANSWERED: pre-existing (the skip line is unchanged by this diff), and for recorded-by such a run is refused PLAN_CHANGED, its count being neither N nor N − K. ESCALATED to triage as a reading question on ADR-0119 D2 item 5 ("the first chunk lacking chunk_done" against a chunk whose compensated undid it); no registered plan reaches it. Carrier on this PR: none.
  • Reviewer's own, from ① row 4b — the N − K binding cannot tell a shrinking load() from a non-shrinking one that lost exactly K rows; the honest remedy is a plan-declared load shape, a published member, which is Clause-② work and was correctly not taken here. ESCALATED to the engine lane as a card candidate; not a condition on this head.
  • Pending at my read (the reading time above), named and not judged: Lint & Repo Gates, Type Check · workspace, Test Core 1/6 through 6/6, Dogfood Regression Gate 1/3 and 3/3, Temporal Conformance (live PG + MySQL). Green at my read: Build Core, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Governed Surface Queue Guard, Dogfood Regression Gate (2/3), Dogfood Verify CLI, Check PR Size, Check Documentation Links, Auto Label, filter, Flag docs affected by code changes, and the three claim and part-of guards. Skipped by design: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in). Red: Check Changeset only, judged in ②. Governed surfaces in the file list: none; head repo equals base repo; 726 changed lines.

Implemented-by: claude/issue-21528-resume-plan-identity
Reviewed-by: e9b4084e-558f-5388-aae5-1c69d5d0d420

The Reviewed-by: value is CLAUDE_CODE_SESSION_ID as this isolated reviewer subagent read it from its environment; the seat that adopts this record is session_01DDZNkDVwPQnevTFcYE47H3. The dev was a mode:subagent run under that same seat, so Implemented-by: carries its branch, as the template prescribes.

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 06:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 06:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 10454b3 Oct 3, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21528-resume-plan-identity branch October 3, 2026 07:30
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ion in words instead of a tracker number (stage 5) (objectstack-ai#21568)

Part of objectstack-ai#20749
Clause-②: no

Stage 5 of the `domain:spec` lane's share of the runtime-string
burn-down (ruling `5902360492`, form D): the rest of class (b), the
protocol 17 → 18 conversion summaries in
`packages/spec/src/conversions/registry.ts`. Every rewritten summary now
states in words what the cited decision was, or drops a citation its
sentence already explained. Text only.

## What changed

- **35 ADR-0087 conversion summaries** (40 tracker ids, 34 distinct
cards): every toMajor-18 summary that carried an id, from
`field-malformed-scale-precision-removed` to
`flow-decision-mode-inclusive-explicit`. A summary is what `os migrate
meta --json` reports under `specChanges` (its chain already runs to
protocol 18, `CHAIN_TERMINUS_MAJOR`), and it becomes the upgrade guide's
"Change" column and the `to` text of `spec-changes.json`'s `converted[]`
once protocol 18 ships, so an author upgrading metadata reads it.
- One `@objectstack/spec` **patch** changeset, `Clause-②: no` (message
text only).
- **No generated file changes** (A2 below): no generator projects a
toMajor-18 summary today.

## Census at the base (A1)

Stage 4's instrument (`convtable.cjs`, byte-identical copy, md5
`9b7539067ffc38598172c692de637db5`) at base `e901c27449` (the worktree
before any edit): 127 conversions, **35 summaries with ids, 40 id
occurrences, 34 distinct cards** — stage 4's split unchanged. The
toMajor-18 conversion that PR objectstack-ai#21547 added carries no id. Under the
~60-card bar, so one stage. The stage-3 census re-run at the same base
agrees (class b: 35 messages / 40 ids; nothing else in class b).

| file:line (base) | id | conversion |
|---|---|---|
| `registry.ts:6551` | objectstack-ai#8321 | `field-malformed-scale-precision-removed`
|
| `registry.ts:6659` | objectstack-ai#8762 | `record-chatter-position-vocabulary` |
| `registry.ts:6777` | objectstack-ai#9198 | `element-input-target-variable-removed` |
| `registry.ts:7024` | objectstack-ai#9220 | `element-filter-removed` |
| `registry.ts:7177` | objectstack-ai#9249 | `element-form-removed` |
| `registry.ts:7356` | objectstack-ai#15178 | `translation-per-app-settings-removed` |
| `registry.ts:7356` | objectstack-ai#19620 | `translation-per-app-settings-removed` |
| `registry.ts:7610` | objectstack-ai#9249 |
`translation-component-submit-label-removed` |
| `registry.ts:7782` | objectstack-ai#3951 | `field-column-lists-canonicalized` |
| `registry.ts:7783` | objectstack-ai#9227 | `field-column-lists-canonicalized` |
| `registry.ts:7909` | objectstack-ai#10414 | `metric-filters-removed` |
| `registry.ts:8104` | objectstack-ai#17296 | `cube-sub-day-granularities-removed` |
| `registry.ts:8237` | objectstack-ai#18612 | `cube-join-sql-and-relationship-removed`
|
| `registry.ts:8502` | objectstack-ai#10054 | `record-highlights-field-icon-removed` |
| `registry.ts:8759` | objectstack-ai#11027 | `page-component-responsive-removed` |
| `registry.ts:8860` | objectstack-ai#11805 | `object-grid-default-sort-removed` |
| `registry.ts:9047` | objectstack-ai#21445 | `object-grid-resizable-columns-removed`
|
| `registry.ts:9250` | objectstack-ai#17260 | `object-kanban-quick-add-removed` |
| `registry.ts:9634` | objectstack-ai#12497 | `permission-allow-restore-purge-removed`
|
| `registry.ts:9637` | objectstack-ai#1883 | `permission-allow-restore-purge-removed`
|
| `registry.ts:9954` | objectstack-ai#6837 | `field-reference-to-alias` |
| `registry.ts:10372` | objectstack-ai#14478 | `hook-timeout-to-timeout-ms` |
| `registry.ts:10413` | objectstack-ai#14478 | `job-timeout-to-timeout-ms` |
| `registry.ts:11017` | objectstack-ai#14478 |
`api-endpoint-cache-ttl-to-cache-ttl-seconds` |
| `registry.ts:11086` | objectstack-ai#14478 |
`dashboard-refresh-interval-to-refresh-interval-seconds` |
| `registry.ts:11447` | objectstack-ai#14478 |
`memory-persistence-auto-save-interval-to-ms` |
| `registry.ts:11671` | objectstack-ai#14478 | `turso-config-timeout-to-timeout-ms` |
| `registry.ts:11761` | objectstack-ai#17063 | `view-page-mount-removed` |
| `registry.ts:11866` | objectstack-ai#17053 | `list-view-sort-string-clause-to-array`
|
| `registry.ts:11868` | objectstack-ai#8221 | `list-view-sort-string-clause-to-array`
|
| `registry.ts:12366` | objectstack-ai#19054 |
`object-tenancy-organization-field-removed` |
| `registry.ts:12476` | objectstack-ai#20085 | `view-item-owner-hidden-removed` |
| `registry.ts:12620` | objectstack-ai#20230 | `view-overlay-owner-hidden-removed` |
| `registry.ts:13214` | objectstack-ai#17321 |
`page-component-filter-record-to-rule-array` |
| `registry.ts:13214` | objectstack-ai#6206 |
`page-component-filter-record-to-rule-array` |
| `registry.ts:13463` | objectstack-ai#20161 | `report-joined-chart-removed` |
| `registry.ts:13728` | objectstack-ai#20221 | `form-layout-inline-grid-to-vertical` |
| `registry.ts:13884` | objectstack-ai#19992 | `currency-config-precision-removed` |
| `registry.ts:13988` | objectstack-ai#20321 | `permission-rls-tags-removed` |
| `registry.ts:14128` | objectstack-ai#15429 | `flow-decision-mode-inclusive-explicit`
|

## Projections (A2)

Neither generator projects a toMajor-18 summary at this base.
`build-spec-changes.ts` and `build-upgrade-guide.ts` both loop `major`
from `MIGRATION_SUPPORT_FLOOR + 1` to `PROTOCOL_MAJOR`, which are 16 and
17 here (`PROTOCOL_VERSION = '17.0.0'`), so `spec-changes.json` carries
one `perMajor` record (16 → 17) and the guide one "Protocol 16 → 17"
table. `check:generated` reads all 15 artifacts up to date on this head
with no regeneration, so no generated file is in the diff. Both
projections will pick these summaries up when protocol 18 ships.

## Delivered: each site, the decision read, the new words (A3)

Every cited card was read through REST with all its comments (30
objectstack cards, objectui#3951, objectstack-ai#6206, objectstack-ai#6837, objectstack-ai#8221). The record
column names the comment the decision was read from. Where a summary
already said why, the citation is dropped and the sentence kept; where
an `(#N, ADR-0049 — …)` opener cited both, the card number goes and the
ADR stays, as stage 4 did. In the
`cube-join-sql-and-relationship-removed` row, `ALIAS` stands for the
angle-bracket placeholder in the source.

| conversion (head line) | cited | decision as read (record) | summary
now reads |
|---|---|---|---|
| `field-malformed-scale-precision-removed` (:6550) | objectstack-ai#8321 | refuse a
malformed scale/precision at the producer (z.number().int().min(0)); a
stored malformed value takes the D2 strip so the row stays loadable;
citation dropped, the sentence already said it (body + ACCEPT
5296942541) | malformed field 'scale'/'precision' declarations
(non-integer or negative) are removed — they were silently unenforced;
the schema now refuses them at authoring |
| `record-chatter-position-vocabulary` (:6658) | objectstack-ai#8762 | the row's
vocabulary converges on the renderer's bottom/right/left: one
vocabulary, no mapping layer; the three old spellings take a conversion
(ruling 5299771841) | record:chatter / record:discussion 'position'
respelled to the renderer's vocabulary — 'sidebar' → 'right', 'inline' →
'bottom', 'drawer' → 'right' (one vocabulary, the renderer's, rather
than a mapping layer between two: the renderer compares only
bottom/right/left, and the old set fell through every branch) |
| `element-input-target-variable-removed` (:6778) | objectstack-ai#9198 | ADR-0049
enforce-or-remove: verdict dead (a declarative hint with zero readers),
retired with tombstones and a D2 conversion (ACCEPT 5311252358 (PR body
verdict)) | text-input/record-picker component prop 'targetVariable'
removed (retired under ADR-0049 enforce-or-remove as a declarative hint
nothing read; the live binding resolves from the page variable whose
`source` names the component id) |
| `element-filter-removed` (:7025) | objectstack-ai#9220 | dead at ELEMENT grain (no
renderer anywhere; Studio excludes it from the palette), so the whole
element retires under ADR-0049, not key by key (verdict 5312176877 +
ACCEPT 5312709553) | the whole 'element:filter' element retired
(ADR-0049 enforce-or-remove at element grain, not key by key — no
renderer for it ever shipped in any repo, so every key was a capability
claim nothing kept; list surfaces own their filtering via a view's
userFilters / the list filter builder). All six props are stripped; the
bare node the conversion leaves is refused by name at the parse, with
the prescription to delete the component |
| `element-form-removed` (:7179) | objectstack-ai#9249 | dead at element grain, the
objectstack-ai#9220 precedent: the whole element retires; the palette already names
object-form as the replacement (dev report 5384430470 (verdict re-taken
in the PR body)) | the whole 'element:form' element retired (ADR-0049
enforce-or-remove at element grain, not key by key — no renderer for it
ever shipped in any repo, so every key was a capability claim nothing
kept; use the object-bound 'object-form' block instead — rendered and
designer-publishable). All six props are stripped; the bare node the
conversion leaves is refused by name at the parse, with the prescription
to delete the component |
| `translation-per-app-settings-removed` (:7358) | objectstack-ai#15178, objectstack-ai#19620 |
objectstack-ai#15178: the bundle type splits, the platform bundle keeps `settings`, a
per-app bundle refuses it (settings is a platform key). objectstack-ai#19620 ruling B:
`settings` leaves the translation item too, because the file door and
the item door are two authoring surfaces of one app metadata type and
accept one shape (ruling 5653315643 (objectstack-ai#15178); ruling 5770445203
(objectstack-ai#19620)) | translation group 'settings' removed from both
application-authored faces, the per-app bundle entry and the registered
translation item: settings copy belongs to the platform, and the two
authoring doors of one application translation type accept one shape. It
is keyed by SettingsManifest.namespace and only platform code declares a
manifest. A per-app bundle entry could only fill gaps the platform's own
bundle left in the one merged served tree, and was overwritten wherever
both defined the key; a stored item OVERRODE the platform copy, because
the runtime-authored layer is read over the shipped bundles. Overrides
now give way to the platform copy, gaps fall back to the manifest
literal, and the group stays on the PLATFORM bundle,
PlatformTranslationData |
| `translation-component-submit-label-removed` (:7613) | objectstack-ai#9249 |
`element:form` retired whole because no renderer for it ever shipped
(dead at element grain), which left `submitLabel` with no carrier (dev
report 5384430470) | translation component-copy key 'submitLabel'
removed (retired rather than re-anchored — its only declared carrier,
'element:form', retired whole because no renderer for it ever shipped,
so the resolver no longer overlays it and a stored string was read by
nothing; the live form surface's submit copy is 'object-form''s
'submitText', localized at its own authoring site, and re-anchoring the
key there would only have added a second place to translate one word) |
| `field-column-lists-canonicalized` (:7787) | objectui#3951, objectstack-ai#9227 |
objectui#3951: the published spec spelling `name` wins and the grid
reader is fixed to read it. objectstack-ai#9227: `inlineColumns` gets a strict
name-keyed element schema (an unknown key is a named rejection at
publish, not a blank cell); `relatedListColumns`, checked in the same
pass, takes field-name strings (ruling 5236150020 (objectui#3951);
ruling 5315735776 + ACCEPT 5317488979 (objectstack-ai#9227)) | inline-grid column
entries respelled 'field' → 'name' (the declared spelling wins, and the
grid renderer now reads 'name' too) and related-list column objects
folded to their child field-name string (both lists were z.any(), so a
mis-keyed column published clean and rendered blank cells; inline
columns now take a strict name-keyed shape and related-list columns
plain field names, so a mis-keyed column is refused at publish) |
| `metric-filters-removed` (:7916) | objectstack-ai#10414 | the remove leg of
enforce-or-remove: zero consumers (measured with a positive control) and
a raw-SQL carrier; retire per the playbook; citation dropped, the
sentence already said it (triage grading 5363699539) | cube metric key
'filters' removed (ADR-0049 — no strategy ever read it: the authored
raw-SQL condition was parsed and dropped, and the query returned the
unfiltered aggregate. Filter at query time with `where`, or use an
ADR-0021 dataset measure's structured `filter`; a metric's own `sql` is
a column reference) |
| `cube-sub-day-granularities-removed` (:8111) | objectstack-ai#17296 | each of
second/minute/hour is residue and removed: no layer outside the enum
names them and `queryDateGranularity` cannot advertise them; ADR-0049
prefers removal with no committed roadmap; citation dropped (dev report
5648182389 + landing 5648923185) | cube dimension granularities 'second'
/ 'minute' / 'hour' removed (ADR-0049 — no backend bucketed them and
none could advertise them: `supports.queryDateGranularity` is a record
over `DateGranularity`, which declares day, week, month, quarter, year.
Offer the coarsest interval that still answers the question) |
| `cube-join-sql-and-relationship-removed` (:8244) | objectstack-ai#18612 | retire
`sql` and `relationship` from CubeJoin: the join is derived from the FK
relationship and no author-supplied ON clause executes; the addendum
adds the D2 strip for persisted artifacts; citation dropped, the
sentence already said it (ruling 5725370783 + addendum 5727426171) |
cube join keys 'sql' and 'relationship' removed (ADR-0049 — neither was
ever read: both strategies synthesise the ON clause as a foreign-key
equality, so an authored join condition was REPLACED under a 200 and a
declared cardinality changed no SQL. Keep `joins.ALIAS.name` alone; the
record KEY is the foreign-key field on the base object) |
| `record-highlights-field-icon-removed` (:8509) | objectstack-ai#10054 | option A:
measured dead (zero read points, not designer-publishable), so it
retires under the ADR-0087 flow; citation dropped (ruling 5364978909) |
record:highlights highlight-field key 'icon' removed (ADR-0049 — no
render path: the highlight chip has no icon slot, the register hook
carries field names only, and the Studio designer publishes the field
list as plain strings, so an authored icon was accepted and drawn by
nothing) |
| `page-component-responsive-removed` (:8766) | objectstack-ai#11027 | ruling B:
retire `page.components[].responsive` (ADR-0049, wired into no renderer)
and repair the texts that redirected authors to it (ruling 5380752244) |
page component key 'responsive' removed (ADR-0049 enforce-or-remove — no
renderer ever applied per-component breakpoint layout overrides, and the
shared ResponsiveConfig shape leaves with its last carrier; use
responsiveStyles (ADR-0065) for breakpoint behaviour that IS applied) |
| `object-grid-default-sort-removed` (:8868) | objectstack-ai#11805 | retire
object-grid `defaultSort` (the strict route per the playbook),
completing the objectui-side direction ruling at the producer (ruling
5404972152) | object-grid component prop 'defaultSort' removed (retired
under ADR-0049 enforce-or-remove as the legacy single-sort second
spelling of 'sort', read only when 'sort' was absent; the pair moves to
sort: [{ field, order }], the array shape every read path honours) |
| `object-grid-resizable-columns-removed` (:9055) | objectstack-ai#21445 | `resizable`
is canonical and `resizableColumns` retires now as a tombstone naming
it: zero writers, so no window (immediate retirement) (triage direction
5958164933) | object-grid component prop 'resizableColumns' removed (the
legacy second spelling of 'resizable', read only when 'resizable' was
absent, retires at once so 'resizable' is the one spelling; the value
moves to 'resizable' when that is absent, and is deleted when it is
present) |
| `object-kanban-quick-add-removed` (:9258) | objectstack-ai#17260 | option B:
`quickAdd` leaves `object-kanban` (accepted and dropped there); this
repo carries the tombstone half (card body (the objectui ruling it
executes, option B) + triage 5620331176) | object-kanban component prop
'quickAdd' removed (retired from the board under ADR-0049
enforce-or-remove — the affordance is gated on a host-supplied
'onQuickAdd' function no producer puts on an object-kanban node, so the
key was accepted and dropped; delete the key — object-kanban offers no
quick-add control) |
| `permission-allow-restore-purge-removed` (:9643) | objectstack-ai#12497, objectstack-ai#1883 |
option B: retire `allowRestore` / `allowPurge`, which gate operations
that do not exist; objectstack-ai#1883 stays open as the M2 anchor, where
undelete/purge ship as feature + RBAC in one batch and the keys return
with it (card body (objectstack-ai#12497); ruling 5421209848 (objectstack-ai#1883)) |
object-permission keys 'allowRestore' and 'allowPurge' removed (ADR-0049
— the `restore`/`purge` operations they claimed to gate have never
existed, so granting the bits delivered nothing; dispatched destructive
lifecycle verbs stay denied fail-closed. The keys return with the M2
lifecycle initiative, which builds undelete and purge together with the
permission bits that gate them) |
| `field-reference-to-alias` (:9962) | objectui#6837 | ruling C:
protocol normalisation belongs to the server and the frontend only
executes the protocol; half 1 (this repo) guarantees the serve path
carries only `reference`, half 2 deletes objectui's legacy fallback arms
(ruling 5475017957 + half-1 pointer 5475055291) | field key
'reference_to' → 'reference' (the legacy objectql runtime dialect for a
lookup/master_detail target; normalising to the protocol is the server's
job and the renderer only executes the protocol, so stored rows must
serve the canonical spelling before objectui deletes its `reference ??
reference_to` fallback arms) |
| `hook-timeout-to-timeout-ms` (:10383) | objectstack-ai#14478 | ruling B: a
duration-shaped number key carries its unit in its name (or a
unit-carrying value), every existing offender renamed under an ADR-0087
conversion, no grandfathered baseline (ruling 5518649320 + population
ruling 5548763981) | hook key 'timeout' → 'timeoutMs' (a duration key
carries its unit in its name, and this one's unit lived only in the
description; the value, milliseconds, is unchanged) |
| `job-timeout-to-timeout-ms` (:10424) | objectstack-ai#14478 | as above (as above) |
job key 'timeout' → 'timeoutMs' (a duration key carries its unit in its
name, and this one's unit lived only in the description; the value,
milliseconds, is unchanged) |
| `api-endpoint-cache-ttl-to-cache-ttl-seconds` (:11028) | objectstack-ai#14478 | as
above (as above) | api endpoint key 'cacheTtl' → 'cacheTtlSeconds' (a
duration key carries its unit in its name, and this one's unit lived
only in the description; the value, seconds, is unchanged, and the key
stays GET-only) |
| `dashboard-refresh-interval-to-refresh-interval-seconds` (:11097) |
objectstack-ai#14478 | as above (as above) | dashboard key 'refreshInterval' →
'refreshIntervalSeconds' (a duration key carries its unit in its name,
and this one's unit lived only in the description; the value, seconds,
is unchanged) |
| `memory-persistence-auto-save-interval-to-ms` (:11458) | objectstack-ai#14478 | as
above (as above) | memory datasource key
'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both
the file and auto arms (a duration key carries its unit in its name, and
this one's unit lived only in the description; the value, milliseconds,
is unchanged) |
| `turso-config-timeout-to-timeout-ms` (:11682) | objectstack-ai#14478 | as above (as
above) | turso datasource key 'config.timeout' → 'config.timeoutMs' (a
duration key carries its unit in its name, and this one's unit lived
only in the description and a .meta() title no parse reads; the value,
milliseconds, is unchanged) |
| `view-page-mount-removed` (:11772) | objectstack-ai#17063 | the maintainer chose to
retire (「撤」) over finishing the objectui render half or parking it: the
`page` member and its mount leave the spec under enforce-or-remove (card
body (the maintainer ruling it records)) | list-view type 'page' and its
`pageName` binding removed (retired rather than finished: the delegating
render half was never built, so a page view fell through to the grid
branch and drew an empty table; ADR-0049 enforce-or-remove) |
| `list-view-sort-string-clause-to-array` (:11877) | objectstack-ai#17053,
objectui#8221 | objectui#8221 option B: the legacy string `sort` is
retired, one spelling platform-wide, the array. objectstack-ai#17053: the spec slot
that produces those documents stops accepting the string objectui now
refuses (triage 5620223775 (objectstack-ai#17053); ruling 5567944420 (objectui#8221))
| the bare string list-view `sort` clause becomes the `{ field, order
}[]` array (one sort orthography platform-wide, the array: objectui
already refuses the string, so the schema stops minting documents its
own consumer refuses) |
| `object-tenancy-organization-field-removed` (:12377) | objectstack-ai#19054 | take
`organizationField` off the authorable surface; its one real use stays a
platform-internal fact; citation dropped, the sentence already said it
(card body (the maintainer ruling it records)) | object
`tenancy.organizationField` removed (ADR-0049 — the stamp-only column
declaration was authorable by every application and declared exactly
once in the whole protocol, on the platform's own credential table; the
divergence moves to a platform-internal table in
@objectstack/metadata-core and stops being a knob) |
| `view-item-owner-hidden-removed` (:12487) | objectstack-ai#20085 | retire both keys
(ADR-0049 enforce-or-remove, zero pull) via the retirement playbook;
citation dropped (triage direction 5826969296) | view item keys
'owner'/'hidden' removed (ADR-0049 — declared on the view item record
and stored verbatim, read by nothing: no view switcher ever filtered on
`hidden`, and no per-user scope ever read `owner`, so a view marked as
one user's was listed for everyone) |
| `view-overlay-owner-hidden-removed` (:12631) | objectstack-ai#20230 | follow
objectstack-ai#20085's disposition for the same key pair on the overlay door: the same
retirement (triage direction 5856621469) | flattened view overlay keys
'owner'/'hidden' removed (ADR-0049 — the view item's pair on the overlay
door, retired the same way: declared, accepted by the write door and
stored verbatim, read by nothing, so a `hidden: true` overlay hid no
view and an `owner` scoped none) |
| `page-component-filter-record-to-rule-array` (:13220) | objectui#6206,
objectstack-ai#17321 | objectui#6206 option B: one filter orthography platform-wide,
the rule array. objectstack-ai#17321 ruling B: a partial D2 conversion of what maps
losslessly; combinator-carrying rows pass through untouched and are
named as a TODO (flattening would silently change what a page selects)
(ruling 5406409590 (objectui#6206); ruling 5644018752 (objectstack-ai#17321)) | a
record-form or single-level AST filter at a converged rule-array door
becomes the `[{ field, operator, value }]` rule array wherever the
mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the
mapped operator, AST comparisons → one rule each); a filter carrying
`$and` / `$or` / `$not` or any part with no lossless rule spelling is
left exactly as stored — reported as a TODO, which `os migrate meta
--stored` lists — and is not the form its door declares (one filter
orthography platform-wide, the rule array; the migration converts only
what maps losslessly and names the rest, because flattening a combinator
would silently change what a page selects) |
| `report-joined-chart-removed` (:13477) | objectstack-ai#20161 | retire, not build
block charts: the joined arm refuses a container chart, the block key
goes, a non-joined report keeps its live chart; citation dropped (triage
direction 5852548444) | a joined report's 'chart' removed from its
blocks and refused on the container (ADR-0049 enforce-or-remove: the
joined renderer draws each block as a table and never read either, so
the chart parsed and nothing was plotted; a non-joined report keeps its
live 'chart') |
| `form-layout-inline-grid-to-vertical` (:13742) | objectstack-ai#20221 | retire the
`inline` / `grid` arms: multi-column already exists as `columns` and
`inline` is not a record-form layout; citation dropped, the sentence
already said it (triage direction 5855767378) | form 'layout' arms
'inline' and 'grid' rewritten to 'vertical' (ADR-0049 — no renderer ever
gave either a behaviour of its own: every form presentation folded both
to 'vertical'. Multi-column is 'columns', honoured under either layout,
and is left untouched) |
| `currency-config-precision-removed` (:13898) | objectstack-ai#19992 | remove
`currencyConfig.precision`: a currency's decimal places are the
currency's, not a setting; citation dropped, the sentence already said
it (triage 5817146460 (ruling 乙 on objectstack-ai#19910 it executes)) | currency field
key 'currencyConfig.precision' removed (ADR-0049 — no renderer or
runtime ever read it: an amount's decimal places are its currency's ISO
4217 minor unit, derived from the currency itself. Its ISO 4217
contradiction check and the default `2` baked into parse output went
with it; the field-level `precision` is a total digit count and is
untouched) |
| `permission-rls-tags-removed` (:14002) | objectstack-ai#20321 | RETIRE by the
maintainer's criterion (no mainstream platform has the capability);
citation dropped, the sentence already said it (triage verdict
5860425529) | RLS-policy key 'tags' removed (ADR-0049 — nothing ever
read a policy's tags and no mainstream platform tags a row-level policy;
dropping it changes no access decision) |
| `flow-decision-mode-inclusive-explicit` (:14141) | objectstack-ai#15429 | align with
mainstream engines: an edge-branched decision is exclusive (first
match), and taking every true edge must be declared (`mode:
'inclusive'`); the migration writes it explicitly for existing nodes so
authored behaviour is unchanged (ruling C narrows that promise to
sources and artifacts) (ruling 5793803317; ruling C 5863827385) |
edge-branched decision with two or more conditioned out-edges and no
`mode`: `mode: 'inclusive'` written explicitly (the traversal became
exclusive, first match in declaration order, as mainstream engines treat
a decision, and taking every true edge must now be declared; the key
keeps the every-true-edge behaviour those nodes had, and the author
deletes it where the branches partition) |

No site was left in place as unclear; `open_questions` is empty.

## Text only (A4)

Stage 3's AST-skeleton plus string-text tool (`skeleton.cjs`, TypeScript
6.0.3; stage 4's copy with only its TypeScript load path changed to this
worktree, md5 `3b10ec8a7e6284f19def54d35f001698` →
`32b4630d7d0a532ee26239319269fe91`). Leg 1 compares an AST skeleton with
every string's text masked (a `+` chain of string operands reads as one
string, so re-wrapping is invisible); leg 2 compares the text of every
string group, requiring each changed group to carry a tracker id before
and none after, and every other group byte-identical.

- `registry.ts`, base `e901c27449` vs the committed copy at
`9c1d040145`: **1 of 1 SAME**, exit 0 — 62656 tokens both sides, 4955
string groups, 35 changed, every changed group carried an id before and
carries none after; parse diagnostics 0/0.
- Controls on scratch copies of the head file, each mutation counted on
disk first (anchor hits 1, replacement present 1, anchor left 0):
`renameFlowConfigAliases` renamed → DIFF exit 1; `!==` flipped to `===`
→ DIFF exit 1; one summary re-split into two `+` operands → SAME, 0
groups changed, exit 0; the `hook.timeout` surface string (never carried
an id) changed → skeleton SAME, text leg VIOLATION, exit 1. No repo file
was mutated for the controls.
- The edits were applied by a script whose every anchor was asserted to
hit exactly once, inside its own conversion's summary line span, and
verified on disk after the write (36 anchors over 35 conversions). The
conversion-table extractor reads 35 summaries changed, `toMajor` /
`surface` / declaration unchanged on all 127, and 0 ids left in any
summary.

## Pins and quotes (A6)

No test asserts a summary, so no pin moves: nothing under `*.test.*`
reads `.summary` off a conversion (the only summary reads are
`spec-changes.ts` and `build-upgrade-guide.ts`), and every removed
id-bearing fragment was searched across the repo. The hits are other
files' own prose with their own citations (CHANGELOGs,
`migrations/registry.ts` rationale, docblocks, `liveness/*.json` notes,
test titles such as `permission.test.ts:278`, docs prose in
`content/docs/permissions/*.mdx`), not quotes of a summary.
`content/docs/**`: no quote of a changed summary. `skills/**`: none.

## Verification

All builds and tests through `scripts/pm/os-verify-lock.sh` (slot
`issue-20749-s5`), each `VERDICT command-exit 0`:

- `pnpm --filter @objectstack/spec build`, then `check:generated` on the
merged head `9a35e049e5`: "✓ All 15 generated artifacts are up to date".
- The package `test` script (`vitest run --project local
--maxWorkers=2`) on `9a35e049e5`: "Test Files 605 passed (605) / Tests
17904 passed | 1 todo (17905)".
- `test:repo`: the 15 repo-project files that read the conversion
registry, `spec-changes` or the guide, "Test Files 15 passed (15) /
Tests 221 passed (221)". NOT MEASURED:
`scripts/build-schemas-check-mode.test.ts` (88 cases at about 7 s each,
over the foreground cap; it reads only conversion surfaces, which the
proof shows unchanged) and the remaining repo-project files; reason:
wall clock on a shared box. CI runs them.
- `pnpm --filter @objectstack/spec run typecheck` on `9a35e049e5`: exit
0; "check:test-typecheck: OK — 52 file(s) / 246 error(s) / 135 pinned
signature(s) held".
- `pnpm turbo run build --concurrency=2 --filter=./packages/*
--filter=./packages/*/*`: "Tasks: 71 successful, 71 total", for the
gates that read built packages.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths) at `9a35e049e5`
derives 79 commands; each ran with its exit code written to disk before
any pipe. Three first exited 3 (PREREQUISITE NOT MET:
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` need built packages) and exit 0 after the
build; the dist-reading gates were re-run after it too. `--ran`: "✓
dispatch-gates --ran: 79 derived famil(ies) accounted for — 79 run, 0
NOT-MEASURED".
- `pnpm check:doc-authoring` (self-test and run): exit 0, "17323
customer-facing string(s) across 1250 spec sources clean"; the
sibling-package ledger holds its baseline (`packages/spec` sits outside
it, so no ledger change). `pnpm check:nul-bytes`: exit 0, "no raw ASCII
control bytes".
- Changeset gates with this body as the `--event` payload:
`check-changeset-no-major.mjs --base origin/main --event` exit 0 ("✓
LEVEL AXIS: this PR declares clause-② `no`", declaration line `Clause-②:
no`); `check-partof-closing-keyword.mjs` with `PR_BODY` exit 0 ("no
Part-of/closing-keyword contradiction");
`check-adr-0087-registration.mjs --base origin/main` exit 0 ("adds no
declared-breaking changeset (1 non-breaking changeset(s) seen)");
`check-empty-changeset.mjs --base origin/main` exit 0;
`check-changeset-fixed.mjs` exit 0.
- ESLint, a proven narrowing: `eslint --no-inline-config --format json`
over the one changed TS file reads 1 file, 0 errors, 0 warnings; the
population is read from ESLint's own config (`calculateConfigForFile`
resolves it, `isPathIgnored` false); invariance: `eslint.config.mjs`
enables no type-aware linting (`parserOptions.project` /
`projectService` null for this file, its header at :327-328 says so), so
a string-text edit cannot move an untouched file's verdict. Repo-wide
`pnpm lint` is CI's.

## Acceptance notes

- `origin/main` was merged once (`9a35e049e5`, five commits: objectstack-ai#21539,
objectstack-ai#21473, objectstack-ai#21554, objectstack-ai#21556, objectstack-ai#21557; spec moved only in
`contracts/approval-service.ts` TSDoc); spec rebuilt, `check:generated`,
the spec test project, typecheck and the gate union re-ran on the merged
head. No os-regen deferral was recorded.
- Hot file: no open PR touches `conversions/registry.ts`,
`spec-changes.json` or the upgrade guide (all nine open PRs' file lists
read just before opening this one).
- Census after this PR (stage 3's instrument at `9a35e049e5`): non-test
160 → 125 messages, 358 → 318 ids; class (b) is empty. Left for the
later stages: class (c) conformance-case notes 58 messages / 68 ids (the
`objectstack-ai#5322` selector and the `objectstack-ai#8934` name pin move with their tests), class
(f) internal registry rationale 17 / 33, the test strings 1804 / 1920 in
425 files; `migrations/registry.ts` 50 / 217 stays with objectstack-ai#20234's stage
11. Word-form hits (an id spelled after "PR", "issue" and the like) stay
6, all outside this diff.
- Excluded, untouched: `migrations/registry.ts`, comments anywhere,
classes (c) and (f), test strings, the `.mjs` gate scripts. No gate is
added or loosened.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants