Repository navigation
[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
Description
Activity
objectstack-fleet commented
on Oct 2, 2026 ContributorAuthorMore actionsTriage: first grade —
bug·priority:p2·domain:cli·area:access·pm:queue;findingremoved. The install-local uninstall runs the protocol's registered uninstall cleanupsTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-02T23:54Z. ⛔ Not a claim, ⛔ not a dispatch.Why p2. It is class (a), against ADR-0090's "No ghost grants". The package's permission sets survive its uninstall through this door. A later reinstall, or a same-named package, silently inherits grants that should have died.
Routing.
packages/cloud-connection(handleUninstall) isdomain:cli.Direction:
handleUninstallruns the protocol's registered uninstall cleanups, the same registrydeletePackage/uninstallPackagerun. Soplugin-security'ssecurity.package-permissionscleanup, and every other registered cleanup, fires on this door too.- ⛔ No second permission-removal path in
cloud-connection. - This mirrors Hot install via os package install leaves record-change flows unbound and the package's permission sets unprojected until a restart, and says nothing #21322's direction for the install side: one registration seam that both doors run.
Pins: after an install-local uninstall, there is no package-managed
sys_permission_setrow and no binding, before or after a restart. The protocol uninstall door is unchanged; that is the control.Serial: #21322 (dispatched) edits the same
marketplace-install-local-plugin.ts. Whichever lands later mergesmain.
Generated by Claude Code
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardspriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 2, 2026 objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsClaim: PM loop round 1
Session:session_016GiHYRmLSNWTfbX9gVQkpz
Account:os-bill(the seat's linked user asget_meanswers it; the card's assignee)
Branch:claude/issue-21490-install-local-uninstall-cleanups
Worktree:objectstack-issue-21490
Domain:domain:cli
Seat:domain:cli#1
File surface:packages/cloud-connection/src/marketplace-install-local-plugin.ts(handleUninstall: it runs the protocol's registered uninstall cleanups) plus its tests and a pin, and.changeset/. The cleanups' runner today sits insidedeletePackage(packages/metadata-protocol/src/protocol.ts,domain:engine). If the measurement shows that no existing protocol door can run them for an install-local package, an extracted runner there is a declared cross-lane source edit. ⛔ Anypackages/specedit stops the claim (spec seat). (stop on breach; explain in the report). Amended at the step-1 report:Clause-②is nowyes. The extractedrunUninstallCleanupsis a new public method on the exportedObjectStackProtocolImplementation, andreferences/lanes/spec.mdreads 「放宽接受集或扩大公开面的卡,不论多小,即条款②」
Container & model:M,mode:subagent,model: default (opus)—dispatch-gates --tier --repo objectstack-ai/objectstackataa4632235forpackages/cloud-connectionpaths: no path-derived mandate, the tier is the PM's per-card call; an access-boundary fix with a possible cross-lane seam, so the default tier
Clause-②: yes
Thread-read: 5963310358
Serial constraints cleared: #21322 (PR #21488, triage's named serial onmarketplace-install-local-plugin.ts) LANDED asab52182b4a.⚠️ Open PR #21473 (#21468) editspackages/metadata-protocol/src/protocol.ts, so any edit this claim needs in that file is HARD SERIAL behind it: the merge releases it. In flight on this seat: #21471 (packages/cli/src/commands/secret/,utils/data-migration-plugins.ts) and #21454 (packages/runtime/src/sandbox/body-runner.ts,action-execution.ts), both disjoint. 8 open PRs' file lists were read 2026-10-03T00:31Z onorigin/main9ff74285f1.domain:cliseat ·session_016GiHYRmLSNWTfbX9gVQkpz· 2026-10-03T00:31Z
Generated by Claude Code
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21490,
"status": "blocked",
"branch": "claude/issue-21490-install-local-uninstall-cleanups",
"pr": "#21512",
"session": "session_016GiHYRmLSNWTfbX9gVQkpz — mode:subagent, so the PM's id; the identity is the branch",
"premise_still_valid": true,
"blocked_on": "ONE remaining edit in packages/metadata-protocol/src/protocol.ts, which the dispatch (A3) puts HARD SERIAL behind open PR #21473. Checked at report time: #21473 is open and unmerged. Only the seat can sequence it. The edit: (1) addasync runUninstallCleanups(request: Pick of DeletePackageRequest over packageId, organizationId and actor): Promise of UninstallCleanupOutcome[]right after registerUninstallCleanup, whose body is deletePackage's step-7 loop moved verbatim (warn tag renamed to [protocol.runUninstallCleanups]); (2) replace that loop in deletePackage withconst cleanups = await this.runUninstallCleanups(request);; (3) add the new test packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts; (4) add '@objectstack/metadata-protocol': patch to .changeset/21490-install-local-uninstall-cleanups.md. A ready patch for all of this, never committed or pushed, is at /tmp/claude-0/-home-user-objectstack/28096981-7668-5afc-b5eb-9c1b7c56993a/scratchpad/issue-21490/21490-protocol-runner.patch (sha256 ae98b181f42d2295e31bf87a25c9a2805f9381352cff67dcdaf9511f4437ee91, 239 lines). It applies cleanly to this branch and to #21473's head 296d734 (offsets only). Verified in a detached throwaway worktree: with it applied, metadata-protocol typecheck is green and the new test plus the 4 existing suites that register uninstall cleanups pass, 54/54. Once it lands on this branch, the integration pin turns green (measured through a dist overlay, see tests).",
"summary": "Reproduced at the public door (premise holds). Hot install, then DELETE /api/v1/marketplace/install-local/ID, then restart: the object answers 404 but the managed_by: package sys_permission_set row and the user grant of it survive. install, restart, DELETE, restart reads the same. A2 measured. The protocol HTTP door DELETE /api/v1/packages/ID refuses an install-local package (422 WRITABLE_PACKAGE_REQUIRED; the set survives, and the ledger keeps the package across a restart). The verb deletePackage refuses without a scope (400 TENANT_SCOPE_REQUIRED). With allTenants it answers success:false (0 rows), issues a sys_packages delete and calls registry.uninstallPackage, which withdraws the package from the running kernel (this door never did). It would also delete sys_metadata overlay rows bound to the package, dropping their tables by default. So A3 applies: one extracted runner that deletePackage and this door both call. Built: handleUninstall runs protocol.runUninstallCleanups after the ledger removal succeeds. It passes the MANIFEST id (not the ledger's catalog packageId), no organization, and the admitted operator as actor. Every outcome is answered as data.cleanups, the way deletePackage reports it. A failed cleanup is reported and warned with its remedy (reinstall, then uninstall). No protocol service gives []. A protocol without the runner, or a runner that throws, gives one failed outcome named protocol.runUninstallCleanups. A refused caller, an unknown id or a failed ledger write revokes nothing. There is no second revocation path in cloud-connection. The types come from the producer (DeletePackageRequest, UninstallCleanupOutcome; the same per-consumer narrowing PackagesDomainProtocol uses), so cloud-connection now declares @objectstack/metadata-protocol (already transitive via runtime). The lockfile adds that importer hunk only.",
"tests": "Integration pin packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts (--project integration, 5 boots in beforeAll): on main (ebf0b8e, test only) "Tests 6 failed | 2 passed (8)", the reproduction. With this PR and no runner (14c2c47): "6 failed | 2 passed", and the DELETE body carries cleanups:[{name:protocol.runUninstallCleanups,success:false,...}] while the sets survive (the blocked state, reported loudly). With this PR plus the runner through a temporary dist overlay of @objectstack/metadata-protocol (14c2c47): "Tests 8 passed (8)". ablation-dist-preflight confirmed the marker present before the run ("marker present in 2 built files") and absent after ("marker absent from all 24 built files"); the restore was proven by sha256 -c (index.js OK, index.cjs OK) and a trap restored the files on any exit. Unit pin packages/cloud-connection/src/marketplace-install-local-uninstall-cleanups.test.ts: "Tests 8 passed (8)". Ablation through scripts/ablation-replace.mjs (the call replaced by an empty list; "anchor 1 -> 0, blob f9929f2f700a -> 7354dee7e165"): "4 failed | 4 passed". The red ones are every case needing the call; the green ones are the revoke-nothing and no-protocol cases, which assert an absence the ablation also produces. Restored: "blob == HEAD (f9929f2f700a) and git diff HEAD is empty". pnpm --filter @objectstack/cloud-connection typecheck && test at head 6c2eed8: "Test Files 33 passed (33) / Tests 414 passed (414)". Both tsc programs compile the touched files (--listFiles count 1 each). pnpm --filter @objectstack/cli exec vitest run --project unit at 6c2eed8: "Test Files 249 passed (249) / Tests 3555 passed (3555)". test/vitest-tiers-partition.test.ts is in that unit run; vitest list puts the new file in [integration] only. The integration tier ran locally for the file I added (above). Lint, as a proven narrowing (not pnpm lint): eslint (the repo entry, --stack-size=4000, --no-inline-config, --format json) over the 7 touched paths. 4 files linted, errors=0, warnings=0. ESLint itself reports .md / package.json / pnpm-lock.yaml as outside its configured population. Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules), and its only file reads are scripts/slot-lookup-baseline.json and scripts/query-options-erasure-baseline.json, both untouched. So no untouched file's verdict can move. Protocol patch (not in this PR), in a detached throwaway worktree: metadata-protocol typecheck green; vitest over the new runner test plus protocol.package-delete-refusal, durable-package, protocol.driver-text-disclosure and protocol.marked-refusal-classification gave "Test Files 5 passed (5) / Tests 54 passed (54)".",
"gates": "Derived at 6c2eed8 (after merging origin/main 85e29b8) with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 75 commands, all exit 0. --ran reconciliation: "75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN", a DERIVED zero from the recorded exit codes. Note: pnpm check:dual-build-cjs-loads first exited 3 with PREREQUISITE NOT MET (9 packages had no dist/). It was re-run after a later gate in the same sweep had built them, and exited 0 ("106 published require entry point(s) across 66 package(s) load"). The pnpm lint family is covered by the proven narrowing in tests. CI convergence is not awaited; PR #21512 is draft.",
"files_changed": [
".changeset/21490-install-local-uninstall-cleanups.md",
"packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts",
"packages/cloud-connection/package.json",
"packages/cloud-connection/src/cloud-connection-route-ledger.ts",
"packages/cloud-connection/src/marketplace-install-local-plugin.ts",
"packages/cloud-connection/src/marketplace-install-local-uninstall-cleanups.test.ts",
"pnpm-lock.yaml"
],
"line_budget": "n/a (no skills/** or governed-ledger file touched)",
"deviations": [
"protocol.ts was never edited on this branch. Its prepared patch was applied only in a detached throwaway worktree (/home/user/objectstack-issue-21490-protocheck: never committed or pushed; restored to clean, proven by git status, then removed without --force) to verify typecheck and tests. Dispatch A3 says not to touch protocol.ts; I read that as keeping it off this branch while #21473 is open, and I name it here in case the seat reads it more strictly.",
"A temporary in-place edit of packages/metadata-protocol/dist/index.js and index.cjs (gitignored build output) added the runner for one integration leg. It was restored from a byte backup (sha256 -c OK) and confirmed absent by ablation-dist-preflight --absent.",
"Lint was delivered as a proven narrowing (three pieces of evidence in tests), not a pnpm lint run.",
"origin/main (85e29b8, 3 commits, disjoint from this diff) was merged into the branch before deriving gates, because dispatch-gates flagged the tree as stale. The rebuilt packages were types, lint, plugin-approvals and cloud-connection.",
"The lockfile hunk was hand-trimmed to the one importer entry: pnpm install had also rewritten unrelated deprecated: registry metadata. pnpm install --frozen-lockfile accepts the result.",
"The PR footer is the AGENTS.md session-URL form, not the harness attribution reminder's form, and commit trailers are model-free per the dispatch. The reminder yields by its own precedence clause."
],
"mcp_calls": "0 — no MCP tool used, read or write",
"api_writes": "3 REST writes, each a repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches via the fleet-write relay as objectstack-fleet[bot]: (1) pr_create, which issued POST /repos/objectstack-ai/objectstack/pulls with draft forced, giving #21512, body read back byte-identical (9798 bytes); (2) label-write.mjs --issue 21512 --assign os-bill, which issued POST /repos//issues/21512/assignees, read back matching (assignee os-bill; size/l was set by the size labeler, not this write); (3) this os-dev-report comment through post-stamped.mjs, POST /repos//issues/21490/comments. Plus git push (not REST) of 5 commits.",
"open_questions": [
{
"question": "Clause-② for the remaining protocol.ts edit. The runner is a NEW public method on the exported ObjectStackProtocolImplementation class of @objectstack/metadata-protocol. It is not declared in packages/spec: PackageProtocol, MetadataProtocol and DataProtocol are unchanged, the same posture as deletePackage, so A4 does not trigger and nothing on this branch touches packages/spec. Read literally, though, the Clause-② criterion (does this card widen the acceptance set or expand the public surface?) is met by any new exported method. Which reading governs the metadata-protocol changeset when the seat lands the edit? This PR's own diff (cloud-connection: an additive data.cleanups key on a server-only route, plus a dependency already in the install tree) is declared Clause-②: no.",
"options": [
"A: Clause-② stays no and metadata-protocol takes a patch changeset. The method is an extraction of an existing loop, reached only through the uncontracted protocol service slot, with no spec declaration.",
"B: Clause-② flips to yes and metadata-protocol takes a minor changeset; the PR body line changes to Clause-②: yes."
],
"recommendation": "A. Reasoning on the four axes:\n- Business need: the method exists only so the second door can run the existing registry. It adds no new capability.\n- Long-term soundness: there is one runner and no spec surface to maintain.\n- AI-error resistance: the cleanups are still declared through registerUninstallCleanup and enforced on both doors, so nothing is declared without being enforced.\n- No scope creep: no new contract is minted.\nThe cost of B is a minor bump across the fixed release group for a verb nobody outside the platform calls. If the seat reads the criterion literally, B is consistent with the wording, so this is the seat's call at that edit."
}
],
"out_of_scope_findings": [
"class: (none, an unmeasured inference) · reach: not measurable on main today, because nothing removes the set there. Re-seed window: after this door's DELETE the package stays registered until restart, and plugin-security's metadata:reloaded subscriber re-runs seedCatalogPermissions over EVERY registered package. So a later hot install or publish before that restart would re-project the uninstalled package's sets, orphaning the row again after the restart (the grants stay deleted, because the cleanup removed them). carrier: the seat landing the protocol.ts edit on this PR. Once the runner is live, a third order of events in the pin (DELETE, then another hot install, then restart) measures it. Noted in the PR Acceptance notes, not filed. dedupe words: install-local uninstall metadata:reloaded reseed; seedCatalogPermissions uninstalled package; permission set reprojected after uninstall",
"carrier: 承接者:无 · noted, not filed. The DELETE response note says the kernel API "does not support unregistering apps in-place", but SchemaRegistry.uninstallPackage exists and deletePackage uses it. The wording was kept because withdrawing from the running kernel is outside this card. Recorded in the PR Acceptance notes.",
"carrier: 承接者:无 · noted, not filed. A failed cleanup cannot be retried through this door: the ledger entry is already gone, so the DELETE answers 404. The protocol door has the same property once its sys_packages row is gone. The warn names the working remedy (reinstall, then uninstall). Recorded in the PR Acceptance notes."
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsStep reported: the install-local half is built in PR #21512 (draft). The one protocol edit is serial behind PR #21473 →
pm:blockeddomain:cliseat ·session_016GiHYRmLSNWTfbX9gVQkpz· read 2026-10-03T01:51ZBlocked-by: #21468
Unlock-action: re-check PR #21473What the dev measured. The os-dev report on this card is the source.
- The premise is reproduced: after an install-local DELETE and a restart, the package-managed
sys_permission_setrow and its user grant survive. - A2 is measured: no existing protocol door fits.
DELETE /api/v1/packages/IDrefuses an install-local package (422WRITABLE_PACKAGE_REQUIRED).deletePackagerefuses without a scope.- With
allTenants,deletePackagealso withdraws the package from the running kernel and would drop overlay tables.
- So triage's direction lands as A3: one extracted runner that both doors call.
What PR #21512 carries (draft, not yet reviewed for ACCEPT):
handleUninstallcalls the protocol's runner after the ledger removal succeeds, and answers every outcome asdata.cleanups.- A missing runner is reported as a failed outcome, ⛔ never swallowed.
- There is no second revocation path in
cloud-connection. - Pins:
- an integration pin, 6 red on
mainand 8/8 green with the runner present; - a unit pin, 8/8 green, which ablation turns 4 red.
- an integration pin, 6 red on
The one remaining edit is in
packages/metadata-protocol/src/protocol.ts(domain:engine):runUninstallCleanups, extracted verbatim fromdeletePackage's cleanup loop, which then calls it, plus its test and a changeset row.- It is HARD SERIAL behind open PR fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour #21473 (security(forms): withdrawing a public form from anonymous intake on a walled (and degraded walled) tenancy posture — follow-up to #21331, detail withheld pending maintainer #21468), which edits the same file. The dev prepared the patch and verified it in a throwaway worktree; it was never pushed.
- The extraction is ruled at the seat:
Clause-②: yes. It adds a public method to the exportedObjectStackProtocolImplementation, andreferences/lanes/spec.mdreads 「放宽接受集或扩大公开面的卡,不论多小,即条款②」. The claim is amended. - So at the unlock:
@objectstack/metadata-protocoltakes a minor changeset;- the PR's
Clause-②line becomesyes; - an at-tier contract review is owed before the PR enqueues;
- the
protocol.tspath is declared on the engine seat post.
At the unlock, when PR #21473 merges: merge
maininto this branch, apply the runner, re-run the pins, then review for ACCEPT.Noted for the landing, not filed:
- the re-seed window between this DELETE and the next restart, which the pin measures once the runner is live;
- the stale "does not support unregistering apps in-place" note in the DELETE response;
- a failed cleanup cannot be retried through this door.
All three are in the PR's Acceptance notes.
Generated by Claude Code
- The premise is reproduced: after an install-local DELETE and a restart, the package-managed
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsUnlocked: #21468 is closed and PR #21473 has landed (
ce532184d1). The resume round is dispatched under the standing claim5963651219domain:cliseat ·session_016GiHYRmLSNWTfbX9gVQkpz· read 2026-10-03T09:12Z- The blocker: security(forms): withdrawing a public form from anonymous intake on a walled (and degraded walled) tenancy posture — follow-up to #21331, detail withheld pending maintainer #21468 closed
completedat 2026-10-03T07:22Z, via PR fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour #21473 →ce532184d1. That merge releases theprotocol.tshold named in5964293631. - Re-checked on
origin/mainbd70706713: two other open PRs editprotocol.ts, and both are disjoint from this edit:- PR fix(rest): one anonymous-intake rule honours every declared public-form withdrawal #21566 edits the import block.
- PR fix(objectql): an in-process engine verb refuses an object name the registry does not resolve (#21516) #21545 edits the object-name resolution.
- This edit is the method after
registerUninstallCleanup, plusdeletePackage's cleanup loop.
The later-landing PR mergesmain. The path is declared on the engine seat post (5967585445).
- The claim stands as amended:
- the same branch,
claude/issue-21490-install-local-uninstall-cleanups, and the same PR, fix(cloud-connection): an install-local uninstall runs the protocol's registered uninstall cleanups #21512; Clause-②: yes, with a minor changeset for@objectstack/metadata-protocol;- an at-tier contract review is owed before it enqueues.
- the same branch,
- The resume round: merge
main, apply the prepared runner patch, re-run the pins (including the re-seed order of events), then report for ACCEPT. - Serial on this seat: [Decision] install-local: a package's declared jobs are never scheduled — refuse the install, name them in the install answer, or make job handlers declarable bodies (the jobs half of #21322) #21489 (claim
5967523158) edits the install and rehydrate path ofmarketplace-install-local-plugin.ts, and this PR editshandleUninstallin the same file. Neither is a single-claim path, so it is ordinary concurrency: the later-landing PR mergesmain.
pm:blocked→pm:dispatchedin this act.
Generated by Claude Code
- The blocker: security(forms): withdrawing a public form from anonymous intake on a walled (and degraded walled) tenancy posture — follow-up to #21331, detail withheld pending maintainer #21468 closed
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21490,
"status": "done",
"branch": "claude/issue-21490-install-local-uninstall-cleanups",
"pr": "#21512",
"session": "session_016GiHYRmLSNWTfbX9gVQkpz — mode:subagent, so the PM's id; the identity is the branch",
"premise_still_valid": true,
"summary": "Resume round: the protocol half is now in PR #21512, which is still draft. The identity check passed: the newest Claim: (5963651219) names this branch. The worktree was attached to the existing branch at 6c2eed8 and merged with origin/main bd70706. A1: the prepared patch (sha256 ae98b181…ee91 verified, 239 lines) passed git apply --check and applied cleanly, with offsets of 26 and 362 lines and no hand re-make. runUninstallCleanups sits right after registerUninstallCleanup; deletePackage's loop moved verbatim, and the warn tag is the only rename. deletePackage now calls it, and its four existing suites are the green control. A2: the changeset adds '@objectstack/metadata-protocol': minor, keeps cloud-connection at patch, and its Clause-② line now reads plain yes. The PR body carries the same line, line-initial under Fixes #21490. check-changeset-no-major, driven offline with the new body as the event, reads the level axis as discharged by metadata-protocol: minor. A3: the pins are green on real built packages (turbo build of cli^..., no overlay). The integration pin's original 8 cases are 8/8, the unit pin is 8/8, and the runner test plus the 4 cleanup suites are 54/54. A4: measured RED. After the DELETE, another package's hot install re-projects the uninstalled package's set as a fresh managed_by: package row, and it survives the restart as an orphan while the package's object answers 404. The grant stays revoked. This is pinned in the integration file as a third order of events: a plain precondition, a plain "grant stays revoked" case, and two it.fails readings for the set. It is not fixed here; out_of_scope_findings[0] carries it for filing. A5: both ablation legs went red and were restored with proof. origin/main was merged again at 6dd99b8, because PR #21566 had landed in protocol.ts; that merge was clean. The final runs are at cd5cabc.",
"tests": "All at head cd5cabc unless named. Packages built with: NODE_OPTIONS=--max-old-space-size=4096 os-verify-lock -c "pnpm turbo run build --filter='@objectstack/cli^...' --concurrency=2" → "Tasks: 58 successful, 58 total", VERDICT command-exit 0. ablation-dist-preflight finds the runner's warn tag "protocol.runUninstallCleanups] uninstall cleanup" in metadata-protocol dist/index.js and index.cjs. (1) Integration pin: pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/package-install-local-uninstall-cleanups.integration.test.ts → "Test Files 1 passed (1) / Tests 10 passed | 2 expected fail (12)". Orders 1 and 2 are 8/8 on real source, where the previous round read green only through a dist overlay. Order 3 adds 2 plain greens and 2 it.fails. The A4 measurement run, with all its readings as plain its: "2 failed | 9 passed (11)". The re-projected row was ps_mus6qtl18p44s0jj, created_at 09:24:22Z (after the DELETE), managed_by package, package_id com.example.tasksapp. The same id was read after the restart. (2) pnpm --filter @objectstack/metadata-protocol exec vitest run on protocol.uninstall-cleanups-runner, durable-package, protocol.driver-text-disclosure, protocol.marked-refusal-classification and protocol.package-delete-refusal → "Test Files 5 passed (5) / Tests 54 passed (54)". (3) pnpm --filter @objectstack/metadata-protocol typecheck green, and the full suite → "Test Files 207 passed | 3 skipped (210) / Tests 3192 passed | 19 skipped (3211)". tsc --listFiles counts the new test once. (4) pnpm --filter @objectstack/cloud-connection typecheck (both programs) green, and the full suite → "Test Files 33 passed (33) / Tests 414 passed (414)". The unit pin, run verbose, is 8/8. (5) pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 → "Test Files 2 failed | 250 passed (252) / Tests 3672 passed | 29 skipped". The 2 failures were published-subpath-console.pin and published-subpath-hook-body.pin, refused at collection with "packages/cli is not built (./dist/index.js is absent)": a prerequisite, not a reading. After pnpm --filter @objectstack/cli build they read "Test Files 2 passed (2) / Tests 29 passed (29)". (6) Ablation leg A, the door's call, at ea93d27. Anchor "const cleanups = await this.runUninstallCleanups(ctx, manifestId, admission.userId);" x1 was replaced with an empty list plus the marker ABLATION-21490-DOOR: "anchor 1 -> 0, blob f9929f2f700a -> 1771b1b9b2f3". The build's DTS step exited 1 (TS6133, the now-unused private helper), but the JS bundles were emitted, and preflight found the marker in both runtime bundles, dist/index.js and index.cjs. The unit pin read "4 failed | 4 passed (8)". The integration pin read "8 failed | 2 passed | 2 expected fail (12)". Restore: "blob == HEAD (f9929f2f700a) and git diff HEAD is empty". The rebuild exited 0, and preflight --absent found the marker absent from all 6 built files with the tree clean. (7) Ablation leg B, the runner, at ea93d27. Anchor "for (const [name, cleanup] of this.uninstallCleanups) {" x1 was replaced with a loop over an empty Map plus the marker ABLATION-21490-RUNNER: "anchor 1 -> 0, blob 4308b1f47cce -> 721cc1b5c8b8". The build exited 0, and the marker was in dist/index.js and index.cjs. The five suites read "Test Files 5 failed (5) / Tests 11 failed | 43 passed (54)": the runner test 4/4 red, durable-package 3, driver-text-disclosure 2, marked-refusal 1, package-delete-refusal CONTROL 1. The integration pin read "8 failed | 2 passed | 2 expected fail (12)". Restore: "blob == HEAD (4308b1f47cce) and git diff HEAD is empty". The rebuild exited 0, and preflight --absent found the marker absent from all 24 built files with the tree clean. Both legs ran through scripts/ablation-replace.mjs in WRAP mode, under a driver with trap EXIT INT TERM. (8) Lint: the full pnpm lint (node --stack-size=4000 eslint . --no-inline-config) exited 0 at cd5cabc; no narrowing.",
"gates": "At cd5cabc, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 77 commands over the 9 PR paths, against merge base 6dd99b8. All 77 exited 0. The --ran reconciliation read "77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN", a DERIVED zero. pnpm check:dual-build-cjs-loads first exited 3, PREREQUISITE NOT MET: 9 packages had no dist/. Later gates in the same sweep built them, and the re-run exited 0. The ran file records the re-run. check-changeset-no-major run locally reads the level axis as NOT APPLICABLE, because it has no PR payload. Driven with --event and the new PR body, it reads "LEVEL AXIS: this PR declares clause-② yes … @objectstack/metadata-protocol: minor", exit 0. pnpm --filter @objectstack/spec check:generated after the merge: "All 15 generated artifacts are up to date". pnpm lint: exit 0. CI convergence is not awaited, and PR #21512 stays draft.",
"files_changed": [
".changeset/21490-install-local-uninstall-cleanups.md",
"packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts",
"packages/cloud-connection/package.json",
"packages/cloud-connection/src/cloud-connection-route-ledger.ts",
"packages/cloud-connection/src/marketplace-install-local-plugin.ts",
"packages/cloud-connection/src/marketplace-install-local-uninstall-cleanups.test.ts",
"packages/metadata-protocol/src/protocol.ts",
"packages/metadata-protocol/src/protocol.uninstall-cleanups-runner.test.ts",
"pnpm-lock.yaml"
],
"line_budget": "n/a (no skills/** or governed-ledger file touched)",
"deviations": [
"Worktree: git worktree add -b refused, because a local branch claude/issue-21490-install-local-uninstall-cleanups already existed at 6c2eed8, the same sha as the remote head. The worktree was attached to that ref and its upstream set to origin. No new branch, no rebase, no force-push.",
"Two merges of origin/main: bd70706 (the dispatch tip) and 6dd99b8, which landed PR #21566 in the protocol.ts import block. Both were clean, and neither deferred a regeneration. PR #21545 was still open at report time.",
"The A4 case is committed with its two red set readings as it.fails (precedent: packages/cli/test/commands.test.ts) beside plain assertions for the precondition and the revoked grant. The reading is measured in the file and CI stays green; it is not fixed here. Each it.fails turns red when its half is fixed, the cue to promote it.",
"Leg A's mutate build exited 1 on the DTS step (TS6133: the private helper is unused once its only call is ablated). The ESM and CJS bundles the suites load were emitted, and preflight proved the marker in both. The leg is a measurement of runtime behaviour, not of the .d.ts.",
"The ablations were measured at ea93d27, before the second merge. The final suites and gates were rerun at cd5cabc. The merge brought no change to either ablated anchor or to the pins.",
"cli unit project: 2 files refused at collection for want of packages/cli's own dist/ (only its dependencies had been built). They were run again after building cli, 29/29. Recorded as a prerequisite, not a failure.",
"Commit trailers are model-free (Claude-Session plus Co-Authored-By: Claude), per the dispatch and AGENTS.md. The PR footer is the AGENTS.md session-URL form. The harness attribution reminder yields by its own precedence clause."
],
"mcp_calls": "0 — no MCP tool used, read or write",
"api_writes": "2 REST writes, each a repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay as objectstack-fleet[bot]. (1) issue_patch on #21512 with body, which issued PATCH /repos//issues/21512. The relay read back "12989 byte(s) sent, 12989 stored — identical", and a separate REST read confirmed it: draft true, head cd5cabc, assignee os-bill. (2) This os-dev-report comment through post-stamped.mjs: POST /repos//issues/21490/comments. Not REST: 4 git pushes (the probe, which was a no-op; then 47a5b2b, ea93d27 and cd5cabc). No label write, because the dispatch budget named none: the PR already carries assignee os-bill, and size/l → size/xl is the size labeler's.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: public door, measured —os package installA (POST /api/v1/marketplace/install-local), then DELETE /api/v1/marketplace/install-local/A (200, cleanups ran, set and grant gone), thenos package installof ANOTHER package B, hot. GET /api/v1/data/sys_permission_set?name=SET then answers A's set again: a fresh row, managed_by package, package_id A. After a restart the same row is still there, while GET /api/v1/data/A_OBJECT answers 404. That is an orphan package-managed set, grantable by an admin, which a later reinstall or a same-id package inherits. The grant itself stays revoked. evidence: packages/cli/test/package-install-local-uninstall-cleanups.integration.test.ts on PR #21512, order 3 — the two it.fails readings, plain-red in the measurement run (row ps_mus6qtl18p44s0jj at both readings). mechanism: the install-local DELETE leaves the package registered in the running kernel until restart, and plugin-security's metadata:reloaded subscriber (security-plugin.ts, the hook over seedCatalogPermissions) re-runs bootstrapDeclaredPermissions, whose readDeclared(ql, 'permission') still lists the uninstalled package's sets. Governing text: ADR-0090 line 232, "No ghost grants", and triage's pin "no package-managed sys_permission_set row … before or after a restart". Likely fix seams: withdraw the package from the running registry on this DELETE (SchemaRegistry.uninstallPackage, which deletePackage uses), or have the seeding skip uninstalled packages. Either touches the install-local file's non-uninstall path or plugin-security, outside this claim. Not fixed here; the seat files it. dedupe words: install-local uninstall reseed metadata:reloaded; uninstalled package permission set reprojected; seedCatalogPermissions after DELETE install-local; orphan sys_permission_set after restart",
"carrier: 承接者:无 · noted, not filed. The DELETE response note still says the kernel API "does not support unregistering apps in-place", but SchemaRegistry.uninstallPackage exists and deletePackage uses it. The wording is kept, because withdrawing from the running kernel is outside this card; it is also the likely fix seam for the finding above. Recorded in the PR Acceptance notes.",
"carrier: 承接者:无 · noted, not filed. A failed cleanup cannot be retried through this door: the ledger entry is already gone, so the DELETE answers 404. The protocol door has the same property once its sys_packages row is gone. The warn names the working remedy (reinstall, then uninstall). Recorded in the PR Acceptance notes."
],
"cleanup": "Worktree /home/user/objectstack-issue-21490: node_modules removed, then git worktree remove without --force. Nothing was started in the background, and no dev server or monitor is left running. The scratchpad logs are kept under issue-21490/."
}
Generated by Claude Code
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsACCEPT — PR #21512 at
cd5cabce34, pending its contract reviewdomain:cliseat ·session_016GiHYRmLSNWTfbX9gVQkpz· review of record, read on GitHub 2026-10-03T10:32Z · ⛔ classes, doors and roles onlyShape:
- Draft, base
main. The first line isFixes #21490, andClause-②: yesis line-initial. The footer uses the session-URL form. - 9 files, +1040 / −45 (the PR's own counts). Not governed.
- CI on
cd5cabce34: 31success(Test Core 1–6 and its rollup, Type Check ×5, Lint & Repo Gates, Dogfood ×5 and Temporal Conformance among them) and 4skipped(Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke). The skips are judged bycheck-expected-skipsbefore landing.
Against triage's direction (
5963310358) and the seat's ruling at the step report (5964293631), read from the diff:- One registry, both doors.
handleUninstallcalls the protocol'srunUninstallCleanupsafter the ledger removal succeeds.- It answers every outcome as
data.cleanups. - A missing or throwing runner is one failed outcome, ⛔ never swallowed.
- ⛔ There is no second revocation path in
cloud-connection.
- The runner is a verbatim extraction.
runUninstallCleanupssits right afterregisterUninstallCleanup.deletePackage's registered-cleanup loop moved into it byte for byte, including the [finding]metadata-protocolinterpolates raw driver text into client-facing messages — three downstream sanitizers each have a hole because of it (option C of #8086) #8136 and [Decision]metadataStoreUnavailableErrordestroys a producer'suserMessagemark AT THE PRODUCER — a metadata app's marked refusal onsys_metadatacan never reach any door #12536 withholding and the refusal mark. The only change is the warn tag.deletePackagenow calls it. Its four existing cleanup suites are the green control.
Clause-②: yes, as ruled: a new public method on the exportedObjectStackProtocolImplementation. The changeset gives@objectstack/metadata-protocolminor and@objectstack/cloud-connectionpatch.- Checked against the diff sentence by sentence, it matches: the method's contract, "never throws", the log tag being the only visible change in
deletePackage, and the declared dependency. - The contract review of record is owed before enqueue.
- Checked against the diff sentence by sentence, it matches: the method's contract, "never throws", the log tag being the only visible change in
- Pins on real source, with no dist overlay this round:
- the integration pin, orders 1 and 2: 8/8;
- the unit pin: 8/8;
- the runner test plus the 4 cleanup suites: 54/54.
- Reverse verification: two ablations, the door's call and the runner itself. Each went red where predicted, and each restore was blob-proven.
- The cross-lane path
protocol.tsis declared on [PM seat] domain:engine · seat 2 — ⏳ vacant #20966 (5967585445). The latermainmerge brought PR fix(rest): one anonymous-intake rule honours every declared public-form withdrawal #21566's import-block edit in cleanly.
A4, measured red and filed as #21576. After this door's uninstall, a hot install of another package re-projects the uninstalled package's permission set, which survives the restart as an orphan package-managed row. The grant stays revoked. Order 3 pins it:
- plain cases for the precondition and the revoked grant;
- two
it.failsreadings for the set.
Each
it.failsturns red when #21576 is fixed, which is the cue to promote it. The carrier is #21576. ⛔ This is not a skipped or disabled test. It is a measured red recorded in place, and the repo has precedent for the form (packages/cli/test/commands.test.ts).Gates:
- 77 derived, and all 77 exit 0.
- The full
pnpm lintexits 0, with no narrowing. check-changeset-no-major, driven with the PR body as its event, reads the level axis as discharged bymetadata-protocol: minor.
Deviations, accepted:
- The worktree was attached to the existing local branch.
- Two clean
mainmerges. - The
it.failsform for A4's readings. - Leg A's DTS step exited 1 under ablation, an expected consequence of the unused helper. The JS bundles carried the marker.
Noted, not filed, both in the PR's Acceptance notes:
- the stale 「does not support unregistering apps in-place」 response note, a candidate seam on [finding] After an install-local uninstall, a later hot install of ANOTHER package re-projects the uninstalled package's permission set, which survives the restart as an orphan package-managed row #21576;
- a failed cleanup cannot be retried through this door.
needs:contract-reviewis hung on PR #21512 in this act.
Generated by Claude Code
- Draft, base
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsLanded: PR #21512 →
74281a8e4adomain:cliseat ·session_016GiHYRmLSNWTfbX9gVQkpz· read 2026-10-03T11:20Z · ⛔ classes, doors and roles only- Merged 2026-10-03T11:20Z through the merge queue (
added_to_merge_queue2026-10-03T10:46Z). The head wascd5cabce34, which both the ACCEPT5968325661and the contract review PASS5968400867read. - Shape:
git rev-list --parents -n 1 74281a8e4agives 2 fields, so it is a single-parent squash. The commit is an ancestor oforigin/main. It is 9 files, +1040/−45, matching the PR. - Content read on
origin/main:packages/metadata-protocol/src/protocol.tscarriesasync runUninstallCleanupsonce;marketplace-install-local-plugin.tscalls it.
- The card closed
completedviaFixes #21490.pm:dispatchedis stripped in this act. - Unlocks [finding] After an install-local uninstall, a later hot install of ANOTHER package re-projects the uninstalled package's permission set, which survives the restart as an orphan package-managed row #21576 (
Blocked-by: #21490): the uninstall withdraws the package from the running registry, and the twoit.failsreadings this PR landed become plain passes. It is claimed and dispatched by this seat next.
Generated by Claude Code
- Merged 2026-10-03T11:20Z through the merge queue (
- added 3 commits that reference this issue
on Oct 7, 2026
Filing gate: ① a defect with a named position, a
findingof class (a).reach:was measured at a public door.Source: the os-dev report on #21322 (
5962851713),out_of_scope_findings[0], measured onmain4c8363f4. Filed by thedomain:cliseat,session_016GiHYRmLSNWTfbX9gVQkpz. ⛔ Not a claim.Reader who acts: triage grades and routes. The door is
packages/cloud-connection; the cleanup it skips isplugin-security's.What happens (measured, public door)
sys_permission_setwithmanaged_by: package.DELETE /api/v1/marketplace/install-local/PACKAGE_IDanswers 200.GET /api/v1/data/sys_permission_set?name=SET_NAMEstill returns the package-managed row.The path that existed before #21322's change gives the same orphan row: install, restart, DELETE, restart.
Why (read from source at
origin/main)handleUninstall(packages/cloud-connection/src/marketplace-install-local-plugin.ts:1074) removes the ledger entry. It never runs the protocol's uninstall cleanups.plugin-securityregisters exactly that cleanup:protocol.registerUninstallCleanup('security.package-permissions', …)atpackages/plugins/plugin-security/src/security-plugin.ts:4293. It removes package-owned sets with their position and user bindings and the package's suggestion rows, "so grants die with the package".Governing text
ADR-0090 (
docs/adr/0090-permission-model-v2-concept-convergence.md:232): "(removing its sets bypackageId, ADR-0086 D3) revokes it everywhere at once. No ghost grants."Dedupe
MCP
search_issues, repo-scoped, open and closed together:sys_metadatarows #7557 and [finding]uninstallPackageunregisters the namespace BEFORE the verb that can refuse — a rejected uninstall leaves the package half-mutated #7970 are closed and concern the protocol uninstall door.DELETE /packages/:idthrough the dispatcher removes the package from the live registry, then refuses withTENANT_SCOPE_REQUIRED: a 400 that leaves the uninstall half applied #20492, Product question: an uninstall with no organizationId deletes EVERY organization's rows for that package (measured 5 of 5, including a foreign org's) #7780, Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphanedsys_metadatarows #7557, [finding]uninstallPackageunregisters the namespace BEFORE the verb that can refuse — a rejected uninstall leaves the package half-mutated #7970, Uninstalling a package orphans its tenants' bare-key ADR-0005 overlays — warned about, but nothing resolves them #7951,protocol.deletePackagefinds zerosys_metadatarows the data plane finds 3 of — uninstall leaves orphaned rows (persistence half of #7557) #7705. All six concernprotocol.deletePackage/uninstallPackage, ⛔ not install-local'shandleUninstall.None covers this door.
Dedupe words: install-local uninstall orphan permission set; registerUninstallCleanup install-local; ghost grants after package DELETE; sys_permission_set survives uninstall.
Generated by Claude Code