Skip to content

fix(runtime)!: the package enable door refuses while a rule it would serve reads a field a disabled package withholds - #31018

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-fyy60n
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-fyy60n

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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) and content/docs/api/error-catalog.mdx (the code's 409 entry) name the refusal; skills/** teaches no package enable door (grep for packages/:id/enable, enablePackage, enableOnInstall: none), so no skill or eval moves.

Claim: 6689904888 (amended by the seat: registry.ts asEnabled, the enableOnInstall: true overwrite 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)

维护者速读

  • 改了什么:模块 X 停用时,再启用一个"有校验规则读 X 加到别的对象上的字段"的包 P,现在会被拒绝(409,沿用停用门的错误码 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE),提示逐条列出「包 · 对象 · 规则」并点名要先启用的 X,什么都不改。客户在自己那份对象上关掉的规则算关闭;只判这次启用新带上线的规则。POST /packages 以 enableOnInstall: true 覆盖一个已停用的包同样判断:装上,但保持停用,答同一个 409。启动时若持久化状态已经落在这种状态(状态文件只记停用),记一条 warn 列出规则和补救办法,不拒绝启动、不改运营者的选择。
  • 为什么改:以前这种启用答 200,之后该对象的每一次写入都 400 VALIDATION_FAILED,而且报错说"对象没声明这个字段",管理员看不出是 X 停用造成的。停用门([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)已经拦了反方向,这是对称的那一半。
  • 风险与回滚:收窄(BREAKING):原来能启用的这一种情况现在被拒。判断读注册表自己的折叠结果(新增 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)

  • After the walled-catalog and query checks, and before registry.enablePackage and setPackageDisabled, the door asks readEnableDependentRules(registry, id). If that is non-empty it answers 409 with error.code PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE and nothing changes (row, served objects, state file). The message names every rule as package · 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. No error.details beyond the code, as on the disable door.
  • Which rules count. Judged on the registry's own fold, twice: the fold the enable would leave (getAllObjects(undefined, { asEnabled: id }): P's extend contributions 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's active: false honoured; a rule is named after the last layer that declares it (ruleLayerLabel, shared with the disable door). Reads use the disable scan's addRuleFieldReads (own column and one hop through a reference field).
  • Error code: reused, no new code. The refused state is the one the disable door refuses: a disabled package's withheld field under an active rule. The enable door refuses entering it from the other side. The code is held as the same wire pair { code, status: 409 }.

@objectstack/runtime: POST /api/v1/packages with enableOnInstall: true (mirror of the #30825 false arm)

  • The true arm asks the same scan before enablePackage. 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 follow pkg.enabled), and the door answers the same 409 with the lead Package 'ID' was installed and stays disabled: enableOnInstall: true cannot enable it. The shared variable is renamed installDisableRefusal → installLifecycleRefusal.

@objectstack/runtime: the boot (app-plugin.ts, the card's "boot replay path")

  • Measured reachable: the environment's state file records only DISABLES (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 answers 400 VALIDATION_FAILED with 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.
  • Decision: say so once, loudly; neither refuse nor rewrite. The boot has no enable act to refuse, refusing the whole boot would trade one object's writes for the whole runtime, and re-enabling X or disabling P would silently override the operator's recorded choice. AppPlugin registers one kernel:bootstrapped read per registry (after the kernel:ready rehydrates have registered every package, so it is the boot's final reading, not a verdict a later registration contradicts) of readServedWithheldFieldReaders, and warns once naming every rule, the objects whose writes are refused, and the three remedies. warn, not error: every refused write tells its requester, so nothing claims to persist that did not (AGENTS.md degradation rule).

@objectstack/objectql: ObjectExtensionFoldOptions.asEnabled (registry.ts)

  • Optional key: getAllObjects(undefined, { asEnabled: id }) folds a DISABLED package's extend contributions 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 install true arm). The wire-code count does not move (no new code).

Changeset: @objectstack/runtime and @objectstack/objectql patch, BREAKING banner, Clause-②: no (narrowing), Metadata-protocol: none, ADR-0087 disposition not-required (no-migration-prescription).

Tests

packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts (the #30362/#30379 harness: real HttpDispatcher doors over a file-backed SQLite kernel), 27 cases, 7 new or flipped:

  • Flipped ([#30379] describe): the pin that measured "a reading package enabled while the field stays withheld refuses writes (400)" now asserts the enable answers 409 with the code, the package stays disabled, and a write lands.
  • [#30756] the enable door refuses …:
    • CONTROL: with the field's package enabled, disable then enable of the rules package answers 200 as before, state file not listing it.
    • Refusal: rules package off, then the field's package off (the order the disable door allows); the enable answers 409 + code, the message is asserted whole (three rules in order, including the hop account.qx_credit on the visit, the switched-off rule absent, enable 'com.qa.extsuite.orders' first), error.details absent, the row stays disabled, the rule is not served, a write lands, the state file still lists the package.
    • CONTROL: tenant overlays switch the three rules off (active: false); the enable then answers 200, the packaged rule is served switched off, a write lands.
  • [#30756] POST /packages with enableOnInstall: true …: an overwrite of a disabled reader with enableOnInstall: true answers 409 (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 answers 400 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 and getObject is unchanged; CONTROL: unset, {}, a package that is not disabled and an unknown id each serve JSON.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, blob c38a69765e94 → 072561ead485. Result: 2 failed | 25 passed, both reading expected { 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 == HEAD c38a69765e94, git diff HEAD empty.

Acceptance notes

  • Out of scope, not measured: the cloud route /api/v1/cloud/environments/:env/packages/:pkg/disable (and any cloud enable route) — named on the card as unmeasured; carrier: cloud.
  • Not covered by this door (noted, not filed): a FRESH install of a package whose rule reads a field a disabled package withholds lands enabled (an install with no enableOnInstall, or true on 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.
  • Spec prose (spec lane): the ledger comment on PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE in packages/spec/src/api/error-code-ledger.zod.ts says it "names WHY a disable is refused"; it now also answers the mirror enable. No spec edit here (claim). Carrier: spec lane.
  • Duplication: readWithheldFieldReaders repeats the disable scan's per-rule judging loop (about 25 lines) rather than refactoring readDisableDependentRules, 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).
  • Serial with Package state store: let the host supply where module enable/disable state lives; today it is a file under OS_HOME, which a hosted container loses on replacement (cloud#3636 F2) #30962: the persistence lines (setPackageDisabled try/catch) in both handlers are untouched; whichever PR opens second merges origin/main after the first lands.
  • The assignee (os-tesla, the claim's account) rides on the relay 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-test first exited 3 (pinned commit missing from the shallow clone), exit 0 after git 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/objectql exit 0.
Typecheck: pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/runtime typecheck VERDICT command-exit 0.
Suites: objectql Test Files 559 passed (559), Tests 11202 passed; runtime Test 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.ts 19/19, packages-disable-module-extension-fields.test.ts 27/27. Full dogfood (pnpm --filter @objectstack/dogfood test under 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

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/runtime, touching 20 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 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; 104 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 — 38 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 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 169ad4c6721ee6588be6f5f4deeec55f685a035b — the merge of head de2d28ef2dc98bd2f8026e2da787fda58bcd9f1a into base 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c, 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 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

⚠️ 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 33bc8ae5ff55cad16ca2c32ecf3161f5fda5da5c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9788f1d570b6a7f46eb8cb59585756a40df58939
Local-runs: none
Scope: the net diff origin/main (bcf8a37e08) ...9788f1d5, 9 files, +672 / −27, no governed path: packages/runtime/src/domains/packages.ts (the enable door's pre-check, the install door's enableOnInstall: true arm, readWithheldFieldReaders, readEnableDependentRules, readServedWithheldFieldReaders, servedWithheldFieldReadersMessage, enableDependentRulesMessage, EnableDependentRule, the DisableDependentRule export), packages/runtime/src/app-plugin.ts (reportWithheldFieldReadersAtBoot, the WITHHELD_FIELD_READERS_REPORTED WeakSet, the call at the end of seedPersistedDisabledPackages), packages/objectql/src/registry.ts (ObjectExtensionFoldOptions.asEnabled, withholdDisabledExtensions(contributors, asEnabled), resolveObjectLeavingExtensionsOut), the two test files, the changeset .changeset/30756-enable-door-dependent-rule.md (read as the ② input) and the three docs pages (prose face) + cited at the head: packages.ts 803 (the wire pair), 852–900 (addRuleFieldReads, declaredFieldNamesOf, declaredRulesOf), 952–1090 (readDisableDependentRules, ruleLayerLabel, disableDependentRulesMessage), 2270–2330 and 2355–2500 (the POST /packages overwrite path, overwritesEnabledRow, both flag arms, the rowDisabled persistence and the shared refusal return), 2507–2540 (the enable door), 2543–2595 (the disable door); registry.ts 464–476 (ObjectContributor.packageId: string | undefined), 766–776 (leavesExtensionsOut, isLeftOutExtension), 4926 (registerObject, packageId optional), 5179–5230 (resolveObject: no disabled-owner filter, cache), 5237–5265 (getStorageObject, resolveStorageObject), 6924–6948 (getAllObjects(packageId?, options?) → resolveObjectLeavingExtensionsOut), 8395, 8410–8422 (isPackageDisabled, isObjectPackageDisabled), 8509–8650 (installPackage carries enabled: existing.enabled on an overwrite, #18877), 8910–8940 (getAllPackages, enablePackage, disablePackage); packages/metadata-protocol/src/protocol-package-install.ts 370–420 (the primitive's own enableOnInstall arms, enablePackage(manifest.id) at 412); packages/core/src/types.ts 71 (PluginContext.hook, required) and packages/core/src/kernel.ts 72 (BOOT_PHASE_HOOKS: kernel:ready, kernel:bootstrapped, kernel:listening); every enablePackage( / enableOnInstall caller in non-test source (git grep on origin/main); skills/** grep for packages/:id/enable, enablePackage, enableOnInstall (none); content/docs/** grep for enableOnInstall outside the three pages; PR #31008's head 8e94a195 (git merge-tree --write-tree 9788f1d5 8e94a195) and its packages.ts / app-plugin.ts hunks over the common base c77dd17f; the queue branches (git ls-remote origin refs/heads/gh-readonly-queue/*).

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 /mnt/project-files/server-seat-2/r2026-11-06/dispatch-30756.md and FRAME.md; PR #31018's body, its five commits over origin/main (cbecc19f, 121157b7, 7a8a90a2, 08be854f, the main merge 9788f1d5), the docs-drift comment 6691499923 (advisory, 16 hand-written pages, 4 release-owned, none re-read as contradicting); PR #30825 (merged 1de7def0): its body's carrier lines and its changeset .changeset/30379-disable-door-dependent-rule.md, still unconsumed on origin/main; PR #31008: title, state (open, not draft, auto_merge null, queued as gh-readonly-queue/main/pr-31008-6106b730), its 6-file list; the check-runs on 9788f1d5, read at 2026-11-06T18:00Z: 49 total, 33 completed/success, 12 completed/skipped, 4 in_progress (Test Core 1/8–4/8), 0 failed — Lint & Repo Gates with every Lint · leg, Type Check · workspace / consumer gates / source gates / debt ledger, Build Core, Test Core 5/8–8/8, Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Dogfood Regression Gate, Test UI, Live E2E UI, Build Docs green. Both carriers read by eye: the PR body and the changeset each carry Clause-②: no (narrowing) and Metadata-protocol: none as line-initial keys, one each, plus Skills-docs: updated here — …. The PR is a draft, mergeable: true, mergeable_state: blocked (draft, checks running), author objectstack-fleet[bot], assignee os-tesla, no reviewers, no reviews.

① Derived judgments

  • Every door that turns a package on, at the head. registry.enablePackage( is called from exactly two non-test sites in this repository: domains/packages.ts (the enable door at 2531 and the install door's true arm at 2412) and protocol-package-install.ts:412 (the metadata-protocol primitive's own enableOnInstall: true arm). The HTTP install door hands the primitive { manifest, settings } only (2288), so the primitive's arm never fires from HTTP; the door applies its own arms after the install, and both HTTP sites now ask readEnableDependentRules first. An overwrite with the flag ABSENT is no door: installPackage carries enabled: existing.enabled (8645, [Decision] the package boot seed set is never updated by enable/disable, so a flag-absent re-install durably reverts an operator later enable once #18752 lands — and the obvious producer fix re-opens #18058 F1 pins #18877), so a disabled row stays disabled. A FRESH install lands enabled by construction and is not disabled when any scan could run (readEnableDependentRules returns [] for a package that is not disabled), so it can land in the refused state through the install door — the dev's acceptance note, carrier seat (should-fix 2). The boot seeds only disables (loadDisabledPackageIds → setInitialDisabledPackageIds) and has no enable act (the boot half below). Not measured, as the card and the order say: the cloud route (packages/client/src/index.ts:3835 names it; the handler lives in objectstack-ai/cloud). The primitive's own arm is reachable only by an in-process caller that spells enableOnInstall: true to protocol.installPackage — no such caller exists in this repository's non-test source (the client SDK at client/src/index.ts:2964 sends it over HTTP, to the door). TRUE for every door this repository owns; the two residuals are stated in the body (fresh install) or on the card (cloud).
  • The reused code, status and envelope are the disable door's. One wire pair PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE = { code, status: 409 } (803), both doors answer deps.error(message, .status, { code: .code }) and nothing else — no error.details beyond the promoted code (pinned: expect(refused.details).toBeUndefined()). The sentence is the mirror of disableDependentRulesMessage: the same lead: N active validation rules … — PKG · OBJECT · RULE (reads FIELD); … . Switch off or change those rules first, or … skeleton, the remedy swapped (enable 'X' first, X from withheldBy, falling back to enable the package that adds the field first). The install true arm's lead mirrors the false arm's (was installed and stays disabled: \enableOnInstall: true` cannot enable it), and the install's refusal return is the one the false arm already used (installDisableRefusal→installLifecycleRefusal`, a rename, the return unchanged). No new code: the spec ledger is untouched, the error-catalog's code count does not move. TRUE.
  • Order inside the doors. Enable: requireManageMetadata → walled catalog → query → the scan → enablePackage → summaryRefreshSettled → setPackageDisabled(…, false). The scan sits ahead of the registry switch and the state file, so a refused enable moves nothing (pinned: row enabled === false, the rule not served, a write lands, the state file still lists the package). An unknown id passes the scan empty (isPackageDisabled(unknown) is false) and still meets the 404. Install true arm: scan → (refused: no switch; else enablePackage) → rowDisabled = pkg.enabled === false persisted → the 409. The install itself has landed, as the false arm's precedent ([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 delta review must-fix 3) ruled for the mirror. The persistence lines (setPackageDisabled try/catch) are untouched here, as the claim's serial note promised — but see must-fix 1. TRUE.
  • Which rules count — the registry's own fold, twice. readEnableDependentRules(registry, P): after = readWithheldFieldReaders(fold(getAllObjects(undefined, { asEnabled: P })), offAfter) where offAfter(id) = id !== P && isPackageDisabled(id); before = the same over getAllObjects() with offNow; result = after minus before keyed by object · rule · fields. A field is withheld on an object when getStorageObject(object).fields declares it and the served fold does not — the registry's reading (withholdDisabledExtensions), nothing re-assembled in the door ([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 must-fix 1 honoured). Objects whose owner is off under the respective predicate are skipped (a disabled owner's object is refused whole at the data door, isObjectPackageDisabled), so under offAfter the objects P OWNS are judged with their rules from every enabled layer — resolveObject applies no disabled-owner filter, so getAllObjects serves them. Rules: the served copy's active === false skips it (tenant overlay honoured; pinned CONTROL), one per name, labelled after the last layer in fold order that declares the name (ruleLayerLabel, shared). Reads: addRuleFieldReads (own column, one hop through a reference field; the hop pinned as account.qx_credit on the visit). The difference means an enable is never refused over a rule that already read a withheld field before it (the object was already refusing writes; the enable makes nothing worse) — the body's "a copy that replaces a served rule by name" counts only when the replacing copy changes the read set or the served copy was switched off; otherwise its key equals the before key and it is not a dependent, which is the stated semantics ("reads a withheld field after the enable and did not before"), one sentence of the body being broader than the code (nit 3). TRUE; the P-owns-the-object case is enforced by reading but not pinned (should-fix 3).
  • asEnabled is byte-identical when unset. resolveObjectLeavingExtensionsOut: asEnabled is taken only when the option names a package that isPackageDisabled; otherwise undefined. With it undefined and no withoutExtensionsOf / onlyExtensionsOf, contributors is not even fetched and the cached resolveObject(fqn) answers — the same statement as before the diff. withholdDisabledExtensions(contributors, undefined) filters exactly as the one-argument form did (asEnabled === undefined || short-circuits). getAllObjects(undefined, undefined) and {} therefore hit the identical path; { asEnabled: X } for an enabled or unknown X too. Pinned by JSON.stringify equality for unset, {}, a package that is not disabled and an unknown id. TRUE. One latent spelling in the .some predicate — c.ownership === 'extend' && c.packageId === asEnabled with asEnabled undefined matches an UNPACKAGED extend contributor (ObjectContributor.packageId is string | undefined, registerObject's packageId optional) — can only fire on a fold that already carries withoutExtensionsOf (the disable scan's read), and sends such an object down the uncached branch, which folds the same contributors onto the same base as resolveObject does and so answers the same bytes without the cache. No served fold moves; nit 1.
  • With asEnabled: P the fold is the one the enable would leave. withholdDisabledExtensions(kept, P) exempts P's extend contributions from the withheld set; every other disabled package's stay withheld; kept is every contributor (isLeftOutExtension is false for all when neither leave-out key is set). Uncached, no registry state moves (pinned: isPackageDisabled(P) still true, getObject unchanged after the read). TRUE.
  • The boot replay — what it does. seedPersistedDisabledPackages (init, Phase 1) ends with reportWithheldFieldReadersAtBoot(ctx), which registers one kernel:bootstrapped hook per registry object (module WeakSet), and that hook calls readServedWithheldFieldReaders(registry) — the served fold as it stands, every active rule reading a withheld field on an object whose owner is enabled, empty when no package is disabled (getAllPackages().some(off) short-circuits) — and logs ONE warn naming every rule as package · object · rule (reads FIELD), the objects whose writes answer 400 VALIDATION_FAILED, and three remedies (switch the rules off, disable the serving package, enable the named adders), with a structured rules array beside it. It refuses nothing and rewrites nothing. kernel:bootstrapped is the third of BOOT_PHASE_HOOKS, after kernel:ready where the sys_packages / marketplace rehydrates register, so the reading is the boot's final one — the Startup-registry-reads rule's cure 3 (judge after the registry can no longer grow in this boot), not the defect it names. Pinned on a real file-backed boot: the state file disables the field's package, the reader boots enabled, exactly one warning whose text is asserted whole, a write answers 400 VALIDATION_FAILED, the named remedy (enable the adder) lets a write land; CONTROL: a boot with nothing disabled warns nothing. TRUE.
  • Is that right for a deployment already in the state? Yes, and for the reasons the body gives, re-derived: (a) there is no act to refuse — the state file records only disables, and the reading package's enablement is not a recorded choice but the absence of one; (b) refusing the boot trades one object's writes for the whole runtime; (c) re-enabling X or disabling P at boot would silently override the operator's one recorded choice (the disable of X), which is exactly what setInitialDisabledPackageIds exists to honour ([Decision] the package boot seed set is never updated by enable/disable, so a flag-absent re-install durably reverts an operator later enable once #18752 lands — and the obvious producer fix re-opens #18058 F1 pins #18877, "a BOOT HYDRATION input, not a standing verdict"); (d) warn, not error, is the AGENTS.md level: the degradation is functional and visible — every refused write tells its requester (400, code, the rule's own message), nothing claims to have persisted — so the line is the operator's remedy, printed once at the first boot that lands there. The one thing warn does not fix is the validator's wrong-repair text on each refused write ("declare the field"), which the card measured and which stays as it is; the boot line is the operator's pointer to the right repair, and a per-write correction is outside this card (nit 4). Holds.
  • The narrowing face is declared on both carriers. PR body lines 7–8 and changeset lines 8–9: Clause-②: no (narrowing) (BREAKING, ! on the PR title and the changeset's first line) and Metadata-protocol: none — none is right: nothing an author can write changes; the refused act is an HTTP enable, and fix(runtime)!: the package disable door refuses while an enabled package's validation rule reads a field the package adds #30825's accepted carriers for the disable door read the same pair. Skills-docs: present on both with the grep result; the grep re-run on origin/main finds nothing under skills/**. The level patch on both moved packages follows AGENTS.md Post-Task Checklist 3 ("a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition"), the same as fix(runtime)!: the package disable door refuses while an enabled package's validation rule reads a field the package adds #30825's changeset. Exactly one adr-0087: not-required (no-migration-prescription) marker; Check Changeset green. TRUE.
  • The changeset marks the breaking change and names who moves. **BREAKING** accept-set narrowing … banner; Before / Now / Which rules count / The install door too / The boot / @objectstack/objectql / Who moves (an administrator or client enabling such a package, or overwriting a disabled one with enableOnInstall: true) / The remedy (one line: enable the named disabled package first, or switch off or change each named rule, then enable). TRUE.
  • The tests and the ablation pin each refusal. Runtime harness (real HttpDispatcher doors over a file-backed SQLite kernel), 27 cases: 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 enable-measure pin flipped to 409 + code + lead text + row disabled + a write lands; the enable refusal with the message asserted whole (three rules in order, the hop, the tenant-switched rule absent, enable 'MODULE' first), details absent, nothing changed (row, served rules, data plane, state file); CONTROL field package enabled → 200; CONTROL tenant overlays switch all three off → 200, the packaged rule served switched off, a write lands; install true arm refusal with the message asserted whole, row disabled, state file listing it, rule not served; CONTROL no reading rule → 201 enabled; the boot pair. Objectql: the asEnabled fold and its byte-identity CONTROL (19 in file). Ablation reported once, through scripts/ablation-replace.mjs, on the enable door's call (const dependents = readEnableDependentRules(registry, id); → an empty array), blob c38a69765e94 → 072561ead485, 2 failed / 25 passed, both expected { status: 200, code: undefined } to deeply equal { status: 409, … }, restored to the HEAD blob. The install arm's call site (readEnableDependentRules(registry, pkgId)) is a second refusal pin that the one ablation does not reach — the frame's standing rule asks for one per refusal pin (should-fix 1). The dev's readings are not re-run here (Local-runs: none); the four Test Core shards that carry them were still running at the read above.
  • PR body vs diff. Every claim in "What changes" is in the diff: the pre-check and its position, the true arm and the rename, readEnableDependentRules / readWithheldFieldReaders / foldByName / the two messages / readServedWithheldFieldReaders, the DisableDependentRule export, app-plugin.ts's hook and WeakSet, registry.ts's option and the two touched methods, the three docs pages, the changeset. The claim amendment 6689925548 named exactly this widened surface before the PR opened; no spec file, no new error code, no governed path, +699 changed lines.

② Semver level

Both carriers read Clause-②: no (narrowing) and Metadata-protocol: none, line-initial, one each. patch on @objectstack/runtime and @objectstack/objectql is the level AGENTS.md assigns a none protocol change that is breaking on a door; the BREAKING banner, the one-line fix and the single ADR-0087 marker are present; the objectql entry is owed for the asEnabled option (an optional key on an exported interface, a widening of the fold options the changeset states under the narrowing, which is the same spelling #31006's record accepted for a new method on an exported class). Consistent with #30825's accepted changeset in every field. ! on the title. Check Changeset green on this head.

Check-runs on 9788f1d5 at the read above: 33 success / 12 skipped / 4 in progress (Test Core 1/8–4/8) / 0 failed. Draft. No governed surface: Tier S/H does not apply; the seat lands through the queue on green — after must-fix 1.

③ Boundary flags

④ Design judgment

  • The narrowing against the disable door's ruling. [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 ruling A made the disable door refuse a state in which an active rule reads a withheld field; this PR refuses entering the same state from the other side, with the same code, the same 409, the same message skeleton and the same "nothing changes" guarantee. The four-axis reading: declared ⇒ enforced holds (both HTTP doors that can enable a disabled package ask the scan; the one unguarded path, a fresh install, is declared in the body and not claimed by the docs — the handbook and metadata-api.mdx describe the enable door and the overwrite arm only); contract tightening over leniency holds (a loud 409 naming the rule and the package to enable first, instead of a 200 followed by a 400 on every write whose text pointed at the wrong repair); the registry stays the one reader of its own fold (the asEnabled option is the mirror of withoutExtensionsOf, ~15 lines, no door re-assembles a fold); the boot neither records a verdict it can still contradict nor overrides a recorded choice. Holds.
  • packages.ts. readWithheldFieldReaders is parameterised on the fold and the off-predicate, so the three readers (enable's after and before, the boot's served) are one loop; the before/after difference is the right predicate for "what this enable brings into service" and makes the door indifferent to a refusal already standing. withheldBy is read from the contributors (declaredFieldNamesOf of each disabled extend layer), so the remedy names the real adder(s), several when several. The ~25-line repetition of the disable scan's per-rule judging (stated in the body) is the price of the different withheld-set reading; a shared judgeServedRule(rule, withheldOf, …) helper would fold it later without moving behaviour — not owed here. Holds.
  • registry.ts. The option lives where the fold options already live, is honoured in the one method every served fold passes (withholdDisabledExtensions) and the one uncached resolver (resolveObjectLeavingExtensionsOut), is ignored for a package that is not disabled (so the cache is never bypassed for nothing), and moves no registry state. Holds, with nit 1.
  • app-plugin.ts. One hook per registry, registered where the disable seed is (so a kernel with no engine returns early), read at the sealed moment, at warn, with the structured payload a log reader can act on. ctx.hook is required on PluginContext (core/src/types.ts:71); the sibling block in init still guards it for a hookless mock, and this method does not (nit 5). Holds.
  • The pins. The matrix the order asked for (refusal; CONTROL tenant overlay; CONTROL no dependent) plus the flipped [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 pin, the install arm's pair, the boot pair and the objectql fold pair; every refusal message asserted whole; every "nothing changed" asserted on the row, the served rules, the data plane and the state file. Holds, with should-fix 1 and 3.

Findings:

  1. must-fix (landing, not a defect of this head) — PR feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008 is in the merge queue ahead of this PR and the two heads conflict in packages/runtime/src/domains/packages.ts and packages/runtime/src/app-plugin.ts (git merge-tree, above). Do not enqueue 9788f1d5. After feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008 lands, merge origin/main into claude/project-thread-fyy60n by hand (no rebase, no force-push) with: the readEnableDependentRules pre-check placed AFTER the query check and the 404 and BEFORE writePackageState(deps, _context, id, false) in the enable door, so a refused enable writes nothing to the file or a host store; the true arm's scan kept ahead of enablePackage, the installLifecycleRefusal rename kept, feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's writePackageStateAfterChange kept on the rowDisabled line; this.reportWithheldFieldReadersAtBoot(ctx) kept as the last statement of the now-async seedPersistedDisabledPackages. Then pnpm --filter @objectstack/runtime typecheck, the two harness files (27 and 19 expected), and a delta review of the new head by this reviewer before the seat enqueues. The verdict below is on this head's contract; the landing head is that merge's.
  2. should-fix — ablate the install true arm's refusal once: scripts/ablation-replace.mjs on const dependents = readEnableDependentRules(registry, pkgId); (the install arm's call, distinct from the enable door's id call that was ablated), expected exactly 1 red — [#30756] POST /packages with enableOnInstall: true … is refused … reading 201 for 409 — and its CONTROL green; record the output in the report. The frame's standing rule is one ablation per refusal pin; the order's "once" named the door's refusal, and this arm is a second call site of it. Report only; no code moves.
  3. should-fix — file the fresh-install case as its own card (domain:server, carrier seat): POST /api/v1/packages of a package whose validation rule reads a field a DISABLED package withholds (no enableOnInstall, or true on a new id) lands enabled and the object refuses every write with the validator's wrong-repair text until the next boot's warning. Evidence: the harness's door(…) shape with the install after the disable; the body's acceptance note already states the honest decision space (refuse after landing, as the true arm does, or land disabled). Prime Directive 10: a reproducible defect is filed, not only noted.
  4. should-fix — one pin for the owned-object case the body and the changeset claim ("every rule on an object P owns, which comes into service with it, other enabled packages' included"): P owns O, enabled Q extends O with an active rule reading a field disabled X adds, P disabled → the enable of P answers 409 naming Q · O · rule and enable 'X' first; CONTROL with X enabled → 200. Enforced by reading (resolveObject serves a disabled owner's object, offAfter lets it through, the rule's layer is Q's), but no test exercises an object P owns — the claim is declared on two carriers and pinned on none. In the shared harness file as a top-level describe.

Nits:

  • nit — registry.ts resolveObjectLeavingExtensionsOut, the .some predicate: spell (asEnabled !== undefined && c.ownership === 'extend' && c.packageId === asEnabled); as written, asEnabled undefined matches an unpackaged extend contributor on a withoutExtensionsOf fold and bypasses the cache for the same bytes. No served fold changes; one guard. Can ride the merge lap.
  • nit (spec lane, one sentence) — the key's description in the spec ("true enables the installed row"), restated by the generated reference rows (references/api/package-api.mdx, references/api/protocol.mdx, references/kernel/package-registry.mdx) and the ledger comment on the code: on an overwrite of a disabled row true can now be refused. Carrier: spec lane, with the dev's ledger note.
  • nit (prose) — the body's "a copy that replaces a served rule by name included": by the key object · rule · fields, a replacing copy that reads the same withheld fields as the served active copy is NOT a dependent (the object was already refusing writes; the enable brings no new refusal), which is the stated rule one sentence later. The changeset's wording ("including a copy that replaces a served rule by name") reads the same way; true when the read set changes or the served copy is off. Leave or tighten in the merge lap.
  • nit — the validator's refusal text on each write in the boot-landed state still says "declare the field" (the card's measured wrong repair); the boot line is now the operator's pointer. A later card could have the validator name the withheld field's package when the storage fold declares it — outside this card.
  • nit — reportWithheldFieldReadersAtBoot calls ctx.hook unguarded where the sibling block in init guards typeof ctx.hook === 'function' for a hookless mock; PluginContext.hook is required in the type and the runtime suite is green, so nothing is wrong today. One guard when the file is next touched.

Implemented-by: claude/project-thread-fyy60n
Reviewed-by: session_01L9qibyvxbrLP4jm4PargoH

VERDICT: PASS

Record written 2026-11-06T18:23Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

Contract review · delta

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: de2d28ef2dc98bd2f8026e2da787fda58bcd9f1a
Local-runs: none
Delta of: the PASS record on 9788f1d570b6a7f46eb8cb59585756a40df58939 (pr-31018/record.md), must-fix 1 (the merge lap behind #31008), should-fix 1 (the install true arm ablation), should-fix 3 (the owned-object pin) and nit 1 (the asEnabled !== undefined guard). Should-fix 2 (file the fresh-install card) is a seat act outside the head and is not re-judged here.
Scope: git diff 9788f1d5 de2d28ef restricted to this PR's files — the feature commit 239fd9db (packages/objectql/src/registry.ts one line, packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts +70) and the merge de2d28ef (parents 239fd9db and origin/main 33bc8ae5, which carries #31008 as d9f8f7fb); the merge's resolution read against both parents (git diff 239fd9db de2d28ef and git diff 33bc8ae5 de2d28ef on packages.ts and app-plugin.ts); the three handlers at the head (packages.ts: the enable door, the install true arm and its persistence line, the disable door; app-plugin.ts: seedPersistedDisabledPackages, reportWithheldFieldReadersAtBoot, registeredPackageStateStore, refuseLatePackageStateStore, the start call); #31008's own files on main (package-state-host-store.ts, package-state-host-store.test.ts, index.ts) for any touch by the merge; the ablation log /mnt/project-files/server-seat-2/r2026-11-06/out/31018-ablation-install-true-arm.log; the check-runs on de2d28ef. Blob ids read: packages.ts c38a69765e94 at 9788f1d5 and 239fd9db, 2f5b879b9c2e at 33bc8ae5, efdec37c82f1 at de2d28ef; registry.ts 56d265c5459f at 239fd9db and de2d28ef (main did not move it: git diff bcf8a37e 33bc8ae5 on it is empty); app-plugin.ts a7b1c9907070 at 33bc8ae5, bc7dbef2a4f0 at de2d28ef.

Judgments, one per item

  1. The enable door's order (dev claim: 404 → scan → writePackageState with the HostPackageStateRefusal branch → enablePackage). TRUE at de2d28ef: requireManageMetadata → walled catalog → query → if (!registry.getPackage(id)) 404 → readEnableDependentRules(registry, id) and the 409 return → try { await writePackageState(deps, _context, id, false) } catch { HostPackageStateRefusal → hostPackageStateRefused(…, 'enabled') (503); else warn } → registry.enablePackage(id) → summaryRefreshSettled. A refused enable returns before writePackageState, so neither the file under OS_HOME nor a host store is written and the registry is untouched — the property both PRs require, and the one the record's must-fix 1 named. The comment above the scan was re-worded for the new neighbour ("BEFORE the state write and enablePackage"). The net diff over main (33bc8ae5...de2d28ef) on this door is exactly the scan block inserted between feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's 404 and its try; no feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008 line is removed or moved.
  2. The install true arm (dev claim: unchanged, ahead of enablePackage). TRUE: the arm's bytes at de2d28ef equal the reviewed head's (the net hunk at 2489 is identical to the one reviewed on 9788f1d5): scan → refusal message, else enablePackage + summary refresh. The persistence line below it is feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's await writePackageStateAfterChange(deps, _context, pkgId, rowDisabled) inside the existing best-effort try, and the shared refusal return keeps the installLifecycleRefusal rename. On a refusal the row stays disabled, rowDisabled is true, and the store (file or host) records the disable — the same "the row the door returned" rule as before.
  3. The async seed (dev claim: the host branch no longer returns early, the file branch sits in an else, the report is the last statement, the only earlier exit is the setter-not-a-function return). TRUE, with one more exit the claim leaves out and which is right: main's return; at the end of the host branch became } else { … } around the file read, so both branches fall through to // [#30756] … this.reportWithheldFieldReadersAtBoot(ctx);. The exits before it are if (typeof setter !== 'function') return; (no engine: nothing to seed and nothing to read, as on 9788f1d5 where the report returned on its own getService read) and the throw when a HOST store's loadDisabledPackageIds fails, which fails the boot by feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's design — a boot that does not complete owes no final reading. The host branch's body (packageStateSeedSource = 'host', the string filter, setter.call, the store: 'host' info line, the thrown message naming PACKAGE_STATE_STORE_PLUGIN_NAME) is byte-for-byte main's; the file branch's body and its warn catch are main's moved one indent in. await this.seedPersistedDisabledPackages(ctx) in init is feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's; the hook registration inside the report is synchronous, so being reached after an await changes nothing about when kernel:bootstrapped fires (Phase 1 is still running).
  4. feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's host-store behaviour is intact. TRUE on every surface the merge touched and every one it did not: the enable and disable doors both keep store-first-then-registry with the 503 SERVICE_UNAVAILABLE branch (the disable door is not in this PR's net diff at all); the install and delete arms keep writePackageStateAfterChange (error log, request not undone); writePackageState / hostPackageStateRefused / HostPackageStateRefusal are main's; registeredPackageStateStore, refuseLatePackageStateStore and its call at the top of start, packageStateSeedSource, the optionalDependencies entry com.objectstack.package-state-store are main's; git diff 33bc8ae5 de2d28ef on package-state-host-store.ts, its test and runtime/src/index.ts is empty. The one semantic interaction is item 1's: a refused enable now never reaches the host store, which is what feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's own door pin ("a refusing store fails the enable door visibly, and the package stays disabled") needs to stay green — that pin's package has no dependent rule, so the scan is empty and the door reaches the store as before.
  5. Should-fix 1, the install true arm ablation. TRUE as reported: anchor const dependents = readEnableDependentRules(registry, pkgId); ×1 → ×0, replacement const dependents: EnableDependentRule[] = []; void readEnableDependentRules; ×0 → ×1, blob c38a69765e94 → 787358e86918; result Test Files 1 failed (1), Tests 1 failed | 28 passed (29), the one red being [#30756] POST /packages with enableOnInstall: true asks the same scan › an overwrite of a disabled package that asks to come back enabled is refused … with AssertionError: expected { status: 201, code: undefined } to deeply equal { status: 409, …(1) }; its CONTROL and every other pin (the enable door's refusal included, which the arm's ablation must not reach) among the 28 green; restored blob after restore == blob at HEAD c38a69765e94, git diff HEAD empty. The reading's tree: c38a69765e94 is packages.ts at 239fd9db (the pre-merge feature parent; 29 cases means the owned-object pins were present), not at de2d28ef (efdec37c82f1). The arm's bytes are identical in both trees (item 2), so the reading carries; the record names the tree it was taken on (nit 1).
  6. Should-fix 3, the owned-object pin. TRUE and the pin is the case the body claims: com.qa.own.owner owns qo_thing; enabled com.qa.own.reader extends it with an active rule reading qo_flag, which com.qa.own.flags adds by extend; owner disabled, flags disabled → PATCH /packages/com.qa.own.owner/enable answers 409 + the code, the message asserted whole (com.qa.own.reader · qo_thing · qo_flag_required (reads qo_flag), enable 'com.qa.own.flags' first), the row stays disabled. CONTROL: flags enabled → 200, row enabled. By the code: under offNow the owner is off so qo_thing is skipped from before; under offAfter it is judged, qo_flag is withheld (storage declares it, served does not), the reader's rule reads it, the layer label is the reader's. A top-level describe in the shared harness file, as the record asked; the same HttpDispatcher-over-SchemaRegistry shape the install-arm pair uses.
  7. Nit 1, the asEnabled guard. TRUE: (asEnabled !== undefined && c.ownership === 'extend' && c.packageId === asEnabled), one line, the only registry.ts change since 9788f1d5; the byte-identity CONTROL is unaffected (the guarded branch is now unreachable when asEnabled is undefined, which is the cached path the CONTROL pins).
  8. Nothing else moved. git diff 9788f1d5 de2d28ef on this PR's nine files is the feature commit's two files plus the two merge resolutions; the changeset, the three docs pages, the objectql test and packages.ts outside the two doors are unchanged. The PR's net diff over main is now 9 files, +754 / −38 (the +82 / −11 over the reviewed head being the 70-line pin, the guard and the seed's re-indentation). Both carriers unchanged; Check Changeset green on de2d28ef.
  9. Check-runs on de2d28ef, read at 2026-11-06T18:22Z: 47 total, 14 completed/success (Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Spec property liveness, Plan Test Core shards, Dogfood Regression Gate, Test UI, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Flag docs affected by code changes, Check Documentation Links, Auto Label, filter), 12 completed/skipped, 21 in_progress (Build Core, Test Core 1/8–8/8, Type Check · workspace / consumer gates / source gates / debt ledger, Lint · ESLint and the other Lint · legs, Live E2E UI, Build Docs), 0 failed. Not green yet; the seat enqueues only once every required context reads success (Multi-agent discipline §7), which is the standing rule and not a finding of this delta.

Findings:

Nits:

Implemented-by: claude/project-thread-fyy60n
Reviewed-by: session_01L9qibyvxbrLP4jm4PargoH

VERDICT: PASS

Record written 2026-11-06T18:24Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

维护者速读

  • 改了什么:模块 X 停用时,启用一个「有校验规则读 X 加到别的对象上的字段」的包 P 现在被拒(409,沿用停用门的错误码 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE),提示逐条列出「包 · 对象 · 规则」并点名先启用 X,什么都不写(文件状态与宿主状态都不写)。POST /packages 以 enableOnInstall: true 覆盖已停用的包同样判断。启动时若已处于这种状态,只记一条 warn,不拒绝启动、不改运营者的选择。
  • 为什么改:以前这种启用答 200,之后该对象每次写入都 400,且报错看不出是 X 停用造成的;停用门([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)已拦反方向,这是对称的另一半。
  • 风险与回滚:收窄(BREAKING):原来能启用的这一种情况现在被拒;判断读注册表自己的折叠结果,新选项 asEnabled 不传时结果逐字节不变(有测试钉住)。已与 feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008 的宿主状态存储合并,拒绝发生在状态写入之前。回滚=撤销本 PR。
  • 先看:packages/runtime/src/domains/packages.ts 的 readEnableDependentRules 与启用门顺序(404 → 依赖规则检查 → 状态写入 → 启用),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,预期 29 条全绿(含「自有对象」一条与其对照)。
  • 需要你做的:无,席位落地。

席位意见:可以落地。

席位核对

ACCEPT · seat domain:server#2 · head de2d28ef · 2026-11-06T18:50Z

  1. Shape: Fixes #30756, draft, base main, 9 files, +754/−38 (792 changed lines, under 3,000; check-governed-merges --pr 31018: no governed path). Clause-②: no (narrowing), Metadata-protocol: none on both carriers; patch on runtime + objectql with the BREAKING banner, who moves and the one-line remedy. Claim 6689904888, amendment 6689925548.
  2. Premise: measured in the PR: on main such an enable answered 200 and every later write to the object answered 400 naming an undeclared field. The disable door's dependent scan (fix(runtime)!: the package disable door refuses while an enabled package's validation rule reads a field the package adds #30825, [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) already refuses the reverse.
  3. Review: CONTRACT_REVIEW_TIER review 6692238619 (PASS on 9788f1d5; must-fix 1 = the merge lap behind feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008) and delta 6692259121 (PASS on this head, no findings): the merge keeps the scan after the 404 and before writePackageState, so a refused enable writes nothing to either store; feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's host-store lines are intact; should-fix 1 (install true-arm ablation, exactly 1 red) and 3 (owned-object pin and control) are in; the asEnabled !== undefined guard applied.
  4. Gates / check-runs: on head de2d28ef: 38 success, 12 skipped, 0 failed. Dev: 95/95 derived gates on the first head; runtime harness + feat(runtime): a host can supply the package-state store (createPackageStateStorePlugin); a refused write fails the enable/disable door with nothing changed #31008's host-store test 40/40 and objectql 19/19 on this head.
  5. Accepted deviations / out of scope: a fresh POST /packages of a package whose rule reads a withheld field still lands enabled (no disabled row to judge); the seat files it as its own card (review should-fix 2). The boot reading warns and does not refuse, judged right: no enable act to refuse, and rewriting state would override the operator's recorded disable.
  6. Landing: not governed: the seat readies the PR and arms auto-merge on the maintainer's word in the seat thread 「你应该使用 pm-dispatch 技能审核和落地pr」(2026-11-06T16:49Z).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 18:52
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 18:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit 9052337 Nov 6, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-fyy60n branch November 6, 2026 19:17
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime: enabling a package whose validation rule reads a field of a disabled package is accepted, and every write to that object then answers 400

2 participants