Repository navigation
metadata-protocol: a delete naming one package with dropStorage=true drops the object table while another package row of the object remains — the remaining package object loses its data #23415
Description
Filing gate ①, data-integrity: a reachable defect that destroys data, measured at a public door. Filed by domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy, from #23297's dev report (os-dev-report on #23297, out-of-scope finding 1; PR #23408). ⛔ Not a claim. Grade and lane are triage's.
Reader who acts: triage, to grade and route (the data-integrity path). Then the owning seat. The landing point is packages/metadata-protocol/src/protocol.ts deleteMetaItem's storage drop (shouldDropStorage → dropObjectStorage).
What happens (measured)
On a booted showcase at the #23297 branch tree (ae6b35c16, main e4ec6ec1a merged):
- Package
com.qa.pand packagecom.qa.qeach hold a row of the objectqa_probe_drop. A record of the object exists. DELETE /api/v1/meta/object/qa_probe_drop?package=com.qa.p&dropStorage=trueanswers200. Its receipt says the item still exists and its row bound tocom.qa.qremains.GET /api/v1/data/qa_probe_dropthen answers500 DATABASE_ERROR: the table is gone, and the record created before the delete with it.POSTanswers404 OBJECT_NOT_FOUND.
The delete removed one package's row, then dropped the object's storage without asking whether a row of the object remains. The remaining package's object keeps its definition and loses its data.
What done looks like
- A per-package delete that leaves another row of the object stored never drops the object's storage. The owning seat chooses one remedy: refuse
dropStorage=truewhile another row remains, with a prescription, or skip the drop and say so in the receipt. A loud refusal is the stricter shape, but the choice is the seat's. - A pin on a real kernel: two packages hold an object,
DELETE ...?package=P&dropStorage=true, and Q's records survive. The negative control is that the last row's delete withdropStorage=truestill drops the table.
Family
The package-axis delete family of #23297 (PR #23408 fixes the registry heal after the same per-package delete; it does not touch the storage drop). #23165 (closed) made the delete name its package.
Dedupe
MCP search_issues on this repo, closed included: dropStorage per-package delete drops table while another package row remains data loss gives 7 hits.
- metadata-protocol: three package-axis gaps PR #23290's review leaves open — the registry heal after a per-package delete, the draft save's storage-order bind with two packages, and the delete
_lockgate that takes no package #23297 (open) is the family card. - Uninstalling a package after a kernel reboot leaves its permission set's sys_permission_set row behind (managed_by admin, package_id null) #23299, organization-ownership column-drops never completes where no platform-global objects are declared: organization_id is still injected on the sys_metadata family and sys_flow_dispatch, so the drop is never proposed #23399 and Attachment tombstoning silently no-ops on a PREDICATE delete — the beforeDelete→afterDelete stash dies with the per-row context #10240 are other delete or drop paths: a permission-set row, organization-column drops, attachment tombstoning.
- metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165,
protocol.deletePackagefinds zerosys_metadatarows the data plane finds 3 of — uninstall leaves orphaned rows (persistence half of #7557) #7705 and Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphanedsys_metadatarows #7557 (closed) are earlier package-axis or uninstall defects.
None is this.
Dedupe words: dropStorage per-package delete remaining row; shouldDropStorage row remains table dropped; DELETE package dropStorage data loss.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade ·
bug·priority:p1·domain:spec·target:v18·pm:queue· data integrity · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T07:56ZRead fresh, and checked on
main:deleteMetaItemdrops storage throughshouldDropStorage(protocol.ts:20694, called at:28661). It asks aboutdropStorage, the type and the state, not whether another package's row of the object remains.manage_metadata, an explicitdropStorage=trueand two packages holding rows of one object, so it is not a broad exposure.dropStorage=truewhile another row remains) narrows what the metadata delete door accepts. So it is p1 +target:v18, and it rides the 18 line.domain:spec: the package-axis delete family's lane. metadata-protocol: three package-axis gaps PR #23290's review leaves open — the registry heal after a per-package delete, the draft save's storage-order bind with two packages, and the delete_lockgate that takes no package #23297 (domain:spec, dispatched to the filing seat) and 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 touch the same delete path and fixtures. Clause ②: narrowing,no, so it stays in the lane and owes the contract-tier review.dropStorage=truestill drops the table.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 1 · 2026-10-14T08:14Z
Session:
session_0121ooBYRmS8ziFXm9ai5YHmAccount:
sunhaolin(the seat's linked user asGET /useranswers it; this card's assignee)Branch:
claude/issue-23415-drop-storage-other-rowsWorktree:
objectstack-issue-23415Domain:
domain:specSeat:
domain:spec#4(seat post #18917)File surface:
packages/metadata-protocol/src/protocol.ts, thedeleteMetaItemstorage-drop region only (shouldDropStoragenear:20741,dropObjectStoragenear:20719, and its two call sites near:28710and:28900, read at4f81f315); the delete receipt's shape inpackages/spec/src/api/protocol.zod.tsonly if the chosen remedy names the skip there; one real-kernel pin beside the delete path's existing tests (packages/objectql/src/protocol-delete-object-registry-heal.test.tsor a sibling); one.changeset/23415-*.md. ⛔ NotclassifyObjectContribution(#23416's region, serial behind this card). Stop on a breach and explain it in the report.Container & model:
M,mode:subagent,model: default tier(dispatch-gates --tier --repo objectstack-ai/objectstackat4f81f315: "no path-derived mandate"); the narrowing owes one review atCONTRACT_REVIEW_TIERbefore the queue (same output: "A card that only NARROWS one isClause-②: no… owes one contract-review-tier review before the queue").Clause-②: no
Responsibility:
deleteMetaItem's storage drop inpackages/metadata-protocol/src/protocol.tsproduces it (shouldDropStorageasks aboutdropStorage, type, state and thesys_prefix, never whether another package's row of the object remains) | none: PR #23408 (2de9c038) heals the registry after a per-package delete and does not touch the drop | amanage_metadatacaller issuingDELETE /api/v1/meta/object/NAME?package=P&dropStorage=trueon an object two packages hold, measured on a booted showcase per the card; no live user of the two-package shape is namedThread-read: 6163682328
Serial constraints cleared: PR #23408 (#23297, the package-axis family on this file and fixture) merged as
2de9c038; #23357 (seat 1) fencesprotocol.tsout; none of the 10 open PRs touchesprotocol.ts(read this act). #23416 (queued, same file atclassifyObjectContributionand the sametwoPackageObjectfixture) is serial behind this card and is not claimed by it.objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{
"issue": 23415,
"status": "done",
"branch": "claude/issue-23415-drop-storage-other-rows",
"pr": "#23464",
"session": "session_0121ooBYRmS8ziFXm9ai5YHm — the parent PM session's id (mode:subagent); claim 6163962196 names this branch, verified newest Claim: on the card before any edit",
"premise_still_valid": true,
"summary": "deleteMetaItem (packages/metadata-protocol/src/protocol.ts, repository path) now decides the table drop once, after the row probe and before any write: when shouldDropStorage says yes and another stored ACTIVE row of the object stays after this delete (another package's row, or the package-less row), it throws dropStorageRemainingRowsRefusal, 409 RESOURCE_CONFLICT, nothing deleted, prescription first (send without dropStorage, or delete the other rows first and drop with the last one), then the remaining rows named via remainingRowsPhrase. It reuses the delete's own address read (packagelessAddress rowsAtAddress) with no second lookup; the receipt's remaining-row values are hoisted to the decision point and the drop call site reads the one verdict (dropsStorage); the refusal is rethrown as is from the catch like addressRefusal. Ruling premise HOLDS: RESOURCE_CONFLICT (standard catalog, the code packagelessAddressAmbiguityRefusal answers for the same rows) describes it; no code minted, Clause-2 stays no. Measured before/after on the real stack (better-sqlite3 tables, real ObjectQL, protocol, REST routes): before, DELETE ?package=P&dropStorage=true answered 200 with rows [Q] left and hasTable false; after, 409 with rows [P,Q], table and Q's record kept. Last row with dropStorage still drops the table; a per-package delete without dropStorage is unchanged. Rows-that-count reading: ACTIVE rows only (a draft is never materialised/served, so it holds no records); a package-less row counts. SECOND DOOR, measured: deletePackage (uninstall, keepData unset) sends the same per-item delete with dropStorage:true; it now reports success:false, failed[0] = this 409, and leaves P's row, the table and the record (before: dropped Q's table). See open_questions.",
"tests": "Head 8a7b08b unless noted (pre-merge head dce4a7e for objectql, typecheck, ablation; merge of origin/main 05ab9ef touched no protocol.ts/rest/delete path). New pin packages/rest/src/meta-delete-drop-storage-other-rows.test.ts (real stack, 5 cases: refusal on control-plane AND environment kernel with code+status and message naming Q, store/table/record unchanged; package-less remaining row refused; without dropStorage unchanged then last row WITH dropStorage drops table; single-package control drops). RED at base (dist built from 04a3148 tree): Tests 3 failed | 2 passed (the two controls); probe at base: rows [com.probe.q], hasTable false on both kernels, unbound case rows [null] hasTable false. GREEN after fix: Tests 5 passed (5). Suites under os-verify-lock, VERDICT command-exit 0 each: metadata-protocol test 235 files passed | 3 skipped, 28385 tests passed (post-merge 28389 passed); objectql test 415 files passed, 8087 tests passed; rest test 282 files passed, 5850 passed | 326 skipped (same post-merge). Typecheck: metadata-protocol exit 0, rest (tsc + check:test-typecheck) exit 0, each exit captured individually; rest tsconfig.test.json --listFilesOnly lists the new test (count 1). CLI not run: os meta delete has no dropStorage flag by design (delete-reset-carriers.test.ts pins its absence). ABLATION (rest test resolves metadata-protocol via dist, KNOWN_UNALIASED_TEST_IMPORTS): committed fix first; scripts/ablation-replace.mjs anchor 'if (dropsStorage && remains) {' x1 to x0, blob 4d93ece86aea to 79bb58a95ed6, rebuild, ablation-dist-preflight marker present in dist/index.js and dist/index.cjs, pin RED 3 failed | 2 passed; restore: blob == HEAD 4d93ece86aea, git diff HEAD empty, rebuild, preflight --absent marker absent from all 24 built files and tree clean, pin GREEN 5 passed. Trap restore (git checkout HEAD -- path) armed throughout.",
"gates": "LOCAL SUBSET: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands at 8a7b08b (change set = 3 paths vs merge base 05ab9ef); all 65 run unlocked, exit captured before any pipe: 65 x exit 0. --ran with 'command :: exit N' lines: 'Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN' and '65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero ...)'. Earlier run of the same list at 9534b10 had 3 non-zero, each resolved before the final run: check:rest-log-spy-declared exit 1 (real finding: first pin spied console.warn; spy removed, gate OK 32 of 288 files declare OS_REST_LOG), check-plugin-teardown-shape --self-test exit 3 (shallow clone could not read pinned fixture 621a487; fetched that commit, 48 cases pass), check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (missing dist) then exit 0 at 8a7b08b (110 entry points across 68 packages load). Notable passes: check:adr-0087-registration (BREAKING+bang+clause-narrowing, not-required (no-migration-prescription)), check:changeset-no-major, check:engine-double-contract, check:durability-log-level, check:doc-authoring, check:test-source-alias, check:cross-package-test-inputs, check:nul-bytes. Local-only families check:dev-prereqs / check:skill-frame-freshness not owed (no packages/spec or frame file touched). CI (PR check-runs at 8a7b08b when this report was written): success = Auto Label, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter; in_progress = Build Core, Check Changeset, Lint (7 lanes incl. ESLint, PM dispatch-gates), Test Core 1-6/6, Type Check (4 lanes); skipped = Build Docs, Console Pin Gate, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. Not waited on, per contract.",
"mcp_calls": "0 — no MCP GitHub tool called (gh api REST reads only)",
"api_writes": "2 relay strokes via scripts/pm/fleet-write/dispatch.mjs (transport dispatch, executed as objectstack-fleet[bot]), each one POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23464/assignees [sunhaolin], run 38553161973 success, read-back identical 10156 bytes; (2) this os-dev-report comment = POST /repos//issues/23415/comments. Labels: zero writes (dispatch named none; skip-changeset does not apply, the package publishes). Plus git push (not REST).",
"files_changed": [
".changeset/23415-drop-storage-other-rows.md",
"packages/metadata-protocol/src/protocol.ts",
"packages/rest/src/meta-delete-drop-storage-other-rows.test.ts"
],
"deviations": [
"Pin placement: claim named protocol-delete-object-registry-heal.test.ts (twoPackageObject) or a sibling; measured that fixture's engine double has no tables and no dropObjectSchema, so 'Q's records survive' would rest on a hand-modelled drop. The pin is a new file beside the REST delete door's real-stack tests (packages/rest/src, mirroring meta-draft-head-package-inheritance.test.ts). Test file only; no production file outside the claimed drop region changed. Also covers the dispatch's @objectstack/rest consumer reading.",
"Production edits stay in the claimed region of protocol.ts: new private static dropStorageRemainingRowsRefusal placed beside shouldDropStorage, the deleteMetaItem dropStorage JSDoc, the decision block and drop call site, the hoisted receipt values, and the catch's rethrow list. The legacy raw-engine drop call site is unchanged (unreachable for object: allowRuntimeCreate true routes every object delete to the repository path).",
"origin/main merged once before opening the PR (AGENTS §10), merge commit 8a7b08b with trailer pair; post-merge re-ran the closure build, metadata-protocol and rest suites and the full derived gate list. Main moved 12 more commits after that merge (one touches protocol.ts, a doc comment at ~:5981 only); not merged again.",
"Fetched the pinned fixture commit 621a487 into the shared object store so check-plugin-teardown-shape --self-test could run on this shallow clone.",
"One locked objectql run was killed by my own timeout 590 wrapper (exit 124) mid-suite; no orphan processes found; re-run in the background under the lock with a tail --pid wait, VERDICT command-exit 0.",
"Two temporary probe test files (packages/rest/src/zz-probe-23415.test.ts) were created, run and deleted, never committed: the base data-loss reading and the deletePackage reading.",
"Attribution: commit trailers are the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude); the harness reminder's model-named Co-Authored-By line was not used (pre-push refuses a model identifier). PR footer uses the AGENTS.md session-URL form instead of the harness's robot-emoji line."
],
"open_questions": [
{
"question": "Uninstall (deletePackage, keepData unset) of a package whose object another package also holds now reports the 409 for that object in failed[] and leaves the package's row of the object, its table and records (measured in process). With a package service registered, sys_packages is deleted at step 4 before this per-item loop, so the result is a partial uninstall, and the per-item text names dropStorage, which an uninstall request never sends. Which shape should deletePackage take? Outside this card's file surface; no code changed for it.",
"options": [
"A — keep this PR's state: the per-item refusal in failed[] (deletePackage's existing per-item contract). Cost: zero. Leaves a half-uninstalled package (row stranded) and a prescription phrased for the metadata door.",
"B — pre-flight in deletePackage before step 4 (its first durable step): when keepData is unset and any of the package's active object rows has another stored row, refuse the whole uninstall 409 RESOURCE_CONFLICT, nothing changed, prescribing keepData:true or removing the other package's row first. Cost: one bounded read per object row through packagelessAddress, one refusal sentence, one real-stack pin; small.",
"C — the uninstall skips the drop for a shared object and names it in its response. Cost: small. It is the skip route this card's ruling rejected for the metadata door."
],
"recommendation": "B. Four axes: (1) real business need: no live user of the two-package object is named (claim's Responsibility line), but if the shape exists the uninstall is the main door that sends dropStorage:true, since it is its default; (2) long-term: B keeps deletePackage's documented guarantee that a refusal at steps 1 to 4 leaves everything as it was, where A leaves sys_packages gone with a stranded row; (3) AI-proofing: B is a loud, atomic refusal whose remedy is in the request (keepData), where A's per-item text names a parameter the caller never sent and C is a silent skip of a destructive default; (4) startup focus: B is one verb, no new code, no new gate, no spec change. A is acceptable as an interim state because it already stops the data loss."
}
],
"out_of_scope_findings": [
"carrier: none (承接者:无) · deletePackage shape for a shared object (see open_questions); measured in process only (protocol.deletePackage on the real stack), not at DELETE /api/v1/packages/:id, so no reach line; noted in PR Acceptance notes, not filed · dedupe words: deletePackage shared object dropStorage refused; uninstall partial stranded row; keepData shared table",
"carrier: none (承接者:无) · DeleteMetaItemRequestSchema.dropStorage describe (packages/spec/src/api/protocol.zod.ts) still lists only type/state/sys_ conditions and does not name this refusal; the claim scoped that file to the skip remedy only; noted in PR Acceptance notes, not filed · dedupe words: dropStorage describe refusal last row; DeleteMetaItemRequestSchema dropStorage doc"
]
}
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23464 comment 6166898065 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T11:04Z. Landing waits on CI convergence and aCONTRACT_REVIEW_TIERreview on the landing head.objectstack-fleet commentedon Oct 14, 2026
Landed · PR #23464 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T12:18Z1baf976cf9origin/maingit merge-base --is-ancestor 1baf976cf9 origin/mainexits 0;dropStorageRemainingRowsRefusalpresent inpackages/metadata-protocol/src/protocol.tstheregh-readonly-queue/main/pr-23464-*ref remainsState change: the card is closed
completedbyFixes #23415, andpm:dispatchedis removed in this act. The uninstall half is #23473, dispatched next by this seat (lane decision B,6168058499).