Skip to content

fix(objectql)!: withhold a disabled package's objectExtensions fields from the object they extend, and keep their columns - #30362

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-hsmw0g
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-hsmw0g

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

维护者速读

  1. 客户在设置里停用一个模块后,这个模块加到别的对象上的字段(例如合同上的 O2C 汇总字段)现在也一起隐藏了,和模块自己的对象、菜单、动作一样。
  2. 隐藏只管"展示和读写":对象元数据、数据接口的读写、记录页都看不到这些字段;数据库里的列和已存的值一个不动。
  3. 重新启用后字段和原来的值原样回来;一个从"已停用"状态启动的环境也照样建好这些列,启用后马上能写。
  4. 停用期间再写这些字段,回答和写一个对象上不存在的字段完全一样(400 INVALID_FIELD),没有新增拒绝码。
  5. 需要你知道的一点:如果另一个仍启用的模块有校验规则读了被停用模块的字段,停用期间这个对象的写入会被该规则拦下(400 VALIDATION_FAILED,点名规则);公式读到的是空值,不会 500。
  6. 这是一次对外对象形状的收窄(停用状态下少了字段),按 BREAKING 申报,合并前需要合同审查。

Fixes #30304

Clause-②: yes (narrowing) — while a package is disabled, the served object it extends (GET /api/v1/meta/object/:name, the record page, the /data field map) no longer carries the fields, validation rules and indexes the package's objectExtensions add, so a /data write, filter, sort or fields projection naming one is refused as a field the object does not have, and GET /api/v1/packages/:id/customizations no longer lists them among the base's contributions.
Metadata-protocol: semantic

