Skip to content

fix(objectql)!: refuse at boot a controlled_by_parent detail whose master the engine cannot compose a read over - #25166

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25144-controlled-by-parent-unservable-master-refused
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25144-controlled-by-parent-unservable-master-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25144

Clause-②: yes (narrowing)

Metadata-protocol: none

What

A controlled_by_parent detail whose master is in one of ADR-0139's two uncomposable classes now refuses the boot. Before this change the stack booted, and the id-list pre-scan served it. Once #24932's leaf switch lands, every read of such a detail would answer INTERNAL_ERROR / 500. The two classes:

  • (a) D5.3: a master bound to another datasource than the detail;
  • (b) D5.2: a master that an object-scoped read middleware narrows (registerMiddleware(fn, { object })), for example sys_attachment.

The refusal is INVALID_METADATA / 422. It names each detail, its master field, the master and the class, plus the three remedies: bind both to one datasource; or choose a master without an object-scoped read middleware; or declare the detail without controlled_by_parent. It is one error listing every offender. There is no warn-and-continue, no silent skip and no id-list fallback. The read-time refusal (refuseUncomposableReadableIdsClasses) is unchanged, and its messages are byte-identical.

  • packages/objectql/src/engine.ts: the read path's two checks move into one private predicate, uncomposableReadableIdsParent(object, parent). It is hasObjectMiddleware and then resolveDatasourceBinding on both sides, in the read path's own order. The read refusal and the new public ObjectQL#findUnservableControlledByParentMasters() both call it, so the boot rule and the read rule cannot drift. The master is the detail's one required master_detail (isControlledByParentMasterField, ADR-0055 §1).
  • packages/objectql/src/plugin.ts: describeUnservableControlledByParentMasters(), thrown from ObjectQLPlugin's kernel:bootstrapped handler.
  • .changeset/25144-cbp-unservable-master-boot-refusal.md: BREAKING, patch (Metadata-protocol: none), with the remedy and the ADR-0087 disposition not-required (no-migration-prescription).

Placement (measured first)

On origin/main 23540582f4, the kernel runs every start(), then kernel:ready (handlers in registration order), then kernel:bootstrapped, then kernel:listening, which opens the socket.

  • The engine plugin registers its kernel:ready handler in its own start(), so that handler runs before its dependents' handlers.
  • Most of the card's object-scoped read middlewares are registered inside the owning plugin's kernel:ready handler, not in start():
    • service-mailbox: installMailReadVisibility;
    • plugin-audit: comment, activity, activity redaction, audit-log redaction, audit-log visibility;
    • service-storage: installAttachmentReadVisibility.
  • plugin-approvals and plugin-security register theirs in start().
  • No object-scoped registration runs in any kernel:bootstrapped or kernel:listening handler. I grepped every one of them: sharing, security, auth, automation, mcp, pinyin, storage, organizations.

So registerApp sees neither final fact, and the engine's kernel:ready sees the bindings but not the later middlewares. kernel:bootstrapped sees both and is still a boot refusal, because no socket is open yet. The same precedent exists in organizations-plugin.ts#runMembershipPolicyGate. The placement stays in packages/objectql, so no needs_decision is raised.

Probe (one-off, not committed). I moved the check into the engine's kernel:ready and removed it from kernel:bootstrapped. Class (a) still went red as expected. The three pins whose middleware is registered at a plugin's kernel:ready passed through unrefused: 3 failed | 5 passed (8). The restored blob equals HEAD. A first attempt at this probe was a no-op: ablation-replace refused it because the replacement contained its anchor. It was redone with a different anchor.

Verification (head 0f5d9a6bc after merging origin/main 06900c9c2; the merge brings in no objectql file)

  • New pins, plugin-controlled-by-parent-unservable-master.test.ts, run on a real ObjectKernel + ObjectQLPlugin, with the middleware registered from a plugin's kernel:ready the way the platform's own gates are:
    • class (a);
    • class (b);
    • class (b) over sys_attachment;
    • several offenders in one refusal.
    • Controls: HotCRM's six controlled_by_parent shapes (e6d5d3e), with a gated system object nobody masters on; global middleware (* / no object); both bound to one second datasource; a non-CBP detail across datasources. 8/8.
  • Ablation: if (unservable) throw unservable; was replaced via scripts/ablation-replace.mjs (anchor 1 → 0). Result: 4 failed | 4 passed (8): the four class pins went red and the four controls stayed green. Restored, blob 2eccc2620e81 equals HEAD, and git diff HEAD is empty.
  • @objectstack/objectql typecheck: exit 0. Full suite: 461/461 files, 8645/8645 tests (on e0ca492fd).
  • @objectstack/runtime full suite: 433/433 files, 6805 passed, 19 skipped (on e0ca492fd).
  • pnpm --filter @objectstack/core test:repo: 6/6 files, 82/82 tests.
  • Dogfood battery (pnpm --filter @objectstack/dogfood test, full, under the lock, on e0ca492fd): VERDICT command-exit 0, Test Files 287 passed + 3 skipped (290), Tests 2395 passed + 18 skipped (2413). It boots the showcase, whose two controlled_by_parent objects are controls; the CRM example's crm_opportunity_line_item is covered by its suites.
  • After the merge: full turbo run build (77/77), objectql typecheck and the new pins, all exit 0 on 0f5d9a6bc.
  • Derived gates: dispatch-gates.mjs --commands lists 71 families. Every one was run with its exit code captured before any pipe, and --ran reconciles 71 derived, 71 run, 0 NOT-MEASURED. check:dual-build-cjs-loads exited 3 (no dist/) until the full build, then exited 0. check:adr-0087-registration passes: 1 declared-breaking changeset, not-required (no-migration-prescription).

Acceptance notes


Generated by Claude Code

…e master at boot

Claude-Session: https://claude.ai/code/session_01Dw4pQznF7eB7VtCsFQXMAH
Co-authored-by: Claude <noreply@anthropic.com>
… over an unservable master

Claude-Session: https://claude.ai/code/session_01Dw4pQznF7eB7VtCsFQXMAH
Co-authored-by: Claude <noreply@anthropic.com>
…boot refusal BREAKING

Claude-Session: https://claude.ai/code/session_01Dw4pQznF7eB7VtCsFQXMAH
Co-authored-by: Claude <noreply@anthropic.com>
…ntrolled-by-parent-unservable-master-refused
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 7 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters), controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/data-modeling/objects.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/deployment/cli.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/deployment/install-modes.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters))
  • content/docs/getting-started/common-patterns.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/permissions/attachments-access.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/permissions/authorization.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters), controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/permissions/index.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/permissions/permissions-matrix.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/permissions/rls.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/permissions/sharing-rules.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/protocol/kernel/error-handling.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters), controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/protocol/objectql/security.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))

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

  • content/docs/releases/implementation-status.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/releases/v15.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/releases/v17/17-0.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters))
  • content/docs/releases/v17/17-1.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/releases/v17/17-2.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/releases/v17/17-3.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/releases/v17/17-5.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters))
  • content/docs/releases/v17/17-6.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters))
  • content/docs/releases/v17/17-7.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters))
  • content/docs/releases/v17/index.mdx (via controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))
  • content/docs/releases/v18/18-0.mdx (via INVALID_METADATA (literal, a string literal in describeUnservableControlledByParentMasters), controlled_by_parent (literal, a string literal in findUnservableControlledByParentMasters))

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

Coarse fallback — 18 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 425c743fd9b80005d83e528dfdc8a5f8c68c8b30 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 63415434f143c07588b907206beccfb618c04622 — the merge of head 0f5d9a6bce5d740326bb7ba06291ddcff428d9de into base 425c743fd9b80005d83e528dfdc8a5f8c68c8b30, 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 63415434f143c07588b907206beccfb618c04622 && git checkout 63415434f143c07588b907206beccfb618c04622
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 425c743fd9b80005d83e528dfdc8a5f8c68c8b30 0f5d9a6bce5d740326bb7ba06291ddcff428d9de && git checkout -B drift-repro 425c743fd9b80005d83e528dfdc8a5f8c68c8b30 && git merge --no-ff 0f5d9a6bce5d740326bb7ba06291ddcff428d9de

node scripts/docs-audit/affected-docs.mjs --json 425c743fd9b80005d83e528dfdc8a5f8c68c8b30

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 22, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0f5d9a6bce5d740326bb7ba06291ddcff428d9de
Local-runs: none

Scope: .changeset/25144-cbp-unservable-master-boot-refusal.md, packages/objectql/src/engine.ts, packages/objectql/src/plugin.ts, packages/objectql/src/plugin-controlled-by-parent-unservable-master.test.ts + packages/core/src/kernel.ts, packages/core/src/hook-dispatch.ts, packages/core/src/lite-kernel.ts, packages/objectql/src/index.ts, packages/objectql/src/core.ts, packages/objectql/package.json, packages/objectql/src/picklist-resolution.ts, packages/objectql/src/registry.ts, packages/objectql/vitest.config.ts, packages/spec/src/data/controlled-by-parent-master.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/contracts/objectql-engine.ts, packages/runtime/src/app-plugin.ts, packages/plugins/plugin-audit/src/audit-plugin.ts, packages/plugins/plugin-audit/src/comment-access-hooks.ts, packages/plugins/plugin-audit/src/activity-read-visibility.ts, packages/plugins/plugin-audit/src/activity-field-redaction.ts, packages/plugins/plugin-audit/src/audit-log-read-visibility.ts, packages/plugins/plugin-audit/src/audit-log-field-redaction.ts, packages/services/service-mailbox/src/mailbox-service-plugin.ts, packages/services/service-mailbox/src/mail-read-visibility.ts, packages/services/service-storage/src/storage-service-plugin.ts, packages/services/service-storage/src/attachment-access-hooks.ts, packages/plugins/plugin-approvals/src/approvals-plugin.ts, packages/plugins/plugin-approvals/src/request-read-gate.ts, packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts, packages/plugins/plugin-approvals/src/payload-predicate-guard.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/platform-ownership-policies.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-hono-server/src/hono-plugin.ts, packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts, packages/plugins/organizations/src/organizations-plugin.ts, packages/services/service-automation/src/plugin.ts, packages/mcp/src/plugin.ts, packages/cli/src/utils/console.ts, packages/verify/src/handle.ts, examples/app-crm/src/objects/opportunity-line-item.object.ts, examples/app-showcase/src/data/objects/expense-report.object.ts, examples/app-showcase/src/data/objects/invoice.object.ts, docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md, docs/adr/0049-no-unenforced-security-properties.md, docs/adr/0055-master-detail-controlled-by-parent.md, AGENTS.md, scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .github/workflows/lint.yml, .github/workflows/pr-automation.yml, .github/workflows/ci.yml

Inputs read as the contract: card #25144 (Wanted 1-5, Acceptance), ruling 6318800595 on #24932 (batch #382 item 1, Q1 C), measurement 6318648840 (P3). The dev report 6332304138 and the PR body were read as claims and each one below was checked against the diff and the cited code. Net diff: git diff 06900c9c2 origin/claude/issue-25144-controlled-by-parent-unservable-master-refused, 4 files, +343/-8. origin/main (425c743fd9) has moved since the merge-base, but git diff --stat 06900c9c2 origin/main -- packages/objectql/src/engine.ts packages/objectql/src/plugin.ts packages/objectql/src/index.ts packages/core/src/kernel.ts is empty, so nothing below is stale against today's main.

① Derived judgments

1. packages/objectql/src/engine.ts:6598-6615 — the read path's two D5 checks move into one private predicate uncomposableReadableIdsParent(object, parent). Right.

  • Conditions, order and messages are unchanged. On main (engine.ts:6546-6563) the loop skipped an unregistered parent, then hasObjectMiddleware(parent), then compared resolveDatasourceBinding(parent) with resolveDatasourceBinding(object). The branch does the same in the same order (engine.ts:6603-6608), and the two refusal strings are byte-identical: parentDatasource replaces bound and objectDatasource replaces outer inside the same template (engine.ts:6574-6577).
  • The one mechanical difference is that resolveDatasourceBinding(object) is now computed per parent, inside the predicate, instead of once before the loop. resolveDatasourceBinding (engine.ts:12078-12143 on main) is a pure read of the registry, the mapping rules, the drivers map and the manifests map, so the result cannot differ.
  • Nested chains: composeReadableIdsChain judges every nested parent against chain.object, the read's outermost object (engine.ts:6471-6477 on main). The boot rule judges each (detail, master) link pairwise. Datasource equality is transitive, so any chain the read path refuses on D5.3 has a link the boot rule refuses. A middleware-gated parent anywhere in the chain is a D5.2 master of its own detail. Same question, same answer.

2. packages/objectql/src/engine.ts:6617-6633 — new public ObjectQL#findUnservableControlledByParentMasters(). Right, with one nit.

  • It walks getAllObjects(), keeps sharingModel === 'controlled_by_parent', derives the master from exactly one isControlledByParentMasterField field (packages/spec/src/data/controlled-by-parent-master.ts:87-91: type === 'master_detail' && required === true), reads its reference, skips a master the registry does not hold, and asks the same predicate as the read path. ADR-0055 §1 (docs/adr/0055-master-detail-controlled-by-parent.md:35) is the rule it reads.
  • The two skips are correct, not gaps: a detail with zero or several required masters is refused by the spec parse (controlled-by-parent-master.ts:98-120), and an unregistered master answers $in: [] at read time, in none of D5's classes (engine.ts:6566-6568).
  • Public-surface fact: ObjectQL is exported from both published entries (packages/objectql/src/index.ts:158, packages/objectql/src/core.ts:63; package.json exports . and ./core), so this method widens the published surface. That is what the yes half of the declaration says.
  • Nit (non-blocking): the return type UnservableControlledByParentMaster (engine.ts:4854-4864) is exported from engine.ts but re-exported from neither index.ts nor core.ts, so a consumer cannot name it from the package root. Acceptable for a plugin-facing seam; a one-line export type would close it.

3. packages/objectql/src/plugin.ts:89-113 — describeUnservableControlledByParentMasters(). Right.

  • One error listing every offender; each line names the detail, its master field, the master and the class with its ADR section; the tail carries the three remedies the card names (bind both to one datasource; choose a master without an object-scoped read middleware; drop controlled_by_parent). No warn-and-continue, no id-list fallback.
  • It is exported from plugin.ts but index.ts:578 re-exports only ObjectQLPlugin and OBJECTQL_DRIVERS_READY_HOOK, so it is not on the published surface.
  • Envelope: INVALID_METADATA / 422 (plugin.ts:109-111). See ③ item 1.

