Skip to content

fix(metadata-protocol)!: an uninstall that would drop a table another stored row still serves is refused 409 before anything is removed - #23504

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-23473-uninstall-shared-object-refusal
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-23473-uninstall-shared-object-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23473

Clause-②: no

What changes

deletePackage (packages/metadata-protocol/src/protocol.ts) now refuses the WHOLE uninstall 409 RESOURCE_CONFLICT before its first durable step (step 4, the sys_packages delete) when, without keepData: 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: the sys_packages row, the registry entry, every sys_metadata row, 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 409 only after step 4, collected it in failed[] and the door answered 400 PACKAGE_DELETE_PARTIAL over 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 at 1baf976cf9, quoted:

  1. the registry's uninstall refusal (another package extends an object this one owns, ADR-0029), asked through SchemaRegistry.assertPackageUninstallable — pure; thrown as is;

[#21276] The steps, in order. Nothing durable happens before step 4, so a refusal at any of steps 1–4 leaves everything as it was:

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)

  • Same reads as the per-item delete, no new store surface. For each active object row whose table the uninstall would drop (shouldDropStorage, the same guard: object type, active, not sys_), the pre-flight calls packagelessAddress(type, name, undefined, 'active'), the private address read deleteMetaItem itself takes for that row (its rowsAtAddress), with the type folded as deleteMetaItem folds it (canonicalMetaType, then PLURAL_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: true really skips every drop. The only table-drop call sites are deleteMetaItem's two paths into dropObjectStorage, both gated by shouldDropStorage on the request's dropStorage, and deletePackage sends dropStorage only when keepData !== true. So with keepData sharing decides nothing and the pre-flight reads nothing.
  • No measured flow needs shape C. deletePackage has two callers: the DELETE /api/v1/packages/:id door, and rollBackUnmanagedImport, 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-less Error that the door answered 500 INTERNAL_ERROR. Since PR #23522 (#23505, 989aa483fd) it answers 409 RESOURCE_CONFLICT with the way out, pinned in package-uninstall-store-refusal.integration.test.ts on main. So both up-front uninstall refusals now share one code. No code is minted.

The refusal, as measured (two shared objects)

Uninstall package 'com.probe.p' with keepData (?keepData=true; keepData: true in process), which keeps all its object tables, or first delete, without dropStorage, its row or the other rows of each object below (DELETE /api/v1/meta/object/NAME?package=ID). Nothing was removed: an uninstall drops an object's table only with the object's last row, and 2 objects of this package are also held by other rows: 'probe_ticket' (its row bound to package 'com.probe.q' remains); 'probe_case' (its row bound to package 'com.probe.q' remains).

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 commit 5e97584201 (main 8d05456cbe plus this PR). main was merged in as a merge commit, 192d2051da (no conflict; protocol.ts auto-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:

Error: Cannot layer object 'probe_ticket': the overlay is bound to package 'com.probe.q', but the object is owned by package 'com.probe.p'. An object has exactly one registry entry, ...

Cause: #23510 (222293f60a, ADR-0029 D9.9) landed on main after this PR's previous merge base f9b421716d (merge-base --is-ancestor exit 1, control leg f9b421716d~60 exit 0; it is in 8d05456cbe, 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.ts was not the cause and is unchanged by this round; the step-3 CONTROL in package-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 ObjectQL over better-sqlite3, the real protocol and HttpDispatcher.handlePackages (one-time readings, probe not kept):

producer result stored active rows
save door: P, then Q Q refused 422 OBJECT_OVERLAY_PACKAGE_MISMATCH P
save door: P, then a save naming no package addressed to P's row P
save door: a save naming no package first, then P, then Q all three succeed (the registry owner is the sys_metadata sentinel, which D9.9 leaves out) P, Q, package-less
Q saves a DRAFT before P's row exists; P publishes; Q promotes (publishMetaItem, package Q) promotion succeeds; the registry keeps P P, Q
the same, promoted by POST /api/v1/packages/com.probe.q/publish-drafts at the door handler 200, outcome: published, failed: []; the registry keeps P P, Q
duplicatePackage P to Q (targetNamespace given) the copy is renamed qq_probe_ticket P
boot (loadMetaFromDb) over a stored Q row beside P's counts the row in errors, leaves it stored (code reading) unchanged

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 keepData answered 409 RESOURCE_CONFLICT and removed nothing. The pre-flight guards a live path.

The fixture fix. twoPackageObject now 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 by hashSpec), 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 in packages/runtime on queue commit 75e7900e67 (main 23e689dc7d plus addcca0cf5). main was merged in as a merge commit, ce7a65df2c (no conflict; protocol.ts auto-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:

Error: Unknown field 'organization_id' on object 'sys_metadata'
 at seedStoredRow (src/package-uninstall-shared-object.integration.test.ts:156)

Cause: #23469 (dd29bbe60d, ADR-0131 D6/D7) dropped the organization column from the sys_metadata family, and with it organization_id: null from the sibling pin's seed (meta-delete-drop-storage-other-rows.test.ts). The fix, 4f639e56b0: this pin's seed follows main'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'), deleteMetaItem with (singularTypeForRepo, name, member, targetState). #23469 changed only that function's body: its where was { type, name, state, organization_id: null, ...member } read by engine.find; it is now { type, name, state, ...member } read through findEnvironmentRows, 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: real ObjectQL over a real better-sqlite3 SqlDriver (real tables), the real PackageServicePlugin (sys_packages), the real protocol and the real HttpDispatcher.handlePackages. Five cases:

  1. P and Q hold an object, uninstall P without keepData: 409 RESOURCE_CONFLICT (status and code asserted), message names the object, Q and keepData; the sys_packages row, GET /packages/P 200, both rows, the table, the record and the registry entry all intact.
  2. The same with the package-less row as the other holder.
  3. ?keepData=true: 200, failed: [], P gone, Q's row, the table and the record stay, the registry serves Q's definition.
  4. P's own row deleted first (no dropStorage): the uninstall answers 200, failed: [], keeps the shared table and drops the table P alone held.
  5. CONTROL: an object one package holds — 200, row gone, table dropped.

Readings at 4f639e56b0 (--maxWorkers=2, each under the shared verify lock, verdict from its VERDICT command-exit line):

  • red before, at the merge commit ce7a65df2c: the pin 4 failed / 1 passed (the setup error above; the CONTROL passed).
  • the pin: 5/5 passed.
  • @objectstack/runtime uninstall sweep (every src/package-uninstall-*.integration.test.ts, every src/domains/packages-*.test.ts): 30 files, 542 tests passed.
  • @objectstack/runtime local project, whole (four vitest shards): 387 files passed; 5843 tests passed, 19 skipped. typecheck (tsc --noEmit plus check:test-typecheck) exit 0.
  • @objectstack/metadata-protocol whole suite (three vitest shards): 236 files passed, 3 skipped; 20550 tests passed, 19 skipped. typecheck exit 0.

Earlier round, at addcca0cf5: the same set green, and @objectstack/cli both tiers green (unit 290 files; integration 131 files; CI's Test Core (2/6) slice, OS_TEST_SHARD=1/3, 141 files).

Ablation (one-shot, not kept)

@objectstack/metadata-protocol reaches the pin through its dist/, so each leg rebuilt it and proved the marker's presence or absence there. At 4f639e56b0, through scripts/ablation-replace.mjs (WRAP mode; anchor 1 to 0, blob 498517e78a to 4bee71e85b):

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4f639e56b0 derives 65 families (the same 65 as on addcca0cf5). All 65 ran locally; --ran reconciles 65 derived, 65 run, 0 NOT-MEASURED. 63 exit 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, packages without dist/) and exited 0 once they were built. check-empty-changeset.mjs --base origin/main exits 1 on the 23415 note, the deliberate correction the maintainer confirmed (6184266311); CI's Check Changeset is 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: on 4f639e56b0 all five required contexts are green (Build Core, TypeScript Type Check, Lint & Repo Gates, Governed Surface Queue Guard, Test Core with all six shards).

Test Core (2/6) (CLI slice 1/3) failed once on addcca0cf5 (run 38686154595) and did not reproduce: the slice and the whole CLI suite passed locally, and the shard is green on 4f639e56b0.

Acceptance notes


Generated by Claude Code

… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

5 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 deletePackage))
  • content/docs/api/error-handling-server.mdx (via RESOURCE_CONFLICT (literal, a string literal in deletePackage))
  • 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 deletePackage))
  • content/docs/permissions/permission-sets.mdx (via RESOURCE_CONFLICT (literal, a string literal in deletePackage))

