fix(cloud-connection): an install-local uninstall withdraws the package from the running kernel - #21581
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Fixes #21576
Clause-②: no
What this changes
DELETE /api/v1/marketplace/install-local/:manifestIdnow withdraws the package from the running kernel. It does this right after the ledger removal, throughSchemaRegistry.uninstallPackage, the one verbdeletePackageuses. The uninstall cleanups from [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 then run as before.metadata:reloaded, which is what re-projected the uninstalled package's set.5968468119: the first seam, the root. There is no "skip uninstalled" check in plugin-security.packages/metadata-protocol,packages/plugins/plugin-securityorpackages/spec. The install and rehydrate path ofmarketplace-install-local-plugin.tsis untouched.Files:
packages/cloud-connection/src/marketplace-install-local-plugin.ts: the uninstall path only. That ishandleUninstall, the newwithdrawFromRunningKernel, 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 twoit.failspromoted to plainit, a hot-object case in orders 1 and 2, and a new order 4..changeset/21576-install-local-uninstall-withdraws-registration.md:@objectstack/cloud-connectionpatch,Clause-②: no.A1: reproduction on
74281a8e4ait.failschanged to plainit, on packages built in this worktree. It readTests 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:72bd1cc16e), andgit status --porcelainwas empty.A2: how the door reaches the registry
deletePackagereaches the registry asthis.engine.registry.uninstallPackage(packageId).this.engineis the ObjectQL engine the protocol is assembled over (assembleMetadataProtocol(ctx, this.ql, …)inpackages/objectql/src/plugin.ts).objectqlservice. Themanifestservice, which this door installs through, callsql.registerAppon it.ctx.getService('objectql').registryand callsuninstallPackage(manifestId). The call is typed by the spec contractIObjectQLEngine.registry(EngineSchemaRegistryView.getPackage/uninstallPackage), with noas any.installPackagefiles 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:
deletePackageuses the same order: the registry withdrawal, thenrunUninstallCleanups.metadata:reloadedcould re-project the sets they had just removed.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 assuccess: true, and the set and the grant are revoked.Nothing is withdrawn when the uninstall did not happen. The unit pin covers each case:
When the withdrawal fails:
registry.uninstallPackageincleanups. That is the way a failed cleanup is reported.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
uninstallPackagewithdraws, and what the object doors answerFrom
SchemaRegistry.uninstallPackage(packages/objectql/src/registry.ts), the verb withdraws:unregisterItemsByPackage);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:
74281a8e4a, measured)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
upgradedFromisnullinstead ofprevious-marketplace-version. A reinstall after a restart already answerednull. No key moves, no value leaves the existing set, and nothing in this repository reads the field.A5: the note
cleanups."registry.uninstallPackageentry.A6: reverse verification
The mutation.
scripts/ablation-replace.mjsreplaced the anchorregistry.uninstallPackage(manifestId);with a marker statement that logsABLATED_21576_withdrawal. The anchor went x1 to x0 and the replacement x0 to x1. The blob changed from7667b86205to83d263078b.The build. I rebuilt
@objectstack/cloud-connection.ablation-dist-preflightfound the marker indist/index.jsanddist/index.cjs.The readings:
2 failed | 12 passed. The two red cases are the ones that read the withdrawal.5 failed | 11 passed. The red cases are:The restore.
ablation-replacerestored the file: its blob equals HEAD's7667b86205, andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentfound the marker absent from all 6 built files, with the whole tree clean.Tests and gates, at
9f3e65608d@objectstack/cli^..., then@objectstack/cloud-connectionrebuilt):Test Files 1 passed (1) / Tests 16 passed (16).@objectstack/cloud-connectionfull suite:34 passed (34)files,420 passed (420)tests.@objectstack/cloud-connectiontypecheck: both programs are green, and--listFilesshows the new test file in both.@objectstack/cli: typecheck is green, includingcheck:test-typecheck, and the tier partition pin passes 22/22. The onlypackages/clichange 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-loadsfirst answered PREREQUISITE NOT MET, because a whole-repodist/was missing. Afterpnpm buildit exits 0.--ranreconciliation with an exit code per line reads "64 run, 0 NOT-MEASURED (a DERIVED zero)".check-issue-citations --censusandcheck-shard-attestation. NOT MEASURED, left to CI.pnpm lint(eslint . --no-inline-config), the full run with no narrowing: exit 0.Acceptance notes
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.bindArtifactHandlersbound, 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.objectql.registryfake inmarketplace-install-local-id-gate.test.tscarries onlygetAllPackages. So its DELETE case now reports a failedregistry.uninstallPackageoutcome. That case asserts the status,successand the ledger removal, and all three still hold. I left it as is.Generated by Claude Code