4. packages/objectql/src/plugin.ts:1026-1038 — the check thrown from the kernel:bootstrapped handler, registered in start(). Right; the placement is the one the card's measurement called for.

  • Kernel order verified in packages/core/src/kernel.ts:560-630: every start(), then kernel:ready, then kernel:bootstrapped, then kernel:listening. Each boot-phase hook is dispatched through dispatchHookPropagating (packages/core/src/hook-dispatch.ts:146-157), which awaits each handler in order and lets a throw propagate; bootstrap()'s catch rolls back started plugins and rethrows (kernel.ts:631-672). LiteKernel uses triggerHookOrThrow for the same three hooks (lite-kernel.ts:140-149). So a throw here aborts the boot.
  • Nothing is served before it: the HTTP socket opens at kernel:listening (plugin-hono-server/src/hono-plugin.ts:699); the MCP plugin's kernel:bootstrapped handler only closes a bind window (packages/mcp/src/plugin.ts:667-670).
  • Both facts are final by then. Every object-scoped registerMiddleware(fn, { object }) in the tree was enumerated (git grep registerMiddleware over packages/**, apps/**, examples/**, tests excluded) and placed: mailbox (mailbox-service-plugin.ts:160-170, inside its kernel:ready handler registered in start()), audit comment, activity, activity redaction, ledger visibility and redaction (audit-plugin.ts:329-591, one kernel:ready handler registered in start()), attachment (storage-service-plugin.ts:430-509, inside its kernel:ready handler), approvals request, child and snapshot gates (approvals-plugin.ts:346-369, directly in start()), position assignment (security-plugin.ts:4610-4612, in start() at 1786). plugin-sharing's is '*' (sharing-plugin.ts:759), which hasObjectMiddleware does not count (engine.ts:7110-7112 on main). No kernel:bootstrapped or kernel:listening handler registers one: the bodies at console.ts:1107, mcp/plugin.ts:667, auth-plugin.ts:922, pinyin-search-plugin.ts:85, sharing-plugin.ts:1074, service-automation/src/plugin.ts:1574, storage-service-plugin.ts:438 were read.
  • Bindings: the stack's datasourceMapping is installed on OBJECTQL_DRIVERS_READY_HOOK inside ObjectQLPlugin.start() (app-plugin.ts:609, :815-824); drivers register in AppPlugin.start() (app-plugin.ts:1633); the federated reconcile runs in the engine's own kernel:ready (plugin.ts:985), a diagnostic pass that changes no binding (plugin.ts:1839-1864 on main). Explicit, mapping, lifecycle, package-default and default routes all resolve through the one resolveDatasourceBinding, so a master reached by a mapping rule or a package default is judged the same as an explicit one.
  • The dev's measured premise correction stands: the card said the middlewares register in start(); three of the five owners register in their kernel:ready handlers, which run after the engine's, so neither registerApp nor the engine's kernel:ready could see them. kernel:bootstrapped is the first point that sees both facts and still precedes the socket. This is inside the card's own instruction (Wanted 2: measure first, report a fork only if neither candidate sees them) and inside packages/objectql, so no needs_decision was owed.

5. packages/objectql/src/plugin-controlled-by-parent-unservable-master.test.ts — the pins. Right.

  • They drive the production boot path: new ObjectKernel(...), kernel.use(new ObjectQLPlugin()), artifacts through the manifest service, kernel.bootstrap() (:69-76). No mock of the kernel or the plugin.
  • The late gate mirrors the platform: a dependent plugin registers ctx.hook('kernel:ready', ...) from its start() (:56-67), so its handler runs after the engine's, exactly as mailbox, audit and storage do.
  • Class pins (:93-143): (a) another datasource, (b) a kernel:ready-registered object middleware, (b) over sys_attachment, and several offenders in one refusal. Each asserts err matches the code and the message, so removing if (unservable) throw unservable; turns all four red. The dev's ablation reading (4 red, 4 green) is consistent with the file's structure.
  • Controls (:146-186) exercise each false-positive line asked: a global middleware with no object and with '*', detail and master both explicitly on the same second datasource, a non-controlled_by_parent detail across datasources, and the six HotCRM shapes with a gated system object no detail masters on, including a two-level chain.
  • The file sits under src/**/*.test.ts, which the package's default vitest project includes (vitest.config.ts:117), so the Test Core shards run it.

6. In-repo controls outside the test. The three in-repo controlled_by_parent details (examples/app-crm/src/objects/opportunity-line-item.object.ts:21, examples/app-showcase/src/data/objects/expense-report.object.ts:138, examples/app-showcase/src/data/objects/invoice.object.ts:210) master on crm_opportunity, showcase_expense_report and showcase_invoice: same package, no datasource or lifecycle key on any example object except the two external ones (external/customer.object.ts:22, external/order.object.ts:20), which no detail masters on, and none of the masters is a platform-gated system object. None can hit either class, so the card's control (showcase and examples boot unchanged) holds by shape. CI's Dogfood Regression Gate shards were skipped on this PR by path filter, so the dev's local battery run is the only executed showcase boot; the shapes above make that claim credible.

7. .changeset/25144-cbp-unservable-master-boot-refusal.md. Right. @objectstack/objectql: patch; fix(objectql)!: headline; both declaration lines; BREAKING banner; the two classes; the kernel:bootstrapped placement and why; the new engine method named; the Remedy section carrying the three fixes; exactly one ADR-0087 marker.

② Semver level

  • patch, Clause-②: yes (narrowing), Metadata-protocol: none. Judged against AGENTS.md:1085-1096 (ruling A, 2026-10-21) and scripts/pm/clause2-line.mjs:109-120.
  • yes (narrowing) is the right spelling: the diff widens one surface (a new public method on the exported ObjectQL class, ① item 2) and narrows another (a composition that booted yesterday is refused today). no (narrowing) would deny the widening; yes (widening) would deny the break.
  • Metadata-protocol: none is right: ObjectSchema.parse accepts exactly what it accepted (no spec file in the diff); both facts the refusal reads are deployment facts (datasource routing, middleware registrations). This is the value the card itself prescribed (Wanted 5) absent a spec shape moving, and none moved.
  • patch follows from none under ruling A: a non-protocol narrowing ships patch and keeps its BREAKING banner and its ADR-0087 disposition. The changeset does both. The two cross-checks in check-changeset-no-major.mjs:916-923 are satisfied (none beside a (narrowing) arm is not a contradiction).
  • ADR-0087 marker not-required (no-migration-prescription): no key is added, removed, renamed or re-shaped, so objectstack migrate meta has nothing to rewrite. The category is refused only when the body carries a FROM → TO rewrite prescription (scripts/check-adr-0087-registration.mjs:147-154); the Remedy section is an author's choice among three deployment compositions, not a rewrite, and the gate ran green in CI (Lint · CI, release & engine gates, Check Changeset). The marker's closing of the other categories (published package, no existing id, nothing retired, one method added) matches the facts.
  • The AGENTS.md requirement that a breaking changeset carry its migration is met by the Remedy section, which ships in the package's CHANGELOG.

③ Boundary flags

Each of the dev's open questions and deviations, answered; then what the seat decides.

  1. Envelope: INVALID_METADATA / 422 rather than D3's INTERNAL_ERROR / 500 (dev open question 1; plugin.ts:109-111). Non-blocking; a declared deviation from the card's words, honouring its substance. The card's Wanted 1 says "Loud, as D3's envelope". D3's envelope (docs/adr/0139-…:176-179) is a read-time driver fault: "a composition fault, not an author error, so ⛔ not INVALID_FILTER", status 500. At boot the fault is a declaration the deployment cannot honour, which is what 422 INVALID_METADATA means in this package, and every sibling boot refusal uses it (picklist-resolution.ts:67-69, registry.ts:150-152, the unbound-action refusal thrown from the same start()). The loud half is honoured in full: the boot aborts, the message names each detail, field, master, class and the remedies, and there is no warn, skip or id-list fallback. At boot no HTTP envelope is answered, so the code matters only to a host that relays boot errors. The ruling text (6318800595, option C) asks for a loud refusal with the remedy and names no code. Judged acceptable; the seat may return it for a literal 500 if it reads the card's phrase as binding on the code.

  2. Late installs are not judged (dev open question 2). Non-blocking; a visible limit, and a follow-up is warranted. A manifest registered after boot through the manifest service, or an object ingested on metadata:reloaded (plugin.ts:1041), is not judged by this rule; its detail is still refused by name at read time (D5.2 / D5.3, unchanged). The card asks for the boot (Wanted 1, Acceptance line 1), so this does not break the contract. But the same file already judges late artifacts against two sealed vocabularies, before anything of them registers (plugin.ts:773-789 picklists, :791-800 bound actions), and the dev's stated reason ("resolving bindings for objects not yet registered") does not hold for metadata:reloaded, where the objects are registered. Recommend one card: judge a late artifact's controlled_by_parent details at arrival once the boot has sealed, the picklist precedent's shape.

  3. Alias spellings and the security.sharingModel location (dev open question 3). Non-blocking; an acceptable limit. The boot rule reads reference and the top-level sharingModel. plugin-security's resolver also tolerates reference_to / referenceTo (security-plugin.ts:8576) and security.sharingModel (platform-ownership-policies.ts:331). The spec admits neither: FieldSchema rejects the aliases (security-plugin.ts:8535-8536 records it) and object.zod.ts:2971 declares sharingModel at the top level only. So only a raw registerObject body can carry them, and such a detail is still refused loudly at read. Not the card's population.

  4. Placement deviation from the card's two candidates (dev deviation 1). Resolved in the dev's favour; see ① item 4. The card authorised the measurement and asked for a fork only if neither candidate saw the registrations; a third point inside the same package that does see them is the card's intent, and the one-off probe (three late-middleware pins pass unrefused at the engine's kernel:ready) is consistent with the registration sites read here.

  5. Dogfood battery ran on the pre-merge head e0ca492fd, not on 0f5d9a6bc (dev deviation 4). Acceptable. The merge commit brings in no file under packages/objectql or packages/runtime (git diff --stat 06900c9c2 origin/main over the touched files is empty), and the post-merge build, typecheck and pins were re-run per the report. CI's dogfood shards are path-skipped on this PR, so main's battery after landing is the executed check.

  6. Commit trailers (dev deviation 2). The three branch commits carry Claude-Session plus Co-authored-by: Claude, AGENTS.md's model-free pair. Not a contract matter.

  7. HotCRM control. The card's "HotCRM fixture" is modeled in the test (:149-163) from the six shapes 6318648840 measured; no in-repo HotCRM boot fixture exists (lint.yml:944 notes the vendored artifact has no loader), so this is the control the repo allows. Non-blocking.

  8. bootPhaseHooks: false hosts. A host that withholds the boot-phase hooks (kernel.ts:598-603) runs neither this check nor the picklist and action seals, and the kernel logs a warn naming the withheld handlers. A pre-existing, host-chosen limit shared by every boot-phase check. Non-blocking.

  9. CI at record time. 40 check-runs on the head: 29 success, 7 skipped, 4 in progress (Test Core 2/6, 3/6, 4/6, 6/6). Test Core 1/6 and 5/6, Type Check · workspace, every lint and gate job, Check Changeset, Build Core and the claim and queue guards are green. No failure. Landing waits for the four shards as it would anyway.

