fix(metadata-protocol): a refused sys_packages delete fails the uninstall before anything is removed - #21438
fix(metadata-protocol): a refused sys_packages delete fails the uninstall before anything is removed#21438objectstack-fleet[bot] wants to merge 12 commits into
Conversation
…st and fails when the store refuses deletePackage ran the sys_packages delete after the metadata deletes, inside a catch that logged a returned or thrown refusal and answered success. The delete is now the first durable step; a refusal on either channel throws packagePersistFailureError before anything is removed. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…tart and the ordinary uninstall Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…s delete Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…aller's limit and refuses combinators Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 39 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 0de452df73be8c6fac89afe332e9007ea01a5534 && git checkout 0de452df73be8c6fac89afe332e9007ea01a5534
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65 fc6b6515a5511def57ff8895286a4160b0cf9258 && git checkout -B drift-repro 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65 && git merge --no-ff fc6b6515a5511def57ff8895286a4160b0cf9258
node scripts/docs-audit/affected-docs.mjs --json 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65
|
…lete-package-refusal
…raws the package or clears its disable record The door ran registry.uninstallPackage and setPackageDisabled(..., false) before protocol.deletePackage, so a store that refused the sys_packages delete got a 500 while the running process had already dropped the package and its durable disable record. Existence is now read with getPackage, and the withdrawal and the disable clear follow a deletePackage that answered. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ete, across a restart A real SQLite composition (ObjectQL, SqlDriver, PackageServicePlugin, the metadata protocol and the dispatcher) booted twice over one file. The #7557 envelope double now models an unregistered package through getPackage, the read the door now makes. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…lete-package-refusal
…ninstall refusal before the sys_packages delete The ADR-0029 extender refusal was decided only by performing the uninstall, which now runs after the store delete. Its refusal pass becomes SchemaRegistry.assertPackageUninstallable, which unregisterObjectsByPackage itself calls (one predicate), and deletePackage asks it before its first durable step, so the refusal is thrown with nothing removed. The door's late-withdrawal comment no longer says the refusal arrives there. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…try, protocol and door Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21276
Clause-②: no
When the store refuses the
sys_packagesdelete,DELETE /api/v1/packages/:idnow answers that failure and changes nothing. The running process keeps serving the package, a disabled package stays disabled across a restart, and its metadata, tables and grants stay in place. There are two halves:deletePackagenow deletes the stored row as its first durable step, and a refusal on either channel throws before anything else is removed.deletePackagebefore it withdraws the package from the running registry or clears its disable record.Cross-lane surfaces, named before the change list.
packages/metadata-protocol/src/protocol.ts(domain:engine): this PR editsdeletePackage. Beside it, the two On MySQL, sys_packages is never created (its raw DDL fails three ways), so package publish answers 500 and an installed or edited package silently disappears on restart while install and PATCH answer success #21243 helpers it reuses (packagePersistFailureErrorandpackagePersistFailureMessage) take one more verb value,'delete', with one new sentence. That is the only edit outsidedeletePackage.packages/runtime/src/domains/packages.ts(domain:cli, added by the claim revision on DELETE /api/v1/packages/:id answers success when the sys_packages delete fails, so the package is gone from the live registry and comes back after the next restart #21276): theDELETEarm only, plus the header note of its organization-scope refusal.packages/runtime/src/package-uninstall-store-refusal.integration.test.ts(new), and the registry double ofpackages/runtime/src/domains/packages-uninstall-envelope.test.ts.service-packageis unchanged: itsdeletealready states a refusal on both channels (PackageDeleteResult).The door, measured live
pnpm dev:crmwas booted twice over one SQLite file (a privateOS_HOME, no--fresh) and driven as the seeded platform admin. A trigger refusesDELETEonsys_packagesfor the forced package only:CREATE TRIGGER refuse_forced_delete BEFORE DELETE ON sys_packages WHEN OLD.id = 'com.example.forced' BEGIN SELECT RAISE(ABORT, '…'); END. The control package has no trigger.main22c2d6f4d, forced6d823ae7a), forced608838a73), forcedDELETE /api/v1/packages/:id200,success: true500 DATABASE_ERROR500 DATABASE_ERROR200GET, same process404404200404GETafter a restart200: comes back200200404GETafter a restartenabled: true,status: installedenabled: false,status: disabledThe disable leg has a control leg on
6d823ae7a: the package was disabled and the server restarted with noDELETE, and it came backdisabled.The
500message reads: "Package 'com.example.forced' was not uninstalled: the package registry could not delete its stored record, and a package whose record is kept comes back on the next restart, so its metadata, data and grants were left in place. The reason is in the server log." The driver'sSQLITE_CONSTRAINT_TRIGGERline stays in the server log and oncause.The steps of
deletePackage, in their new order400 TENANT_SCOPE_REQUIREDsys_metadataread503or a classified refusalsys_packagesdelete (onmain: after step 4){ success: false }, or thrownpackagePersistFailureError; nothing changed (onmain:console.warn, then success)sys_metadatadeletes and table teardownfailed[]; the door answers400 PACKAGE_DELETE_PARTIALSchemaRegistry.uninstallPackage, which also releases the namespace)console.warn; the package leaves at the next restartcleanups[]The error shape is #21243's, unchanged. A declared 4xx leaves as the producer answered it. Anything else is a
500that quotes nothing, with the original oncauseand a catalogued code carried (DATABASE_ERRORfrom a live SQL driver;INTERNAL_ERRORderived for a returned{ success: false }). No code is minted. There is no undo machinery, because nothing durable precedes step 3.Steps that can still refuse after the store delete (triage's ruling asks for them to be moved ahead or reported here):
deleteMetaItem's own checks, which is anotherprotocol.tsregion. A store fault cannot be decided ahead. This is unchanged frommain, where the store delete ran after these steps whatever they answered. The door answers400 PACKAGE_DELETE_PARTIALand lists what was left.SchemaRegistryhas no verb that answers "would this uninstall be refused?" without performing it, and a copy of its extender predicate would be a second place that must agree with the first. This is the one behaviour this PR moves later at the door. Before this PR, the door's own up-frontuninstallPackagerefused this case with a500before anything was deleted. Now the stored rows go first, and the door answers what the store bears out:200withregistryRemoved: false. The process keeps serving the package until it restarts. This is pinned below and reported to the seat.cleanups[].Door half
DELETE /api/v1/packages/:idused to runregistry.uninstallPackage(id)andsetPackageDisabled(environmentId, id, false)before it calledprotocol.deletePackage. It now runs in this order:requireManageMetadata, the ADR-0070 read-only gate, and the organization-scope mirror;registry.getPackage(id);deletePackage. A throw is answered througherrorFromThrown, exactly as before, and nothing has been touched at that point;deletePackagealready withdrew it), and the clear of the disable record for a package this request found.The refusal envelopes are unchanged:
403,422 WRITABLE_PACKAGE_REQUIRED,400 TENANT_SCOPE_REQUIRED, the throwndeletePackagefailure,400 PACKAGE_DELETE_PARTIALand the404. The404now asks whether the package existed rather than whether it was withdrawn, so a package whose withdrawal was refused after its stored row was deleted is not answered "not found".A host with no persisted half keeps its old behaviour. There the withdrawal is the uninstall, and its refusal is the request's refusal.
Tests (code at
608838a73; the heade54dee56ediffers from it only in the changeset)New door pins in
packages/runtime/src/package-uninstall-store-refusal.integration.test.ts(4 tests). They use the shipped pieces, booted twice over one SQLite file: a realObjectQLandSqlDriver(better-sqlite3); the realPackageServicePlugin, whosestart()hydratessys_packages; the real protocol; the realHttpDispatcher; and the disable seedAppPluginplants at boot. A trigger refuses thesys_packagesdelete. The pins:500 DATABASE_ERROR, and the same process still serves the package, disabled, with its view row;200, then404in the same process and404after a restart. Its rows are gone and its disable record is cleared;200withregistryRemoved: false. The process keeps serving the package, its rows are gone, and the restart does not bring it back.Protocol pins in
packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts(6 tests):{ success: false }gives500 INTERNAL_ERROR, and a thrownDATABASE_ERRORgives500 DATABASE_ERROR. The store delete is the only step that runs, and nothing is removed;409passes through unchanged;Fixture change. In
packages-uninstall-envelope.test.ts, the registry double answeredgetPackagewith a package even in its "unknown package" case, and modelled "unknown" only throughuninstallPackage's return value. The door now reads existence withgetPackage, so both verbs read oneregisteredflag. The case still asserts the same404.Results:
runtime, the full local project (vitest run --project local --maxWorkers=2, shards 1/2 and 2/2): Test Files 154 and 154 passed; Tests 2040 passed with 4 skipped, and 2320 passed with 15 skipped.runtimetypecheck(tsc --noEmitandcheck:test-typecheck): exit 0. The test layer's shrink-only ledger is unchanged.metadata-protocol: the 6 pins passed. Its full suite passed on6d823ae7a(Test Files 103 and 100 passed with 3 skipped; Tests 1326 passed, then 1668 passed with 19 skipped), andprotocol.tshas not changed since.objectqlpassed in full on6d823ae7a(Test Files 183 and 182; Tests 3610 and 3777).restpassed with--project localon6d823ae7a(255 files; 4814 tests passed and 322 skipped).Ablations (every mutation through
scripts/ablation-replace.mjs; each anchor hit once; each restore proven with the blob equal toHEADandgit diff HEADempty)Both pin files import their subject from
src, so nodistis involved.6d823ae7acatchthat logs a warning and goes on restored6d823ae7a409, thrown-restart6d823ae7adeletePackage608838a73expected 404 to be 200) and the extender pin (500)deletePackage608838a73enabled: true,status: installed)Void attempts, declared: the first A1 attempt, and the first two attempts of A3's call leg, were refused by the tool before any test ran, because each replacement contained its own anchor. They were re-run.
Gates (head
e54dee56e)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 65 commands, includingcheck:route-envelopefor the door file. All 65 ran on that head and exited 0. Reconciling with--rangives "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded.origin/mainwas merged at7d29e5c5d. The 2 commits that landed after that merge touch none of this PR's files.ESLint was narrowed to the five touched TypeScript files, which is a measured narrowing:
--print-configresolves every one of them;--format jsoncounts 5 files, with 0 errors and 0 warnings;parserOptions.projectand noprojectService, so type-aware linting is off and no untouched file's verdict can move.The repo-wide
pnpm lintis CI's.Docs
I grepped
content/docs/**(outsidereleases/andreferences/) andskills/**for uninstall and package-delete semantics:api/metadata-api.mdx,kernel/contracts/metadata-service.mdx,permissions/permission-sets.mdx,protocol/kernel/plugin-spec.mdxanddeployment/publish-and-preview.mdx. Each describes a successful uninstall, and that path is unchanged, so none of their sentences is now false. No doc edits.Acceptance notes
SchemaRegistry(packages/objectql/src/registry.ts, outside this claim), which is reported to the seat.sys_metadatarows carriespersisted.success: falseinside the door's200, becausedeletePackagecomputessuccessasfailed.length === 0 && deleted.length > 0. The door does not read that field.Generated by Claude Code