Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23505-extension-refusal-409
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23505-extension-refusal-409

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23505

Clause-②: no

Dispatched by the domain:spec seat 4 PM (sunhaolin) to session session_0121ooBYRmS8ziFXm9ai5YHm, branch claude/issue-23505-extension-refusal-409, claim comment 6172782630.

What changed

SchemaRegistry.assertPackageUninstallable (packages/objectql/src/registry.ts) threw a bare Error with no code and no status when the package owns an object another package extends (ADR-0029). deletePackage step 3 rethrows it as is, so DELETE /api/v1/packages/:id fell through resolveThrownHttpError to its 500 INTERNAL_ERROR fallback. The caller could change the state that blocked the uninstall, but the answer said the server failed.

The fix is at the producer. The refusal is now an unexported PackageUninstallExtendedError, in the registry's house shape (code, status and httpStatus, like NamespaceConflictError and ObjectOwnershipConflictError):

  • code: 'RESOURCE_CONFLICT', status: 409. No new code: this is the standard catalog member that the verb's sibling refusals already answer for environment state (dropStorageRemainingRowsRefusal, and the shared-object pre-flight in PR fix(metadata-protocol)!: an uninstall that would drop a table another stored row still serves is refused 409 before anything is removed #23504).

  • The way out comes first, because a 4xx message can be truncated. The diagnostic sentence the refusal always carried follows, and it now names every extended object (the first five, then a count of the rest), each with its extenders in registration order. Measured at the door:

    Uninstall the package that extends an object of "com.owner" first (com.ext), then uninstall "com.owner" again: a package cannot be uninstalled while another package extends an object it owns (objectExtensions, ADR-0029). Cannot uninstall package "com.owner": object "alpha" is extended by com.ext.

  • Structured fields for in-process callers: packageId, extendedObjects, extenders.

The decision itself is unchanged: a package is refused in the same cases as before, and before any mutation. packages/metadata-protocol/src/protocol.ts is not edited. Step 3 already rethrows the registry's error as is, so the door gets the status without touching steps 1–4, which PR #23504 holds.

Every caller, measured

Caller How it meets the throw What changes
deletePackage step 3, then DELETE /api/v1/packages/:id (errorFromThrown(e, 500)) rethrown as is 500 INTERNAL_ERROR becomes 409 RESOURCE_CONFLICT with the prescription. Nothing changes in storage or the registry (pinned below).
The same door on a host with no persisted half (the door calls registry.uninstallPackage(id) itself; terminal errorFromThrown) thrown through uninstallPackage Also 500 to 409 RESOURCE_CONFLICT. The package and its object stay registered. Measured with a one-off probe, deleted after reading. Not pinned.
The door's safety net after a persisted delete, and deletePackage step 6 caught; console.warn with the message Log text only.
@objectstack/cloud-connection install-local withdrawal caught; reads err.message only The cleanups outcome is unchanged; only the log text changes.
MetadataFacade.unregisterPackage, ObjectQL.unregisterObject(name, packageId) propagate No production caller in the tree (git grep).
ObjectQL.removePackage force: true Never asks.

Tests

Every reading below is at fdc2dc05fc (this branch after merging origin/main at 222293f60a), except the full objectql run, which is noted.

  • packages/runtime/src/package-uninstall-store-refusal.integration.test.ts (the existing pin). It runs on a real stack: ObjectQL over SqlDriver, PackageServicePlugin, the protocol and HttpDispatcher, booted twice over one SQLite file.
    • The extender case moves from { status: 500, code: 'INTERNAL_ERROR' } to { status: 409, code: 'RESOURCE_CONFLICT' }. It also asserts the opening of the prescription and the object's name.
    • Every "nothing changes" assertion is kept: stored rows, registry entry and disable record, in the same process and after a restart.
    • New CONTROL case: the extender is installed as a real package, so the first uninstall is refused 409. Then DELETE of the extender through the same door, and the same uninstall answers 200 and stays gone after a restart.
    • Result: 5/5 pass.
  • packages/objectql/src/registry-assert-package-uninstallable.test.ts pins:
    • the envelope;
    • the prescription first;
    • every object and extender named, in walk order;
    • the singular wording;
    • the five-then-count cap;
    • the same refusal through unregisterObjectsByPackage and uninstallPackage.
  • packages/objectql/src/registry.test.ts: the order pin added for the refusal-pass ordering fix now reads the order off the structured refusal (first refusable object first, its extenders in registration order) instead of the old sentence.
Run Result
runtime package-uninstall-* + domains/packages-* 29 files / 537 tests pass
runtime pin + package-uninstall-org-scope 2 / 11 pass
objectql registry*.test.ts + the two objectql tests the merge brought 37 / 480 pass
metadata-protocol deletePackage tests 10 / 140 pass
objectql vitest run --project local (at a9a88b1e34; the merge changed no objectql source, only the two tests re-run above) 415 / 8095 pass
pnpm --filter @objectstack/objectql typecheck, same for runtime exit 0; test-typecheck debt held

