Repository navigation
fix(runtime)!: the package enable door refuses while a rule it would serve reads a field a disabled package withholds - #31018
Conversation
…serve reads a withheld field The enable door (and the install door's enableOnInstall: true arm on an overwrite of a disabled row) asks readEnableDependentRules before enablePackage: the rules the enable would put into service that read a field a disabled package withholds, judged on the registry's own fold with the package folded in (the new asEnabled fold option) against the fold served now. Refused with the disable door's code and wire pair. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA
…nstall arm and the asEnabled fold Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA
…eld says so once The state file records only disables, so a reading package that boots enabled beside a disabled field package reaches the state the enable door refuses without passing any door. AppPlugin reads it once per registry at kernel:bootstrapped and warns with every rule and the remedies. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA
…arrowing Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 38 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 169ad4c6721ee6588be6f5f4deeec55f685a035b && git checkout 169ad4c6721ee6588be6f5f4deeec55f685a035b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c de2d28ef2dc98bd2f8026e2da787fda58bcd9f1a && git checkout -B drift-repro 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c && git merge --no-ff de2d28ef2dc98bd2f8026e2da787fda58bcd9f1a
node scripts/docs-audit/affected-docs.mjs --json 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c
|
…rd the asEnabled exemption on its key Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA
# Conflicts: # packages/runtime/src/app-plugin.ts # packages/runtime/src/domains/packages.ts
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract reviewServed-tier: Inputs read: card #30756 body and all six comments (triage 6673640757, the park 6675441362, the unblock and release 6689542338, the seat's claim 6689904888, its amendment 6689925548, the os-dev report 6691598734); the order ① Derived judgments
② Semver levelBoth carriers read Check-runs on ③ Boundary flags
④ Design judgment
Findings:
Nits:
Implemented-by: VERDICT: PASS Record written 2026-11-06T18:23Z. |
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract review · deltaServed-tier: Judgments, one per item
Findings:
Nits:
Implemented-by: VERDICT: PASS Record written 2026-11-06T18:24Z. |
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH 维护者速读
席位意见:可以落地。 席位核对ACCEPT · seat
|
Requested by Tesla · project thread
Fixes #30756
domain:server#2 · os-tesla · session_01L9qibxjrKo69n5BEsgi5qA
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: updated here —
content/docs/api/metadata-api.mdx(the enable door's entry),content/docs/deployment/marketplace-handbook.mdx(the Enable-and-disable step and the Disabling-a-module list) andcontent/docs/api/error-catalog.mdx(the code's 409 entry) name the refusal;skills/**teaches no package enable door (grep forpackages/:id/enable,enablePackage,enableOnInstall: none), so no skill or eval moves.Claim: 6689904888 (amended by the seat:
registry.tsasEnabled, theenableOnInstall: trueoverwrite arm, the three docs pages) · Branch:claude/project-thread-fyy60n· Mirror of: #30379 ruling A (record 6670643454), PR #30825 · Error code: reused,PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE(#30724) · Serial: #30962 (same handlers, persistence lines untouched here)维护者速读
PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE),提示逐条列出「包 · 对象 · 规则」并点名要先启用的 X,什么都不改。客户在自己那份对象上关掉的规则算关闭;只判这次启用新带上线的规则。POST /packages以enableOnInstall: true覆盖一个已停用的包同样判断:装上,但保持停用,答同一个 409。启动时若持久化状态已经落在这种状态(状态文件只记停用),记一条 warn 列出规则和补救办法,不拒绝启动、不改运营者的选择。asEnabled选项,不传时折叠结果与现在逐字节相同,有测试钉住)。回滚=revert 本 PR。packages/runtime/src/domains/packages.ts的readEnableDependentRules/readWithheldFieldReaders(启用前后两次折叠求差)和启用门、安装门true分支;packages/objectql/src/registry.ts的asEnabled;packages/runtime/src/app-plugin.ts的reportWithheldFieldReadersAtBoot。pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-disable-module-extension-fields.test.ts,预期 27 条全绿。What changes
@objectstack/runtime:PATCH /api/v1/packages/:id/enable(domains/packages.ts)registry.enablePackageandsetPackageDisabled, the door asksreadEnableDependentRules(registry, id). If that is non-empty it answers409witherror.codePACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULEand nothing changes (row, served objects, state file). The message names every rule aspackage · object · rule (reads FIELD)and the disabled package(s) to enable first:Package 'P' cannot be enabled: 3 active validation rules the enable would serve read fields that a disabled package adds through objectExtensions, which stay withheld — … . Switch off or change those rules first, or enable 'X' first.Noerror.detailsbeyond the code, as on the disable door.getAllObjects(undefined, { asEnabled: id }): P'sextendcontributions folded in, every other disabled package's withheld) against the fold served now (getAllObjects()). A field is withheld from an object when the storage fold (getStorageObject, every contribution) declares it and the served fold does not, which is the registry's own reading (withholdDisabledExtensions), not re-assembled. A rule counts only when it reads a withheld field after the enable and did not before, so an enable is never refused over a rule it does not bring into service. That is P's own rules (a copy that replaces a served rule by name included) and every rule on an object P owns (which comes into service with it). One rule per name, the copy that wins; a tenant overlay'sactive: falsehonoured; a rule is named after the last layer that declares it (ruleLayerLabel, shared with the disable door). Reads use the disable scan'saddRuleFieldReads(own column and one hop through a reference field).{ code, status: 409 }.@objectstack/runtime:POST /api/v1/packageswithenableOnInstall: true(mirror of the #30825falsearm)truearm asks the same scan beforeenablePackage. It is non-empty only on an overwrite of a DISABLED row (the scan is empty for a package that is not disabled, which covers a fresh install). On a refusal the install has landed, the package stays disabled (row and state file followpkg.enabled), and the door answers the same409with the leadPackage 'ID' was installed and stays disabled: enableOnInstall: true cannot enable it. The shared variable is renamedinstallDisableRefusal→installLifecycleRefusal.@objectstack/runtime: the boot (app-plugin.ts, the card's "boot replay path")loadDisabledPackageIds→setInitialDisabledPackageIds), so a package that is not listed boots enabled. A state file listing X, with P booting enabled and P's rule reading X's field (a release that added the rule, or a state file written before this release), lands in the refused state without passing any door. Pinned: the harness boots exactly that and a write then answers400 VALIDATION_FAILEDwith the validator's own text "The predicate reads 'qx_credit', which this object does not declare — fix the rule's condition, or declare the field", which points the operator at the wrong repair.AppPluginregisters onekernel:bootstrappedread per registry (after thekernel:readyrehydrates have registered every package, so it is the boot's final reading, not a verdict a later registration contradicts) ofreadServedWithheldFieldReaders, and warns once naming every rule, the objects whose writes are refused, and the three remedies.warn, noterror: every refused write tells its requester, so nothing claims to persist that did not (AGENTS.md degradation rule).@objectstack/objectql:ObjectExtensionFoldOptions.asEnabled(registry.ts)getAllObjects(undefined, { asEnabled: id })folds a DISABLED package'sextendcontributions in as if it were enabled (withholdDisabledExtensions(contributors, asEnabled)exempts it), every other disabled package's stay withheld, uncached, and no registry state moves. Unset, or naming a package that is not disabled, the fold is the cached one, byte for byte (pinned).Docs:
metadata-api.mdx(enable door entry),marketplace-handbook.mdx(Enable-and-disable step, Disabling-a-module list),error-catalog.mdx(the code's Cause and Fix name the enable door and the installtruearm). The wire-code count does not move (no new code).Changeset:
@objectstack/runtimeand@objectstack/objectqlpatch, BREAKING banner,Clause-②: no (narrowing),Metadata-protocol: none, ADR-0087 dispositionnot-required (no-migration-prescription).Tests
packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts(the #30362/#30379 harness: realHttpDispatcherdoors over a file-backed SQLite kernel), 27 cases, 7 new or flipped:[#30379]describe): the pin that measured "a reading package enabled while the field stays withheld refuses writes (400)" now asserts the enable answers409with the code, the package stays disabled, and a write lands.[#30756] the enable door refuses …:200as before, state file not listing it.409+ code, the message is asserted whole (three rules in order, including the hopaccount.qx_crediton the visit, the switched-off rule absent,enable 'com.qa.extsuite.orders' first),error.detailsabsent, the row stays disabled, the rule is not served, a write lands, the state file still lists the package.active: false); the enable then answers200, the packaged rule is served switched off, a write lands.[#30756] POST /packages with enableOnInstall: true …: an overwrite of a disabled reader withenableOnInstall: trueanswers409(message asserted whole), stays disabled, state file lists it, the rule is not served; CONTROL with no reading rule:201, enabled, state file clear.[#30756] a boot whose state file leaves …: state file disables the field's package, the reader boots enabled; exactly one warning, asserted whole; a write answers400 VALIDATION_FAILED; enabling the field's package (one named remedy) lets a write land. CONTROL: a boot with nothing disabled warns nothing.packages/objectql/src/registry-disabled-package-extensions.test.ts, new top-level describe[#30756] the asEnabled fold(19 cases in the file): the named disabled package's field and rule fold in, another disabled package's stay withheld, the package stays disabled andgetObjectis unchanged; CONTROL: unset,{}, a package that is not disabled and an unknown id each serveJSON.stringify-identical folds.Ablation (the refusal, once, through
scripts/ablation-replace.mjs, runtime source resolved directly by the relative import, so no dist leg):const dependents = readEnableDependentRules(registry, id);→const dependents: EnableDependentRule[] = []; void readEnableDependentRules;, anchor 1 → 0, blobc38a69765e94→072561ead485. Result: 2 failed | 25 passed, both readingexpected { status: 200, code: undefined } to deeply equal { status: 409, … }(the flipped pin and the refusal pin; the two controls stay green, as they must). Restored: blob == HEADc38a69765e94,git diff HEADempty.Acceptance notes
/api/v1/cloud/environments/:env/packages/:pkg/disable(and any cloud enable route) — named on the card as unmeasured; carrier: cloud.enableOnInstall, ortrueon a new id) and reaches the refused state through the install door, not the enable door. The install has already registered the package when any scan could run, so the honest answer there is its own decision (refuse after landing, or land disabled). The boot warning names the state on the next restart. Carrier: seat.PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULEinpackages/spec/src/api/error-code-ledger.zod.tssays it "names WHY a disable is refused"; it now also answers the mirror enable. No spec edit here (claim). Carrier: spec lane.readWithheldFieldReadersrepeats the disable scan's per-rule judging loop (about 25 lines) rather than refactoringreadDisableDependentRules, which the [Decision] A package can be disabled while an enabled package's validation rule reads one of its extension fields, and then every write to that object is refused #30379 contract review settled; the two differ in how the withheld set is read (the disable door's fields of one package plus its taken-off rules, here the storage/served difference of the whole state).setPackageDisabledtry/catch) in both handlers are untouched; whichever PR opens second mergesorigin/mainafter the first lands.pr_create; no reviewers are requested, and the harness's MCP assignee/reviewer step is skipped per the frame.Verification
Local gates on 08be854 (pre-merge head), 95 derived families
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack→ 95 commands, run each with stdin redirected from /dev/null after a full build (env -u OS_SKIP_DTS pnpm turbo run build --filter='!@objectstack/docs' --filter='!@object-ui/*', 82/82). 95 exit 0;check-plugin-teardown-shape.mjs --self-testfirst exited 3 (pinned commit missing from the shallow clone), exit 0 aftergit fetch origin 621a487607881c66b2899b7e3477115229a156b4 --depth=1.dispatch-gates.mjs --ran→ "95 derived famil(ies) accounted for — 95 run, 0 NOT-MEASURED".Lint:
npx eslint --no-inline-config packages/runtime packages/objectqlexit 0.Typecheck:
pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/runtime typecheckVERDICT command-exit 0.Suites: objectql
Test Files 559 passed (559),Tests 11202 passed; runtimeTest Files 550 passed (550),Tests 8823 passed | 19 skipped.After merging origin/main (9788f1d)
Reinstall, full rebuild (82/82), both typechecks exit 0,
registry-disabled-package-extensions.test.ts19/19,packages-disable-module-extension-fields.test.ts27/27. Full dogfood (pnpm --filter @objectstack/dogfood testunder the verify lock, no shard, no path) was still running on 9788f1d when this PR opened; its VERDICT line and counts are in the os-dev-report comment on the card.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L9qibxjrKo69n5BEsgi5qA