Skip to content

fix(cloud-connection): an install-local uninstall withdraws the package from the running kernel - #21581

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21576-uninstall-withdraws-registration
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21576-uninstall-withdraws-registration

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21576

Clause-②: no

What this changes

Files:

  • packages/cloud-connection/src/marketplace-install-local-plugin.ts: the uninstall path only. That is handleUninstall, the new withdrawFromRunningKernel, the corrected header block and the corrected log line.
  • packages/cloud-connection/src/marketplace-install-local-uninstall-withdrawal.test.ts: a new unit pin, 6 cases.
  • packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts: order 3's two it.fails promoted to plain it, a hot-object case in orders 1 and 2, and a new order 4.
  • .changeset/21576-install-local-uninstall-withdraws-registration.md: @objectstack/cloud-connection patch, Clause-②: no.

A1: reproduction on 74281a8e4a

  • I ran order 3 with its two it.fails changed to plain it, on packages built in this worktree. It read Tests 2 failed | 11 passed (13). The 13 includes one temporary measurement case, used for A4 below. The two red cases are exactly the two set readings:
    • the other package's hot install re-projects the set;
    • the set survives the restart.
  • Then I restored the file: its blob equals HEAD's (72bd1cc16e), and git status --porcelain was empty.
  • After the fix, the same two cases are plain greens, and the grant-stays-revoked case stays green.

A2: how the door reaches the registry

  • deletePackage reaches the registry as this.engine.registry.uninstallPackage(packageId). this.engine is the ObjectQL engine the protocol is assembled over (assembleMetadataProtocol(ctx, this.ql, …) in packages/objectql/src/plugin.ts).
  • The same plugin registers that engine as the objectql service. The manifest service, which this door installs through, calls ql.registerApp on it.
  • So the door reads ctx.getService('objectql').registry and calls uninstallPackage(manifestId). The call is typed by the spec contract IObjectQLEngine.registry (EngineSchemaRegistryView.getPackage / uninstallPackage), with no as any.
  • It uses one verb, and there is no second unregistration mechanism.
  • The id is the manifest id, which is the key installPackage files the record under. The cleanups get the same id.

A3: order and failure

The order is: ledger removal, then the withdrawal, then the cleanups. The withdrawal is synchronous, and nothing is awaited between it and the ledger removal. It goes first for three reasons:

  1. deletePackage uses the same order: the registry withdrawal, then runUninstallCleanups.
  2. It closes a window. The cleanups await the store row by row. If the package were still registered while they ran, a concurrent hot install's metadata:reloaded could re-project the sets they had just removed.
  3. The one cleanup registered today, security.package-permissions, loses nothing. It selects by package id in the store and never reads the registry. Measured: orders 1 and 2 still report it as success: true, and the set and the grant are revoked.

Nothing is withdrawn when the uninstall did not happen. The unit pin covers each case:

  • a refused caller (401);
  • an id this door never installed (404), even one the running registry holds;
  • a failed ledger write (500).

When the withdrawal fails:

  • If the withdrawal throws (ADR-0029: another package extends an object this one owns), or the registry cannot be asked, the response carries one failed outcome named registry.uninstallPackage in cleanups. That is the way a failed cleanup is reported.
  • The cleanups still run, and the request still succeeds, because the ledger entry is already gone.
  • The operator log names the cause and the remedy. The thrown text stays in the log and never reaches the wire.

When the registry does not hold the package (for example, a cloud install whose hot-register failed), there is nothing to withdraw: no call and no outcome.

A4: what uninstallPackage withdraws, and what the object doors answer

From SchemaRegistry.uninstallPackage (packages/objectql/src/registry.ts), the verb withdraws:

  • the package's object contributions, including the overlay layer over an owned object;
  • its namespace;
  • every metadata item keyed to the package (unregisterItemsByPackage);
  • its boot disable seed;
  • the package record.

It does not touch tables or rows.

The object doors' answer moves, as intended. These are the package object's answers on the data route, read hot in the same process right after the DELETE:

order 1 order 2 order 3 order 4
before the fix (74281a8e4a, measured) 200, empty list 200, empty list 200, empty list (the order is new)
after the fix (pinned) 404 404 read, not asserted 404

After the fix, the hot answer carries the same error code the object answers after a restart. This is the consequence of "withdraws the package from the running registry". Orders 1, 2 and 4 pin it.

The reinstall control. I added order 4: hot install, then DELETE, then a hot reinstall of the same package in the same process. The object answers 200 again, and the set is projected again, exactly once, as the package's own. So the install path puts back everything the withdrawal removes.

One more value moves with the registry state. A same-process reinstall after an uninstall is now classified as a fresh install, so the install answer's upgradedFrom is null instead of previous-marketplace-version. A reinstall after a restart already answered null. No key moves, no value leaves the existing set, and nothing in this repository reads the field.

A5: the note

  • Before: "… The app remains loaded in the running kernel until the next restart (the kernel API does not support unregistering apps in-place)."
  • After: "Cached manifest removed, the package withdrawn from the running kernel, and the uninstall cleanups this runtime's plugins registered ran — each one's outcome is in cleanups."
  • When the withdrawal fails, the note says the package stays loaded until the next restart, and points at the registry.uninstallPackage entry.
  • The file header and the info log line are corrected the same way. Nothing parses the note.

A6: reverse verification

The mutation. scripts/ablation-replace.mjs replaced the anchor registry.uninstallPackage(manifestId); with a marker statement that logs ABLATED_21576_withdrawal. The anchor went x1 to x0 and the replacement x0 to x1. The blob changed from 7667b86205 to 83d263078b.

The build. I rebuilt @objectstack/cloud-connection. ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs.

The readings:

The restore. ablation-replace restored the file: its blob equals HEAD's 7667b86205, and git diff HEAD is empty. After a rebuild, ablation-dist-preflight --absent found the marker absent from all 6 built files, with the whole tree clean.

Tests and gates, at 9f3e65608d

  • Integration file, on packages built in this worktree with no dist overlay (turbo build of @objectstack/cli^..., then @objectstack/cloud-connection rebuilt): Test Files 1 passed (1) / Tests 16 passed (16).
  • @objectstack/cloud-connection full suite: 34 passed (34) files, 420 passed (420) tests.
  • @objectstack/cloud-connection typecheck: both programs are green, and --listFiles shows the new test file in both.
  • @objectstack/cli: typecheck is green, including check:test-typecheck, and the tier partition pin passes 22/22. The only packages/cli change is the integration-tier file above, which ran in full. The rest of the unit tier is declared to CI.
  • dispatch-gates --commands: it derives 64 commands, and all 64 exit 0.
    • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET, because a whole-repo dist/ was missing. After pnpm build it exits 0.
    • The --ran reconciliation with an exit code per line reads "64 run, 0 NOT-MEASURED (a DERIVED zero)".
    • Two path-matched families take a value from the workflow and cannot run locally: check-issue-citations --census and check-shard-attestation. NOT MEASURED, left to CI.
  • pnpm lint (eslint . --no-inline-config), the full run with no narrowing: exit 0.

Acceptance notes

  • A collision edge, not handled. Suppose a ledger entry's id is also a config-defined app's id. The install door refuses to create that (MANIFEST_CONFLICT), but an older ledger can still overlay the app at rehydrate. The DELETE of that entry now withdraws the id from the running kernel until a restart registers the config app again. The [finding] An install-local uninstall (DELETE /api/v1/marketplace/install-local/:id) leaves the package's permission sets in sys_permission_set: the "no ghost grants" uninstall cleanup never runs on that door #21490 cleanups already removed that id's package-managed sets. Carrier: none.
  • State outside the registry is untouched by the withdrawal, as it was before: the package's tables and rows, the handlers bindArtifactHandlers bound, and the i18n bundles and seed datasets stashed at install. Once the objects are withdrawn, the bound handlers have no object route to fire through. This PR does not change any of it, and I did not measure it further.
  • An existing unit fake. The objectql.registry fake in marketplace-install-local-id-gate.test.ts carries only getAllPackages. So its DELETE case now reports a failed registry.uninstallPackage outcome. That case asserts the status, success and the ledger removal, and all three still hold. I left it as is.
  • A failed withdrawal cannot be retried through this door, because the ledger entry is already gone. The remedy is the restart, and the log line names it.

Generated by Claude Code

claude added 3 commits October 3, 2026 11:35
…ge from the running kernel

The install-local DELETE removed the ledger entry and ran the protocol's
uninstall cleanups, but left the package registered in the running kernel
until the next restart. Every reader of "registered packages" kept counting
it, and one of them re-created a ghost grant: another package's hot install
announces metadata:reloaded, the declared-permission seeding re-runs over
every registered package, and the uninstalled package's permission set came
back as a package-managed row that outlived the restart as an orphan.

The DELETE now withdraws the package through SchemaRegistry.uninstallPackage,
the one verb the protocol's own uninstall uses, on the objectql engine's
registry, right after the ledger removal and before the cleanups. A refused
withdrawal is reported on the response as a failed outcome in `cleanups`,
with the cause and remedy in the operator log. The response note no longer
says the kernel cannot unregister in place.

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
…withdrawal and its reinstall control

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
… its refusals; changeset

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
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 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cloud-connection, touching 5 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via MarketplaceInstallLocalPlugin (symbol, a top-level class))
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 — 3 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 cc645f2385b3410e0f00a4d5c6ae4b0fae14b69f → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 12:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 12:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 901e7cf Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21576-uninstall-withdraws-registration branch October 3, 2026 12:47
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