Ablation (the producer's code and status renamed away)

The mutation went through scripts/ablation-replace.mjs (anchor hit 1 time; blob 69df6079e5a7 to 7d89ea3120fa). objectql was rebuilt, and ablation-dist-preflight found the planted marker in 4 dist files.

  • Re-pinned case: red on exactly the status and code assertion. It expected { status: 409, code: 'RESOURCE_CONFLICT' } and received { status: 500, code: 'INTERNAL_ERROR' }, which is the pre-fix answer.
  • CONTROL: red on its precondition (expected 500 to be 409).
  • Envelope test in registry-assert-package-uninstallable.test.ts: red.
  • Restore. The blob equals HEAD, git diff HEAD is empty and git status --porcelain is empty. After an objectql rebuild, --absent found the marker in 0 of 14 dist files, and the pin passed 5/5 again.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran at fdc2dc05fc: 68 derived, 67 run with exit 0, 1 NOT-MEASURED, 0 UNRUN.

  • NOT MEASURED: pnpm check:dual-build-cjs-loads. It answered PREREQUISITE NOT MET (exit 3) because it reads every package's dist/ and no full workspace build was run locally. CI runs it.
  • The families this change reads most directly:
    • check:dispatcher-error-vocabulary is green. RESOURCE_CONFLICT is a standard member, so no table row is owed.
    • check:error-status-conformance is green. Its deriver records RESOURCE_CONFLICT/409 from class PackageUninstallExtendedError; I measured this by calling deriveRuntimeStatuses on registry.ts, because the report shows only four sites per code.
    • check-adr-0087-registration reports "no declared-breaking changeset (1 non-breaking changeset(s) seen)".
    • check-changeset-no-major and check-empty-changeset are green.
  • Extra readings outside the derived set: check:error-code-provenance and check:error-code-casing, both exit 0.

The changeset is .changeset/23505-extension-refusal-409.md: @objectstack/objectql patch, with FROM, TO and Unchanged sections.

Acceptance notes

  • Two comments now say 500 where the door answers 409. Both are outside this claim's file surface and change no behaviour:
  • A test fixture keeps the old text. packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts builds its fake refusal with the old sentence. It pins pass-through identity, not the text, so it stays valid.
  • "Detach" is not offered as a way out. No verb detaches an extension short of uninstalling the extending package, or installing a version without it (not measured), so the message names only the measured remedy.
  • The no-persisted-half door path (second row of the table) moves 500 to 409 too. It was measured, not pinned.

Generated by Claude Code

…n owned object answers 409 RESOURCE_CONFLICT with the way out

SchemaRegistry.assertPackageUninstallable threw a bare Error, so
DELETE /api/v1/packages/:id fell through to its 500 INTERNAL_ERROR
fallback for a client-actionable state. The refusal now carries the
ADR-0112 envelope (RESOURCE_CONFLICT / 409), opens with the way out
(uninstall the extending packages first) and names every extended
object and extending package.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 17 documentable anchor(s).

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 222293f60a7fff2779269b20bb4e5ce28a34c935.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 38 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 17 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 222293f60a7fff2779269b20bb4e5ce28a34c935 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 222293f60a7fff2779269b20bb4e5ce28a34c935

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6172782630 on #23505 · reviewed head fdc2dc05fc4c8029dad8cdc1c2f9b9e2c7d9cfe1 · 2026-10-14T17:55Z

Verdict: ACCEPT. Not governed (check-governed-merges --pr 23522): 5 files, +271 / −34.

  1. Shape.
    • Draft PR. Its first line is Fixes #23505, and Clause-②: no sits at line start.
    • The changeset .changeset/23505-extension-refusal-409.md is @objectstack/objectql patch and carries FROM / TO / Unchanged plus a migration note for anyone matching the old text.
    • The ADR-0087 gate reads it as non-breaking. The request is refused in exactly the same cases as before; only the answer changes.
  2. The seat's decision against the diff.
    • The fix is at the producer alone. PackageUninstallExtendedError (unexported, the registry's house shape) carries code: 'RESOURCE_CONFLICT', status: 409 and httpStatus: 409. It reuses a standard catalog member and mints no code.
    • The message leads with the way out and names every extended object (five, then a count) with its extenders.
    • packages/metadata-protocol/src/protocol.ts is untouched; PR fix(metadata-protocol)!: an uninstall that would drop a table another stored row still serves is refused 409 before anything is removed #23504 holds deletePackage steps 1–4. The door answers the thrown status as is.
    • The refusal predicate and its iteration order are unchanged.
    • Caller census:
      • deletePackage step 3 and the door with no persisted half: 500 → 409.
      • deletePackage step 6, the door's safety net, and cloud-connection's install-local withdrawal read err.message only, so only their log text changes.
      • ObjectQL.removePackage passes force and never asks.
  3. Gates and evidence.
    • Head check-runs: 34 success, 6 skipped (the full-battery or opt-in set), 0 failures. mergeable_state is clean.
    • The re-pinned real-stack pin passes 5/5, every nothing-changes assertion kept, plus a prescription assertion and a door-level control.
    • Ablation: renaming code/status turned the re-pinned case red on exactly { status: 409, code: 'RESOURCE_CONFLICT' } → { status: 500, code: 'INTERNAL_ERROR' }. The dist marker was proven present, then absent after the restore.
    • Local reconciliation: 68 derived, 67 run, 1 NOT MEASURED (check:dual-build-cjs-loads, whole-tree; CI reads it).
    • check:dispatcher-error-vocabulary and check:error-status-conformance pass.
  4. Accepted deviations.
    • The message names every extended object, within the seat's "names the object(s)" decision. The order pin now reads the structured refusal.
    • A one-off probe was deleted.
    • The objectql full suite was read at a9a88b1e34; the merge brought no objectql source.
  5. Out-of-scope dispositions.
  6. Landing clauses.
    • (a) Every check green or skipped by design: met.
    • (b) mergeable_state clean at enqueue: met.
    • No contract review is owed. No accept set and no public key moves; only a refused request's answer changes from a server-fault 500 to the standard 409.

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