⛔ 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), deletePackage (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/releases/v17/17-6.mdx (via RESOURCE_CONFLICT (literal, a string literal in deletePackage))
  • content/docs/releases/v17/17-7.mdx (via RESOURCE_CONFLICT (literal, a string literal in deletePackage))

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 23e689dc7d1de961a168997685295ddf009d5c2c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 367c6c50d8455a7ea6c2178b431a7ba8da570de1 — the merge of head 4f639e56b06075f522098437d04b08dc06075c11 into base 23e689dc7d1de961a168997685295ddf009d5c2c, 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 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

⚠️ 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 23e689dc7d1de961a168997685295ddf009d5c2c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset is red by design on this head: a deliberate correction of a pending release note · domain:spec seat 4 · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-14T15:19Z

The gate and why it is red. On 7d36e8d205, check-empty-changeset.mjs refuses because this PR changes .changeset/23415-drop-storage-other-rows.md, a changeset it did not add. It is the DELIBERATE CORRECTION class, not a collision.

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.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7d36e8d205d783c9171b952d66dc06b16fa15ae0
Local-runs: none

Inputs read: card #23473 (body; lane decision 6168058499; claim 6168221878; dev report 6170924033; addendum 6172200644); PR #23504 (body, file list, the diff against main re-fetched at this head, byte-equal to the three-dot net diff ignoring index lines: 4 files, +402/-1); .changeset/23415-drop-storage-other-rows.md on origin/main (at 1418de4) versus this head; .github/workflows/pr-automation.yml and scripts/check-empty-changeset.mjs (identical on origin/main and the head); the ruleset for main; the published dist-tags of @objectstack/metadata-protocol; and the head's 40 check-runs, read last. Not read: the Collected comment on the card, the PR comment that opens with the Check Changeset sentence, any seat self-check comment. The PR is a draft from the base repository, 402/1 lines, no governed path, model-free commit trailers.

① Derived judgments

  1. The narrowing itself: right. deletePackage (packages/metadata-protocol/src/protocol.ts, between step 3 and step 4) now throws RESOURCE_CONFLICT / 409 when keepData is not true and an active, non-sys_ object row of the package has, at its address, another package's active row or the package-less active row. The door (packages/runtime/src/domains/packages.ts, the catch around protocol.deletePackage) answers the thrown status and code as it already did for step 3, before it touches the registry or the disable record; so DELETE /api/v1/packages/:id answers 409 RESOURCE_CONFLICT with nothing removed, where main answers 400 PACKAGE_DELETE_PARTIAL over a package whose sys_packages row is already gone. Shape B of the lane decision; DELETE /api/v1/packages/:id answers success when the sys_packages delete fails, so the package is gone from the live registry and comes back after the next restart #21276's guarantee ("nothing durable before step 4") holds, the pre-flight only reads, and the docblock's numbering is unchanged.
  2. The pre-flight asks exactly the per-item delete's question: right, on every element. (a) Rows: the step-2 read (sys_metadata where package_id is the package, all states); drafts and rows without a string name are skipped, the same split the per-item loop makes for state. (b) Type fold: canonicalMetaType then PLURAL_TO_SINGULAR; deleteMetaItem folds through canonicalizeMetaRequestType (the same canonicalMetaType, plus a refusal for an unrecognised spelling of a declared type) and then singularTypeForRepo, the same PLURAL_TO_SINGULAR fold. A spelling that refusal would catch never folds to object, so it drops no table on either side. (c) Guard: shouldDropStorage(type, name, true, 'active') is the per-item call with the dropStorage: true and state: 'active' the loop sends for an active row when keepData !== true (object only, active only, never sys_). (d) Address: packagelessAddress(type, name, undefined, 'active'); deleteMetaItem passes member = resolveBundleMemberSelector(type, undefined), which returns undefined, and overlayMemberWhere('object', undefined) is {}, so both read where: { type, name, state: 'active', organization_id: null }. (e) Verdict: the pre-flight refuses when the package is among candidates and another candidate or unbound remains; deleteMetaItem refuses when the package's row exists, dropsStorage holds, and remainingPackages is non-empty or unbound with a non-null packageId, and packageId is always the package here. The same predicate; when the package holds no org-null active row at the address the per-item delete answers "nothing was deleted" without refusing, and the pre-flight continues. (f) The per-item object delete reaches that refusal site: object carries allowRuntimeCreate: true (packages/spec/src/kernel/metadata-plugin.zod.ts:786), so useRepoPath is true and the legacy raw-engine drop, which has no remaining-rows check, is not on the object path. (g) The asked set is keyed by name only, but is written only after the object guard, so it collapses nothing but a legacy organization-scoped duplicate, which the loop removes through removeLegacyOrganizationRowOnUninstall with no table drop and which packagelessAddress never counts.
  3. keepData: true skips it: right. The whole block sits under request.keepData !== true, the predicate that also decides dropStorage for the loop; with keepData nothing is read, and since no per-item dropStorage is sent, nothing drops or refuses later either. The door sends keepData: true for ?keepData=true or ?keepData=1, so the changeset's prescription is the door's spelling.
  4. A failed read is the step-2 read failure: right. packagelessAddress tolerates only an unprovisioned store and rethrows every other failure; the pre-flight wraps any throw in metadataReadFailureError, step 2's own classifier (the non-quoting 503, or a marked application refusal). An unprovisioned store cannot reach the pre-flight: step 2 deliberately does not tolerate it and throws first.
  5. Nothing durable before it: right. Steps 1 to 3 are pure or reads; the pre-flight reads; step 4's packageStore.delete is still the first write. Pinned: the sys_packages row, GET 200, both rows, the table, the record and the registry entry are intact after the refusal.
  6. Reused 409 RESOURCE_CONFLICT, no code minted: right. It is the code dropStorageRemainingRowsRefusal answers for the same rows on the per-item path (environment state, not the request: the same uninstall is accepted once the other rows or the package's own row are gone), so the metadata door and the package door speak one code for one condition. Step 3's extender refusal is a code-less Error from SchemaRegistry.assertPackageUninstallable (packages/objectql/src/registry.ts:4256), which the door answers 500 INTERNAL_ERROR (pinned at package-uninstall-store-refusal.integration.test.ts:265); copying that family would hand an operator a server-fault status with a withheld message for a condition the caller can act on. Right not to copy it; step 3's own code is packages: an uninstall refused because another package extends one of its objects (ADR-0029) answers 500 INTERNAL_ERROR, a code-less server error, instead of a located client refusal #23505, not this PR.
  7. The refusal text: right. Prescription first ("Uninstall package 'P' with keepData (?keepData=true; keepData: true in process), which keeps all its object tables, or first delete, without dropStorage, its row or the other rows of each object below (DELETE /api/v1/meta/object/NAME?package=ID)"), then the fact ("Nothing was removed: an uninstall drops an object's table only with the object's last row, and N objects of this package are also held by other rows:"), then the list, each object with remainingRowsPhrase naming its other holders (up to five packages, and the package-less row), the list cut at five objects with "; and N more". Two ways out, the second answering the card body's concern with shape B (the package's own row deleted first keeps the shared table and still lets the uninstall drop the private tables; pinned as case 4). Singular and plural forms both spelled. No tracker number in the runtime string. The ?keepData=true spelling matches the door.
  8. The two callers: neither worse off. The door: the same catch, the same "nothing was touched on this path" (registry and disable record untouched until the verb answers), now 409 with an intact package instead of 400 over a half-removed one. rollBackUnmanagedImport: it uninstalls the new base without keepData; the import refused any name an organization_id: null stored row of any binding already holds (unmanagedImportCollisions) before writing, so the pre-flight can fire there only on a row written between that read and the rollback. In that race main left the base half-uninstalled (sys_packages gone, failedCount above 0); the head catches the throw, reports "the uninstall failed: ..." in the import's error and the operator log, and leaves the base whole and uninstallable. Not worse.
  9. The real-stack pin: right, and it pins what the changeset says. packages/runtime/src/package-uninstall-shared-object.integration.test.ts: real ObjectQL over better-sqlite3 on disk, the real PackageServicePlugin, the real protocol, the real HttpDispatcher.handlePackages; the store is read through knex, not the door's word. Case 1 asserts status and code, the message's object, holder and keepData, and the full untouched state including the running registry; case 2 seeds the package-less row at rest (the only honest way, since the save door resolves a package-less save to the package's row); case 3 pins the keepData way out, failed: [], registry served by Q; case 4 pins the own-row-first way out with the private table dropped and the shared one kept; case 5 is the single-package control. Not pinned, and not required by the card: the cut at five objects and the plural wording (the PR body's measured two-object sample shows the plural), and a draft row of the shared object by another package on the uninstall path (the per-item note already fixes drafts as not counting; the pre-flight reads 'active' only).
  10. Public surface: unchanged, right. No export, type, schema or error code is added, removed or renamed; DeletePackageRequest is as it was. Clause-②: no is truthful.
  11. Observed, not a defect of this PR: the loop's other per-item refusals (lock, packaged base, origin gate) still arrive after step 4 and ride failed[], as on main; PR feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469 owns that loop. Cost: one sys_metadata read per active object row of the package without keepData, none with it.

② Semver level

  • .changeset/23473-uninstall-shared-object-refusal.md: '@objectstack/metadata-protocol': minor, a fix(metadata-protocol)!: summary, Clause-②: no (narrowing), the BREAKING banner "shipped as minor under the launch-window convention", and one adr-0087 marker comment reading not-required (no-migration-prescription). Consistent: the (narrowing) arm is the breaking declaration the gates read (AGENTS.md Post-Task step 3; scripts/pm/clause2-line.mjs), major is refused in the window and breaking-ness rides the banner plus the disposition (pr-automation.yml). The disposition is honest: nothing authorable, exported or stored is removed, renamed or rewritten; the remedy lives in the request (keepData, or delete a row first), and the Remedy section is prose, not a code rewrite, so the category's prescription check holds, confirmed by CI: step 13 of the Check Changeset job ("Require an ADR-0087 disposition on a declared-breaking changeset") is success on this head. Only metadata-protocol is bumped; runtime gains a test file and no published code, so no bump is owed there.
  • Clause-②: no on the claim and the PR body: right, the diff widens no accept set and adds no public key; the arm sits in the changeset, where the gate reads it.
  • FROM: the published dist-tags are latest 17.7.0 and next 18.0.0-next.1; .changeset/23415-drop-storage-other-rows.md is still pending on origin/main (one of 27 pending notes) and no CHANGELOG section carries its text, so PR 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's per-item 409 and the failed[] / 400 PACKAGE_DELETE_PARTIAL shape have never shipped. The last release's uninstall of P, for an object P and Q both hold, removed P's row and dropped the table (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's FROM: "The same held ... for an uninstall"), leaving Q's definition served over no table, next data read 500. The 23473 FROM says exactly that and names no PACKAGE_DELETE_PARTIAL. Right for a reader upgrading from 17.7.0 or 18.0.0-next.1. The summary line's "instead of dropping the object's table while the other row still serves it" matches.
  • The 23415 edit, judged sentence by sentence: the diff to that file is one line (+1/-1); frontmatter, summary, Clause-②, the adr-0087 marker, the banner, FROM (including "and for an uninstall (deletePackage, ...)"), Unchanged and Remedy are byte-identical to origin/main. Removed: "An uninstall reports the refusal for that object in its failed[] list, as it reports any per-item refusal, and leaves the object's row and table in place." True only between PR 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 and this PR, false once this lands. Written: "An uninstall of a package holding such an object, without keepData, is refused whole, 409 RESOURCE_CONFLICT, before anything is removed; its message names the object, the rows that keep it, and the ways out." True after this PR (① 1, 5, 7). Both notes are pending for the same release, so the two TO sentences agree when they ship. The sentence stands without a cross-reference; see ③.
  • Check Changeset red: job 115934636963 on this head, step 12 ("Reject an empty-frontmatter changeset added by this PR", the scripts/check-empty-changeset.mjs run) is the only failure; steps 11 (Require a changeset), 13 (ADR-0087 disposition) and 15 (major guard) are success. The script's foreign-changeset rule refuses every M row of a .changeset/*.md present at the merge base, and this PR's .changeset rows are one A (23473) and one M (23415): the DELIBERATE CORRECTION class, whose remedy in the script and in the workflow's route 0 is do not restore it, do not apply skip-changeset, confirm on the PR, leave the check red. That is the only reason it is red. It is not a required context: the main ruleset (12119582) requires TypeScript Type Check, Test Core, Build Core, Lint & Repo Gates and Governed Surface Queue Guard, all success on this head; pr-automation.yml runs on pull_request only, never merge_group. The three conditions for carrying a designed red into the queue hold: the source says so, the job does not run in the queue, and the PR carries a comment naming the gate and the reason; for a PR that also changes code, this same-head record is the confirmation, and it names the note and the sentence above.
  • Check-runs on this head: 40; 33 success, 6 skipped (the full-battery set: Build Docs, Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 1 failure (Check Changeset, above), 0 in progress.

③ Boundary flags

Dev report 6170924033, deviations:

  1. Two print-only scratch tests placed and deleted, never committed. Answered: the head's file list is four files, no residue.
  2. One suite run outlived the 600 s foreground cap and was waited on by its recorded PID. Answered: a harness mechanic; the verdict is CI's, and Test Core 1/6 to 6/6 and the aggregate are success on this head.
  3. One locked call joined two sweeps with ;. Answered the same way; the post-merge rerun used &&, and CI is the reading of record.
  4. ablation-dist-preflight once refused on an untracked scratch file during a plain rebuild. Answered: read as no measurement, no gate result was claimed from it; the ablation itself is one-shot evidence, not a gate.

Dev report, out-of-scope findings:

  1. The 23415 sentence. Edited in the addendum round and judged true in ②; the red it causes is the designed class. Closed here.
  2. After a keepData uninstall or an own-row delete, the running registry serves Q's definition and data reads answer. The expected outcome of Q's row keeping the object defined; measured, pinned (cases 3 and 4), no action.

Addendum 6172200644, deviations:

  1. The tracker number left out of the 23415 sentence (the suggested text said "see the 23473 changeset"). Accepted: the sentence states the new behaviour in full and needs no pointer; the 23473 note ships in the same release.
  2. The 23473 summary line changed to name the released behaviour. Accepted: it follows the FROM correction and matches it.
  3. "Docs: no edit". Checked on the head: content/docs/kernel/contracts/metadata-service.mdx:501 ("every row bound to it ... ?keepData=true keeps the object tables"), content/docs/api/metadata-api.mdx:135 ("Uninstall a package.") and the generated package-api.mdx keepData row say nothing about what an uninstall does to a shared object's table and list no status for it; nothing is falsified. The docs-drift bot's rows come from the RESOURCE_CONFLICT literal and the class symbol, not from a restated rule. Accepted.
  4. The merge of origin/main without a source re-test. Accepted under AGENTS.md Multi-agent discipline item 10 (none of the 10 commits touches protocol.ts or the packages domain), and CI ran the full PR set on the merged tree.

Nothing escalated. open_questions is empty on both reports.

Implemented-by: claude/issue-23473-uninstall-shared-object-refusal
Reviewed-by: session_0121ooBYRmS8ziFXm9ai5YHm

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6168221878 on #23473 · reviewed head 7d36e8d205d783c9171b952d66dc06b16fa15ae0 · 2026-10-14T16:03Z

Verdict: ACCEPT, landing held on the maintainer's confirmation (clause d). Not governed. 4 files, +402 / −1. The CONTRACT_REVIEW_TIER review on this head passed (6172644842).

  1. Shape.
    • Draft. First line Fixes #23473. Clause-②: no at line start.
    • The changeset .changeset/23473-uninstall-shared-object-refusal.md: metadata-protocol minor, BREAKING, Clause-②: no (narrowing), not-required (no-migration-prescription).
    • The one-sentence correction of the pending note .changeset/23415-drop-storage-other-rows.md is the DELIBERATE CORRECTION described on the PR (6171760091).
  2. Premises against the dispatch (lane decision B, 6168058499).
    • The pre-flight asks exactly the per-item delete's question: the same rows, type fold, shouldDropStorage guard, packagelessAddress read and verdict predicate. The review checked each element.
    • It is skipped under keepData: true.
    • A failed read is the step-2 read failure.
    • Nothing durable happens before step 4.
    • No measured flow needs shape C.
  3. Gates and evidence.
    • Head check-runs: 33 success, 6 skipped (the full-battery set), 1 failure, 0 in progress.
    • The failure is Check Changeset, step 12 (check-empty-changeset.mjs) alone, on the DELIBERATE CORRECTION class. That red is by design:
      • the source says to leave it red (pr-automation.yml route 0);
      • the job runs on pull_request only, never on merge_group;
      • the PR names the gate and the cause (6171760091).
    • All five required contexts are success.
    • The dev's local reconciliation: 65 of 65 at fee128e3da, and 64 of 65 at this head (the one exception is the designed red).
    • The real-stack pin passed 5/5. The ablation went red on cases 1–2 and was restored.
  4. Accepted deviations.
    • The file surface was extended by the seat for the patch round, to the 23415 note.
    • The 23415 sentence states the new behaviour without a tracker number, because shipped text carries none.
    • The 23473 summary line now names the released behaviour.
    • No docs edit: the review re-checked the pages the docs-drift check named.
  5. Out-of-scope dispositions.
  6. Landing clauses.

This was referenced Oct 14, 2026
…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test Core (2/6) on addcca0cf5 · domain:spec seat 4 · session_0121ooBYRmS8ziFXm9ai5YHm · 2026-10-15T07:50Z

What is red.

  • Test Core (2/6), check-run 116261623517 in run 38686154595, and with it the Test Core rollup.
  • The annotation reads "command (…/packages/cli) pnpm run test exited (1)".
  • The job log cannot be read from a seat or dev container (the log host is refused by the egress proxy), so the failing test is not named.

Why the seat reads it as not this PR's.

  • Wrong package. This head changes packages/metadata-protocol and one packages/runtime test, and no CLI test reads the runtime pin (dev report 6187145964, git grep).
  • Not reproduced. The dev ran CI's exact slice locally (OS_TEST_SHARD=1/3, both tiers): 141 files and 1,956 tests passed. The whole CLI suite passed as well.
  • The same shard has been green on nearby trees. It was green on the previous head, 7d36e8d205. It was also green on queue commit 5e97584201, whose tree differs from this head only in that runtime pin. On main it is green on each of the last five commits.

What follows. The seat re-runs the failed jobs of run 38686154595 once, and only once. A second failure would be read as real and investigated before any enqueue. There is no ported fix: the failure did not reproduce, so nothing exists to port.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: addcca0cf5fc63ba01412d211c8c93dc8dbf8941
Local-runs: none

Second record on this PR; the first (6172644842, PASS) named head 7d36e8d205. This head is 7d36e8d205 plus a merge of main (192d2051da, merge base now 8d05456cbe) and one fixture commit (addcca0cf5, the pin only: 34/16 lines). Judged on its own.

Inputs read: card #23473 (body; every comment, among them the lane decision 6168058499, the claims 6168221878 / 6184449232 / 6184860304, the dev reports 6170924033, 6172200644 and 6187145964, the release 6183361784, the ruling 6184273013); PR #23504 (the body as rewritten at 2026-10-15T07:50Z, the file list, the net diff git diff 8d05456cbe...addcca0cf5: 4 files, +420/-1, no governed path; and its comments, the first record and the confirmation 6184266311 included); the head's source where the diff lands (packages/metadata-protocol/src/protocol.ts, packages/runtime/src/domains/packages.ts, packages/objectql/src/registry.ts, the overlay-index helpers, sys-metadata-repository.ts, ADR-0029 D9.9, commit 222293f60a); the changeset gate's source for the rule it applies; and, last, the 46 check-runs on this head with the two non-green jobs' step lists. Not read: the dispatch order, any seat pointer line, the PR's Landing readings comment, the main ruleset (the required-context list is taken from 6172644842 and 6183361784, not re-read).

Between 7d36e8d205 and this head, this PR's own delta to protocol.ts is unchanged line for line (the diff of each head against its own merge base, hunk headers aside, is identical), and both changesets are byte-identical. Only the pin moved.

① Derived judgments

  1. The narrowing: right. deletePackage throws RESOURCE_CONFLICT / 409 between step 3 and step 4 when keepData is not true and an active, non-sys_ object row of the package has, at its address, another package's active row or the package-less active row (protocol.ts:26210-26245). It sits after assertPackageUninstallable (:26178) and before packageStore.delete (:26283), the first write; everything above only reads. The door's catch answers the thrown status and code before it touches the registry or the disable record (packages/runtime/src/domains/packages.ts:2278-2286, "Nothing was touched on this path"). So DELETE /api/v1/packages/:id answers 409 RESOURCE_CONFLICT with the package intact, where main answers 400 PACKAGE_DELETE_PARTIAL over a package whose sys_packages row is gone. Shape B of the lane decision; DELETE /api/v1/packages/:id answers success when the sys_packages delete fails, so the package is gone from the live registry and comes back after the next restart #21276's guarantee holds and the docblock's numbering is unchanged (:26089-26091). Pinned at the real door: status and code (package-uninstall-shared-object.integration.test.ts:255, :274), and the untouched state (the sys_packages row, GET 200, both rows, the table, the record, the running registry's entry: :235-243).

  2. Which rows it refuses: exactly the rows the per-item delete refuses, element by element.

    • Rows: the step-2 read, every sys_metadata row bound to the package in every state (:26154); the pre-flight skips drafts and rows without a string name (:26214), the split the loop makes on state (:26302).
    • Type fold: canonicalMetaType then PLURAL_TO_SINGULAR (:26215-26216); deleteMetaItem folds through canonicalizeMetaRequestType (the same canonicalMetaType, plus a refusal for an unrecognised spelling, which never folds to object) and singularTypeForRepo = PLURAL_TO_SINGULAR[type] ?? type (:29131, :29244).
    • Guard: shouldDropStorage(type, name, true, 'active') (:26217) is the per-item call (:29402) with the dropStorage: true the loop sends when keepData !== true (:26293, :26318) and the 'active' state of an active row; object only, active only, never sys_ (:21000-21007).
    • Address: packagelessAddress(type, name, undefined, 'active') (:26221) against packagelessAddress(singularTypeForRepo, name, member, targetState) (:29321-29323), where member = resolveBundleMemberSelector(type, undefined) is undefined (:2454-2455) and overlayMemberWhere adds no predicate for an undefined member of an undiscriminated type (migrations/overlay-index.ts:506-511, :531-534). Both read type, name, state: 'active', organization_id: null (:19043-19046).
    • Verdict: the pre-flight refuses when the package is among candidates and another candidate or unbound remains (:26225-26227). The per-item delete refuses when the package's row exists (current, :29346-29356 otherwise answers "nothing was deleted" without refusing), dropsStorage holds, and remainingPackages (the candidates minus the package) is non-empty or unbound with a non-null package (:29394-29407); packageId is always the package on the uninstall path (:26316). The same predicate. The dev's ablation on this head (6187145964: the :26227 condition widened so nothing refuses) sent cases 1 and 2 through to the per-item RESOURCE_CONFLICT in failed[] on the same rows, which is the measured form of this equality.
    • Legacy organization-scoped rows: asked at the organization-null address like any other; the package is not among the candidates there, so they never refuse, and the loop removes them without a table drop (:26305). Consistent.
    • The two refusals also speak one code on this head: step 3's extender refusal is PackageUninstallExtendedError, RESOURCE_CONFLICT / 409 since 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 (packages/objectql/src/registry.ts:4359, :4372), and the per-item refusal is dropStorageRemainingRowsRefusal, RESOURCE_CONFLICT / 409 (:21040-21058). No code minted; the per-item code is the right one, since the condition is environment state and the same request is accepted once the other rows or the package's own row are gone.
  3. The keepData path: right. The whole block is under request.keepData !== true (:26210); with keepData nothing is read. The same predicate decides the loop's dropStorage (:26293, :26318), and the only two table-drop call sites (dropObjectStorage at :29464 and :29653) are inside deleteMetaItem behind shouldDropStorage on that flag. So with keepData nothing drops and nothing refuses later either. The door sends keepData: true for ?keepData=true or ?keepData=1 (packages.ts:2277), the spelling the changeset and the refusal prescribe. Pinned: case 3 (:278-291): 200, failed: [], P gone, Q's row, the table and the record kept, the running registry serving Q's definition.

  4. A failed read is the step-2 read failure: right. packagelessAddress tolerates only an unprovisioned store and rethrows the rest (:19047-19051); the pre-flight wraps any throw in metadataReadFailureError (:26223), step 2's own classifier (:26158; :4110-4116). An unprovisioned store never reaches the pre-flight: step 2 refuses to tolerate it (:26143-26153).

  5. The refusal text: right. Prescription first, since a 4xx message is truncated at the boundary: keepData in both spellings, or delete without dropStorage the package's own row or the other rows of each object listed (DELETE /api/v1/meta/object/NAME?package=ID); then the fact ("Nothing was removed", a table goes only with the object's last row) and the list, each object with remainingRowsPhrase naming its other holders (up to five packages and the package-less row, :1203-1215), cut at five objects with "; and N more" (:26231), singular and plural spelled (:26237-26240). No tracker number in the runtime string. The second way out answers the card body's concern with shape B: deleting the package's own row of a shared object first keeps that table for the other rows and still lets the uninstall drop the tables the package alone held, pinned as case 4 (:293-312, the private table dropped at :311). The package-less row cannot be deleted first through the metadata door while the package's row exists (a delete naming no package addresses the package's row), so for that holder the working route is the package's own row, which the message names. Pinned: the message carries the object, Q and keepData (:258-260). Not pinned and not required: the five-object cut and the plural wording.

  6. The two callers: neither worse off. The door (packages.ts:2278), above. rollBackUnmanagedImport (:27404) uninstalls the new base without keepData and catches a throw as residue "the uninstall failed: ..." (:27404-27410), logged at error and carried on the import's error; on main the same race left the base half-uninstalled with failedCount above 0, which rides the same residue path. The import refuses a name another stored row already holds before writing, so the pre-flight can fire there only on a row written between that read and the rollback.

  7. Nothing durable before the refusal: right. Reads only (engine.find through packagelessAddress); step 4's packageStore.delete is still the first write (:26283). The registry withdrawal (:26382) and the cleanups stay after the loop.

  8. The re-seeded fixture tests a state that really arises: yes, and the seeded shape is the right one.

  9. Public surface: unchanged, right. No export, type, schema, route or error code is added, removed or renamed; DeletePackageRequest is as it was; the pin is a test file in packages/runtime/src/, outside that package's published files (packages/runtime/package.json:93-97). Clause-②: no is truthful.

  10. The two changesets, FROM and TO.

  • 23473 FROM (.changeset/23473-uninstall-shared-object-refusal.md:15), against the released behaviour: .changeset/23415-drop-storage-other-rows.md is still pending on origin/main (one of 87 pending notes) and no CHANGELOG.md carries its text, so the per-item 409 and the failed[] / 400 PACKAGE_DELETE_PARTIAL intermediate have never shipped. The last release's uninstall of P, for an object P and Q both hold, deleted P's row with dropStorage through the per-item delete and dropped the table (the 23415 note's own FROM, :15, "the same held ... for an uninstall"), leaving the object defined by Q's row with its records gone, next data read 500. The FROM says that and names no PACKAGE_DELETE_PARTIAL. Right for a reader upgrading from the last release; the summary line (:5) matches it.
  • 23473 TO (:17): true by 1, 5 and 7. Unchanged (:19): true by 3 and the CONTROL. Remedy (:21): the door's ?keepData=true spelling (packages.ts:2277), and the own-row-first route pinned as case 4. The adr-0087 marker (:9), not-required (no-migration-prescription): honest, nothing authorable, exported or stored is removed, renamed or rewritten, and the remedy lives in the request.
  • 23415 TO, the maintainer-confirmed sentence (:17, confirmed at 6184266311 / 6184273013; not re-asked): "An uninstall of a package holding such an object, without keepData, is refused whole, 409 RESOURCE_CONFLICT, before anything is removed; its message names the object, the rows that keep it, and the ways out." True against this head: "such an object" is one another package's row or the package-less row also holds; the uninstall without keepData sends dropStorage for every active object row, and the pre-flight refuses whole, 409, before step 4, naming each object, its other holders and both ways out (1, 2, 5). The removed sentence ("reports the refusal for that object in its failed[] list ... and leaves the object's row and table in place") was true only on main between PR 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 and this PR and would ship false in the same release. The rest of the 23415 note is byte-identical to origin/main; its FROM and its "Only active rows count" stay true (the pre-flight reads 'active' only and skips drafts). Both notes are pending for one release and agree.
  1. Observed, not a defect of this PR, for a later carrier: the [#21276] comment above step 3 (:26170) still 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 it 409 and left the comment. Outside this PR's hunks; the PR body names it. A one-line fix on the next edit of deletePackage (PR feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469 touches the file). Also, the loop's other per-item refusals (lock, packaged base, origin gate) still arrive after step 4 and ride failed[], as on main; PR feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469 owns that loop.

② Semver level

  • .changeset/23473-uninstall-shared-object-refusal.md: '@objectstack/metadata-protocol': minor (:2), a fix(metadata-protocol)!: summary (:5), Clause-②: no (narrowing) (:7), one adr-0087 marker (:9), the BREAKING banner "shipped as minor under the launch-window convention" (:11). Consistent with the diff: (narrowing) is the breaking declaration (AGENTS.md Post-Task step 3), major is refused in the window (scripts/check-changeset-no-major.mjs:58), and the breaking note carries its FROM, TO and remedy and its disposition. Only metadata-protocol is bumped; runtime gains a test file and publishes no change (9). The Clause-②: no line on the PR body (line 6), on the claims and on the changeset (with the arm, where the gate reads it) all agree with ①.9: no accept set widens, no public key is added.
  • The 23415 edit is one sentence of a pending note, no bump change, judged in ①.10.
  • Check Changeset is red on this head by design, twice (jobs 116261343575 at 05:19Z and 116310305727 at 07:50Z, the second pr-automation run after the body rewrite); in each the only failing step is step 12, the scripts/check-empty-changeset.mjs run. That script's foreign-changeset rule refuses every M row of a .changeset/*.md present at the merge base (scripts/check-empty-changeset.mjs:11-13, :119-127, :163), the DELIBERATE CORRECTION class, whose remedy is: do not restore, do not apply skip-changeset, confirm on the PR. The confirmation is on the PR (6184266311, the maintainer's 「确认」 carried with provenance under ruling D on [finding] the skip-changeset label suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375). Not a required context and not run on merge_group (per 6172644842 and 6183361784, not re-read here).

③ Boundary flags

Dev report 6187145964 (this round):

Dev report 6170924033 and addendum 6172200644 (judged again on this head, since their flags carry):

  • Two scratch tests placed and deleted; one suite run waited on by PID; one ;-joined sweep; one refused ablation-dist-preflight on a scratch file: harness mechanics, no residue in the four-file list, no gate verdict claimed from them. Accepted.
  • The 23415 sentence (out-of-scope 1): edited, confirmed, judged true in ①.10. Closed.
  • After a keepData uninstall or an own-row delete the registry serves Q's definition (out-of-scope 2): the expected outcome of Q's row keeping the object defined; pinned (:246-247, cases 3 and 4). No action.
  • The tracker number left out of the 23415 sentence; the 23473 summary line renamed to the released behaviour; no docs edit; the earlier merge of main without a source re-test: accepted as the first record accepted them; nothing on this head changes those readings.
  • needs_from_seat, the written confirmation: delivered, 6184266311.

Check-runs on addcca0cf5fc63ba01412d211c8c93dc8dbf8941, read last at 2026-10-15T07:57:25Z: 46; 35 success, 8 skipped (Build Docs, Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance, and the second pr-automation run's Auto Label and Check PR Size), 2 failure (both Check Changeset, by design, above), 1 in progress: Test Core (2/6), check-run 116310608119, run 38686154595 attempt 2, started 07:50:59Z, step 12 "Run this shard's tests" running — the seat's single re-run (6187232341). No Test Core rollup is listed yet. Build Core, TypeScript Type Check, Lint & Repo Gates, Governed Surface Queue Guard and Test Core 1/6, 3/6, 4/6, 5/6, 6/6 are success; 6/6 is the runtime and metadata-protocol shard that failed in the queue on the old fixture. This record does not stand in for that shard's conclusion: landing waits on it, and a second red is read as real and investigated, not re-run, as 6187232341 says.

Nothing escalated.

Implemented-by: claude/issue-23473-uninstall-shared-object-refusal
Reviewed-by: session_0121ooBYRmS8ziFXm9ai5YHm

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917, temporary re-seat 6184406254) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6184449232 (amended 6184860304) on #23473 · reviewed head addcca0cf5fc63ba01412d211c8c93dc8dbf8941 · 2026-10-15T08:06Z

Verdict: ACCEPT on this head. It supersedes the seat record 6172664218, which named 7d36e8d205. Not governed (check-governed-merges --pr 23504). 4 files, +420 / −1.

  1. Review owed, and on record. The narrowing owes one CONTRACT_REVIEW_TIER review before the queue. That review PASSed on this head: 6187465541. The earlier PASS 6172644842 named the old head.
  2. What moved since the last record, as the seat measured it.
    • The PR's own delta to protocol.ts is identical to 7d36e8d205's, and both changesets are byte-identical. In each case I compared the diff against the head's own merge base, hunk headers excluded.
    • Only the pin changed: commit addcca0cf5, +34 / −16. Q's row in twoPackageObject is now seeded as a row stored before ADR-0029 D9.9, where it used to be saved through the door. No expect line changed.
    • The merge of main (192d2051da) had no conflict.
  3. The dev's open question: A. Keep the seeded row. The pin's subject is the uninstall over stored rows, and the seed is the repository's own row shape, the same seed PR fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused (ADR-0029 D9.9) #23510 used for its sibling pin. Option B would tie the fixture to a D9.9 gap that is filed to be closed.
  4. Out-of-scope dispositions.
  5. Prose read sentence by sentence.
  6. CI on this head.
    • 47 check-runs: 37 success, 8 skipped, every skip in the roster (check-expected-skips OK), and 2 failures, both Check Changeset.
    • Check Changeset is red by design: the DELIBERATE CORRECTION class, confirmed, not a required context, and not run on merge_group.
    • Test Core (2/6) failed once in packages/cli, which this PR does not touch, and did not reproduce (stand-down 6187232341). Its one re-run passed (check-run 116310608119), and the Test Core rollup passed with it. All six shards are success, including 6/6, the runtime and metadata-protocol shard that failed in the queue.
  7. Landing clauses.
    • (a) Every check is green, skipped by design or red by design: met.
    • (b) The skip roster answers OK: met.
    • (c) The head is unchanged, and (d) mergeable is true: both read in the landing act. mergeable_state reads unstable only from the by-design red.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 38703520759 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (6/6) — 失败步骤: Run this shard's tests

    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > without keep
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > a package-le
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > the way out,
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    @objectstack/runtime:test:  FAIL   local  src/package-uninstall-shared-object.integration.test.ts > [#23473] DELETE /packages/:id of a package whose object another stored row also holds > the way out,
      ↳ 失败原因: @objectstack/runtime:test: Error: Unknown field 'organization_id' on object 'sys_metadata'
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 1 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 11 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4f639e56b06075f522098437d04b08dc06075c11
Local-runs: none

Third record on this PR; the two earlier ones (6172644842 on 7d36e8d205, 6187465541 on addcca0cf5, both PASS) name other heads and are read here as comments, not as a verdict inherited. This head is addcca0cf5 plus a merge of main (ce7a65df2c) and one fixture commit (4f639e56b0, the pin only: 1/1 lines, organization_id: null removed from seedStoredRow's insert). Judged on its own.

Inputs read: card #23473 (body and all 15 comments, among them the lane decision 6168058499, the claims 6168221878 / 6184449232 / 6184860304, the dev reports 6170924033, 6172200644, 6187145964 and 6188900262, the release 6183361784, the ruling 6184273013); PR #23504 (the body as rewritten after this round, the file list: 4 files, and the net diff against main at this head); the maintainer's confirmation on the PR (6184266311) and the two earlier records; the head's source where the diff lands (packages/metadata-protocol/src/protocol.ts, packages/runtime/src/domains/packages.ts, migrations/overlay-index.ts, sys-metadata-repository.ts, the sys_metadata object and the sibling pin on main, ADR-0029 D9.9, the two changeset gates' headers, the release tags); and, last, the 47 check-runs on this head with the two non-green jobs' step lists. Not read: the dispatch order, any seat pointer line, both ## 席位核对 comments, the PR's Landing readings comment, the main ruleset. One correction to the brief: it names 8d05456cbe as the merge base and git diff 8d05456cbe...addcca0cf5; those describe the previous head. For THIS head git merge-base 4f639e56b0 origin/main is 23e689dc7d, and the net diff is git diff 23e689dc7d 4f639e56b0: 4 files, +420/-1, no governed path. The brief's pairing is the earlier head's and was not used.

What moved since the last record, measured: this PR's delta to protocol.ts against its own merge base is identical before and after the second merge, hunk headers and index lines ignored (git diff 8d05456cbe addcca0cf5 versus git diff 23e689dc7d 4f639e56b0 on that file: cmp equal); the two changesets are byte-identical between addcca0cf5 and 4f639e56b0 (git diff --stat on those two paths: empty). Only the pin changed, by one line.

① Derived judgments

  1. The narrowing itself: right. deletePackage throws RESOURCE_CONFLICT / 409 between step 3 and step 4 when keepData is not true and an active, non-sys_ object row of the package has, at its address, another package's active row or the package-less active row (protocol.ts:26176-26212). It sits after assertPackageUninstallable (:26142-26145) and before packageStore.delete (:26243-26257), the first write; everything above it reads (the retired-key refusal :26078-26090, the step-2 read :26117-26125, the registry predicate). The door's catch around the verb answers the thrown status and code before it touches the registry or the disable record (packages/runtime/src/domains/packages.ts:2275-2286, "Nothing was touched on this path"), so DELETE /api/v1/packages/:id answers 409 RESOURCE_CONFLICT with the package intact, where main answers 400 PACKAGE_DELETE_PARTIAL (packages.ts:2379-2393) over a package whose sys_packages row is already gone. Shape B of the lane decision; the docblock's step 3 now names both refusals and the numbering, with it "nothing durable happens before step 4", is unchanged (:26048-26067). Pinned at the real door: status and code (package-uninstall-shared-object.integration.test.ts:255, :274) and the untouched state, the sys_packages row, GET 200, both rows, the table, the record and the running registry's entry (:235-243).

  2. Which rows it refuses: the rows the per-item delete refuses, element by element, and still so on the merged tree after feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469.

    • Rows: the step-2 read, every sys_metadata row bound to the package in every state (:26091, :26120); the pre-flight skips drafts and rows without a string name (:26180), the split the loop makes on state (:26268).
    • Type fold: canonicalMetaType then PLURAL_TO_SINGULAR (:26181-26182). deleteMetaItem folds through canonicalizeMetaRequestType (:29045), which is the same canonicalMetaType (:364-366, :410) plus a refusal for an unrecognised spelling of a declared type (:397-409), then singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type (:29158). A spelling that refusal catches never folds to object on the pre-flight side either, so neither side drops a table for it.
    • Guard: shouldDropStorage(type, row.name, true, 'active') (:26183) is the per-item call (:29316) with the dropStorage: true the loop sends when keepData !== true (:26259, :26281) and the 'active' state of an active row (:29211); object only, active only, never sys_ (:20953-20960).
    • Address: packagelessAddress(type, row.name, undefined, 'active') (:26187) against packagelessAddress(singularTypeForRepo, request.name, member, targetState) (:29235-29237), where member = resolveBundleMemberSelector(type, undefined) is undefined (:29049, :2401-2402) and overlayMemberWhere('object', undefined) adds no predicate, since object has no discriminator (migrations/overlay-index.ts:518-523, :543-546). Both read where: { type, name, state: 'active' } through the one findEnvironmentRows (:18997-19000).
    • Verdict: the pre-flight refuses when the package is among candidates and another candidate or unbound remains (:26191-26194). The per-item delete refuses when the package's row exists (current, :29247; otherwise "nothing was deleted" at :29260-29268 or the no-op at :29269-29303, no refusal), dropsStorage holds, and remainingPackages is non-empty or unbound with a non-null package (:29308-29322); on the uninstall path packageId is always the package (:26279). The same predicate. The per-item object delete does reach that site: object carries allowRuntimeCreate: true (packages/spec/src/kernel/metadata-plugin.zod.ts:786; isRuntimeCreateAllowed :16909-16914), so useRepoPath holds (:29184) and the legacy raw-engine drop with no remaining-rows check (:29565-29566) is not on the object path. The dev's ablation on this head (6188900262: the :26193 condition widened so nothing refuses) sent cases 1 and 2 through to the per-item RESOURCE_CONFLICT in failed[] on the same rows; that is the measured form of this equality, read here, not re-run.
    • After feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469, verified, not copied from 6188900262. feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469 (dd29bbe60d) is in this head's base 23e689dc7d and was not in 8d05456cbe (merge-base --is-ancestor: yes / no). On the old base packagelessAddress read engine.find with where: { type, name, state, organization_id: null, ...member } (8d05456cbe protocol.ts:19043-19046); on this head it reads findEnvironmentRows with where: { type, name, state, ...member } (:18997-19000), the ADR-0131 D6/D7 seam that drops a legacy organization's rows (:10241-10243, :10260-10264). Only the function's body changed; both callers still call it, the pre-flight with (type, name, undefined, 'active') and deleteMetaItem with (singularTypeForRepo, name, member, targetState), so both moved together. deletePackage's step-2 read moved from engine.find (8d05456cbe:26151) to the same seam (:26120), and the per-item loop's if (row.organization_id) branch into removeLegacyOrganizationRowOnUninstall (8d05456cbe:26234) is gone with that helper (git diff 8d05456cbe 23e689dc7d, hunks @@ -26231,23 +26197,19 @@ and @@ -26336,54 +26298,6 @@). So the pre-flight's input rows and the rows the per-item delete walks are one set, the environment's, and both ask one address read. The dev comparison holds.
    • Not asked by the pre-flight, by design and not this PR's: the per-item delete's _lock, packaged-base, origin-gated and NOT_CREATABLE doors (:29122-29156) precede its address read; those refusals still arrive after step 4 and ride failed[], as on main.
    • The two up-front refusals speak one code: step 3's extender refusal is PackageUninstallExtendedError, RESOURCE_CONFLICT / 409 (packages/objectql/src/registry.ts:2052-2053, :4344), and the per-item refusal is dropStorageRemainingRowsRefusal, RESOURCE_CONFLICT / 409 (:20993-21011). No code minted; the per-item code is the right one, since the condition is environment state and the same request is accepted once the other rows or the package's own row are gone.
  3. The keepData path: right. The whole block is under request.keepData !== true (:26176); with keepData nothing is read. The same predicate decides the loop's dropStorage (:26259, :26281), and the only two table-drop call sites (dropObjectStorage at :29377-29379 and :29565-29566, the function itself :20931-20945) are inside deleteMetaItem behind shouldDropStorage on that flag. So with keepData nothing drops and nothing refuses later either. The door sends keepData: true for ?keepData=true or ?keepData=1 (packages.ts:2277-2280), the spelling the changeset and the refusal prescribe. Pinned: case 3 (:278-291): 200, failed: [], P gone, Q's row, the table and the record kept, the running registry serving Q's definition.

  4. A failed read is the step-2 read failure: right. packagelessAddress tolerates only an unprovisioned store and rethrows the rest (:19001-19005, :8896-8910); the pre-flight wraps any throw in metadataReadFailureError (:26188-26190), step 2's own classifier (:26124, :4051-4058). An unprovisioned store never reaches the pre-flight: step 2 deliberately does not tolerate it (:26107-26125).

  5. The refusal text: right. Prescription first (:26199-26201): keepData in both spellings, matching the door (packages.ts:2277), or delete without dropStorage the package's own row or the other rows of each object listed, DELETE /api/v1/meta/object/NAME?package=ID, the REST door's ?package= reading (packages/rest/src/index.ts:208); then the fact ("Nothing was removed", a table goes only with the object's last row) and the list (:26202-26206), each object with remainingRowsPhrase naming its other holders, up to five packages and the package-less row (:1206-1218), the list cut at five objects with "; and N more" (:26197), singular and plural spelled (:26203-26205). No tracker number in the runtime string. The second way out answers the card body's concern with shape B: deleting the package's own row of a shared object first keeps that table for the other rows and still lets the uninstall drop the tables the package alone held, pinned as case 4 (:293-312, the private table dropped at :311). The package-less row cannot be deleted first through the metadata door while the package's row exists (a delete naming no package addresses the one package's row, :29244), so for that holder the working route is the package's own row, which the message names. Pinned: the message carries the object, Q and keepData (:258-260). Not pinned and not required: the five-object cut and the plural wording.

  6. The two callers: neither worse off. git grep at this head finds two: the door (packages.ts:2278) and rollBackUnmanagedImport (:27317). The rollback uninstalls the new base without keepData and catches a throw as residue "the uninstall failed: ..." (:27321-27323), logged at error and carried on the import's error; on main the same race left the base half-uninstalled with failedCount above 0, which rides the same residue path (:27318-27320). The import refuses a name another stored row already holds before writing (unmanagedImportCollisions, :27248-27290), so the pre-flight can fire there only on a row written between that read and the rollback.

  7. Nothing durable before the refusal: right. Reads only (findEnvironmentRows through packagelessAddress); step 4's packageStore.delete is still the first write (:26246-26256); the registry withdrawal (:26342-26349) and the cleanups stay after the loop.

  8. The re-seeded fixture tests a state that really arises: yes, and the seeded shape is now main's row shape.

    • The state is sys_metadata holding P's and Q's active rows of one object while the registry holds P alone. ADR-0029 D9.9 (docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md:660-678) names it: a row bound to a package that does not own the object is refused at the save door, and "at boot the row is not layered and is counted in loadMetaFromDb's per-record errors"; the ADR records that the overlay-uniqueness index lets sys_metadata "legitimately hold two active rows for one (type, name) bound to two packages". The head's boot does exactly that (protocol.ts:29868-29880: the mismatch arm throws into the per-record catch, the row stays stored). So every store holding such a Q row reaches this state at every boot, and the uninstall meets the row through the store reads at :26120 and :26187, never through the registry.
    • Such rows exist in released stores: fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused (ADR-0029 D9.9) #23510 (222293f60a, the refusal of a save into a second package of an object a RUNTIME-AUTHORED row owns, this fixture's exact producer) is not an ancestor of the newest release tag @objectstack/metadata-protocol@18.0.0-next.1, and its note .changeset/23416-runtime-owner-overlay-refusal.md is still pending on origin/main. The release tags do carry the older D9.9 refusal (OBJECT_OVERLAY_PACKAGE_MISMATCH, for a code-owned object), which is not this case.
    • The dev's merged-tree measurements (6187145964 producers_measured, the PR body's table) also reach it through today's doors (a Q draft saved before P's row and promoted after it; a package-less row saved first). Those are read here as the dev's readings, not re-measured, and they are the D9.9 gaps filed as metadata-protocol: two producers still store a second package's active row of an object that a runtime-authored row owns — a draft promoted after the owner's save, and a package-less owner — past ADR-0029 D9.9's save-door refusal (family close-out) #23630, which is why seeding rather than driving a door is the right fixture: the pin's subject is the uninstall's reading of stored rows, and a fixture on a gap breaks when the gap closes. Option A of open_questions[0].
    • The seed (seedStoredRow, :153-161) writes id, type, name, package_id, state: 'active', metadata as JSON text, checksum: hashSpec(body, 'object'), version, created_at, updated_at: the columns sys_metadata declares on main (packages/metadata-core/src/objects/sys-metadata.object.ts:34-209), with no organization column, which that object retires (:216-220, ADR-0131 D6/D7, feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469), and the repository's own write shape (sys-metadata-repository.ts:924, :988-989: metadata: JSON.stringify(body), checksum: hash, hash = hashSpec(body, ref.type)). It is the same seed the sibling per-item pin carries on main (packages/rest/src/meta-delete-drop-storage-other-rows.test.ts:144-152, also without organization_id), so the two doors' pins share one fixture shape. This round's one-line fix (4f639e56b0: organization_id: null removed) is exactly what the merged sys_metadata requires; the dev's red-before reading on ce7a65df2c ("Unknown field 'organization_id' on object 'sys_metadata'", 4 failed / 1 passed) is the shape of that cause, and Test Core (6/6) is green on this head (below).
    • Every case assertion, the package-less case (:264-276, seeded since the first head) and the CONTROL (:314-327) are unchanged by both rounds.
  9. Public surface: unchanged, right. No export, type, schema, route or error code is added, removed or renamed; DeletePackageRequest is not in the diff; the pin is a test file in packages/runtime/src/, outside that package's published files (packages/runtime/package.json:93-97). Clause-②: no is truthful.

  10. The two changesets, FROM and TO, against the released behaviour and against this head.

  • 23473 FROM (.changeset/23473-uninstall-shared-object-refusal.md:15): the release tags @objectstack/metadata-protocol@17.7.0 and @18.0.0-next.1 carry neither dropStorageRemainingRowsRefusal nor the 23415 note nor any #23473 text (counts 0; git cat-file -e on the note: absent in both tags), and the note is still pending on origin/main. So the per-item 409 and the failed[] / 400 PACKAGE_DELETE_PARTIAL intermediate never shipped. At 17.7.0 deleteMetaItem drops the table whenever shouldDropStorage says yes, with no remaining-rows check (that tag's protocol.ts:26030, :26195), so the released uninstall of P deleted P's row with dropStorage and dropped the table while Q's row stayed. The FROM says that, names no PACKAGE_DELETE_PARTIAL, and the "next data read answered 500" is the 23415 note's own measured FROM. Right for a reader upgrading from either tag; the summary line (:5) matches.
  • 23473 TO (:17): true by 1, 5 and 7. Unchanged (:19): true by 3 and the CONTROL. Remedy (:21): the door's ?keepData=true spelling and the own-row-first route pinned as case 4. The adr-0087 marker (:9) not-required (no-migration-prescription): honest, nothing authorable, exported or stored is removed, renamed or rewritten, and the remedy lives in the request.
  • 23415 TO, the maintainer-confirmed sentence (.changeset/23415-drop-storage-other-rows.md:17, confirmed at 6184266311 / 6184273013; not re-asked): "An uninstall of a package holding such an object, without keepData, is refused whole, 409 RESOURCE_CONFLICT, before anything is removed; its message names the object, the rows that keep it, and the ways out." True against this head's code: "such an object" is one another package's row or the package-less row also holds; without keepData the uninstall would send dropStorage for every active object row, and the pre-flight refuses whole, 409, before step 4, naming each object, its other holders and both ways out (1, 2, 5). The sentence it replaces ("reports the refusal for that object in its failed[] list ... and leaves the object's row and table in place") was true only on main between PR 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 and this PR and would ship false in the same release. The diff to that note is one line (+1/-1); its FROM, "Only active rows count" and Remedy stay true (the pre-flight reads 'active' only and skips drafts). Both notes are pending for one release and agree.
  1. Observed, not a defect of this PR, for a later carrier: two comments main left stale say the door answers step 3's refusal "500, nothing changed": protocol.ts:26136 (which the PR body names) and packages/runtime/src/domains/packages.ts:2301 (which it does not). 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 it 409 and left both. Outside this PR's hunks. Also the loop's other per-item refusals still ride failed[] after step 4, as on main (2, last bullet).

② Semver level

  • .changeset/23473-uninstall-shared-object-refusal.md: '@objectstack/metadata-protocol': minor (:2), a fix(metadata-protocol)!: summary (:5), Clause-②: no (narrowing) (:7), one adr-0087 marker (:9), the BREAKING banner "shipped as minor under the launch-window convention" (:11). Consistent with the diff: (narrowing) is the breaking declaration (AGENTS.md Post-Task step 3), major is refused in the window (scripts/check-changeset-no-major.mjs, header rule 1), and the note carries FROM, TO, Unchanged, Remedy and its disposition. Only metadata-protocol is bumped; runtime gains a test file and publishes no change (①.9). The Clause-②: no line on the PR body (line 3), on the claims (6168221878, 6184449232) and on the changeset, with the arm where the gate reads it, agree with ①.9: no accept set widens, no public key is added.
  • The 23415 edit is one sentence of a pending note, no bump change, judged in ①.10.
  • Check Changeset is red on this head by design, twice (jobs 116330662327 at 08:42Z and 116350080272 at 09:30Z, the second pr-automation run after the body rewrite); in each, the only non-green of 18 steps is step 12, "Reject an empty-frontmatter changeset added by this PR", the scripts/check-empty-changeset.mjs run; the ADR-0087 disposition and the major-guard steps are green. That script's foreign-changeset rule refuses every M row of a .changeset/*.md present at the merge base (scripts/check-empty-changeset.mjs:11-13, :119-137, :163-164), the DELIBERATE CORRECTION class, whose remedy is do not restore, do not apply skip-changeset, confirm on the PR (:127). The confirmation is on the PR (6184266311, the maintainer's 「确认」 carried with provenance under ruling D on [finding] the skip-changeset label suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375). Per the brief and 6183361784, not a required context and not run on merge_group; the ruleset was not re-read here.

③ Boundary flags

Dev report 6188900262 (this round):

Dev report 6187145964 (the first merge round; its flags carry):

Dev report 6170924033 and addendum 6172200644 (judged again on this head):

  • Two scratch tests placed and deleted; one suite run waited on by PID; one ;-joined sweep; one refused ablation-dist-preflight on a scratch file: harness mechanics, no residue in the four-file list, no gate verdict claimed from them. Accepted.
  • Out-of-scope 1, the 23415 sentence: edited, confirmed (6184266311), judged true in ①.10. Closed.
  • Out-of-scope 2, the registry serves Q's definition after a keepData uninstall or an own-row delete: the expected outcome of Q's row keeping the object defined; pinned (:246-247, cases 3 and 4). No action.
  • The tracker number left out of the 23415 sentence: accepted, the sentence states the behaviour in full and the 23473 note ships in the same release. The 23473 summary line renamed to the released behaviour: accepted, it matches the corrected FROM. "Docs: no edit": checked on this head, content/docs/kernel/contracts/metadata-service.mdx:498 says only that ?keepData=true keeps the object tables and names no status for a shared object; nothing in content/docs outside references/ names PACKAGE_DELETE_PARTIAL or failed[] for an uninstall; nothing is falsified. The first merge of main without a source re-test: accepted as before; CI ran the full PR set on each merged tree since.
  • needs_from_seat, the written confirmation: delivered, 6184266311.

Check-runs on 4f639e56b06075f522098437d04b08dc06075c11, read last at 2026-10-15T09:35:33Z: 47; 37 success, 8 skipped, 2 failure, 0 in progress. Success includes the five required contexts: Build Core (116331160543), TypeScript Type Check (116334497267, with its four lanes), Lint & Repo Gates (116332700848, with its eight lanes), Governed Surface Queue Guard (116330662530) and Test Core (116340650943) with all six shards, among them (6/6) (116331161229, the runtime and metadata-protocol shard that failed in both queue runs on the old fixtures) and (2/6) (116331160814, the CLI slice that failed once on addcca0cf5). The 8 skipped are the full-battery set (Build Docs, Console Pin Gate, the Dogfood shard matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance) and the second pr-automation run's Auto Label and Check PR Size, which were success on the first run. The 2 failures are both Check Changeset, step 12 only, the designed red of ②. Their conclusions are the gate verdicts; nothing was run here.

Nothing escalated.

Implemented-by: claude/issue-23473-uninstall-shared-object-refusal
Reviewed-by: session_0121ooBYRmS8ziFXm9ai5YHm

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917, temporary re-seat 6184406254) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6184449232 (amended 6184860304) on #23473 · reviewed head 4f639e56b06075f522098437d04b08dc06075c11 · 2026-10-15T09:42Z

Verdict: ACCEPT on this head. It supersedes 6187493234, which named addcca0cf5. Not governed. 4 files, +420 / −1.

  1. Review on record: CONTRACT_REVIEW_TIER PASS on this head (6189137865).
  2. What moved, as the seat measured it.
    • The merge of main (ce7a65df2c) had no conflict.
    • The PR's delta to protocol.ts is identical before and after the merge, and so are both changesets.
    • The only change is one line of the pin: organization_id: null comes out of seedStoredRow, following main's sys_metadata row shape and the sibling pin on main.
  3. main since this head's base. Five commits. The one touching protocol.ts (fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read #23628) changes the optimistic-concurrency conflict near line 14,600 and no function the pre-flight reads. git merge-tree with current main is clean.
  4. CI on this head.
    • 47 check-runs: 37 success, 8 skipped (all in the roster, check-expected-skips OK), and 2 failures, both Check Changeset and red by design (the confirmed DELIBERATE CORRECTION).
    • All six Test Core shards are success, including 6/6, which failed in both queue runs, and 2/6, which failed once.
  5. Out-of-scope dispositions.
  6. Landing clauses.
    • (a) Every check is green, skipped by design or red by design: met.
    • (b) The skip roster answers OK: met.
    • (c) The head is unchanged, and (d) mergeable is true: both read in the landing act.

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 7cb33cf Oct 15, 2026
45 of 47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23473-uninstall-shared-object-refusal branch October 15, 2026 10:09
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