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 367c6c50d8455a7ea6c2178b431a7ba8da570de1 && git checkout 367c6c50d8455a7ea6c2178b431a7ba8da570de1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 23e689dc7d1de961a168997685295ddf009d5c2c 4f639e56b06075f522098437d04b08dc06075c11 && git checkout -B drift-repro 23e689dc7d1de961a168997685295ddf009d5c2c && git merge --no-ff 4f639e56b06075f522098437d04b08dc06075c11
node scripts/docs-audit/affected-docs.mjs --json 23e689dc7d1de961a168997685295ddf009d5c2c
|
…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. |
Contract reviewServed-tier: Inputs read: card #23473 (body; lane decision ① Derived judgments
② Semver level
③ Boundary flagsDev report
Dev report, out-of-scope findings:
Addendum
Nothing escalated. Implemented-by: VERDICT: PASS |
席位核对
Verdict: ACCEPT, landing held on the maintainer's confirmation (clause d). Not governed. 4 files, +402 / −1. The
|
…ge's row as one stored before ADR-0029 D9.9 The save door now refuses a save into a second package of an object a runtime-authored row owns (OBJECT_OVERLAY_PACKAGE_MISMATCH / 422), so the pin's two-package fixture could no longer build its state by saving Q's row: the three P-and-Q cases failed in setup on main. Q's row is now seeded straight into the store, as a row at rest from before that refusal; P's row is still saved through the door and the registry still holds P alone. Every case assertion is unchanged. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
|
What is red.
Why the seat reads it as not this PR's.
What follows. The seat re-runs the failed jobs of run |
Contract reviewServed-tier: Second record on this PR; the first ( Inputs read: card #23473 (body; every comment, among them the lane decision Between ① Derived judgments
② Semver level
③ Boundary flagsDev report
Dev report
Check-runs on Nothing escalated. Implemented-by: VERDICT: PASS |
席位核对
Verdict: ACCEPT on this head. It supersedes the seat record
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 38703520759 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…install-shared-object-refusal
…in main's row shape, with no organization column
The sys_metadata family no longer declares organization_id (ADR-0131
D6/D7), so the engine refuses a row that names it ("Unknown field
'organization_id' on object 'sys_metadata'"), and the four cases that
seed a stored row failed in setup. The seed now writes the row the way
main's sibling pin (meta-delete-drop-storage-other-rows.test.ts) does.
Every case assertion is unchanged.
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Third record on this PR; the two earlier ones ( Inputs read: card #23473 (body and all 15 comments, among them the lane decision What moved since the last record, measured: this PR's delta to ① Derived judgments
② Semver level
③ Boundary flagsDev report
Dev report
Dev report
Check-runs on Nothing escalated. Implemented-by: VERDICT: PASS |
席位核对
Verdict: ACCEPT on this head. It supersedes
|
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.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
The pre-flight answers
RESOURCE_CONFLICT/409, which is also the code the per-item refusal (dropStorageRemainingRowsRefusal) answers for the same rows. Step 3's own refusal, an object extended by another package, used to be a code-lessErrorthat the door answered500 INTERNAL_ERROR. Since PR #23522 (#23505,989aa483fd) it answers409 RESOURCE_CONFLICTwith the way out, pinned inpackage-uninstall-store-refusal.integration.test.tsonmain. So both up-front uninstall refusals now share one code. 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.Merge round after the queue ejection (run 38682506920)
The PR left the merge queue:
Test Core (6/6)failed on queue commit5e97584201(main8d05456cbeplus this PR).mainwas merged in as a merge commit,192d2051da(no conflict;protocol.tsauto-merged). Reproduced on that commit, the three cases of the pin where P and Q both hold the object failed in setup, before any assertion:Cause: #23510 (
222293f60a, ADR-0029 D9.9) landed onmainafter this PR's previous merge basef9b421716d(merge-base --is-ancestorexit 1, control legf9b421716d~60exit 0; it is in8d05456cbe, exit 0). The save door now refuses a save into a second package of an object a runtime-authored row owns (OBJECT_OVERLAY_PACKAGE_MISMATCH/422), so the pin's fixture could no longer save Q's row.protocol.tswas not the cause and is unchanged by this round; the step-3 CONTROL inpackage-uninstall-store-refusal.integration.test.ts(uninstalling the extending package) passes on the merged tree.Does the shared state still arise? Measured on the merged tree, real
ObjectQLover better-sqlite3, the real protocol andHttpDispatcher.handlePackages(one-time readings, probe not kept):422 OBJECT_OVERLAY_PACKAGE_MISMATCHsys_metadatasentinel, which D9.9 leaves out)publishMetaItem,packageQ)POST /api/v1/packages/com.probe.q/publish-draftsat the door handler200,outcome: published,failed: []; the registry keeps PduplicatePackageP to Q (targetNamespacegiven)qq_probe_ticketloadMetaFromDb) over a stored Q row beside P'serrors, leaves it stored (code reading)So the state still arises: through today's doors (the last two save-door rows), and as rows stored before D9.9, which stay at rest. On each door-reached state the uninstall of P without
keepDataanswered409 RESOURCE_CONFLICTand removed nothing. The pre-flight guards a live path.The fixture fix.
twoPackageObjectnow saves P's row through the door, as before, and seeds Q's row straight into the store as a row stored before D9.9 (the save's shape, checksum byhashSpec), the way #23510 re-seeded its own two sibling fixtures. It does not use the draft-promotion path: that path is a D9.9 gap, and a fixture built on it would break again when the gap closes. Every case assertion is unchanged; the package-less case and the CONTROL are unchanged.Second merge round (queue run 38703520759)
The PR left the queue again:
Test Core (6/6)failed inpackages/runtimeon queue commit75e7900e67(main23e689dc7dplusaddcca0cf5).mainwas merged in as a merge commit,ce7a65df2c(no conflict;protocol.tsauto-merged, and this PR's delta to it is byte-identical before and after the merge). Reproduced there, four of the pin's five cases (every case that seeds a stored row) failed in setup:Cause: #23469 (
dd29bbe60d, ADR-0131 D6/D7) dropped the organization column from thesys_metadatafamily, and with itorganization_id: nullfrom the sibling pin's seed (meta-delete-drop-storage-other-rows.test.ts). The fix,4f639e56b0: this pin's seed followsmain's row shape and no longer writes the column. Nothing else moves.The pre-flight still asks the per-item delete's address read. On the merged tree both call the one
packagelessAddress: the pre-flight with(type, name, undefined, 'active'),deleteMetaItemwith(singularTypeForRepo, name, member, targetState). #23469 changed only that function's body: itswherewas{ type, name, state, organization_id: null, ...member }read byengine.find; it is now{ type, name, state, ...member }read throughfindEnvironmentRows, the seam that drops a legacy organization's rows. So both callers moved together.deletePackage's step-2 read goes through the same seam, and its per-item loop no longer has a legacy-organization branch, so the pre-flight's input rows and the rows the per-item delete walks are the same set.Tests
The 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 at
4f639e56b0(--maxWorkers=2, each under the shared verify lock, verdict from itsVERDICT command-exitline):ce7a65df2c: the pin 4 failed / 1 passed (the setup error above; the CONTROL passed).@objectstack/runtimeuninstall sweep (everysrc/package-uninstall-*.integration.test.ts, everysrc/domains/packages-*.test.ts): 30 files, 542 tests passed.@objectstack/runtimelocal project, whole (four vitest shards): 387 files passed; 5843 tests passed, 19 skipped.typecheck(tsc --noEmitpluscheck:test-typecheck) exit 0.@objectstack/metadata-protocolwhole suite (three vitest shards): 236 files passed, 3 skipped; 20550 tests passed, 19 skipped.typecheckexit 0.Earlier round, at
addcca0cf5: the same set green, and@objectstack/cliboth tiers green (unit 290 files; integration 131 files; CI'sTest Core (2/6)slice,OS_TEST_SHARD=1/3, 141 files).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. At4f639e56b0, throughscripts/ablation-replace.mjs(WRAP mode; anchor 1 to 0, blob498517e78ato4bee71e85b):if (others.length === 0 && !at.unbound) continue;widened with a marker condition, so no row ever refuses;ablation-dist-preflightfound the marker indist/index.jsanddist/index.cjs. Predicted 2 red / 3 green; observed 2 failed / 3 passed. Cases 1 and 2 answered the card's defect:400 PACKAGE_DELETE_PARTIAL, 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, which is the same rows the pre-flight refuses.HEAD(498517e78a682bcb5a82432490f55bd39c91661c),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/objectstackat4f639e56b0derives 65 families (the same 65 as onaddcca0cf5). All 65 ran locally;--ranreconciles 65 derived, 65 run, 0 NOT-MEASURED. 63 exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, packages withoutdist/) and exited 0 once they were built.check-empty-changeset.mjs --base origin/mainexits 1 on the 23415 note, the deliberate correction the maintainer confirmed (6184266311); CI'sCheck Changesetis red for the same reason, as on every head of this PR. Every other family CI runs is read from this PR's check-runs, not re-run here: on4f639e56b0all five required contexts are green (Build Core,TypeScript Type Check,Lint & Repo Gates,Governed Surface Queue Guard,Test Corewith all six shards).Test Core (2/6)(CLI slice 1/3) failed once onaddcca0cf5(run38686154595) and did not reproduce: the slice and the whole CLI suite passed locally, and the shard is green on4f639e56b0.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 corrected in one sentence, the uninstall sentence of.changeset/23415-drop-storage-other-rows.md; the maintainer confirmed it on this PR (6184266311).Check Changesetstays red for it by design.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.publish-draftstoo);mainleft stale, not this PR's. The[#21276]comment above step 3 indeletePackagestill says the door answers that refusal "500, nothing changed". PR fix(objectql): an uninstall refused because another package extends one of its objects answers 409 RESOURCE_CONFLICT with the way out, not 500 INTERNAL_ERROR #23522 made it409. This PR does not edit that comment, because it lies outside this PR's change.Generated by Claude Code