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

Draft
objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/issue-23473-uninstall-shared-object-refusal
Draft

objectstack-fleet[bot] wants to merge 7 commits into
mainfrom
claude/issue-23473-uninstall-shared-object-refusal

Conversation

@objectstack-fleet

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. Pinned on the real stack: the keepData uninstall completes, failed: [], the table, the other package's row and the record stay, and the running registry serves the other package's definition.
  • 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

Step 3's own refusal is a code-less Error, which the door answers 500 INTERNAL_ERROR (pinned in package-uninstall-store-refusal.integration.test.ts); that family does not fit a client-actionable refusal. The pre-flight reuses RESOURCE_CONFLICT / 409, the code the per-item refusal (dropStorageRemainingRowsRefusal) answers for the same rows. No code is minted.

The refusal, as measured (two shared objects)

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.

Tests

New real-stack 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 (--maxWorkers=2, under the shared verify lock):

  • the pin: 5/5 passed at fee128e3da (this PR's head).
  • @objectstack/metadata-protocol whole suite: 236 files passed, 3 skipped; 28397 tests passed, 19 skipped, at 2e1269562a (the merge of origin/main; the only later commit edits the pin file).
  • @objectstack/runtime uninstall-route sweep (every src/domains/packages-*.test.ts, every src/package-uninstall-*.integration.test.ts, and the two package org-scope integration tests): 32 files, 551 tests passed, at 2e1269562a.
  • @objectstack/rest uninstall-route sweep (package-door-*, package-envelope.conformance, package-routes-coded-error-mapping, package-publish-mount, direct-mount-introspection, and metadata-protocol: a delete naming one package with dropStorage=true drops the object table while another package row of the object remains — the remaining package object loses its data #23415's meta-delete-drop-storage-other-rows): 12 files, 236 tests passed, at 2e1269562a.
  • typecheck: @objectstack/metadata-protocol tsc --noEmit exit 0; @objectstack/runtime tsc --noEmit plus check:test-typecheck OK (the new file adds no test-layer error), at 2e1269562a; check:test-typecheck again OK at fee128e3da.
  • packages/cli integration layer: not touched, declared to CI.

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 971f6e76a4, through scripts/ablation-replace.mjs (anchor hit 1 → 0, blob changed, WRAP-mode restore):

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 65 families for this diff; all 65 ran locally with exit 0, and --ran reconciles 65 derived, 65 run, 0 NOT-MEASURED (exit codes recorded per family). Readings at fee128e3da are in the dev report on the card. Every family CI runs is read from this PR's check-runs, not re-run here.

Acceptance notes

  • 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 unreleased changeset is superseded in one sentence. .changeset/23415-drop-storage-other-rows.md (on main, not yet released) says an uninstall "reports the refusal for that object in its failed[] list ... and leaves the object's row and table in place". After this PR, such an uninstall is refused whole before anything is removed. Not edited here: that file is outside this claim's file surface. Suggested edit for the owning seat, replacing that sentence: "An uninstall of a package holding such an object is refused whole before anything is removed; see the 23473 changeset."
  • The per-item 409 stays 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 in failed[] as before.
  • Cost. Without keepData, one sys_metadata read per active object row of the package (not sys_), the same read the per-item delete then repeats. With keepData, none.
  • File surface. protocol.ts: the deletePackage docblock's step 3 and the pre-flight between step 3 and step 4 only. Not the per-item loop, deleteMetaItem or classifyObjectContribution.
  • The package-less row as the "other row". A delete naming no package addresses the one package's row of an item while one is stored (packagelessAddress), so the package-less row cannot be deleted first through the metadata door while the package's row exists. The refusal's other way out (delete the package's own row first) covers that case; pinned as case 4's mechanism.

Generated by Claude Code

… 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 f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4

⚠️ 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 f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 → 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.

This branch has not been deployed

No deployments
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