Nothing must be fixed before landing. Items 2 and the ① item 2 nit are follow-ups, not conditions.

Implemented-by: claude/issue-25144-controlled-by-parent-unservable-master-refused
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-22T04:28Z · ACCEPT

Head: 0f5d9a6bce. It carries the boot refusal, the shared predicate, the pins and the changeset.

Card #25144 (p1, bug, area:access; the maintainer's ruling Q1 C on #24932, 6318800595): claim 6331129246, dispatch 6331155848. Dev report: 6332304138.

Item Reading
Shape Draft PR → main. The body opens Fixes #25144. 4 files, +343/−8. Not governed: check-governed-merges matched 0 of 4 paths.
Scope The boot refuses a controlled_by_parent detail whose one required master is bound to another datasource (ADR-0139 D5.3) or narrowed by an object-scoped read middleware (D5.2). The refusal is thrown from ObjectQLPlugin's kernel:bootstrapped handler, before kernel:listening opens the socket. It names each detail, master field, master and class, and the three remedies. The read path's two checks and the boot rule share one private predicate, so they cannot drift.
Seat's own read The placement follows the dev's measurement: mailbox, audit and storage register their object-scoped middleware inside their own kernel:ready, after the engine's, so kernel:bootstrapped is the first point that sees both facts. It sits in packages/objectql, inside the claim's fence, so no fork was owed. The read-time refusal is unchanged and still covers what the boot rule does not see.
Review CONTRACT_REVIEW_TIER (narrowing face). Record 6332548600 is PASS on this head, with no blocking findings.
Declaration One changeset, @objectstack/objectql patch, with Clause-②: yes (narrowing) and Metadata-protocol: none at line starts, as on the PR body. It keeps the BREAKING banner, the Remedy section and the one ADR-0087 marker, not-required (no-migration-prescription).
Cross-lane None edited. The middleware owners (service-mailbox, plugin-audit, service-storage, plugin-approvals, plugin-security) were read, not changed. #24932 (domain:services) merges after this.
Docs drift The bot lists 14 hand-written pages. None states that a controlled_by_parent detail over a master on another datasource or behind an object-scoped read middleware boots, and none restates D5's read-time refusal, so none is falsified. The 11 release-owned pages are read-only and are not edited here.
Gates Dev at 0f5d9a6bc: 71 derived, 71 run, all exit 0. objectql passed 8645/8645 tests and the new pins 8/8. Ablation (the throw removed): the 4 class pins red and the 4 controls green, restored to HEAD. runtime passed 6805 tests (19 skipped). Dogfood battery on e0ca492fd, the pre-merge head (the merge brings no objectql or runtime file): 2395 passed, 18 skipped, no failures.
CI At this read on 0f5d9a6bce: ci-failure GREEN, all 41 check-runs completed, none failed. git merge-tree against today's origin/main (425c743fd9) is clean.

Disposition:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #25144 then closes the card.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 04:29
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 04:29
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 504d8b3 Oct 22, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25144-controlled-by-parent-unservable-master-refused branch October 22, 2026 04:59
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/m tests tooling

Projects

None yet

2 participants