Repository navigation
fix(metadata-protocol,runtime)!: the in-process uninstall verb refuses while an active validation rule reads a field the package adds - #31280
Conversation
…re-uninstall guards; the runtime registers the dependent-rule verdict WIP: seam and registration; pins follow. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2c1rk1PGZ51s32cxZt
… the in-process dependent-rule refusal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2c1rk1PGZ51s32cxZt
…NG narrowing) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2c1rk1PGZ51s32cxZt
… pins Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2c1rk1PGZ51s32cxZt
📓 Docs Drift CheckThis PR changes 2 package(s): 47 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 33 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 c5ba18fc1852de05bc2cdd861d6a482ff249e8a3 && git checkout c5ba18fc1852de05bc2cdd861d6a482ff249e8a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f5e23e2f7c570d14da0f012e0857f4be7c6636d2 1cc4d5f3a94ed3891005625fa4b4bf3c0332fb01 && git checkout -B drift-repro f5e23e2f7c570d14da0f012e0857f4be7c6636d2 && git merge --no-ff 1cc4d5f3a94ed3891005625fa4b4bf3c0332fb01
node scripts/docs-audit/affected-docs.mjs --json f5e23e2f7c570d14da0f012e0857f4be7c6636d2
|
…-qrn7nl # Conflicts: # packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts
…sal, in its uninstall form
The DELETE arm asks packageUninstallDependentRulesRefusal instead of an
inline scan; the guard passes { operation: 'uninstall' } to the builder,
and the in-process pin holds the uninstall sentence.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2c1rk1PGZ51s32cxZt
…usal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2c1rk1PGZ51s32cxZt
|
domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F Seat audit: one re-run on head
|
|
domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F 席位核对 — ACCEPT · domain:server#4 · Head-sha:
|
…-qrn7nl # Conflicts: # packages/metadata-protocol/src/protocol-package-uninstall.ts # packages/metadata-protocol/src/protocol.ts
|
domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F 席位核对 — ACCEPT · domain:server#4 · Head-sha:
|
domain:server#4 · marchtian · session_017ihL2dLQEXWGsE2SZ3Qn5F
Requested by 田騏 · project thread
Fixes #31232
Clause-②: yes (narrowing)
Metadata-protocol: none
维护者速读
protocol.deletePackage)现在在动任何东西之前,先问一遍「卸载前守卫」。runtime 把停用门/卸载门已经在用的那个检查注册成守卫:如果别的已启用包有生效中的校验规则在读这个包通过objectExtensions加的字段,进程内卸载也直接拒绝,抛 409PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE,消息同门上那句话,列出每条规则。拒绝时什么都不读、不动。VALIDATION_FAILED,而包已没了,无法再启用。packageUninstallDependentRulesRefusal。回滚就是 revert 本 PR。packages/metadata-protocol/src/protocol-package-uninstall.ts(askUninstallGuards及其在deletePackage里的调用位置:退役键拒绝之后、第一次读库之前);packages/runtime/src/domains/packages.ts的ensureDependentRulesUninstallGuard/packageUninstallDependentRulesRefusal;packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts末尾的[#31232]describe。pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-disable-module-extension-fields.test.ts -t 31232,预期 4 条全绿:进程内卸载被 409 拒绝且什么都没读没动、HTTP 门答同一句话、两条 CONTROL(无依赖的包照常卸载并清停用记录;规则在租户覆盖层关掉后照常卸载)。What changes
After #31227 the package door (
DELETE /api/v1/packages/:id) refuses an uninstall that would withdraw a field an active validation rule of another enabled package reads. The uninstall verb itself,protocol.deletePackagein@objectstack/metadata-protocol, never asked, so every in-process caller withdrew the field unchecked. This adds the seat-ruled seam and fills it:@objectstack/metadata-protocol).ObjectStackProtocolImplementation#registerUninstallGuard(name, guard), the before-half ofregisterUninstallCleanup. A guard is handed{ packageId, actor? }and answersundefined(proceed) or{ code, status, message }(refuse).deletePackageasks every registered guard in registration order right after its retired-key refusal and BEFORE its first read (sys_metadata), the registry's own refusal, the store delete, the per-item deletes, the registry withdrawal and the cleanups; the first refusal is thrown as anErrorcarrying the guard'smessage,codeandstatus. A guard that throws refuses with its throw. One guard per name; a second registration replaces the first. The typesUninstallGuardandUninstallGuardRefusalare exported from the package entry. With no guard registered the verb is byte-for-byte the old path.@objectstack/runtime).ensureDependentRulesUninstallGuard(ctx)registers, once per protocol, the guardruntime.package-dependent-rules, called fromAppPlugin.startbesideensurePackageStateUninstallCleanup(the after-half packages: DELETE /api/v1/packages/:id deletes unattributed (no actor), and the in-process deletePackage leaves the per-environment disable record behind #31023 added). The guard resolves the engine's registry when it is asked and answerspackageUninstallDependentRulesRefusal(registry, packageId): the doors' verdictreadDisableDependentRulesand sentencedisableDependentRulesMessageunder the leadPackage 'ID' cannot be uninstalled, as the wire pair409PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE.@objectstack/metadata-protocolimports nothing from the runtime and holds no copy of the rule.@objectstack/runtime.Tests
packages/metadata-protocol/src/protocol.uninstall-guards.test.ts(new, 6 cases), a protocol over an engine double that counts every step that reads or moves something (thesys_metadataread, the registry's uninstallability question, the store delete, the registry withdrawal, the cleanups):packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts, new top-level describe[#31232](4 cases) in the shared dependent-rule harness, on a real kernel (ObjectQL on SQLite, MetadataPlugin, AppPlugin), the verb called in process on the kernel'sprotocolservice. Two writable packages extend the app's object with one field each; a third package's active rule reads the first one's field.deletePackagerejects with409and the code; the message equals the exported builder over the exported verdict under the uninstall lead. Nothing was read or moved:findEnvironmentRows,deleteMetaItem,runUninstallCleanupsandregistry.uninstallPackagewere never called, the package and its field are still served, the state file is unchanged, and a write to the object lands.DELETE /packages/:idanswers409, the code, the same message, nodetails.runtime.package-state, the package leaves the registry, its field leaves the served object, its disable record is cleared, and the guard stays registered.Acceptance notes
deletePackageanswered{ success: false, deletedCount: 0, failedCount: 0, cleanups: [runtime.package-state ok] }without throwing, the package left the registry and its field left the served object, and the next write to the object answered400 VALIDATION_FAILED:Validation rule 'qz_tier_known' could not be evaluated (runtime: No such key: qz_tier) — write rejected.57675a21). The guard's sentence is the builder's uninstall form,formatPackageDisableDependents(lead, dependents, { operation: 'uninstall' })("which the uninstall would withdraw", "or keep the package installed"). The in-process refusal pin holds that sentence word for word.packageUninstallDependentRulesRefusal. TheDELETE /api/v1/packages/:idarm asks that one function, with no inline scan, and answers its code, status and message. The door keeps its check ahead ofresolveProtocol(seat ruling A), because a host whose protocol has nodeletePackageor no guard registered uninstalls through the registry. With a guard registered, the door answers first and the verb's guard is not reached on that path. An ablation of the door's call shows the guard alone answers the identical 409, code and message.DELETEdoor.DELETE /api/v1/marketplace/install-local/:manifestId(@objectstack/cloud-connection) does not calldeletePackage: it withdraws throughregistry.uninstallPackageand runs the cleanups itself, so it neither asks the guards nor the verdict. Raised in the report for the seat; outside this card's file surface.Verification
Ablation: the guard call removed from the verb, the refusal pins go red
node scripts/ablation-replace.mjsreplacedawait askUninstallGuards(this.uninstallGuards, request);inprotocol-package-uninstall.tswith a marker assignment (anchor1 -> 0, blob24b1fe27dc2a -> b53ac531fcebon the first spelling, whose comment marker no build emits, so it was restored and redone with a code marker),@objectstack/metadata-protocolwas rebuilt, andnode scripts/ablation-dist-preflight.mjsfound the marker indist/index.jsanddist/index.cjs(the runtime suite reaches the verb through thatdist/). Then restored (blob equals HEAD,git diff HEADempty), rebuilt, and the preflight--absentleg found the marker in none of the 24 built files.protocol.uninstall-guards.test.ts: 5 red, 1 green (the bare-protocol CONTROL, which registers no guard). Refusal case:promise resolved "{ success: false, …(5) }" instead of rejecting.[#31232]describe: the refusal case red (the in-process uninstall was not refused), the door case red by cascade (expected { status: 404 … } to deeply equal { status: 409 … }: the unrefused uninstall had already removed the package); both CONTROLs green.Rework 1 at head ec3a142 (after #31227 landed)
node scripts/pm/dispatch-gates.mjs --commandsderived 73 commands; all 73 exit 0.--ran: 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN.pnpm --filter @objectstack/runtime test: 555 files, 8953 tests passed, 19 skipped.pnpm --filter @objectstack/metadata-protocol test: 325 files, 22542 tests passed, 26 skipped. Typecheck on both, andeslint packages/metadata-protocol packages/runtime --no-inline-config: exit 0.packages-disable-module-extension-fields.test.ts+package-state-host-store.test.ts: 56/56 (fix(runtime)!: refuse a package uninstall while an active validation rule reads a field it adds #31227's 4 and this PR's 4).Local gates and suites at head e052b19 (after merging main)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 67 commands; all 67 exit 0.check:dual-build-cjs-loadsfirst exited 3 before a fullpnpm buildand was rerun after it;check:test-source-aliasfirst exited 1 on the new metadata-protocol test (the protocol reaches@objectstack/lintthrough a dynamic import) and was fixed by a module-topimport '@objectstack/lint', as the gate prescribes.--ranreconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.pnpm --filter @objectstack/metadata-protocol test: 323 files passed, 22533 tests passed, 26 skipped;typecheckexit 0.pnpm --filter @objectstack/runtime test: 555 files passed, 8943 tests passed, 19 skipped;typecheckexit 0.eslint packages/metadata-protocol packages/runtime --no-inline-config(the rootpnpm lintover the touched packages): exit 0.pnpm --filter @objectstack/dogfood test, full, no shard): 435 files passed, 3 skipped; 3610 tests passed, 41 skipped.Human Minimum
packages/specholds no runtime logic. (packages/specis not touched.)pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
Generated by Claude Code