Skip to content

fix(metadata-protocol)!: a delete with dropStorage drops an object's table only with its last stored row, refused 409 while another row remains - #23464

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23415-drop-storage-other-rows
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23415-drop-storage-other-rows

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23415
Clause-②: no

What this does

deleteMetaItem (packages/metadata-protocol/src/protocol.ts) now decides the table drop once, before anything is written. A delete with dropStorage that would leave another stored row of the object (another package's row, or the package-less row) is refused 409 RESOURCE_CONFLICT. Nothing is deleted. The message gives the way out first: send the delete without dropStorage, or delete the other rows first and drop the table with the last one. Then it names the rows that remain.

The decision reads the rows the delete's own address read already holds (packagelessAddress, from the package-axis work in PR #23290 / PR #23408). It adds no second row lookup. The "what remains" values the receipt used are hoisted to the decision point, and the drop call site reads the one verdict (dropsStorage). The refusal is rethrown as is from the delete's catch, like the package-less ambiguity refusal, so it is never re-wrapped as a failed delete.

This is the seat's chosen remedy (a loud refusal, not "skip the drop and say so in the receipt"). An explicit dropStorage=true that drops nothing would be an opt-in the runtime does not honour while the receipt answers success.

Measured

On the real stack: better-sqlite3 :memory: tables, real ObjectQL, real ObjectStackProtocolImplementation, real REST routes. Packages com.probe.p and com.probe.q each hold a row of probe_ticket, and one record exists. The request is DELETE /api/v1/meta/object/probe_ticket?package=com.probe.p&dropStorage=true.

before (base 04a31487, pin red) after
status 200, receipt: "Deleted object 'probe_ticket' (package=com.probe.p), and it still exists: its row bound to package 'com.probe.q' remains." 409 RESOURCE_CONFLICT
stored active rows [com.probe.q] [com.probe.p, com.probe.q]
table exists false (Q's record gone) true, record kept still there
P's row plus a package-less row rows [null], table false 409, rows and table unchanged

The refusal as served:

Send the delete again without dropStorage to remove this row and keep the table, or delete the object's other rows first and drop the table with the last one. Nothing was deleted. After this delete (package=com.probe.p), object 'probe_ticket' is still served: its row bound to package 'com.probe.q' remains, and the object's records are in its table. dropStorage drops an object's table only with the object's last row.

Which rows count (the reading the guard is based on)

  • Only active rows. The guard runs only where shouldDropStorage says yes, and that already requires active. The rows it reads are the active rows at the delete's address. A draft is never materialised or served, so it holds no records. A delete of the last active row with dropStorage still drops the table even if another package holds only a draft. That behaviour is unchanged.
  • A package-less row counts. A package-less row beside the deleted package's row serves the object over the same table, so it counts as a remaining row (pinned).

Premise check: no new error code

RESOURCE_CONFLICT / 409 is the standard catalog's "generic resource conflict" (packages/spec/src/api/errors.zod.ts). It is also the code the package-less ambiguity refusal answers for the same rows (packagelessAddressAmbiguityRefusal, PR #23290). The refusal comes from environment state, not from the request body: the same request is accepted once the other rows are gone. That is the case this code covers. No code is minted, and no spec, ledger or error-code file changes.

A second door reaches the same refusal: uninstall

deletePackage (the uninstall) deletes each of the package's rows with packageId set and, unless keepData is set, with dropStorage: true. Before this change, uninstalling P from an object P and Q both hold dropped Q's table through the same call. I measured the uninstall in process on the same stack: protocol.deletePackage({ packageId: 'com.probe.p' }) with no keepData.

  • Now: success: false, failedCount: 1, and failed[0] carries this refusal (code: RESOURCE_CONFLICT). P's row of the object stays, and the table and its record stay. This is how deletePackage already reports any per-item refusal (its failed[] contract).
  • Before: the per-item delete is the call measured above, so it dropped the table.

The new answer is safer than the data loss it replaces. It is still a partial uninstall: with a package service registered, the sys_packages row is deleted before the per-item loop, so the package's row of the shared object is left behind. The refusal's text also names dropStorage, which an uninstall request never sends. Changing that shape means changing deletePackage, which is outside this card's file surface (see Acceptance notes).

Tests

Head 8a7b08b392 (the fix plus one merge of origin/main at 05ab9ef895). The metadata-protocol and rest suites were re-run on that head after the merge. The objectql suite, the typechecks and the ablation ran on the pre-merge head dce4a7ea77. The merge brought no change to protocol.ts, packages/rest or the delete path: it touched objectql's lookup integrity and spec page parsing.

  • New pin, packages/rest/src/meta-delete-drop-storage-other-rows.test.ts, on the real stack, 5 cases:
    • (a) on a control-plane kernel AND an environment kernel, ?package=P&dropStorage=true is refused 409 RESOURCE_CONFLICT, the message names Q, and both rows, the table and Q's record stay;
    • a package-less remaining row is refused the same way;
    • (b)+(c) without dropStorage, P's delete is unchanged: 200, the receipt names Q, the table and record stay. Then Q's delete (the last row) with dropStorage drops the table;
    • a single-package control: ?package=P&dropStorage=true drops the table.
  • Red at base: 3 failed, 2 passed (the two controls). Green after the fix: 5 passed.
  • pnpm --filter @objectstack/metadata-protocol test: 235 files passed (3 skipped), 28385 tests passed. After the merge: 28389 passed.
  • pnpm --filter @objectstack/objectql test: 415 files passed, 8087 tests passed. The protocol delete tests live here.
  • pnpm --filter @objectstack/rest test: 282 files passed, 5850 tests passed (326 skipped). The same after the merge.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. pnpm --filter @objectstack/rest typecheck (tsc plus check:test-typecheck): exit 0. tsconfig.test.json --listFilesOnly lists the new test.
  • @objectstack/cli was not run. os meta delete has no dropStorage flag on purpose, and delete-reset-carriers.test.ts pins that the flag is absent, so no CLI test reaches this path.

Ablation (one run). The test imports @objectstack/metadata-protocol through its dist/ (KNOWN_UNALIASED_TEST_IMPORTS), so each leg rebuilt it and checked the marker in dist/ before the run.

  • Mutated leg: scripts/ablation-replace.mjs replaced the guard if (dropsStorage && remains) { with a condition that never holds for a real name. The anchor count went 1 to 0 and the blob went 4d93ece86aea to 79bb58a95ed6. ablation-dist-preflight.mjs found the marker in dist/index.js and dist/index.cjs. The pin went red: 3 failed (the three refusal cases), 2 passed (the controls).
  • Restore leg: the blob is back to the HEAD blob 4d93ece86aea and git diff HEAD is empty. After a rebuild, --absent confirmed the marker is gone from all 24 built files and the tree is clean. The pin is green, 5 passed.

Gates (local subset)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands at head 8a7b08b392. I ran all 65 there, unlocked, capturing each exit code before any pipe: 65 exit 0. --ran reconciliation: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN".

An earlier run of the same list at 9534b10c1 had three non-zero results. All three were fixed or explained before the final run:

  • check:rest-log-spy-declared (exit 1) was a real finding: the first version of the pin spied on console.warn. The spy was removed.
  • check-plugin-teardown-shape --self-test (exit 3) could not reach its pinned fixture commit on this shallow clone. It passes once that commit is fetched.
  • check:dual-build-cjs-loads (exit 3) was PREREQUISITE NOT MET: some packages had no dist/.

CI runs everything outside this subset: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core.

Acceptance notes

  • Pin placement. The claim names protocol-delete-object-registry-heal.test.ts (PR fix(metadata-protocol)!: a delete naming one package keeps the item's other rows registered, and a package-less draft save of an item two packages hold is refused 409 #23408's twoPackageObject) or a sibling. That fixture's engine double has no tables and no dropObjectSchema, so "Q's records survive" would rest on a hand-modelled drop there. The pin therefore lives beside the REST delete door's real-stack tests (meta-draft-head-package-inheritance.test.ts is the harness it mirrors), on a real SQLite table. No production file outside the claimed drop region changed.
  • Spec text. DeleteMetaItemRequestSchema.dropStorage's description (packages/spec/src/api/protocol.zod.ts) still lists only the type, state and sys_ conditions. It does not name this refusal. The claim scoped that file to the skip remedy only, so it is untouched here. The in-code JSDoc on deleteMetaItem's dropStorage names the refusal. Carrier: the next PR that touches protocol.zod.ts's delete request.
  • Uninstall shape. See above. Whether deletePackage should refuse the whole uninstall before its first durable step, or skip the drop for a shared object and say so, is a decision about deletePackage. It is outside this card's file surface and is raised in the dev report.
  • Legacy raw-engine path. The drop's second call site (the legacy raw-engine delete path) is unchanged. object is allowRuntimeCreate: true, so an object delete always takes the repository path. The legacy path reaches only code-only types, which shouldDropStorage already rules out.

Generated by Claude Code

…er row still holds (red at base)

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…red row of the object remains

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…op-storage-other-rows

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 5 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via RESOURCE_CONFLICT (literal, a string literal in ObjectStackProtocolImplementation; a string literal in dropStorageRemainingRowsRefusal))
  • content/docs/api/error-handling-server.mdx (via RESOURCE_CONFLICT (literal, a string literal in ObjectStackProtocolImplementation; a string literal in dropStorageRemainingRowsRefusal))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/deployment/install-modes.mdx (via RESOURCE_CONFLICT (literal, a string literal in ObjectStackProtocolImplementation; a string literal in dropStorageRemainingRowsRefusal))
  • content/docs/kernel/services-checklist.mdx (via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/permission-sets.mdx (via RESOURCE_CONFLICT (literal, a string literal in ObjectStackProtocolImplementation; a string literal in dropStorageRemainingRowsRefusal))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via RESOURCE_CONFLICT (literal, a string literal in ObjectStackProtocolImplementation; a string literal in dropStorageRemainingRowsRefusal))
  • content/docs/releases/v17/17-7.mdx (via RESOURCE_CONFLICT (literal, a string literal in ObjectStackProtocolImplementation; a string literal in dropStorageRemainingRowsRefusal))

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

What this run could not see
  • 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 — 11 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 4e0838d997d63a7662c839e6f1f914b22de475b2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 4e0838d997d63a7662c839e6f1f914b22de475b2

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6163962196 on #23415 · reviewed head 8a7b08b392ec3d4baa781315d792fe898705e00d · 2026-10-14T11:03Z

Verdict: ACCEPT. Not governed (3 files, +344 / −7). It lands after the landing clauses in item 6.

  1. Shape. Draft, base main, first line Fixes #23415, Clause-②: no at line start. The changeset carries Clause-②: no (narrowing), **BREAKING**, a FROM → TO with the remedy, and the ADR-0087 not-required (no-migration-prescription) marker. No other closing keyword sits beside a card number. No content/docs/releases/ change.
  2. Premises against the seat ruling in the dispatch.
    • Ruling: a loud refusal before any write, ⛔ not the skip route. deleteMetaItem decides the drop once (dropsStorage), after the address read and before the row delete; a remaining row throws dropStorageRemainingRowsRefusal (409, RESOURCE_CONFLICT, prescription first, remaining rows named), rethrown as is from the delete's catch.
    • Premise held: RESOURCE_CONFLICT is the standard catalog code the sibling package-less ambiguity refusal already answers for the same rows; no code is minted, so Clause-② stays no.
    • The rows that count were measured: active only, and a package-less row counts. Both are pinned.
  3. Gates and evidence. Reviewed with CI still converging on this head: 22 success, 6 skipped, 9 in_progress (Test Core 1–6/6, Type Check workspace and consumer gates, Lint repo and tooling gates); 0 failures so far. The new real-stack pin (packages/rest/src/meta-delete-drop-storage-other-rows.test.ts) asserts statusCode 409 and code RESOURCE_CONFLICT, and that the table and Q's record survive. Controls: the last row's delete still drops the table, and a delete without dropStorage is unchanged. Red at base 3 / 2, green after 5 / 5. The ablation's two legs (rebuilt, dist preflight both ways) read red on the mutated leg only.
  4. Accepted deviations. The pin moved from the twoPackageObject fixture to a real-stack file beside the REST delete door, because that fixture's engine double has no tables and no dropObjectSchema. One origin/main merge was made before the PR opened.
  5. Out-of-scope dispositions (Acceptance notes, carrier: none; not filed).
    • The uninstall (deletePackage, keepData unset) of a package that shares an object now reports this 409 in failed[] and keeps the row and table. The data loss is stopped. The remaining partial-uninstall shape is deletePackage's existing per-item contract.
    • Seat reading on the dev's open question, for whoever next owns deletePackage: dropping storage is the uninstall's DEFAULT, not an explicit opt-in, so the dev's option B (refuse the whole uninstall, prescribe keepData: true) is not obviously right. It would make an admin keep every P-only table to uninstall P. The question was measured only in process, not at a public door, and no live two-package object is named, so it is not filed.
    • DeleteMetaItemRequestSchema.dropStorage's describe does not name the refusal: an absent sentence, not a wrong one.
  6. Landing clauses.
    • (a) Every check green on the landing head.
    • (b) A CONTRACT_REVIEW_TIER ## Contract review PASS on the landing head. The PR is not on a published-schema face, but it narrows a write door and its changeset prose ships, so the stricter reading is taken.
    • (c) mergeable_state is clean at enqueue.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8a7b08b392ec3d4baa781315d792fe898705e00d
Local-runs: none

Inputs: card #23415 (body; triage 6163682328; claim 6163962196; os-dev-report 6166830368; accept 6166905585), PR #23464 (body, file list, three-dot diff against main; merge-base 05ab9ef895, main now 4e0838d997, mergeable clean), and the 40 check-runs on the head: 34 success, 6 skipped (path-filtered or opt-in full-battery jobs), 0 failure; the five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) all success. Three files: .changeset/23415-drop-storage-other-rows.md (+21), packages/metadata-protocol/src/protocol.ts (+84/-7), packages/rest/src/meta-delete-drop-storage-other-rows.test.ts (+239). No governed path. Surrounding context read with git show at the head.

① Derived judgments

  1. The metadata delete door's new 409 refusal: RIGHT. deleteMetaItem (repository path, the only path an object delete takes: object is allowRuntimeCreate: true in DEFAULT_METADATA_TYPE_REGISTRY, so useRepoPath is always true and the untouched legacy raw-engine drop site is unreachable for objects) computes dropsStorage = shouldDropStorage(...) once and throws dropStorageRemainingRowsRefusal (RESOURCE_CONFLICT, 409) when it is true AND remains is true. When it fires: dropStorage truthy, type folds to object, target state active, name not sys_-prefixed, and after removing the addressed row another row would stay. Which rows count: the rows packagelessAddress reads, sys_metadata where type, name, state = active, organization_id = null, no member predicate for object (only email_template is discriminated); remainingPackages is candidates minus the addressed package, unboundRemains is a package-less row beside an addressed package row. So another package's active row counts, the package-less row counts, a draft does not (read at active; never materialised), and archived/deprecated are vocabulary-only (no writer in the metadata packages; findServedOverlayRow serves only active/draft), so "active rows only" equals "served rows". Org-scoped rows cannot reach this door (orgId = null pinned, ADR-0131 D6). The last row, packaged or package-less (packageId === null makes unboundRemains false), still drops the table: the negative control, pinned. What it leaves untouched: the guard sits after the address read, the ambiguity refusal, the repo.get probe and the two no-op early returns, and before storedParentForToken, repo.delete, the registry heal, dropObjectStorage, the audit, the projector and the mutation event, so no store row, table, registry entry or audit row changes; the catch rethrows it by identity ahead of the re-wrap, as it does addressRefusal, and resolveErrorResponse's declared-status pass-through serves it as 409 in the flat { error, code } envelope. The drop site reads the hoisted verdict with identical inputs and the receipt's three hoisted values are the same expressions moved earlier, so every non-refused delete's receipt is byte-identical.
  2. The REST accept set: unchanged in names, narrowed in outcomes. META_ITEM_RESET_PARAMS (state, dropStorage, package, member) is untouched; one request that answered 200 now answers 409. No spec, error-code ledger or route ledger change; RESOURCE_CONFLICT is the standard catalog's member and the code packagelessAddressAmbiguityRefusal already answers for the same rows (an environment-state refusal: the same request is accepted once the other rows are gone). RIGHT, no code minted.
  3. The uninstall path's changed per-item outcome: RIGHT as read, shape escalated in ③. deletePackage (unless keepData: true) sends each row's deleteMetaItem with packageId and dropStorage: true, drafts first. For an object another package also holds: before, P's row went and the table was dropped (Q's data lost); now the per-item call throws, the loop's catch collects { type, name, error, code: RESOURCE_CONFLICT } into failed[], success is false, and the HTTP door answers 400 PACKAGE_DELETE_PARTIAL with failed[] in details. State after: the sys_packages row is gone (step 4, the first durable step), the package's other rows are deleted, the registry withdrawal and cleanups run, and P's row of the shared object, its table and records stay, a stranded row removable with DELETE ...?package=P without dropStorage. Safer than the loss it replaces, loud, and inside the door's existing per-item contract; the changeset's TO paragraph describes it accurately.
  4. The refusal text as an author reads it: RIGHT at the metadata door, one caveat at the uninstall door. Prescription first (send without dropStorage, or delete the other rows first and drop with the last), then "Nothing was deleted", then the rows that remain through remainingRowsPhrase (sorted, at most five named), then the rule; no tracker number, no code prefix; the remedy survives the 500-character 4xx bound. Read inside an uninstall's failed[].error it prescribes omitting a parameter the uninstall caller never sent; the actionable remedy there is keepData: true or removing the other row first. Escalated in ③.
  5. The pin: RIGHT shape. Real stack (better-sqlite3 tables, real ObjectQL, protocol and REST routes), both kernel topologies, five cases; each refusal pin asserts status, code and the unchanged store, table and record (refusal half plus preservation half); the package-less row case; the way out; the last-row and single-package controls. Red at base 3 of 5 and green at head 5 of 5 with a red/green ablation per the report; Test Core green on the head.
  6. Scope: held. Every production hunk sits in the claimed deleteMetaItem region (the new static beside shouldDropStorage, the request JSDoc, the decision block, the drop site, the hoisted receipt values, the catch list); classifyObjectContribution untouched; packages/spec untouched; only @objectstack/metadata-protocol is bumped (the rest change is a test file).

② Semver level

RIGHT. The diff narrows what a public door accepts (409 where 200 was), which this repo grades BREAKING; the changeset declares it with every signal the ADR-0087 gate reads (the fix(metadata-protocol)!: bang, **BREAKING**, and Clause-②: no (narrowing)) and bumps minor, which check-changeset-no-major.mjs (the launch-window guard, ADR-0087's ratified pre-launch exemption) requires in place of major. Exactly one adr-0087 marker comment, category not-required (no-migration-prescription), judged on the facts and not only on the gate's silence: no authorable key, export or stored shape is removed, renamed or re-shaped, no stored row is rewritten, and objectstack migrate meta has nothing to rewrite; the FROM, TO and Remedy paragraphs describe runtime behaviour and a request-level remedy, not a consumer rewrite, so the category is honestly claimed. The Clause-②: line reads no in the PR body (arm-less, a valid declaration) and no (narrowing) in the changeset, the same value with the arm carried where the ADR-0087 gate reads it; no new key on any published payload (the 409 envelope is the standard flat one). Check Changeset and Lint & Repo Gates are green on the head.

③ Boundary flags

  • Pin placement (deviation 1): accepted. The claim's named fixture has no tables and no dropObjectSchema, so the record-survival claim needs a real table; test file only.
  • Production edits confined to the claimed region (deviation 2): verified against the diff; the legacy drop site is unreachable for objects (①.1).
  • One origin/main merge, main since moved (deviation 3): accepted. The merge brought no change to the delete path, main's later protocol.ts commit is a doc comment outside the region, the PR is mergeable clean, and the queue's rebuilt generation is the joint check.
  • Fixture commit fetched into the shared object store (deviation 4): accepted; an object fetch for a self-test, no shared ref moved.
  • Timeout-killed objectql run re-run under the lock (deviation 5): accepted; the final verdict is exit 0 and Test Core is green.
  • Probe test files created and deleted (deviation 6): verified; the diff carries three files.
  • Attribution (deviation 7): verified; all six commits carry the model-free trailer pair and no model identifier appears in the PR, the changeset or the commits.
  • open_questions[0], the uninstall shape: answered. This PR's state (option A) is an acceptable interim because the data loss is stopped and the outcome is loud and coded; it is not the end state. ESCALATED: the owning seat files a card for deletePackage (option B as recommended: a pre-flight before step 4 so a refused uninstall leaves everything as it was, prescribing keepData: true); outside this card's file surface.
  • out_of_scope_findings[1], the DeleteMetaItemRequestSchema.dropStorage describe in packages/spec/src/api/protocol.zod.ts still names only the type, state and sys_ conditions and feeds the auto-generated reference: carrier "none" is not a resting state. ESCALATED: a docs/spec card (the describe text plus gen:schema && gen:docs) is owed. The in-code JSDoc names the refusal and the refusal carries its prescription, so this is not a silent trap and does not block landing.
  • Noted, no action: the refusal records no denied audit row, matching the addressRefusal precedent; an uninstall still discards the package's draft of a shared object before the active row's refusal (a draft holds no records).

Implemented-by: claude/issue-23415-drop-storage-other-rows
Reviewed-by: session_0121ooBYRmS8ziFXm9ai5YHm

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 11:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 11:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 1baf976 Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23415-drop-storage-other-rows branch October 14, 2026 12:15
marchtian pushed a commit that referenced this pull request Oct 14, 2026
…3429)

meta-delete-drop-storage-other-rows (#23464) inserts a package-less
sys_metadata row with organization_id: null; the family declares no such
field after S2, so the engine refuses the insert (INVALID_FIELD). Every
row is the environment's (ADR-0131 D7): the seed drops the field, and the
assertion (a package-less row beside the deleted package's row still
remains) is unchanged. The protocol.app-package-id-key engine double
carried the same retired field on its stored row; dropped likewise.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
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/m tests tooling

Projects

None yet

2 participants