Repository navigation
feat(runtime): an admin can disable a type:module package of an installed app; its nav contributions and record-change flows stop (#28117) - #28308
Conversation
…p a disabled package's nav contributions and record-change flows (#28117) - runtime: PATCH /packages/:id/disable admits a module that directly declares an installed type:'app' package (not system/cloud scoped); every other read-only package still answers 422 and the module stays read-only for authoring. - objectql: applyNavContributions skips a disabled package's contributions on every read that folds them. - trigger-record-change: a flow on an object whose owning package is disabled is not dispatched for an in-process write. - docs: marketplace handbook, "Disabling a module". Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5otJRNvRGLG91cCJr7
…eck:slot-lookup) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5otJRNvRGLG91cCJr7
📓 Docs Drift CheckThis PR changes 3 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 37 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 6892b2d68917860c686bd8f94eabda129c6e6973 && git checkout 6892b2d68917860c686bd8f94eabda129c6e6973
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 91e46522c171a319825aa9a43626472c065c29d0 c403c6bd5a7a21179eded7d5741e16f9ac87a80d && git checkout -B drift-repro 91e46522c171a319825aa9a43626472c065c29d0 && git merge --no-ff c403c6bd5a7a21179eded7d5741e16f9ac87a80d
node scripts/docs-audit/affected-docs.mjs --json 91e46522c171a319825aa9a43626472c065c29d0
|
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo Contract reviewServed-tier: Face: the declaration face only ( ① Derived judgments
Non-blocking notes (no must-fix):
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo 席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
domain:server#2 · os-project-manager · session_01FEkQ5otJRNvRGLG91cCJr7
Requested by Leehom · project thread
Part of #28117
Clause-②: yes (widening) —
PATCH /api/v1/packages/:id/disablenow accepts a module of an installed app, which it refused422 WRITABLE_PACKAGE_REQUIREDbefore (measured red on the base tree, both install paths). Nothing is narrowed.Metadata-protocol: none
Skills-docs: updated here —
content/docs/deployment/marketplace-handbook.mdx(step 7 corrected, new "Disabling a module" section, including what an app hook meets). Noskills/**page names the disable door ornavigationContributions(git grepinskills/: 0 hits). The ADR-0070 D2 addendum line (card item 6) is the seat's follow-up #28285; this PR does not touchdocs/adr/**.维护者速读
manage_metadata)现在可以停用一个已安装应用下的type: 'module'子包;停用后它的导航项从应用菜单里消失,绑在它对象上的记录触发流程也不再运行。其余只读包(应用本身、插件、平台包、没有所属应用的模块)照旧答 422,模块依旧不能被编辑、发布、删除。type: 'module'且直接依赖一个已安装的type: 'app'包,且不是 system/cloud 范围),其他门不变;导航折叠和触发器只在包已停用时才跳过。回滚即 revert。packages/runtime/src/domains/packages.ts(isDisableableAppModule与停用门那一行);packages/objectql/src/registry.ts(applyNavContributions里新增的一行);packages/triggers/trigger-record-change/src/record-change-trigger.ts(handler 里新增的判断)。pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/packages-disable-app-module.test.ts,预期 26/26 通过(两条安装路径各一套:停用 200、对象 404OBJECT_PACKAGE_DISABLED、导航消失、重新启用后恢复、其余七类包 422、重启后保持停用)。ctx.api.object()读已停用模块的对象,实测不会被拒(拿到保留的数据),与补充说明 6523684730 的预期不同;本 PR 不改这个行为,只钉住并写进文档。What changes
1. The disable door (
@objectstack/runtime)PATCH /packages/:id/disableaskedrequireWritablePackagealone, andisWritablePackageanswers read-only for every package of a booted or installed artifact (engine.manifests), so every suite module answered422 WRITABLE_PACKAGE_REQUIRED. The door now reads one exemption first,isDisableableAppModule(engine, id), and only for this verb:manifest.typeis'module';manifest.dependenciesDIRECTLY name a package that is installed here and whosemanifest.typeis'app'(read withdeclaredDependencyIdsfrom@objectstack/core, the reader the registry's co-owner gate uses);manifest.scopeis notsystem/cloud(READ_ONLY_PACKAGE_SCOPES): a module the platform delivers stays the platform's.isWritablePackageis unchanged, sowritablestill means "may author into" and staysfalsefor a module; edit, publish, revert, delete and overwrite still refuse it (pinned below). Everything else answers the disable door exactly as before.The tie, measured on both install paths. A code boot (
AppPlugin→manifest.register,source: 'code') and a marketplace install (manifest.register(…, { source: 'marketplace' })) leave the same three keys on the module's registry row:type: 'module',dependencies: { APP: RANGE },namespace(the app's). Both put the module inengine.manifests(read-only for authoring). The artifact's own package list (ArtifactInstallScope.packageIds) is NOT on the row: it lives only for the length of theregisterAppcall. So the declared dependency on an installed app is the tie the registry actually carries; the namespace is carried too but is not used, because sharing a namespace is itself derived from that declaration (ADR-0141 D5). The HotCRM read in addendum 6523684730 has every module declaringdependencies: { 'app.objectstack.hotcrm': '^6.0.0' };examples/app-multi-package's module declares its core app the same way.2. Navigation (
@objectstack/objectql)SchemaRegistry.applyNavContributionsskips a contribution whosepackageIdis disabled (isPackageDisabled), before the group lookup, so a disabled package's contribution is neither folded, relocated to the top level, nor diagnosed asnav_contribution_group_missing. That one fold serves every reader:getApp,getAllApps, and the protocol'sapplist and by-name reads (protocol.tsL10072 / L11175 callregistry.applyNavContributions). Nothing is cached past the fold (meta-overlay-cache.tscaches only thesys_metadatarow set), so a re-enable restores the items with nothing to invalidate. The stored app is never edited.3. Record-change flows (
@objectstack/trigger-record-change)Measured first, as the card asks. Writes through the data plane to a disabled package's object were already refused (
404 OBJECT_PACKAGE_DISABLED), so no flow fired from them. But the engine itself has no disabled-package check: a write made IN-PROCESS (an app hook'sctx.api.object(...), another flow's data node, a service) lands and fires the object's lifecycle hooks. On the base tree, arecord-after-createflow on such an object stampeddone(red below).RecordChangeTrigger's handler now returns before dispatch whenengine.registry.isObjectPackageDisabled(object)is true (the question the data plane's refusal asks,binding.object ?? ctx.objectasbuildContextreads it). The write itself is untouched, so the data is kept; the check is per write, so an enable resumes the flow with nothing to re-bind.RecordChangeDataEnginegains an optional structuralregistrymember; an engine without it dispatches as before.4. Served verdict: a fork, not written here
The Console decides whether to offer Disable from
writable || !enabled(ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsxL1054), andwritablestaysfalsefor a module, so the Console cannot offer this disable yet. The answer "may this package be disabled here?" needs its own key besidewritableinSERVED_ROW_VERDICTS(packages/spec/src/api/package-api-assembled.zod.tsL155-166), which is a spec-lane edit, so it is NOT written here andwritableis not overloaded.Seam: spec:SERVED_ROW_VERDICTS (a new served key beside writable) → runtime:withWritableVerdict (packages/runtime/src/domains/packages.ts, would read isDisableableAppModule) | renderer:PackagesPage.tsx L1054isDisableableAppModuleis the one predicate both the door and that future served key read, so the Console never re-derives the rule (#8146).5. Docs
content/docs/deployment/marketplace-handbook.mdx: step 7 said every package "provided by code or by an installed app" answers the disable door 422, which this PR makes false; it now names the module exemption. A new "Disabling a module" section under "Extras: dependencies and modules" gives the manifest shape, the one meaning of disabled (data plane, navigation, flows, restart, authoring), and what an app's own hook meets (addendum 6523684730 item 2).Tests
All three suites import the code under change from
src/except where noted; red-first readings are on the base tree69ce01d7e.packages/runtime/src/domains/packages-disable-app-module.test.ts(new, 26 tests). RealObjectKernelwithMetadataPlugin,ObjectQLPlugin,AppPluginand a SQLite:memory:SqlDriver, driven through the runtime dispatcher'sdispatch(). A suite artifact composed withcomposeStacks(…, { manifest: 'preserve' })(app, module with anavigationContributionsentry, a plugin, a module of the plugin), plus four packages registered alone. Booted twice, once per install path (code,marketplace):source, inengine.manifests);nav_accounts, nav_ordersthroughGET /meta/app/qs_appandregistry.getApp;PATCH …/disable200,enabled: false;GET /data/qs_order404OBJECT_PACKAGE_DISABLED; navnav_accountsonly, on both readers; the app's own object still 200;PATCH /packages/:id), publish, delete on the module: 422WRITABLE_PACKAGE_REQUIRED, manifest unchanged;PATCH …/enable200: data back with the kept record, nav back;system-scoped module of the installed app, asystem-scoped plugin.OS_HOMEand environment: the module boots disabled (404 on the data plane, nav gone); enable, boot a third: enabled, nav back.packages/objectql/src/registry-nav-contribution-disabled-package.test.ts(new, 11 tests, realSchemaRegistry): for each ofgetApp,getAllApps,applyNavContributions: enabled control, disabled drop (in its group and its relocated top-level item, other packages' items stay), re-enable restore; no missing-group diagnostic for a disabled package; the stored app unchanged. Base tree: 4 failed / 7 passed.packages/triggers/trigger-record-change/src/record-change-integration.test.ts, new[#28117]block (2 tests, real kernel with ObjectQL, automation, the trigger plugin and SQLite): enabled control stampsdone; disabled, an in-process user insert writes the record and the flow does not stamp; another package's object in the same window still stamps; re-enabled, it stamps again. Base tree: 1 failed / 1 passed (expected 'done' to be null).What an app hook meets (addendum 6523684730 item 2)
Measured and pinned in
packages-disable-app-module.test.ts: with the module disabled through the door, anafterInserthook registered for the APP package onqs_accountcallsctx.api.object('qs_order').find({}); aPOST /data/qs_accountfires it, and the hook gets the module's kept rows ([{ rows: 1 }]), no error. In the same windowGET /data/qs_orderanswers404 OBJECT_PACKAGE_DISABLED. The hook'sctx.apiis aScopedContextover the engine (buildHookApi,engine.ts), and the disabled-package refusal lives in the protocol's data plane (assertObjectRegistered), never in the engine. This PR changes no answer for any caller (addendum item 1); the handbook says there is noOBJECT_PACKAGE_DISABLEDto catch in-process.Ablations (one-time, restored)
Each through
node scripts/ablation-replace.mjsunder the verify lock; every suite imports the mutated file fromsrc/, so no build sits between mutation and run.const readOnly = isDisableableAppModule(qlService, id)→const readOnly = false && isDisableableAppModule(qlService, id)(the unconditionalrequireWritablePackage). Blob53825564f8bb→e14712be2677.packages-disable-app-module.test.ts: 4 failed / 22 passed (both "disables the module (200)", the restart, the measurement). Restored: blob = HEAD,git diff HEADempty.--deletethe lineif (this.isPackageDisabled(c.packageId)) continue;. Blob0d8ee0e6b325→4629981b5d26.registry-nav-contribution-disabled-package.test.ts: 4 failed / 7 passed (the three disabled-drop cases and the diagnostic case). Restored: blob = HEAD.if (written && this.engine.registry?.isObjectPackageDisabled?.(written) === true) {→if (written && false && …) {. Blob9ce4a4d903d3→a08bcd2662b8. The[#28117]block: 1 failed / 1 passed. Restored: blob = HEAD.Package suites and typecheck
Under the verify lock, after building
@objectstack/runtime...and@objectstack/trigger-record-change...:pnpm --filter @objectstack/objectql --filter @objectstack/trigger-record-change --filter @objectstack/runtime --no-bail test(at9808a8fd): objectqlTest Files 501 passed (501),Tests 9761 passed (9761); trigger-record-change11 passed (11),116 passed (116); runtime488 passed (488),Tests 7932 passed | 19 skipped (7951);VERDICT command-exit 0.c403c6bdafter typing its slot lookups:Tests 11 passed (11).pnpm … run typecheckon the three packages: exit 0 (tsc --noEmitplus each package's test program).pnpm exec eslint --no-inline-configon the six touched source and test files: exit 0.Acceptance notes
hooksin its manifest) still run on an in-process write to its objects, and a disabled module's record-change flows bound to an ENABLED package's object (e.g. a module flow on an app object) still fire: the engine and the automation engine read no package state, and this card's item 3 names flows on the disabled package's objects only. Both sit with the in-process question in the report.Clause-②measured: the door's 422 for these rows was the published answer (the handbook's step 7 named it), and the request now accepted is a new accept; no refusal is added anywhere.Verification
Gates from
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon this diff (93 commands), each run on the final headc403c6bdwith its exit code captured before any pipe: all 93 exit 0.dispatch-gates --ran: "93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED". On the first head9808a8fdone was red,check:slot-lookup(the new trigger test block added twoas anyslot lookups, erasure count 11 → 13);c403c6bdtypes them and the ratchet holds. The families CI runs are cited from this PR's check-runs in the report on the card.Human Minimum
packages/specholds no runtime logic. (packages/specuntouched.)pnpm lintpasses (scoped to the touched files).main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code