What changed

  • packages/objectql/src/registry.ts — every served fold leaves out the extend contributions of a disabled package (isPackageDisabled, the predicate objects, flows, navigation and actions already use):
    • resolveObject (behind getObject / getAllObjects), the per-package fold (withoutExtensionsOf / onlyExtensionsOf) and foldObjectExtendersOnto (the protocol's by-name read over a metadata-service body);
    • a base layer that already carries a withheld field, rule or index (a tenant overlay saved from a served read, a metadata-service copy seeded from a resolved body) has it taken off (indexes compared by the fold's own structural key); a member the owner declares, or another enabled extension also adds, stays;
    • new getStorageObject(name) / getAllStorageObjects(): the fold with every contribution, disabled or not. With nothing disabled they are the served answer (same cache, same walk);
    • installPackage drops cached folds when the row lands disabled (a fold cached before the row existed read the package as enabled).
  • Storage readers move to the full fold, so a disable never drops, skips or re-shapes a column:
    • ObjectQLPlugin boot sync, registerSchemasWithoutDdl and the federated reconciliation (readRegisteredObjects(seam, 'storage')); the governance inventory keeps the served read;
    • ObjectQL.syncSchemas / syncObjectSchema;
    • the lifecycle sweep (getAllStorageObjects when the registry has it), so the archive datasource's schema keeps every column the hot rows carry;
    • os migrate's composed coverage (measureComposedCoverage, packages/cli/src/utils/schema-migration-plugins.ts): its DDL-free branch (OS_SKIP_SCHEMA_SYNC) installs each object as the driver's managed field set with registerObjectMetadata, so on the served fold the drift report proposed dropping every column a disable only hides. It reads getAllStorageObjects (falling back to getAllObjects).
  • packages/runtime/src/domains/packages.ts — readBaseContributions' field loop skips a disabled package's contributor, as its nav loop does. This makes the spec docblock spec(package-lifecycle): the customizations read's docblock says a disabled package's contributions are never listed, but its fields are listed and served #30086 names (package-lifecycle.zod.ts "a disabled package's contributions MUST NOT be listed, as the fold drops them") true for fields. The array-level describe there still names only flows and navigation; that text is the spec lane's (spec(package-lifecycle): the customizations read's docblock says a disabled package's contributions are never listed, but its fields are listed and served #30086), not edited here.

Measured, not assumed

  • Disable scope. Disable is per environment (setPackageDisabled(environmentId, …), one registry row per kernel); there is no separate org-level disable to keep in step. Pinned through the real door, including a boot that replays the state file.
  • Schema sync. It read the served fold (getAllObjects). With the served fold narrowed and sync left on it, a boot that starts with the module disabled creates the table without the module's columns, and a write after enable answers 500 (measured in the ablation below). Hence the storage fold.
  • Summary (o2c_probe_total shape). The roll-up index is built from the served fold, so a withheld summary is not recomputed while disabled; its child object is refused (OBJECT_PACKAGE_DISABLED) anyway. Re-enable serves the stored value (pinned: 2000 back).
  • Formula on the object reading a withheld field answers null (200, no 5xx).
  • Filter / sort / fields naming a withheld field: byte-identical to naming an unknown field (400 INVALID_FIELD / INVALID_SORT), pinned by comparison.
  • Write naming a withheld field: byte-identical to an unknown field (400 INVALID_FIELD, Unknown field '…' on object '…'), create and update. No new code.

Tests

  • packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts — real doors (HttpDispatcher → /packages/:id/disable|enable, /meta/object, /data, /packages/:id/customizations) over a file-backed SQLite: control, disable, unknown-field parity, other-field writes, contributions list, re-enable with values, disabled-at-boot column creation, and the cross-module formula / rule / query measurements.
  • packages/objectql/src/registry-disabled-package-extensions.test.ts — the four served readers, the storage readers, a folded base and a tenant overlay (fields, rules and indexes), a row that lands disabled after a cached fold.
  • packages/cli/src/utils/schema-migration-plugins.test.ts — a top-level describe over a real SQLite SqlDriver and a real SchemaRegistry: a table created with the module enabled, the module disabled, measureComposedCoverage(…, { ddlFree: true }), then detectManagedDrift() proposes no drop of the hidden column and the stored value stays.

Ablations (one-time, via scripts/ablation-replace.mjs, restored to HEAD with git diff HEAD empty; dist restore checked with ablation-dist-preflight --absent):

Mutation Result
withholdDisabledExtensions always answers undefined registry suite 7 failed / 17
boot sync and syncSchemas read the served fold (objectql rebuilt) runtime suite 1 failed / 11: write after enable 500
readBaseContributions field loop without the disabled skip runtime suite 1 failed / 11
measureComposedCoverage reads getAllObjects (served) cli pin red: expected [ 'drop_column:qm_credit' ] to deeply equal []
withheld indexes not taken off a folded base registry suite 2 failed / 17 (folded base, tenant overlay)

Gates (at f2ce2c0f7)

  • node scripts/pm/dispatch-gates.mjs --commands on this diff: 65 families, all run, all exit 0. --ran: 65 derived, 65 run, 0 NOT-MEASURED.
  • pnpm --filter @objectstack/cli typecheck / @objectstack/objectql typecheck / @objectstack/runtime typecheck: exit 0 (cli's test layer holds its ledger: 28 errors / 6 pinned signatures, unchanged).
  • pnpm lint: exit 0 (at 12d54cf31, before the review fixes).
  • @objectstack/objectql full suite before the merge of origin/main: 552 files, the one red file (plugin-registry-read-failure.test.ts, 5 cases) fixed by scoping the storage read to the DDL seams. At f2ce2c0f7: the registry pins and plugin-registry-read-failure.test.ts green.
  • @objectstack/cli schema-migration-plugins.test.ts and the runtime route pin: green at f2ce2c0f7.
  • Not run locally (CI's): test:repo, dogfood, the whole-tree families the derivation leaves to CI.

Contract review

Round 1 FAIL (6653307077): M1 (measureComposedCoverage on the served fold) and S1 (a withheld extension's indexes kept on a folded base) are fixed in 024ea96e5, the pin's type in f2ce2c0f7. S2 (the roll-up index reads the served fold) and S3 (another package's rule reading a withheld field) are filed by the seat, not fixed here.

Acceptance notes

  • Rule of another enabled package reading a withheld field (measured, kept as is): it cannot be evaluated, so every write to the object is refused 400 VALIDATION_FAILED naming the rule until the field's package is enabled again. This is the answer any rule reading an undeclared field already gets (fail closed). A suite whose modules read each other's fields should declare that dependency; whether a disable should then be refused, or such a rule skipped, is a ruling, not this card.
  • UI residue: none required by the served object: the record page draws from /meta/object. A page or form layout that names a withheld field explicitly is not measured here (ui lane paused).
  • Layered read GET /meta/object/:name?layers=true measured while disabled: code and effective carry none of the module's fields (the code layer is the metadata-service copy folded by foldObjectExtendersOnto). The registry's own code-layer lookup (resolveOwnerLayer → getArtifactItem, artifact identity for the save gates and the fallback when the service holds no copy) still folds every contribution: it answers "what does the code ship".

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CbdPwGwxWkDNgrE4LE

@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 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/objectql, @objectstack/runtime, touching 30 documentable anchor(s).

15 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/data-modeling/objects.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/deployment/app-dependencies.mdx (via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/deployment/cli.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/deployment/environment-variables.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/frontend/architecture-overview.mdx (via resolveObject (symbol, a method of class SchemaRegistry))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via ObjectQLPlugin (symbol, a top-level class), SchemaRegistry (symbol, a top-level class))
  • content/docs/kernel/services.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/adding-a-metadata-type.mdx (via SchemaRegistry (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/protocol/kernel/index.mdx (via ObjectQLPlugin (symbol, a top-level class), syncSchemas (symbol, a top-level function))
  • content/docs/protocol/kernel/plugin-spec.mdx (via SchemaRegistry (symbol, a top-level class))

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

  • content/docs/releases/v12.mdx (via installPackage (symbol, a method of class SchemaRegistry))
  • content/docs/releases/v17/17-0.mdx (via ObjectQLPlugin (symbol, a top-level class), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/releases/v17/17-1.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-4.mdx (via SchemaRegistry (symbol, a top-level class), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it))
  • content/docs/releases/v18/18-2.mdx (via installPackage (symbol, a method of class SchemaRegistry))

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
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 60 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 870ebce333bf0a5794307febe077ab5652cfbfe9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 77a0afd77bac2334ae6ff88f473dc6e1940544c3 — the merge of head 81f7ce8a12d8ddc8231c9a1f7c86b509fed46a2d into base 870ebce333bf0a5794307febe077ab5652cfbfe9, 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 77a0afd77bac2334ae6ff88f473dc6e1940544c3 && git checkout 77a0afd77bac2334ae6ff88f473dc6e1940544c3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 870ebce333bf0a5794307febe077ab5652cfbfe9 81f7ce8a12d8ddc8231c9a1f7c86b509fed46a2d && git checkout -B drift-repro 870ebce333bf0a5794307febe077ab5652cfbfe9 && git merge --no-ff 81f7ce8a12d8ddc8231c9a1f7c86b509fed46a2d

node scripts/docs-audit/affected-docs.mjs --json 870ebce333bf0a5794307febe077ab5652cfbfe9

⚠️ 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 870ebce333bf0a5794307febe077ab5652cfbfe9 → 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#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract-review-claim: session_01Utp6CZMkHvuDeVYgTD73qF · head 12d54cf · 2026-11-05T08:00Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 12d54cf316b406138a1110e9c5a04b0125567959
Local-runs: none
Scope: packages/objectql/src/engine-compatibility-api.ts, packages/objectql/src/lifecycle/lifecycle-service.ts, packages/objectql/src/plugin.ts, packages/objectql/src/registry-disabled-package-extensions.test.ts, packages/objectql/src/registry.ts, packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts, packages/runtime/src/domains/packages.ts + packages/cli/src/utils/schema-migration-plugins.ts, packages/cli/src/utils/schema-migrate.ts, packages/drivers/driver-sql/src/sql-driver-rotation.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-sql/src/schema-drift.ts, packages/objectql/src/engine-summaries-and-expansion.ts, packages/objectql/src/validation/rule-validator.ts, packages/runtime/src/domains/meta.ts, packages/spec/src/api/package-lifecycle.zod.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/data/object.zod.ts (the published spec and the cited code, read to judge the narrowing, not reviewed; the changeset read for ② only)

Inputs: card #30304 (body, triage 6650917536, claim 6651772742, dev report 6652852213), PR #30362 (body, 8-file list, 3-dot diff against main at merge-base 8c9f48f), the check-runs on the head. Read-only: the head was fetched into a review ref and read with git show; nothing built, run or re-run.

① Derived judgments

Each accept-set or public-surface change the diff implies, read in the head tree:

  1. Served folds withhold a disabled package's extend contributions — resolveObject (behind getObject / getAllObjects), the per-package fold resolveObjectLeavingExtensionsOut, and foldObjectExtendersOnto, all through one predicate withholdDisabledExtensions over isPackageDisabled (the predicate objects, flows, nav and actions already use at L7920, L8005, L8584). Right. Fields, rules, indexes and field patches of the withheld contributor leave the fold because it is folded from withheld.kept; a field the owner or a kept extension also declares stays (keptFields). Pinned in the registry suite on all four readers, control + disable + re-enable.
  2. A base that already carries withheld members is stripped (withoutWithheldMembers: a tenant overlay saved from a served read, a metadata-service copy seeded from a resolved body). Right for fields and rules; incomplete for indexes — see ④ should-fix 1.
  3. Cache — disablePackage / enablePackage already call invalidateAll() (L8441, L8456); installPackage now invalidates when the row lands disabled (L8221, the boot-seed case the registry suite pins); uninstallPackage already does (L8408). mergedObjectCache is keyed by FQN in one registry per kernel and disable is per environment (setPackageDisabled(environmentId, …)), so no second copy stays stale. Right. The metadata-service copy is not a cache of the fold: foldObjectExtendersOnto strips and re-folds it per read, and bridgeObjectsToMetadataService seeds a served body that the enable re-folds (subtractExtenderContributions + fold). Right.
  4. New public surface — SchemaRegistry.getStorageObject(name) / getAllStorageObjects() (the full fold; by identity resolveObject's answer when nothing is disabled, pinned toBe), and the optional LifecycleEngineLike.registry.getAllStorageObjects? member. A widening beside the narrowing; both named in the PR body and changeset. Right.
  5. Storage readers moved to the full fold — ObjectQLPlugin.readRegisteredObjects(seam, 'storage') for syncRegisteredSchemas, registerSchemasWithoutDdl and reconcileFederatedBindings (the governance inventory keeps the served read); ObjectQL.syncSchemas / syncObjectSchema; the lifecycle sweep's declared list, so cold.syncSchema(object, obj) at L1419 mirrors every hot column. Right as far as it goes — one storage reader was missed (must-fix below), and the summary index is a second storage-shaped reader left on the served fold (should-fix 2).
  6. readBaseContributions field loop skips a disabled contributor as its nav loop does; makes package-lifecycle.zod.ts L598/L605 ("A disabled package's contributions are not listed, as the fold drops them") true for fields. Right. The array-level describe (L603–605) still names flows and nav only — spec lane spec(package-lifecycle): the customizations read's docblock says a disabled package's contributions are never listed, but its fields are listed and served #30086, correctly not edited here.
  7. Data plane — no code change: a write, filter, sort or fields naming a withheld field meets the served field map and answers the unknown-field refusal (400 INVALID_FIELD / INVALID_SORT), pinned byte-for-byte against an unknown name on create, update, filter, sort and fields; a write through other fields lands and leaves the stored value alone; re-enable serves 500 and the summary 2000 again. Right: the card's asks (/meta/object, /data reads and writes, re-enable with values) are met through the real doors.
  8. Code layer untouched — resolveOwnerLayer → getArtifactItem('object') still folds every contributor (artifact identity for the save gates). Right: it answers "what does the code ship", not "what is served"; the layered read's code layer comes from the metadata-service copy and is measured without the withheld fields.
  9. A disabled package that OWNS the object still folds whole to registry readers (pinned). Right: the data plane refuses the object itself by owner (isObjectPackageDisabled, 404 OBJECT_PACKAGE_DISABLED).

② Semver level

  • .changeset/30304-disabled-package-extension-fields-withheld.md: @objectstack/objectql: minor, @objectstack/runtime: minor; title carries !; Clause-②: yes (narrowing) line-initial in the changeset body and the PR body, identical arms; Metadata-protocol: semantic on both; exactly one ADR-0087 marker, not-required (no-migration-prescription). The "Who moves" paragraph is the migration text a breaking changeset owes (nothing an author can write is removed, so no FROM → TO mapping and no tombstone are due).
  • Level: AGENTS.md §Post-Task Checklist 3 — a kind other than none takes at least minor on a package the diff moves; the diff moves objectql and runtime. Matches. semantic is the right kind: no key is added or refused, the meaning of an objectExtensions declaration under package disable changes. Check Changeset and Governed Surface Queue Guard are green on the head.
  • One mismatch outside the diff: the seat's claim 6651772742 on the card still reads Metadata-protocol: none; the dev declared semantic and the claim text said the seat would amend. Owed by the seat, not the PR (noted under ③).
  • Clause-②: line: yes (narrowing) is the right arm — the new storage readers and the two registry methods are a widening beside the narrowing, so no (narrowing) would be false.

③ Boundary flags

  • Deviation: PR opened without labels — the order named none. Answered.
  • Deviation: lifecycle sweep moved beyond the In list — read in lifecycle-service.ts L741–746 and L1419: with the served fold the archive datasource would be synced without the withheld column and the hot-row copy would fail. Right call; answered.
  • Open question 1 (a rule of another, enabled package reads a withheld field → every write to the object is 400 VALIDATION_FAILED naming the rule while disabled) — judged below in ④ (acceptable for this PR; ruling card owed).
  • Open question 2 (roll-up index built from the served fold; a withheld summary over a child in an enabled package is stale after re-enable until the next child write) — should-fix 2 below.
  • Dogfood battery owed by the seat — Dogfood Regression Gate reports success on the head (its matrix shards show skipped); the seat reads that as the gate's answer. The ! and the narrowing arm are declared.
  • Follow-ups the dev names (spec(package-lifecycle): the customizations read's docblock says a disabled package's contributions are never listed, but its fields are listed and served #30086 array-level describe, spec lane; ui residue for a layout naming a withheld field, ui lane paused) — acknowledged; neither is this diff's.
  • Claim line Metadata-protocol: none on the card — the seat amends it to semantic (escalated to the seat; see ②).

④ Design judgment

The narrowing itself, against the published spec on main. No published schema declares that an objectExtensions field outlives its package's disable. package-lifecycle.zod.ts L598 and L605 already say a disabled package's contributions are not listed "as the fold drops them"; the error-code ledger (L699) defines OBJECT_PACKAGE_DISABLED as "the data plane refuses rather than serving rows". The fold did not drop them, so the spec's own statement was false for fields. This PR makes the spec's withholding real at the consumer; it takes no declared capability away. ObjectExtensionSchema (object.zod.ts L4979) declares what an extension adds, not that it survives its package being switched off. The design holds; the card's ask is met through the real doors. The judgments below are about the implementation of the design, read in the live tree.

Must-fix 1 — a DDL-free os migrate binds the served fold and then reports the disabled package's columns as orphans to drop. packages/cli/src/utils/schema-migration-plugins.ts L2406 (measureComposedCoverage) reads engine.registry.getAllObjects() — the served fold — and on the ddlFree branch (L2442, reached from schema-migrate.ts L1484 with ddlFree: skipSchemaSync, the OS_SKIP_SCHEMA_SYNC=1 posture) calls driver.registerObjectMetadata([obj]) per object. SqlDriver.registerManagedObjectMetadata (sql-driver-rotation.ts L163–170) ALWAYS overwrites managedObjectFields and managedObjectIndexes for the table — so the boot's own registerSchemasWithoutDdl (now storage, right) is overwritten with a field map missing every withheld column. detectManagedDrift diffs the physical schema against that map (schema-drift.ts L1455–1474) and reports each of those columns as "column exists in the database but not in metadata (orphaned) — os migrate apply --allow-destructive to drop it", with a drop_column op. A tenant that disabled a module (HotCRM's exact shape) and runs the documented drift remedy on a DDL-free deployment is led, by a false plan, to drop the module's columns and values. This is the card's red line (no column drop or value loss) through a path this PR's own rule names ("storage readers move to the full fold") and missed. Fix: that pass reads getAllStorageObjects?.() ?? getAllObjects() like the other storage readers, with a pin that a disabled extension's column is not reported orphaned under ddlFree. Before this PR served and full folds were one answer, so the misreport is new to this head.

Should-fix 1 — foldObjectExtendersOnto leaves a withheld extension's indexes on a folded base. withoutWithheldMembers takes fields and rules off the base; subtractExtenderContributions(contributors, served) is run over withheld.kept only (registry.ts L5738–5740), so a metadata-service copy seeded while the package was enabled keeps serving the withheld extension's indexes entries (naming fields the object no longer serves) through the by-name read while disabled. The registry pin "a base that already carries the withheld members" checks fields and rules, not indexes. Subtract the withheld contributors' index entries from the base too (one line: run the subtraction over listed, or pass withheld index keys into withoutWithheldMembers), and extend the pin.

Should-fix 2 — the roll-up index is a storage-shaped reader on the served fold. ObjectQL.buildSummaryIndex (engine-summaries-and-expansion.ts L134) reads getAllObjects(); the index is memoised against objectRevision, which invalidateAll bumps, so enable/disable rebuilds it correctly — but while disabled a withheld summary whose child object belongs to an ENABLED package is not recomputed, and the stored column is stale after re-enable until the next child write. The card's own shape (child in the same disabled module, refused on the data plane) is unaffected and pinned (2000 back). Because the summary is a stored column, keeping it current is the storage layer's job: build the index from getAllStorageObjects where the child object is readable, or recompute withheld summaries on enable. A card for the lane; not blocking.

Should-fix 3 — open question 1 (cross-package rule reading a withheld field). Read in rule-validator.ts L4231–4238: a predicate over a field the record does not declare is a broken rule and is fail-closed since #4649; this PR adds no code there. The disable door (packages.ts L1922–1947) checks no dependents, so a module of another package can be switched off under a rule that reads it. Judgment: acceptable for this PR. The card's scenario is module → app; a module whose rule reads a field of a module it neither declares nor depends on is the authoring defect the fail-closed policy exists for, the consequence is loud (400 naming the rule, never a 5xx or a silent verdict flip), and the changeset's "Who moves" names it. It is not acceptable to leave undecided: a suite that ships cross-module rules (HotCRM phase 1 PSA/O2C may) needs a ruling — refuse the disable while an enabled package's rule or formula reads the field, skip such rules while disabled, or require the dependency at install. File the ruling card in the server lane with the measured pin as its evidence; the formula case (answers null, 200) needs no ruling.

Tried and held. (a) Column/value loss: boot sync, DDL-free registration, federated binding, syncSchemas / syncObjectSchema (the CLI's deferred, non-DDL-free branch goes through syncObjectSchema, so it is right), lifecycle sweep + archive schema mirror: all on the full fold; a boot that starts disabled creates the columns and an enable with no restart writes them (pinned). (b) Write/filter/sort/fields naming a withheld field: unknown-field parity pinned byte-for-byte on five doors; no new refusal code. (c) Caches: fold cache invalidated on enable, disable, install-disabled, uninstall; metadata-service copies are re-folded per read; a tenant overlay carrying the field is stripped while disabled and re-served on enable; per-environment disable, one registry per kernel. (d) Judged above.

Gates. The check-runs on 12d54cf316b406138a1110e9c5a04b0125567959, read after the last one completed: 48 runs, 35 success, 13 skipped (the UI matrix and build/lint/typecheck UI legs, Build Docs, the opt-in tarball smoke and 18.x harness, the schedule gate, the dogfood matrix shards behind a green Dogfood Regression Gate), 0 failure. Green among them: Build Core, Test Core 1–8 and the aggregate, Type Check workspace / source gates / consumer gates / debt ledger, Lint ESLint / repo & tooling / docs & code guards / CI-release-engine gates / PM dispatch-gates / the three scoped lints, Check Changeset, Check PR Size, PR-body guards, Governed Surface Queue Guard, Live E2E UI. The gates derived from this diff pass; the FAIL above is ④'s must-fix, which no gate measures (the CLI drift plan under ddlFree with a disabled package has no pin).

Implemented-by: claude/project-thread-hsmw0g
Reviewed-by: session_01Utp6CZMkHvuDeVYgTD73qF

VERDICT: FAIL

Rendered 2026-11-05T08:24Z on head 12d54cf3, against the contract-review claim of 2026-11-05T08:00Z.

… and a withheld extension's indexes leave a folded base

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbdPwGwxWkDNgrE4LE
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract-review-claim: session_01Utp6CZMkHvuDeVYgTD73qF · head f2ce2c0 · 2026-11-05T08:44Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f2ce2c0f7c1dbb4b4735c5e6ff2f287cfbc020b9
Local-runs: none
Scope: packages/objectql/src/engine-compatibility-api.ts, packages/objectql/src/lifecycle/lifecycle-service.ts, packages/objectql/src/plugin.ts, packages/objectql/src/registry-disabled-package-extensions.test.ts, packages/objectql/src/registry.ts, packages/runtime/src/domains/packages-disable-module-extension-fields.test.ts, packages/runtime/src/domains/packages.ts, packages/cli/src/utils/schema-migration-plugins.ts, packages/cli/src/utils/schema-migration-plugins.test.ts + packages/cli/src/utils/schema-migrate.ts, packages/cli/src/commands/migrate/blank-single-values.ts, packages/cli/src/commands/migrate/option-codes.ts, packages/cli/src/commands/migrate/text-scalars.ts, packages/cli/src/commands/migrate/value-shapes.ts, packages/drivers/driver-sql/src/sql-driver-rotation.ts, packages/drivers/driver-sql/src/schema-drift.ts, .changeset/config.json, packages/cli/package.json (the cited code and the changeset configuration, read to judge; not reviewed)

Delta review of record 6653307077 (head 12d54cf3), which this record supersedes for the new head. Inputs: card #30304 (body and every comment, the claim 6651772742 as amended at 2026-11-05T08:26Z), PR #30362 (body, 10-file list, the 3-dot diff against main, the delta 12d54cf3..f2ce2c0f: commits 024ea96 and f2ce2c0, 4 files, +96/−7; 12d54cf3 is an ancestor of the new head, no force push), the contract-review claim 6653662896 for this head, the check-runs on f2ce2c0f. Read-only: the head fetched into a review ref and read with git show; nothing built, run or re-run.

① Derived judgments

Judgments 1–9 of record 6653307077 stand on this head unchanged (the delta touches none of the served folds' predicate, the storage readers in objectql/runtime, the cache invalidation points or readBaseContributions). The delta implies two further accept-set changes:

  1. The DDL-free drift binding reads the storage fold — measureComposedCoverage (schema-migration-plugins.ts L2406–2408) reads getAllStorageObjects() when the registry has it, falling back to getAllObjects() for an engine without it, so the registerObjectMetadata([obj]) call on the ddlFree branch installs the full field set as the driver's managedObjectFields and the drift report no longer names a disabled package's columns as orphans. Right, and the exact fix M1 asked for. The pin reaches a real SqlDriver over file-backed SQLite: a table created with the module enabled holding qm_credit: 500, the module disabled (precondition: the served object withholds the field), measureComposedCoverage(…, false, { ddlFree: true }) examines the object, detectManagedDrift() carries no op naming qm_credit, and the stored value reads back 500 — the red line (no column drop, no value loss) measured through the door that would have broken it. syncObjectSchema in the test double throws if reached, so the pin cannot pass by running DDL.
  2. A withheld extension's indexes leave a folded base — WithheldExtensions.withheldIndexes is built from indexKeysOf over stableStringify(index), the same structural key subtractExtenderContributions compares by, net of the owner's and kept extensions' index keys; withoutWithheldMembers drops matching entries from the base's indexes and still answers base by reference when nothing matches. Right, and the exact fix S1 asked for; the two pins (folded metadata-service body, tenant overlay saved from a served read) now assert the index is gone from the served body while the storage fold keeps the field. foldObjectExtendersOnto's re-add path is unaffected: a kept extension's index is subtracted and re-added once, as before.

f2ce2c0 narrows a TS2322 in the new cli pin (initObjects argument typed); no behaviour.

Other storage-shaped readers in the CLI migrate path, swept as asked. schema-migrate.ts L574 and schema-migration-plugins.ts L1046 (arming deferral guards on every object's driver) and schema-migrate.ts L783 (finding connected drivers that can diff drift) read getAllObjects() for object MEMBERSHIP only — a disable withholds fields, never objects, so the two folds answer the same set; right as they are. schema-migrate.ts L1598 (allObjects, the ADR-0120 D5e advisory) describes objects, not columns; right as it is. The deferred, non-DDL-free branch of measureComposedCoverage goes through engine.syncObjectSchema(name), which reads getStorageObject since 12d54cf3; right. No other reader in that path installs a driver field set or proposes a drop. One adjacent observation, not a drop path: the value-migration commands under packages/cli/src/commands/migrate/ (blank-single-values, option-codes, text-scalars, value-shapes) scope their rewrites by engine.getObject(object) — the served fold — so a value migration run while a module is disabled skips that module's columns and leaves them to a later run after enable. They read through engine.find(fields: […]), which refuses a withheld field by design, so a storage-fold read there would not work without engine support; recorded below as a non-blocking should-fix for the lane's follow-up, not this PR's.

② Semver level

  • Unchanged from 6653307077 for @objectstack/objectql and @objectstack/runtime: minor each, !, Clause-②: yes (narrowing) identical in PR body and changeset, Metadata-protocol: semantic, one ADR-0087 marker (not-required (no-migration-prescription)), the "Who moves" paragraph as the migration text. Matches.
  • New with the delta: the diff now moves published source of @objectstack/cli (packages/cli/src/utils/schema-migration-plugins.ts; packages/cli/package.json is not private), and the changeset frontmatter does not name it. Versioning is unaffected — .changeset/config.json places every published package in one fixed group, so cli ships the same version bump regardless — but the cli CHANGELOG gets no line for a consumer-visible change (an os migrate plan under OS_SKIP_SCHEMA_SYNC=1 no longer misreports a disabled module's columns). AGENTS.md §Post-Task Checklist 3: a kind other than none takes at least minor on a package the diff moves. Should-fix 1: add '@objectstack/cli': minor to the frontmatter and one sentence under "Kept" naming the drift plan. Not blocking: the release version is right by the fixed group, check-changeset-no-major wants one moved package at minor or above and has two, and the ADR-0087 disposition does not change.
  • The card claim 6651772742 now reads Metadata-protocol: semantic, matching the PR body and the changeset (the ③ item of the prior record, closed).

③ Boundary flags

④ Design judgment

The narrowing itself is unchanged and holds as judged in 6653307077: no published schema declares that an objectExtensions field outlives its package's disable; package-lifecycle.zod.ts L598/L605 already states that the fold drops a disabled package's contributions, and this PR makes that statement true at the consumer while the storage layer keeps every column and value. The delta strengthens the implementation of that design at the two seams the prior record named and introduces nothing wrong:

  • Tried to break judgment 10. A registry without getAllStorageObjects (an older engine, a double) falls back to the served read — the pre-PR behaviour, and on such a registry the two folds are one answer. The fallback cannot regress anything. The ddlFree branch is the only branch that installs a field set without DDL; the deferred branch already read storage. Held.
  • Tried to break judgment 11. An index the owner or a kept extension declares with the same structure as a withheld one is in keptIndexes and stays — correct, as a shared field stays. A tenant-authored index on the overlay that happens to equal a withheld one structurally is dropped while disabled and re-served on enable — the same ambiguity fields and rules carry, and the storage fold keeps it for DDL. A base with no indexes array answers by reference. Held.
  • Must-fix: none.
  • Should-fix 1 — changeset frontmatter names @objectstack/cli (② above).
  • Should-fix 2 (non-blocking, follow-up for the lane, not this PR) — the os migrate value-rewrite commands scope by the served fold, so a run while a module is disabled skips its columns; the plan output should say so, or the run should be scheduled with modules enabled. Note it on objectql: the roll-up summary index is built from the served fold, so a disabled package's summary over a child in an enabled package is stale after re-enable #30377 or a card of its own.

Gates. The check-runs on f2ce2c0f7c1dbb4b4735c5e6ff2f287cfbc020b9, read after the last one completed: 48 runs, 35 success, 13 skipped (the UI matrix and its build/lint/typecheck legs, Build Docs, the opt-in tarball smoke and 18.x harness, Temporal Conformance, the schedule gate, Dogfood Verify CLI and the dogfood matrix shards behind a green Dogfood Regression Gate), 0 failure. Green among them: Build Core, Test Core 1–8 and the aggregate (the new cli pin and the extended registry pins run there), Type Check workspace / source gates / consumer gates / debt ledger (the TS2322 f2ce2c0 fixed is measured green), Lint ESLint / repo & tooling / docs & code guards / CI-release-engine gates / PM dispatch-gates / the three scoped lints, Check Changeset, Check PR Size, PR-body guards, Governed Surface Queue Guard, Live E2E UI. Every gate derived from this diff passes, and ④ names no must-fix on this head.

Implemented-by: claude/project-thread-hsmw0g
Reviewed-by: session_01Utp6CZMkHvuDeVYgTD73qF

VERDICT: PASS

Rendered 2026-11-05T09:12Z on head f2ce2c0f, against the contract-review claim 6653662896 of 2026-11-05T08:44Z; supersedes 6653307077 for this head.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

席位意见:ACCEPT(#30304)。包停用后,它通过 objectExtensions 加到别的对象上的字段不再出现在 /meta/object、/data 读写和记录页里。列和值都保留:建表、同步、联邦绑定、归档和 DDL-free 的 os migrate 漂移计划都读完整折叠。重新启用后字段和原值都回来。⛔ 这是 BREAKING,排队合并前要等 maintainer 本人点头,席位不在这里排队。

席位核对

Head-sha: 81f7ce8a12d8ddc8231c9a1f7c86b509fed46a2d · read 2026-11-05T09:41Z

Item Reading
Shape Draft → main, author objectstack-fleet[bot] (relay pr_create); attribution block, then signature line, Fixes #30304, Clause-② / Metadata-protocol at line start. 10 files, +857/−15, under packages/objectql, packages/runtime, packages/cli/src/utils/schema-migration-plugins.ts (+ test) and .changeset/. ⛔ packages/spec/** untouched.
Ruling The card's ask (withheld the way its objects are; re-enable brings the fields back with stored values intact) and triage 6650917536. Red line: no column drop or value loss. The packages/cli file joined the surface at review (M1), with the seat's consent.
Gates (check-runs on this head) 35 success, 13 skipped, 0 failure, 0 pending. Dev's runs on f2ce2c0f: 65 derived gates exit 0 (--ran 65/65, 0 NOT MEASURED), cli / objectql / runtime typecheck 0, pnpm lint 0, ablations each red then restored (M1: the pin reports drop_column:qm_credit; S1: 2/17 red).
Contract review CONTRACT_REVIEW_TIER record 6653307077 on 12d54cf3: FAIL, with must-fix M1: the DDL-free os migrate drift plan read the served fold and proposed dropping the withheld columns. Delta record 6654178714 on f2ce2c0f: PASS, M1 and S1 resolved in the tree, no must-fix.
Delta since the record (f2ce2c0f..81f7ce8a, read by the seat) Changeset only: '@objectstack/cli': minor added to the frontmatter and the BREAKING banner, plus one "Kept" sentence on the OS_SKIP_SCHEMA_SYNC=1 drift plan. This closes the delta record's should-fix 1. No source file moves.
Follow-ups #30377 (the roll-up summary index reads the served fold; the record's S2, plus should-fix 2 on the os migrate value rewrites, noted there). [Decision] #30379 (an enabled package's rule reads a field of a disabled one; the record judged the existing fail-closed answer acceptable for this PR; maintainer decision thread open). #30086 (spec docblock, spec lane). ui residue: a layout naming a withheld field (ui lane paused).
Semver @objectstack/objectql, @objectstack/runtime and @objectstack/cli minor with !, BREAKING banner, Clause-②: yes (narrowing), Metadata-protocol: semantic (claim 6651772742 amended to match), one adr-0087: not-required (no-migration-prescription) marker.
Landing Not governed (check-governed-merges --pr 30362: 0 of 10 paths), 872 lines ≤ 3000. ⛔ BREAKING: the seat queues (pr_ready + automerge_enable) only on the maintainer's word.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

维护者速读

  1. 客户在设置里停用一个模块后,这个模块加到别的对象上的字段(例如合同上的 O2C 汇总字段)现在也一起隐藏,和模块自己的对象、菜单、动作一样。
  2. 隐藏只管展示和读写:对象元数据、数据接口读写、记录页都看不到这些字段;数据库里的列和已存的值一个不动(建表、同步、os migrate 漂移计划都读完整折叠)。
  3. 重新启用后字段和原值原样回来;从「已停用」状态启动的环境也照样建好这些列。
  4. 停用期间写这些字段,回答和写一个不存在的字段一样(400 INVALID_FIELD),没有新拒绝码。
  5. 已知边角:另一个仍启用的模块若有校验规则读了被停用模块的字段,停用期间该对象写入会被规则拦下(400 VALIDATION_FAILED);这一点另开了决策卡 [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。
  6. 需要你做的:无,席位落地。这是对外对象形状的收窄(BREAKING),合约审查 delta PASS 6654178714 在案;按 maintainer 13:47Z「技能不需要我审核的不应该等我」,不属 Tier H、不超 3000 行,席位直接落地。

席位意见:ACCEPT(#30304)。包停用后,它通过 objectExtensions 加到别的对象上的字段不再出现在 /meta/object、/data 读写和记录页里。列和值都保留:建表、同步、联邦绑定、归档和 DDL-free 的 os migrate 漂移计划都读完整折叠。重新启用后字段和原值都回来。这是 BREAKING 收窄,按 pm-dispatch 落地条款由席位落地。

席位核对

Head-sha: 81f7ce8a12d8ddc8231c9a1f7c86b509fed46a2d · read 2026-11-05T13:54Z

Item Reading
Shape Draft → main, author objectstack-fleet[bot] (relay pr_create); attribution block, then signature line, Fixes #30304, Clause-② / Metadata-protocol at line start. 10 files, +857/−15, under packages/objectql, packages/runtime, packages/cli/src/utils/schema-migration-plugins.ts (+ test) and .changeset/. ⛔ packages/spec/** untouched.
Ruling The card's ask (withheld the way its objects are; re-enable brings the fields back with stored values intact) and triage 6650917536. Red line: no column drop or value loss. The packages/cli file joined the surface at review (M1), with the seat's consent.
Gates (check-runs on this head) 48 completed: 35 success, 13 skipped, 0 failed (re-read at landing).
Contract review CONTRACT_REVIEW_TIER record 6653307077 on 12d54cf3: FAIL, with must-fix M1: the DDL-free os migrate drift plan read the served fold and proposed dropping the withheld columns. Delta record 6654178714 on f2ce2c0f: PASS, M1 and S1 resolved in the tree, no must-fix.
Delta since the record (f2ce2c0f..81f7ce8a, read by the seat) Changeset only: '@objectstack/cli': minor added to the frontmatter and the BREAKING banner, plus one "Kept" sentence on the OS_SKIP_SCHEMA_SYNC=1 drift plan. This closes the delta record's should-fix 1. No source file moves.
Follow-ups #30377 (the roll-up summary index reads the served fold; the record's S2, plus should-fix 2 on the os migrate value rewrites, noted there). [Decision] #30379 (an enabled package's rule reads a field of a disabled one; the record judged the existing fail-closed answer acceptable for this PR; maintainer decision thread open). #30086 (spec docblock, spec lane). ui residue: a layout naming a withheld field (ui lane paused).
Semver @objectstack/objectql, @objectstack/runtime and @objectstack/cli minor with !, BREAKING banner, Clause-②: yes (narrowing), Metadata-protocol: semantic (claim 6651772742 amended to match), one adr-0087: not-required (no-migration-prescription) marker.
Landing Not governed (check-governed-merges --pr 30362: 0 of 10 paths, re-read 2026-11-05T13:54Z), ≤ 3000 lines. BREAKING (!), and neither Tier H nor over 3000 lines, so per pm-dispatch the seat lands it: pr_ready + automerge_enable, on the maintainer's word 13:47Z 「技能不需要我审核的不应该等我」.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 13:56
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 13:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 8cc709d Nov 5, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-hsmw0g branch November 5, 2026 15: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

1 participant