Repository navigation
fix(metadata-protocol)!: an uninstall that would drop a table another stored row still serves is refused 409 before anything is removed - #23504
Conversation
… stored row still serves is refused 409 before anything is removed deletePackage now asks, before its first durable step, the per-item delete's own address read for each active object row whose table it would drop. An object the package holds beside another package's row or the package-less row refuses the whole uninstall RESOURCE_CONFLICT / 409, naming the objects and the rows that keep them, with nothing removed. keepData: true drops no table and reads nothing. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…LETE /packages/:id door Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…arrowing) Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…install-shared-object-refusal
…stry too Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
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 ff4d3ca2d456f5fa3bf23969763fd935e093b911 && git checkout ff4d3ca2d456f5fa3bf23969763fd935e093b911
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 7d36e8d205d783c9171b952d66dc06b16fa15ae0 && git checkout -B drift-repro f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 && git merge --no-ff 7d36e8d205d783c9171b952d66dc06b16fa15ae0
node scripts/docs-audit/affected-docs.mjs --json f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4
|
…install-shared-object-refusal
…ropStorage changeset's uninstall sentence states the whole-uninstall refusal Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
|
The gate and why it is red. On
The note, and what changed under it.
Confirmation: requested from the maintainer in the seat's session. This PR does not enter the queue before the maintainer's word is recorded here. Also still owed: the at-tier contract review and the seat record. |
Fixes #23473
Clause-②: no
What changes
deletePackage(packages/metadata-protocol/src/protocol.ts) now refuses the WHOLE uninstall409 RESOURCE_CONFLICTbefore its first durable step (step 4, thesys_packagesdelete) when, withoutkeepData: true, an active object row of the package shares its object with another stored active row: another package's row, or the package-less row. Nothing is removed: thesys_packagesrow, the registry entry, everysys_metadatarow, the tables and their records all stay. The refusal names each shared object and the rows that keep it, and prescribes the ways out.Before this, the per-item delete met #23415's per-object
409only after step 4, collected it infailed[]and the door answered400 PACKAGE_DELETE_PARTIALover a package that was no longer installed while its row of the shared object stayed stored and served.Shape B, per the lane decision on the card (
6168058499). Not shape C (uninstall and quietly keep the shared table).Governing text
deletePackage's own docblock at1baf976cf9, quoted:A shared object is the same kind of stake as an extended one, so it joins step 3's up-front refusal. The docblock's step 3 now names both refusals; the numbering, and with it "nothing durable happens before step 4", is unchanged.
How the condition is read (premise, measured)
shouldDropStorage, the same guard: object type, active, notsys_), the pre-flight callspackagelessAddress(type, name, undefined, 'active'), the private address readdeleteMetaItemitself takes for that row (itsrowsAtAddress), with the type folded asdeleteMetaItemfolds it (canonicalMetaType, thenPLURAL_TO_SINGULAR). It refuses exactly when that delete would: the package holds a row there and another package's row or the package-less row stays. A failed read is the uninstall's step-2 read failure (metadataReadFailureError), never "not shared".keepData: truereally skips every drop. The only table-drop call sites aredeleteMetaItem's two paths intodropObjectStorage, both gated byshouldDropStorageon the request'sdropStorage, anddeletePackagesendsdropStorageonly whenkeepData !== true. So withkeepDatasharing decides nothing and the pre-flight reads nothing. Pinned on the real stack: thekeepDatauninstall completes,failed: [], the table, the other package's row and the record stay, and the running registry serves the other package's definition.deletePackagehas two callers: theDELETE /api/v1/packages/:iddoor, androllBackUnmanagedImport, whose import refuses a name another row holds before it writes anything (unmanagedImportCollisions). The install-local uninstall door does not call it.Error code
Step 3's own refusal is a code-less
Error, which the door answers500 INTERNAL_ERROR(pinned inpackage-uninstall-store-refusal.integration.test.ts); that family does not fit a client-actionable refusal. The pre-flight reusesRESOURCE_CONFLICT/409, the code the per-item refusal (dropStorageRemainingRowsRefusal) answers for the same rows. No code is minted.The refusal, as measured (two shared objects)
The second way out answers the card body's concern with shape B: deleting the package's own row of each shared object first (no
dropStorage) keeps that table for the other rows, and the uninstall then still drops the tables the package alone held. That path is pinned too.Tests
New real-stack pin,
packages/runtime/src/package-uninstall-shared-object.integration.test.ts, at the uninstall door itself: realObjectQLover a real better-sqlite3SqlDriver(real tables), the realPackageServicePlugin(sys_packages), the real protocol and the realHttpDispatcher.handlePackages. Five cases:keepData:409 RESOURCE_CONFLICT(status and code asserted), message names the object, Q andkeepData; thesys_packagesrow,GET /packages/P200, both rows, the table, the record and the registry entry all intact.?keepData=true: 200,failed: [], P gone, Q's row, the table and the record stay, the registry serves Q's definition.dropStorage): the uninstall answers 200,failed: [], keeps the shared table and drops the table P alone held.Readings (
--maxWorkers=2, under the shared verify lock):fee128e3da(this PR's head).@objectstack/metadata-protocolwhole suite: 236 files passed, 3 skipped; 28397 tests passed, 19 skipped, at2e1269562a(the merge oforigin/main; the only later commit edits the pin file).@objectstack/runtimeuninstall-route sweep (everysrc/domains/packages-*.test.ts, everysrc/package-uninstall-*.integration.test.ts, and the two package org-scope integration tests): 32 files, 551 tests passed, at2e1269562a.@objectstack/restuninstall-route sweep (package-door-*,package-envelope.conformance,package-routes-coded-error-mapping,package-publish-mount,direct-mount-introspection, and metadata-protocol: a delete naming one package withdropStorage=truedrops the object table while another package row of the object remains — the remaining package object loses its data #23415'smeta-delete-drop-storage-other-rows): 12 files, 236 tests passed, at2e1269562a.@objectstack/metadata-protocoltsc --noEmitexit 0;@objectstack/runtimetsc --noEmitpluscheck:test-typecheckOK (the new file adds no test-layer error), at2e1269562a;check:test-typecheckagain OK atfee128e3da.packages/cliintegration layer: not touched, declared to CI.Ablation (one-shot, not kept)
@objectstack/metadata-protocolreaches the pin through itsdist/, so each leg rebuilt it and proved the marker's presence or absence there. At971f6e76a4, throughscripts/ablation-replace.mjs(anchor hit 1 → 0, blob changed, WRAP-mode restore):if (request.keepData !== true) {made dead with a marker condition;ablation-dist-preflightfound the marker indist/index.jsanddist/index.cjs. The pin went red on cases 1 and 2 with exactly the card's defect:400 PACKAGE_DELETE_PARTIAL,registryRemoved: true, the shared object infailed[]with metadata-protocol: a delete naming one package withdropStorage=truedrops the object table while another package row of the object remains — the remaining package object loses its data #23415's per-itemRESOURCE_CONFLICTtext. Cases 3–5 stayed green.git diff HEADempty, whole tree clean; rebuilt;--absentfound the marker in none of 24 built files; the pin 5/5 green.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 65 families for this diff; all 65 ran locally with exit 0, and--ranreconciles 65 derived, 65 run, 0 NOT-MEASURED (exit codes recorded per family). Readings atfee128e3daare in the dev report on the card. Every family CI runs is read from this PR's check-runs, not re-run here.Acceptance notes
dropStorage=truedrops the object table while another package row of the object remains — the remaining package object loses its data #23415's unreleased changeset is superseded in one sentence..changeset/23415-drop-storage-other-rows.md(onmain, not yet released) says an uninstall "reports the refusal for that object in itsfailed[]list ... and leaves the object's row and table in place". After this PR, such an uninstall is refused whole before anything is removed. Not edited here: that file is outside this claim's file surface. Suggested edit for the owning seat, replacing that sentence: "An uninstall of a package holding such an object is refused whole before anything is removed; see the 23473 changeset."409stays as a safety net. With the pre-flight, it is unreachable on the uninstall path except for a row written concurrently between the pre-flight and the per-item delete; then it is still caught and reported infailed[]as before.keepData, onesys_metadataread per active object row of the package (notsys_), the same read the per-item delete then repeats. WithkeepData, none.protocol.ts: thedeletePackagedocblock's step 3 and the pre-flight between step 3 and step 4 only. Not the per-item loop,deleteMetaItemorclassifyObjectContribution.packagelessAddress), so the package-less row cannot be deleted first through the metadata door while the package's row exists. The refusal's other way out (delete the package's own row first) covers that case; pinned as case 4's mechanism.Generated by Claude Code