Repository navigation
fix(metadata-protocol): a refused sys_packages delete fails the uninstall before anything is removed - #21438
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
DELETE /api/v1/packages/:idis refused — by the store, or by the registry because another package extends an object this one owns — it now answers that refusal 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. Three changes, in three files:metadata-protocol.deletePackageasks the registry's uninstall refusal first, then deletes the stored row as its first durable step. A refusal from either throws before anything else is removed.objectql. The registry's refusal check becomes one public method that changes nothing, so it can be asked before that first durable step.runtime). The dispatcher asksdeletePackagebefore 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):deletePackage. 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.packages/objectql/src/registry.ts(domain:engine, added by claim revision 2 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): the refusal pass ofunregisterObjectsByPackage, extracted intoassertPackageUninstallable.packages/runtime/src/domains/packages.ts(domain:cli, claim revision 1): theDELETEarm only, plus the header note of its organization-scope refusal.packages/objectql/src/registry-assert-package-uninstallable.test.ts(new);packages/metadata-protocol/src/protocol.package-delete-refusal.test.ts(new);packages/runtime/src/package-uninstall-store-refusal.integration.test.ts(new);packages/runtime/src/domains/packages-uninstall-envelope.test.ts.service-packageis unchanged: itsdeletealready states a refusal on both channels (PackageDeleteResult). Nopackages/specedit.assertPackageUninstallableis not added to the spec's engine contract interface;deletePackagereaches it through a capability probe.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. This round's change (the registry question) is pinned at the door by the real-composition pins below rather than re-measured live, because a live extender needs an installed app whoseobjectExtensionstarget a writable package's object.The store-refusal
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 refusalSchemaRegistry.assertPackageUninstallablesys_packagesdelete (onmain: after step 5){ 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 store 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 4.Step 3 reaches the registry the way step 6 already did, through
this.engine.registry. A registry withoutassertPackageUninstallable(an engine double, or a host on a registry without it) is not asked.deletePackagethen behaves as it did before the method existed: the refusal surfaces at step 6.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.cleanups[].SchemaRegistry.assertPackageUninstallableThis is the refusal pass of
unregisterObjectsByPackage(#7970: "refuses before it mutates"), moved into one public method. It has the same predicate, the same iteration order and the same message, and it mutates nothing.unregisterObjectsByPackagecalls it (if (!force) this.assertPackageUninstallable(packageId)), so there is still one copy of the check.forcestays the caller's decision: forcing means not asking.Why the name: this class already names its non-mutating throw-or-pass check
assertSingleOwnerPerObject, and the repo's otherassert…verbs (assertProtocolCompat,assertLockAllowsDelete) also check and throw without changing state. "Uninstallable" names the question at the level both of its callers ask it.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. Its throw — the store's refusal or the registry's ADR-0029 refusal — is answered through the existingerrorFromThrown(e, 500), and nothing has been touched at that point. For the extender refusal this is the envelope the door gave before this PR, when its own up-frontuninstallPackageraised it:500 INTERNAL_ERROR, nothing changed;deletePackagealready withdrew it), and the clear of the disable record for a package this request found.The late withdrawal keeps its
try/catchas a safety net, for a registry without the new method or a throw nothing asked ahead of time. Since the stored rows are gone by then, that case is reported asregistryRemoved: false, not as a failure. The extender refusal no longer arrives there.The refusal envelopes are unchanged:
403,422 WRITABLE_PACKAGE_REQUIRED,400 TENANT_SCOPE_REQUIRED, the throwndeletePackagefailure,400 PACKAGE_DELETE_PARTIALand the404. The404asks whether the package existed rather than whether it was withdrawn. A host with no persisted half keeps its old behaviour: the withdrawal is the uninstall, and its refusal is the request's refusal.Tests (code at
01dae724d; the headfc6b6515adiffers from it only in the changeset)Registry pin (
registry-assert-package-uninstallable.test.ts, 3 tests):unregisterObjectsByPackagerefuses with the same sentence by calling the method (a spy sees the call), andforce: truedoes not ask.Protocol pins (
protocol.package-delete-refusal.test.ts, 7 tests):500with nothing removed;409passes through unchanged;Door pins (
package-uninstall-store-refusal.integration.test.ts, 4 tests). They run on a real composition booted twice over one SQLite file: realObjectQLandSqlDriver; the realPackageServicePluginhydration; the real protocol andHttpDispatcher; andAppPlugin's disable seed.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;500 INTERNAL_ERROR. The stored row, the view row, the registry entry (disabled) and the disable record are all intact, in the same process and after a restart.Fixture change. In
packages-uninstall-envelope.test.ts, the registry double reads oneregisteredflag from bothgetPackageanduninstallPackage, because the door now reads existence withgetPackage. The case still asserts the same404.Full suites at
fc6b6515a:objectqlvitest run --project local, 2 shardsmetadata-protocolvitest run, 2 shardsruntimevitest run --project local, 2 shardsrestvitest run --project local, 2 shardsThe skipped files are the opt-in live-database files.
typecheckpasses forobjectql,metadata-protocolandruntime(each with itscheck:test-typechecklayer where it has one).Ablations (every mutation through
scripts/ablation-replace.mjs; each anchor hit once; each restore proven with the blob equal toHEADandgit diff HEADempty; each prediction written before the run)6d823ae7acatchthat logs a warning and goes on restored6d823ae7a6d823ae7adeletePackage608838a73deletePackage608838a73deletePackageno longer asksassertPackageUninstallable01dae724dunregisterObjectsByPackageno longer calls the method01dae724dregistry.test.ts01dae724dThe protocol and registry pins import their subjects from
src. The door pins readmetadata-protocolthroughdist, so P1's door leg rebuilt it:scripts/ablation-dist-preflight.mjsfound the marker in 2 built files;Void attempts, declared:
P2b is what makes "
unregisterObjectsByPackagenot calling the method" distinguishable: an inline copy behaves exactly like the call, so only the spy can tell them apart.Gates (head
fc6b6515a)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 69 commands. Compared with the previous round's 65, this round's new paths addcheck:engine-split-ratioandcheck:rest-log-declared, each with its self-test. All 69 ran on that head and exited 0. Reconciling with--rangives "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded.origin/mainwas merged at7d29e5c5dand at2db45821b. The second merge brought535d1d25a, which edits otherprotocol.tsregions. The 3 commits onmainafter that merge touch none of this PR's files.ESLint was narrowed to the seven touched TypeScript files, which is a measured narrowing:
--print-configresolves every one of them;--format jsoncounts 7 files, with 0 errors and 0 warnings;parserOptions.projectorprojectService, so type-aware linting is off and no untouched file's verdict can move.The repo-wide
pnpm lintis CI's.Changeset
This PR's own file covers
@objectstack/metadata-protocol,@objectstack/runtimeand@objectstack/objectql, all atpatch. All three are released packages in onefixedversion group.The bump for the new
SchemaRegistrymethod follows the repo's rules:patch, and this method is the mechanism of this fix;check-changeset-no-major.mjs's level axis demandsminoronly for aClause-②: yes(a new key on a published payload), and this PR declaresno.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.assertPackageUninstallable. At the door it answers500with nothing changed, the envelope it had before this PR.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