Repository navigation
fix(metadata-protocol): removing a field that stores nothing is not a destructive change - #24479
Conversation
…eld that stores nothing A formula is computed on read and has no column, so removing it, or retyping it into a stored kind, loses no data. The diff now judges each finding by the spec's storage predicate (isVirtualSearchField), read from the field's own declaration. A stored field's removal or retype keeps today's 409 and message. Claude-Session: https://claude.ai/code/session_01RaFonyZMJU1FzgAT78bA47 Co-authored-by: Claude <noreply@anthropic.com>
…/meta/object/:name Claude-Session: https://claude.ai/code/session_01RaFonyZMJU1FzgAT78bA47 Co-authored-by: Claude <noreply@anthropic.com>
…tructive Claude-Session: https://claude.ai/code/session_01RaFonyZMJU1FzgAT78bA47 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaFonyZMJU1FzgAT78bA47 Co-authored-by: Claude <noreply@anthropic.com>
…stored-field-removal
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin da017bedc95f8938af1bd024d76eb3a168019964 && git checkout da017bedc95f8938af1bd024d76eb3a168019964
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb 3174dbc003142448e408461f3b11410f57c95da1 && git checkout -B drift-repro 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb && git merge --no-ff 3174dbc003142448e408461f3b11410f57c95da1
node scripts/docs-audit/affected-docs.mjs --json 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb |
Contract reviewServed-tier: Inputs: card #24411 (triage 6257436308, claim 6259474942, dispatch 6259559169), the dev's ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24411. 4 files, +261 / −1. Not governed (check-governed-merges.mjs --pr 24479: no path hits the register). |
| Scope | detectDestructiveObjectChanges in metadata-protocol/src/protocol.ts: field_removed and field_type_change are reported only for a stored previous field, judged by the spec's isVirtualSearchField. One minor changeset. The unit pins extend the existing destructive-gate test; the booted pin is in packages/qa/dogfood. |
| Seat's own read | SEARCH_VIRTUAL_TYPES is pinned to ['formula'] and mirrors the drivers' storage rule. The sortability projection, the search and sort lints and the filter doors read the same predicate, and protocol.ts already imported it. An unreadable type stays "stored", so the gate fails toward the warning. |
| Measured | Booted, on main: a formula removal answered 409 DESTRUCTIVE_CHANGE. After: 200 without force, with the stored fields unchanged. A text and an autonumber removal answer the same 409, before and after. |
| Ablation | Deleting the one isStored skip turned the unit and booted formula pins red. The restore read back as the HEAD blob. |
| Review | TIER_DEFAULT record of record 6261617754 on this head: PASS, in-seat. |
| Declaration | Clause-②: yes (widening) on the claim, the PR body and the changeset. |
| Cross-lane | The dogfood pin is declared to domain:cli (6261627636, 6261632524). |
| Gates | Dev, on 3174dbc003: 71 derived, 71 run, 71 exit 0. Typecheck exit 0 for metadata-protocol and the dogfood package. |
| CI | At this read on 3174dbc003: 41 runs, all completed, none failed. |
Disposition:
open_questions: both accepted (record ③.7, ③.8).deviations: all accepted (record ③.1–③.6).out_of_scope_findings: the two drivers' localtype === 'formula'spellings agree with the predicate today. Noted for the next touch, not filed.
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24411 then closes the card.
Generated by Claude Code
Fixes #24411
Clause-②: yes (widening) (the save door newly accepts, without
force, a removal it refused; owes the seat'sTIER_DEFAULTreview of record before the queue)Dispatched by the
domain:engineseat 1 PM (sessionsession_01JtSQQ3EWsbNPzyYMug1tXy, claim comment 6259474942). Dev session:session_01RaFonyZMJU1FzgAT78bA47.What changed
detectDestructiveObjectChangesinpackages/metadata-protocol/src/protocol.ts(the object save's Phase 3a-destructive diff) now reports a finding only when the field holds stored data. "Stored" is the spec's storage predicateisVirtualSearchField(SEARCH_VIRTUAL_TYPES, exactlyformulatoday), read from the field's own declaration. It is the predicate the served sortability projection (packages/spec/src/api/sortability.zod.ts) already reads, andprotocol.tsalready imported it. No name list or type list is kept in the detector.field_removed: skipped when the removed field is unstored.field_type_change: judged by what the stored side loses. An unstored previous field (a formula) held no values, so retyping it into any kind is not destructive. Retyping a stored field into a formula is still refused, because the stored side loses its column.field_required_no_default: unchanged. It is not a storage-loss finding (see Acceptance notes).forcemechanism, to the message of a true removal, or to any error code.Measured (M, booted composition)
Booted with
bootStack(@objectstack/verify, sqlite-wasm) on a fixture app. Objectdfu_invoicewas saved at runtime throughPUT /api/v1/meta/object/dfu_invoicewithname/notes(text),amount(number),doubled(formula) andreference(autonumber), plus one record. Pin:packages/qa/dogfood/test/meta-object-unstored-field-removal.dogfood.test.ts.mainfa26308bb0)doubled(formula), noforce409 DESTRUCTIVE_CHANGE, "Field 'doubled' removed — existing data in this column will become inaccessible. — re-submit with ?force=true to proceed."200.GET /data/dfu_invoice/:idreads backname: 'INV-1',notes: 'keep me',amount: 21,reference: '0001'unchangednotes(stored text), noforce409 DESTRUCTIVE_CHANGE, issues[{code: 'field_removed', field: 'notes', message: "Field 'notes' removed — existing data in this column will become inaccessible."}]409, same issue and messagereference(autonumber: engine-computed but stored)409 DESTRUCTIVE_CHANGE409 DESTRUCTIVE_CHANGE(unchanged)The predicate names only one unstored kind (
formula).summaryandautonumberare stored columns: the sortability docblock records them as "deliberately NOT members". So the "one more kind" reading isautonumber, measured as a stored control that still answers 409. This confirms the axis is storage, notCOMPUTED_VALUE_TYPES.Unit pins (
protocol.destructive-gate-reachable-types.test.ts§4, reusing that file's existing engine double, so no new fake engine):notesalone;field_type_change.Before the fix, the three unstored cases were red and the stored controls green (
Tests 3 failed | 13 passed (16)).Ablation (one-shot, not kept)
node scripts/ablation-replace.mjs --deleteremoved the lineif (!isStored(prevFields[fname])) continue;(anchor x1 → x0, blob767873d0→fb38f325).@objectstack/metadata-protocolrebuilt;ablation-dist-preflight --absentreported the marker absent from all 24 built files.2 failed | 14 passed(formula removal, formula+text). Dogfood1 failed | 2 passed: the formula removal answered the old 409.767873d0, equal to HEAD, andgit diff HEADempty. After a rebuild, the preflight reported the marker present in 2 built files and the tree clean. Unit 16/16 and dogfood 3/3 green.Verification
pnpm --filter @objectstack/metadata-protocol test:Test Files 246 passed | 3 skipped,Tests 20661 passed | 19 skipped(exit 0, pre-merge headfb06fda4c).pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.--listFilesincludes the edited test file.pnpm --filter @objectstack/dogfood typecheck: exit 0.--listFilesincludes the new pin.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 71 commands onfb06fda4c. All 71 were run, each exit captured before any pipe:pnpm check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 unbuilt packages). Those were built, and the re-run exited 0 ("111 published require entry point(s) across 69 package(s) load").--ranreconciliation, with codes recorded: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero".check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:nul-bytes,check:issue-citationsandcheck:doc-authoring: all exit 0.origin/main(ec951e29e) was merged cleanly before this PR opened. On the merge head3174dbc00, every step exited 0:pnpm install --frozen-lockfile;pnpm --filter '@objectstack/dogfood^...' build;pnpm --filter @objectstack/spec check:generated;3174dbc00(the same 71 commands) and re-run. All 71 exited 0,check:dual-build-cjs-loadsincluded, and--ranreconciled "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN".Pin sweep
git grepforfield_removed,DESTRUCTIVE_CHANGEand "will become inaccessible" across tests, docs, ADRs and the checklist found no pin that asserts a formula or unstored field removal is destructive. Every existingfield_removedpin removes atextfield, and those still hold. No hand-written doc states the old behaviour. ADR-0027's table namesfield_removedgenerically and still holds.Acceptance notes
field_required_no_defaulton a formula is left as is. Its claim is about write validation of rows, not stored data becoming unreachable, and nothing here measured whether required-validation reaches a formula. That is outside this card.driver-sqlfieldHasColumn,driver-turso) still spellstype === 'formula'locally instead of calling the spec predicate. It agrees with the predicate today. This is noted as observed drift risk only, not filed (no carrier).isStored(prevField)check covers it with no edit.Generated by Claude Code