Skip to content

feat(runtime): an admin can disable a type:module package of an installed app; its nav contributions and record-change flows stop (#28117) - #28308

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-omgm6f
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-omgm6f

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-project-manager · session_01FEkQ5otJRNvRGLG91cCJr7

Requested by Leehom · project thread

Part of #28117

Clause-②: yes (widening) — PATCH /api/v1/packages/:id/disable now accepts a module of an installed app, which it refused 422 WRITABLE_PACKAGE_REQUIRED before (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). No skills/** page names the disable door or navigationContributions (git grep in skills/: 0 hits). The ADR-0070 D2 addendum line (card item 6) is the seat's follow-up #28285; this PR does not touch docs/adr/**.

维护者速读

  • 改了什么:管理员(有 manage_metadata)现在可以停用一个已安装应用下的 type: 'module' 子包;停用后它的导航项从应用菜单里消失,绑在它对象上的记录触发流程也不再运行。其余只读包(应用本身、插件、平台包、没有所属应用的模块)照旧答 422,模块依旧不能被编辑、发布、删除。
  • 为什么改:维护者裁决「大包 + 设置里启用模块」(方案 A):客户自己的管理员按环境开关模块,「停用」只有一种含义:隐藏、数据面拒绝、数据保留。之前停用门对套件里的每个包都答 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、对象 404 OBJECT_PACKAGE_DISABLED、导航消失、重新启用后恢复、其余七类包 422、重启后保持停用)。
  • 需要你做的:无,席位落地。另有一个需要你拍板的问题在席位的报告里:应用自己的 hook 经 ctx.api.object() 读已停用模块的对象,实测不会被拒(拿到保留的数据),与补充说明 6523684730 的预期不同;本 PR 不改这个行为,只钉住并写进文档。

What changes

1. The disable door (@objectstack/runtime)

PATCH /packages/:id/disable asked requireWritablePackage alone, and isWritablePackage answers read-only for every package of a booted or installed artifact (engine.manifests), so every suite module answered 422 WRITABLE_PACKAGE_REQUIRED. The door now reads one exemption first, isDisableableAppModule(engine, id), and only for this verb:

  • the row's manifest.type is 'module';
  • its manifest.dependencies DIRECTLY name a package that is installed here and whose manifest.type is 'app' (read with declaredDependencyIds from @objectstack/core, the reader the registry's co-owner gate uses);
  • its own manifest.scope is not system / cloud (READ_ONLY_PACKAGE_SCOPES): a module the platform delivers stays the platform's.

isWritablePackage is unchanged, so writable still means "may author into" and stays false for 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 in engine.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 the registerApp call. 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 declaring dependencies: { 'app.objectstack.hotcrm': '^6.0.0' }; examples/app-multi-package's module declares its core app the same way.

2. Navigation (@objectstack/objectql)

SchemaRegistry.applyNavContributions skips a contribution whose packageId is disabled (isPackageDisabled), before the group lookup, so a disabled package's contribution is neither folded, relocated to the top level, nor diagnosed as nav_contribution_group_missing. That one fold serves every reader: getApp, getAllApps, and the protocol's app list and by-name reads (protocol.ts L10072 / L11175 call registry.applyNavContributions). Nothing is cached past the fold (meta-overlay-cache.ts caches only the sys_metadata row 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's ctx.api.object(...), another flow's data node, a service) lands and fires the object's lifecycle hooks. On the base tree, a record-after-create flow on such an object stamped done (red below). RecordChangeTrigger's handler now returns before dispatch when engine.registry.isObjectPackageDisabled(object) is true (the question the data plane's refusal asks, binding.object ?? ctx.object as buildContext reads 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. RecordChangeDataEngine gains an optional structural registry member; 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.tsx L1054), and writable stays false for a module, so the Console cannot offer this disable yet. The answer "may this package be disabled here?" needs its own key beside writable in SERVED_ROW_VERDICTS (packages/spec/src/api/package-api-assembled.zod.ts L155-166), which is a spec-lane edit, so it is NOT written here and writable is 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 L1054

isDisableableAppModule is 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 tree 69ce01d7e.

  • packages/runtime/src/domains/packages-disable-app-module.test.ts (new, 26 tests). Real ObjectKernel with MetadataPlugin, ObjectQLPlugin, AppPlugin and a SQLite :memory: SqlDriver, driven through the runtime dispatcher's dispatch(). A suite artifact composed with composeStacks(…, { manifest: 'preserve' }) (app, module with a navigationContributions entry, a plugin, a module of the plugin), plus four packages registered alone. Booted twice, once per install path (code, marketplace):
    • the tie on the row (type, dependencies, namespace, source, in engine.manifests);
    • enabled control: data 201/200, nav nav_accounts, nav_orders through GET /meta/app/qs_app and registry.getApp;
    • PATCH …/disable 200, enabled: false; GET /data/qs_order 404 OBJECT_PACKAGE_DISABLED; nav nav_accounts only, on both readers; the app's own object still 200;
    • edit (PATCH /packages/:id), publish, delete on the module: 422 WRITABLE_PACKAGE_REQUIRED, manifest unchanged;
    • PATCH …/enable 200: data back with the kept record, nav back;
    • still 422 and still enabled: the app, the plugin, a module of the plugin, an orphan module (no dependency), a module whose app is not installed, a system-scoped module of the installed app, a system-scoped plugin.
    • Restart: disable through the door, shut down, boot a second kernel on the same OS_HOME and environment: the module boots disabled (404 on the data plane, nav gone); enable, boot a third: enabled, nav back.
    • Addendum measurement (see below).
    • Base tree: 6 failed / 20 passed (the two disable cases answer 422, so do the restart and measurement cases that disable first; the 14 refusal cases and the tie readings were already green, as a control should be).
  • packages/objectql/src/registry-nav-contribution-disabled-package.test.ts (new, 11 tests, real SchemaRegistry): for each of getApp, 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 stamps done; 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, an afterInsert hook registered for the APP package on qs_account calls ctx.api.object('qs_order').find({}); a POST /data/qs_account fires it, and the hook gets the module's kept rows ([{ rows: 1 }]), no error. In the same window GET /data/qs_order answers 404 OBJECT_PACKAGE_DISABLED. The hook's ctx.api is a ScopedContext over 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 no OBJECT_PACKAGE_DISABLED to catch in-process.

Ablations (one-time, restored)

Each through node scripts/ablation-replace.mjs under the verify lock; every suite imports the mutated file from src/, so no build sits between mutation and run.

  1. Door: const readOnly = isDisableableAppModule(qlService, id) → const readOnly = false && isDisableableAppModule(qlService, id) (the unconditional requireWritablePackage). Blob 53825564f8bb → 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 HEAD empty.
  2. Nav: --delete the line if (this.isPackageDisabled(c.packageId)) continue;. Blob 0d8ee0e6b325 → 4629981b5d26. registry-nav-contribution-disabled-package.test.ts: 4 failed / 7 passed (the three disabled-drop cases and the diagnostic case). Restored: blob = HEAD.
  3. Trigger: if (written && this.engine.registry?.isObjectPackageDisabled?.(written) === true) { → if (written && false && …) {. Blob 9ce4a4d903d3 → 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 (at 9808a8fd): objectql Test Files 501 passed (501), Tests 9761 passed (9761); trigger-record-change 11 passed (11), 116 passed (116); runtime 488 passed (488), Tests 7932 passed | 19 skipped (7951); VERDICT command-exit 0.
  • The trigger file again at c403c6bd after typing its slot lookups: Tests 11 passed (11).
  • pnpm … run typecheck on the three packages: exit 0 (tsc --noEmit plus each package's test program).
  • pnpm exec eslint --no-inline-config on the six touched source and test files: exit 0.

Acceptance notes

  • Not in this PR (routed): card item 6, the ADR-0070 D2 addendum line, is docs(adr): ADR-0070 D2 addendum — a type: module sub-package of an installed app may be disabled by an admin (#28117 item 6) #28285 (Tier H). The served-verdict key is the Seam above.
  • Read from source, not measured: a disabled module's own CODE hooks (hooks in 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/objectstack on this diff (93 commands), each run on the final head c403c6bd with 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 head 9808a8fd one was red, check:slot-lookup (the new trigger test block added two as any slot lookups, erasure count 11 → 13); c403c6bd types 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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec untouched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (scoped to the touched files).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the seat's required Chinese summary).
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/runtime, @objectstack/trigger-record-change, touching 6 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/deployment/environment-variables.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function))
  • content/docs/plugins/adding-a-metadata-type.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/protocol/kernel/plugin-spec.mdx (via SchemaRegistry (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via SchemaRegistry (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 59 of 211 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 91e46522c171a319825aa9a43626472c065c29d0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6892b2d68917860c686bd8f94eabda129c6e6973 — the merge of head c403c6bd5a7a21179eded7d5741e16f9ac87a80d into base 91e46522c171a319825aa9a43626472c065c29d0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 91e46522c171a319825aa9a43626472c065c29d0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Contract review

Served-tier: TIER_DEFAULT
Head-sha: c403c6bd5a7a21179eded7d5741e16f9ac87a80d
Local-runs: none

Face: the declaration face only (Clause-②: yes (widening) on the PR body and on .changeset/28117-disable-app-module.md). The committed tree touches no packages/spec/src/** and no governed path, so this review has no ④. Read against card #28117 (body items 1–5), scope addendum 6523684730, the dispatch order, and the dev report 6538834934. Inputs: git diff origin/main...c403c6bd and the cited code on the head tree (packages/metadata-protocol/src/package-writability.ts, SchemaRegistry.isPackageDisabled / isObjectPackageDisabled, ObjectQLEngine nav-contribution registration, the marketplace install's register(…, { source: 'marketplace' })).

① Derived judgments

  1. The widened accept set matches the ruling. Correct. PATCH /api/v1/packages/:id/disable reads isDisableableAppModule(qlService, id) before requireWritablePackage. That accepts a package only when all three hold:

    • the row's manifest.type === 'module';
    • its own manifest.scope is not in READ_ONLY_PACKAGE_SCOPES (system, cloud), and the predicate imports that constant rather than spelling it again;
    • declaredDependencyIds(manifest) DIRECTLY names a package other than itself whose registry row says manifest.type === 'app'.

    This is card item 1: a module that belongs to an installed app, with a tie the registry measurably carries on both install paths. The artifact package list is not persisted, the declared dependency is, and the namespace follows from it. The order sits after requireManageMetadata, the catalog wall and the query refusal, so none of those changed. A request outside that set still answers 422 WRITABLE_PACKAGE_REQUIRED exactly as before. That covers the app, plugins, drivers, platform packages, a module of a plugin, an orphan module, a module whose app is absent, and a system-scoped module of the app. The test pins seven of these cases on both install paths. An unknown id still falls through to the existing 404.

  2. Authoring is still refused, and isWritablePackage is unchanged. Correct. packages/metadata-protocol is not in the diff. isDisableableAppModule has exactly one call site (the disable door, L1789). The doors at L1571 (install/overwrite), L1820 (publish), L2336 (revert), L2376 (adopt), L2502 (edit) and L2603 (delete) still ask requireWritablePackage alone, and withWritableVerdict (L832) still serves writable: isWritablePackage(…), which is false for a module. Edit, publish and delete are pinned 422 in the test. Revert, adopt and overwrite are not pinned, but they go through the same unchanged call (read, not measured). writable is not overloaded (card item 4).

  3. REST answer codes. Correct. A disable answers 200 with the row (enabled: false) and an enable answers 200. While the module is disabled, its objects answer 404 OBJECT_PACKAGE_DISABLED (the existing refusal, now pinned on the module path), and the app's own object still answers 200. No code is new and no code is renamed.

  4. The nav fold. Correct and consistent with the one meaning. applyNavContributions skips c.packageId when isPackageDisabled is true, before the group lookup, so the module's items are not folded, not relocated and not diagnosed. Every reader goes through this one fold: getApp, getAllApps, and the protocol's app list and by-name reads. The stored app is not mutated, and the state is asked per read, so an enable restores the items with nothing to invalidate. Contributions are registered only through ObjectQLEngine's manifest registration (engine.ts L8842), and before this PR the door refused every such package. So no package that the public door could disable loses menu items it showed before. This is not a narrowing.

  5. Record-change withholding. Correct and consistent. RecordChangeTrigger returns before dispatch when engine.registry.isObjectPackageDisabled(binding.object ?? ctx.object) is true. That is the owner question the data plane's refusal asks, so an overlay from a disabled package does not take the object offline. The write itself is untouched, so the data is kept, and the check runs per write, so an enable resumes the flow. The base was measured red (an in-process write fired the flow) and is green at head, the ablation bites, and the real-kernel test proves the production engine carries registry. The rule applies to every disabled package, not only modules. That is what card item 3 orders ("a disabled package's objects"). No request is newly refused, so this is not an accept-set narrowing, and the PR's "Nothing is narrowed" stands.

  6. Handbook (content/docs/deployment/marketplace-handbook.mdx). Consistent. Step 7 no longer says every code- or app-provided package answers the disable door 422, which would now be false. It names the module exemption. The new "Disabling a module" section states the one meaning (data plane, navigation, flows, restart, authoring), and every claim matches the code above. It also states plainly what the addendum asked to be measured: an app hook's ctx.api.object() is served the kept rows, with no OBJECT_PACKAGE_DISABLED to catch in-process.

  7. Undeclared widening: none found.

    • The only new accept is the declared one.
    • RecordChangeDataEngine (exported from @objectstack/trigger-record-change) gains an optional structural registry? member. That is an additive, non-breaking type change: implementers need nothing new, and an engine without the member dispatches as before. It falls under the PR's yes (widening) declaration, though the changeset text does not name it (non-blocking).
    • No spec, served key, error code or manifest key moves, so Metadata-protocol: none is right.

Non-blocking notes (no must-fix):

  • (a) The scope exemption reads the MODULE's own scope only. An unscoped module that declares a system-scoped app (for example setup) would qualify. No in-tree type: 'module' package does this today: every platform-shipped package carries scope: 'system' itself. The ruling's "platform packages keep 422" holds for every package that exists now. If a platform app ever ships unscoped modules, this is the line to revisit.
  • (b) The handbook does not yet say what the PR's Acceptance notes do. A disabled module's own code hooks still run on in-process writes to its objects, and its record-change flows bound to an ENABLED package's object still fire. Both are outside card item 3 as written, and both belong to the open decision below. They are worth one sentence in the handbook once that decision is made.

② Semver level

patch for @objectstack/runtime, @objectstack/objectql and @objectstack/trigger-record-change. These are exactly the three published packages whose source the diff moves; the handbook is prose, not a package. AGENTS.md Post-Task Checklist step 3 says Metadata-protocol: none takes patch, and that a non-protocol widening ships patch. Both declaration lines are line-initial in the PR body and in the changeset body, and the Clause-②: yes (widening) arm is well-formed. Correct.

③ Boundary flags

  • open_questions[0], status: needs_decision. Not settled by this PR, and not ruled here. An app hook's in-process ctx.api.object(name) read or write of a DISABLED module's object is served, as measured and pinned. Only the data plane and the by-name reads refuse with 404 OBJECT_PACKAGE_DISABLED. That differs from what addendum 6523684730 item 1 expected app code to meet. The PR does not soften any answer, because it adds no fallback or empty result and the in-process path was never refused. So it keeps addendum item 1's "do not soften" line and fully delivers item 2 (measure, pin, document). Whether to refuse the in-process path (the dev's options A/B/C) is the maintainer's decision, raised to the seat. If that decision is routed onto packages: an admin cannot disable a type:module sub-package of an installed app (422 WRITABLE_PACKAGE_REQUIRED), and a disabled package's nav contributions stay in the app #28117 rather than a new card, Fixes #28117 must become Part of #28117 before landing, as the dev report says.
  • out_of_scope_findings[0], the served-verdict Seam (card item 4). spec:SERVED_ROW_VERDICTS → runtime:withWritableVerdict | renderer:PackagesPage.tsx L1054 is reported, not written, as the order requires. Until that key ships, the Console cannot offer Disable for these rows. The server door accepts it, and the Console card is Blocked-by this one. The seat carries it.
  • Card item 6 (the ADR-0070 D2 addendum line) is routed to docs(adr): ADR-0070 D2 addendum — a type: module sub-package of an installed app may be disabled by an admin (#28117 item 6) #28285. docs/adr/** is untouched, so this PR stays Tier S.
  • Author. The PR's user.login is objectstack-fleet[bot], so no extra login is named.
  • CI at this head. At read time, no check run had failed, and several (Test Core shards, Build Core, Type Check workspace/consumer/debt, Live E2E UI, Lint repo & tooling) were still in progress. CI is the landing check's half, not this record's.

Implemented-by: claude/project-thread-omgm6f
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-31T04:28Z · head c403c6bd · #28117

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main with no assignees. It carries one patch changeset for @objectstack/runtime, @objectstack/objectql and @objectstack/trigger-record-change.
    • It says Part of #28117. The dev's in-process ctx.api question went to the maintainer as decision letter 6538936519 (recommending A), so the card stays open after this lands.
    • check-governed-merges --pr 28308 reads NOT governed, 781 changed lines. docs/adr/** is untouched: card item 6 is the follow-up docs(adr): ADR-0070 D2 addendum — a type: module sub-package of an installed app may be disabled by an admin (#28117 item 6) #28285.
    • The claim (6538161407) names this PR's branch, claude/project-thread-omgm6f.
  2. Against the order:
  3. Contract review: owed for Clause-②: yes (widening) at TIER_DEFAULT. PASS on this head, 6538889847. The body edit since then (signature line, Fixes → Part of) did not move the head.
  4. Gates:
    • Locally, the dev ran 93 derived families with 0 NOT-MEASURED, every one exit 0. Package suites: objectql 9761, runtime 7932 and trigger-record-change 116 passed. Typecheck and scoped lint pass.
    • CI on head c403c6bd: 40 success, 14 skipped, 0 red. The state is clean.
  5. Overlap: no open PR touches packages/runtime/src/domains/packages.ts, packages/objectql/src/registry.ts, record-change-trigger.ts or the marketplace handbook. git merge-tree reports no conflict against main b3394651.
  6. After landing: the landing record, then the card's claim is released and the card goes to needs-user-decision to wait for the ruling. docs(adr): ADR-0070 D2 addendum — a type: module sub-package of an installed app may be disabled by an admin (#28117 item 6) #28285 (the ADR line) and Packages page: offer Enable / Disable on a suite's type:module rows once the server allows disabling them #28118 lose their #28117 blocker, and Packages page: offer Enable / Disable on a suite's type:module rows once the server allows disabling them #28118 still waits on packages: the served package row carries no "may be disabled" verdict, so the Console cannot offer Disable on a suite module the server now accepts #28318.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant