Skip to content

Commit 504d8b3

Browse files
fix(objectql)!: refuse at boot a controlled_by_parent detail whose master the engine cannot compose a read over (#25166)
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 - **Late installs.** A manifest registered *after* the boot (a marketplace install, or `metadata:reloaded`) is not judged by this rule. Its detail is still refused at read time by name (D5.2 / D5.3, unchanged). Judging it on arrival would mean resolving bindings for objects not yet registered. This card asked for the boot. - **Reference alias.** The boot rule reads the master from the canonical `reference` key. plugin-security's CBP resolver also tolerates the rejected `reference_to` / `referenceTo` aliases on raw-registered objects. A raw-registered alias-spelled detail would boot here and still be refused at read. - **Error code.** The refusal uses `INVALID_METADATA` / 422, like the sibling boot refusals (picklists, bound actions), not D3's `INTERNAL_ERROR` / 500. That envelope is a composition fault at read time; this is a declaration refused before anything is served. - Not touched: `ObjectSchema.parse` / `packages/spec`, `os validate` (it resolves no deployment bindings or middleware registrations), and #24932's switch. #25092 and #25106 remain open. --- _Generated by [Claude Code](https://claude.ai/code/session_01Dw4pQznF7eB7VtCsFQXMAH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b93c750 commit 504d8b3

4 files changed

Lines changed: 343 additions & 8 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a `controlled_by_parent` detail whose master the engine cannot compose a read over now refuses the boot, instead of booting and failing at read time
6+
7+
Clause-②: yes (narrowing)
8+
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) A boot refusal of one deployment composition: no key of any metadata schema is added, removed, renamed or re-shaped, and `ObjectSchema.parse` accepts exactly what it accepted before, so `objectstack migrate meta` has nothing to rewrite and no tombstone is owed. Both facts the refusal reads are deployment facts (datasource mapping, a package's default datasource, lifecycle routing, the middleware registrations), and the remedy is the author's choice among three, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this composition (not already-registered); and nothing exported is removed or narrowed, one engine method is added (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (a narrowing of what boots). A stack declaring a `controlled_by_parent` detail over either class of master below booted before this change. It is now refused at `kernel:bootstrapped`, before `kernel:listening` opens the socket. It ships as `patch` because no metadata protocol shape moves.
14+
15+
ADR-0139 D5.2 and D5.3 record two classes of master the engine composes no `ReadableIds` leaf over, and refuses by name at read time:
16+
17+
- **(a)** a master bound to another datasource than the detail. No driver evaluates a subquery across two datasources.
18+
- **(b)** a master whose rows an object-scoped read middleware narrows (`registerMiddleware(fn, { object })`), for example `sys_attachment`, `sys_comment`, `sys_activity`, `sys_audit_log`, `sys_mail_message` or `sys_approval_request`. That WHERE is built at read time and is no part of the master's read scope, so a leaf over it would be wider than a read of the master.
19+
20+
A `controlled_by_parent` detail derives its access from its one required master (ADR-0055 §1). A detail over such a master is a declaration the runtime cannot honour, so under ADR-0049 it is no longer admitted. `ObjectQLPlugin` refuses the boot with `INVALID_METADATA` / 422, naming each detail, its master field, the master and the class, and the remedies below.
21+
22+
- The check is judged at `kernel:bootstrapped`, not at `kernel:ready` or `registerApp`. Several platform plugins (mailbox, audit, attachment storage) register their object-scoped read middleware from their own `kernel:ready` handler, which runs after the engine's, so only then are both facts final.
23+
- It reuses the read path's own predicate (`hasObjectMiddleware` and the datasource binding resolution), so the boot rule and the read rule cannot drift. The read-time refusal is unchanged.
24+
- New engine method: `ObjectQL#findUnservableControlledByParentMasters()` lists the offending details.
25+
26+
No producer is known to be affected. HotCRM's six `controlled_by_parent` objects all sit on one datasource over masters with no object-scoped read middleware, and no in-repo declaration has either shape.
27+
28+
### Remedy
29+
30+
Pick one, for each detail the refusal names:
31+
32+
- bind the detail and its master to one datasource (the same `datasource`, mapping rule or package default);
33+
- or choose a master that no object-scoped read middleware narrows;
34+
- or declare the detail with a sharing model other than `controlled_by_parent`, and grant its access directly.

‎packages/objectql/src/engine.ts‎

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import {
6161
readableIdsRefusalError,
6262
type ComposableReadableIdsSite,
6363
} from './filter-comparand-shape.js';
64-
import { ReadableIds, RLS_DENY_FILTER, markFilterSubtreeProvenance } from '@objectstack/spec/data';
64+
import { ReadableIds, RLS_DENY_FILTER, markFilterSubtreeProvenance, isControlledByParentMasterField } from '@objectstack/spec/data';
6565
import type { ISecurityService } from '@objectstack/spec/contracts';
6666
import {
6767
assertHavingResolvedTemporalTokensInRange,
@@ -4845,6 +4845,24 @@ interface ReadableIdsChain {
48454845
*/
48464846
const READABLE_IDS_MAX_CHAIN_DEPTH = 8;
48474847

4848+
/**
4849+
* [ADR-0139 D5.2 / D5.3] A `controlled_by_parent` detail whose master the
4850+
* engine composes no `ReadableIds` leaf over —
4851+
* `ObjectQL#findUnservableControlledByParentMasters`, refused at boot by
4852+
* `ObjectQLPlugin`.
4853+
*/
4854+
export type UnservableControlledByParentMaster = {
4855+
/** The `controlled_by_parent` detail. */
4856+
readonly detail: string;
4857+
/** Its one required `master_detail` field. */
4858+
readonly field: string;
4859+
/** The object that field names. */
4860+
readonly master: string;
4861+
} & (
4862+
| { readonly class: 'object-scoped-read-middleware' }
4863+
| { readonly class: 'other-datasource'; readonly objectDatasource: string | undefined; readonly parentDatasource: string | undefined }
4864+
);
4865+
48484866
/**
48494867
* [ADR-0139 D2] The engine slot where `ObjectQLPlugin` puts the SOURCE the
48504868
* `ReadableIds` composition reads the registered `ISecurityService` from: a
@@ -6543,27 +6561,77 @@ export class ObjectQL implements IObjectQLEngine {
65436561
`such leaf ${why}. The query was NOT run and no driver was asked. Read this class through its bounded ` +
65446562
'probe, chosen before the leaf is written.',
65456563
);
6546-
const outer = this.resolveDatasourceBinding(object)?.datasource;
65476564
const composable: string[] = [];
65486565
for (const [parent, site] of firstSite) {
65496566
// A parent the registry does not hold has no record anyone may read:
65506567
// its leaf is answered `$in: []` without asking anything, and it is in
65516568
// none of D5's classes.
65526569
if (!this._registry.getObject(parent)) continue;
6553-
if (this.hasObjectMiddleware(parent)) {
6570+
const uncomposable = this.uncomposableReadableIdsParent(object, parent);
6571+
if (uncomposable?.class === 'object-scoped-read-middleware') {
65546572
throw refuse(site, `over '${parent}', whose rows an object-scoped read middleware narrows: that WHERE is ` +
65556573
`built at read time and is no part of its read scope, so the composed leaf would be wider than a read of '${parent}'`);
65566574
}
6557-
const bound = this.resolveDatasourceBinding(parent)?.datasource;
6558-
if (bound !== outer) {
6559-
throw refuse(site, `over '${parent}', which is bound to datasource '${String(bound)}' while '${object}' is ` +
6560-
`bound to '${String(outer)}': no driver evaluates a subquery across two datasources`);
6575+
if (uncomposable?.class === 'other-datasource') {
6576+
throw refuse(site, `over '${parent}', which is bound to datasource '${String(uncomposable.parentDatasource)}' while '${object}' is ` +
6577+
`bound to '${String(uncomposable.objectDatasource)}': no driver evaluates a subquery across two datasources`);
65616578
}
65626579
composable.push(parent);
65636580
}
65646581
return composable;
65656582
}
65666583

6584+
/**
6585+
* [ADR-0139 D5.2 / D5.3] Which of D5's two remaining classes a leaf over
6586+
* `parent` in a read of `object` falls in, or `undefined` when it falls in
6587+
* neither — the ONE predicate behind both the read-time refusal
6588+
* ({@link refuseUncomposableReadableIdsClasses}) and the boot-time refusal of
6589+
* a `controlled_by_parent` detail over such a master
6590+
* ({@link findUnservableControlledByParentMasters}), so the two rules cannot
6591+
* drift. D5.2 is judged first, as the read path always has.
6592+
*/
6593+
private uncomposableReadableIdsParent(object: string, parent: string):
6594+
| { class: 'object-scoped-read-middleware' }
6595+
| { class: 'other-datasource'; objectDatasource: string | undefined; parentDatasource: string | undefined }
6596+
| undefined {
6597+
if (this.hasObjectMiddleware(parent)) return { class: 'object-scoped-read-middleware' };
6598+
const objectDatasource = this.resolveDatasourceBinding(object)?.datasource;
6599+
const parentDatasource = this.resolveDatasourceBinding(parent)?.datasource;
6600+
if (parentDatasource !== objectDatasource) return { class: 'other-datasource', objectDatasource, parentDatasource };
6601+
return undefined;
6602+
}
6603+
6604+
/**
6605+
* [ADR-0139 D5.2 / D5.3 · ADR-0049] Every registered `controlled_by_parent`
6606+
* detail whose master the engine composes no `ReadableIds` leaf over — a
6607+
* master an object-scoped read middleware narrows, or one bound to another
6608+
* datasource than the detail — judged by the read path's own predicate
6609+
* ({@link uncomposableReadableIdsParent}). A detail's access derives from its
6610+
* one required `master_detail` (ADR-0055 §1), so such a declaration is one
6611+
* the runtime cannot honour: `ObjectQLPlugin` refuses the boot on any entry.
6612+
*
6613+
* Both facts are deployment facts (the datasource mapping, a package's
6614+
* default datasource, lifecycle routing, the middleware registrations), so
6615+
* the answer is final only once every plugin has registered its middleware —
6616+
* which several do inside their own `kernel:ready` handler. Asked earlier, it
6617+
* is short. A detail with no single required master, or whose master the
6618+
* registry does not hold, is no entry: other rules own those.
6619+
*/
6620+
findUnservableControlledByParentMasters(): UnservableControlledByParentMaster[] {
6621+
const found: UnservableControlledByParentMaster[] = [];
6622+
for (const object of this._registry.getAllObjects() as Array<{ name?: string; sharingModel?: unknown; fields?: Record<string, unknown> }>) {
6623+
if (object?.sharingModel !== 'controlled_by_parent' || typeof object.name !== 'string') continue;
6624+
const masters = Object.entries(object.fields ?? {}).filter(([, f]) => isControlledByParentMasterField(f));
6625+
if (masters.length !== 1) continue;
6626+
const [field, def] = masters[0];
6627+
const master = (def as { reference?: unknown }).reference;
6628+
if (typeof master !== 'string' || !this._registry.getObject(master)) continue;
6629+
const uncomposable = this.uncomposableReadableIdsParent(object.name, master);
6630+
if (uncomposable) found.push({ detail: object.name, field, master, ...uncomposable });
6631+
}
6632+
return found;
6633+
}
6634+
65676635
/**
65686636
* [ADR-0139 D2 steps 1-2] The scope a leaf over `parent` is composed with,
65696637
* or `null` for `$in: []`. Fails closed on every answer that is not a
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0139 D5.2 / D5.3 · ADR-0049] A `controlled_by_parent` detail whose
5+
* master the engine composes no `ReadableIds` leaf over fails the BOOT, naming
6+
* the detail, the master, the class and the remedy — never admitted to answer
7+
* 500 at read time:
8+
*
9+
* - (a) a master bound to another datasource than the detail;
10+
* - (b) a master an object-scoped read middleware narrows.
11+
*
12+
* Booted through a real `ObjectKernel` and `ObjectQLPlugin`. The middleware is
13+
* registered the way the platform's own gates are (mailbox, audit, attachment):
14+
* from the owning plugin's `kernel:ready` handler, which runs AFTER the
15+
* engine's — so a check made at the engine's `kernel:ready` would not see it.
16+
*/
17+
18+
import { describe, it, expect, afterEach } from 'vitest';
19+
import { ObjectKernel, type PluginContext } from '@objectstack/core';
20+
import { ObjectQLPlugin } from './plugin.js';
21+
import type { ObjectQL } from './engine.js';
22+
23+
interface ManifestService {
24+
register(artifact: unknown): Promise<void> | void;
25+
}
26+
27+
const kernels: ObjectKernel[] = [];
28+
afterEach(async () => {
29+
while (kernels.length) {
30+
const kernel = kernels.pop()!;
31+
try { if (kernel.getState() === 'running') await kernel.shutdown(); } catch { /* noop */ }
32+
}
33+
});
34+
35+
const driver = (name: string): any => ({
36+
name, version: '0.0.0', supports: {},
37+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
38+
async find() { return []; }, async findOne() { return null; }, async count() { return 0; },
39+
});
40+
41+
/** Registers the drivers and the artifacts during init, the way a host composes a stack. */
42+
function stack(artifacts: unknown[], drivers: string[] = ['primary']) {
43+
return {
44+
name: 'test.stack',
45+
dependencies: ['com.objectstack.engine.objectql'],
46+
init: async (ctx: PluginContext) => {
47+
const ql = ctx.getService<ObjectQL>('objectql');
48+
drivers.forEach((name, i) => ql.registerDriver(driver(name), i === 0));
49+
const manifest = ctx.getService<ManifestService>('manifest');
50+
for (const artifact of artifacts) await manifest.register(artifact);
51+
},
52+
};
53+
}
54+
55+
/** Registers a read middleware from its own `kernel:ready` handler — as late as the platform's gates register. */
56+
function lateReadGate(options?: { object?: string }) {
57+
return {
58+
name: 'test.late-read-gate',
59+
dependencies: ['com.objectstack.engine.objectql'],
60+
init: async () => {},
61+
start: async (ctx: PluginContext) => {
62+
ctx.hook('kernel:ready', async () => {
63+
ctx.getService<ObjectQL>('objectql').registerMiddleware(async (_op, next) => next(), options);
64+
});
65+
},
66+
};
67+
}
68+
69+
async function boot(...plugins: any[]): Promise<{ kernel: ObjectKernel; err: any }> {
70+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
71+
kernels.push(kernel);
72+
await kernel.use(new ObjectQLPlugin());
73+
for (const p of plugins) await kernel.use(p);
74+
const err = await kernel.bootstrap().then(() => undefined, (e) => e);
75+
return { kernel, err };
76+
}
77+
78+
const master = (name: string, extra: Record<string, unknown> = {}) => ({
79+
name, ...extra, fields: { title: { name: 'title', type: 'text' } },
80+
});
81+
const detail = (name: string, masterName: string, extra: Record<string, unknown> = {}) => ({
82+
name,
83+
sharingModel: 'controlled_by_parent',
84+
...extra,
85+
fields: { parent: { name: 'parent', type: 'master_detail', reference: masterName, required: true } },
86+
});
87+
const app = (objects: unknown[]) => ({ id: 'com.test.cbp', name: 'cbp', objects });
88+
89+
const REMEDY = 'Bind the detail and its master to one datasource; or choose a master without an object-scoped read '
90+
+ 'middleware; or declare the detail without `controlled_by_parent`.';
91+
92+
describe('[ADR-0139 D5.2 / D5.3] a controlled_by_parent detail over a master the engine cannot compose a read over', () => {
93+
it('(a) a master bound to another datasource refuses the boot, naming both datasources and the remedy', async () => {
94+
const { err } = await boot(stack(
95+
[app([master('cbp_master', { datasource: 'archive' }), detail('cbp_detail', 'cbp_master')])],
96+
['primary', 'archive'],
97+
));
98+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
99+
expect(err.message).toContain(
100+
"'cbp_detail' (master field 'parent') over master 'cbp_master', which is bound to datasource 'archive' "
101+
+ "while 'cbp_detail' is bound to 'primary' (ADR-0139 D5.3)",
102+
);
103+
expect(err.message).toContain(REMEDY);
104+
});
105+
106+
it('(b) a master an object-scoped read middleware narrows — registered at kernel:ready — refuses the boot', async () => {
107+
const { err } = await boot(
108+
stack([app([master('cbp_master'), detail('cbp_detail', 'cbp_master')])]),
109+
lateReadGate({ object: 'cbp_master' }),
110+
);
111+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
112+
expect(err.message).toContain(
113+
"'cbp_detail' (master field 'parent') over master 'cbp_master', whose rows an object-scoped read middleware "
114+
+ 'narrows (ADR-0139 D5.2)',
115+
);
116+
expect(err.message).toContain(REMEDY);
117+
});
118+
119+
it('(b) a detail mastered by sys_attachment, gated as the storage plugin gates it, refuses the boot', async () => {
120+
const { err } = await boot(
121+
stack([app([master('sys_attachment'), detail('cbp_attachment_note', 'sys_attachment')])]),
122+
lateReadGate({ object: 'sys_attachment' }),
123+
);
124+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
125+
expect(err.message).toContain("'cbp_attachment_note' (master field 'parent') over master 'sys_attachment'");
126+
});
127+
128+
it('names every offending detail in one refusal', async () => {
129+
const { err } = await boot(
130+
stack(
131+
[app([
132+
master('cbp_gated'), detail('cbp_detail_b', 'cbp_gated'),
133+
master('cbp_far', { datasource: 'archive' }), detail('cbp_detail_a', 'cbp_far'),
134+
])],
135+
['primary', 'archive'],
136+
),
137+
lateReadGate({ object: 'cbp_gated' }),
138+
);
139+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
140+
expect(err.message).toMatch(/^2 controlled_by_parent details derive their access/);
141+
expect(err.message).toContain("over master 'cbp_gated'");
142+
expect(err.message).toContain("over master 'cbp_far'");
143+
});
144+
});
145+
146+
describe('[ADR-0139 D5.2 / D5.3] controls: a servable controlled_by_parent master boots unchanged', () => {
147+
// HotCRM's six controlled_by_parent shapes (e6d5d3e): every master a crm_* object on the one datasource, with no
148+
// object-scoped read middleware on any of them — while the platform gates its own system objects.
149+
const HOTCRM = [
150+
master('crm_account'), master('crm_opportunity'), master('crm_activity'),
151+
detail('crm_contact', 'crm_account'),
152+
detail('crm_quote', 'crm_opportunity'),
153+
detail('crm_contract', 'crm_account'),
154+
detail('crm_quote_line', 'crm_quote'), // a two-level chain: its master is controlled_by_parent too
155+
detail('crm_opportunity_line', 'crm_opportunity'),
156+
detail('crm_activity_participant', 'crm_activity'),
157+
];
158+
159+
it("HotCRM's six details boot, with an object-scoped read middleware on a system object no detail is mastered by", async () => {
160+
const { kernel, err } = await boot(stack([app([...HOTCRM, master('sys_comment')])]), lateReadGate({ object: 'sys_comment' }));
161+
expect(err).toBeUndefined();
162+
expect(kernel.getService<ObjectQL>('objectql').findUnservableControlledByParentMasters()).toEqual([]);
163+
});
164+
165+
it('a global middleware (no object, or "*") is keyed to no master and does not refuse', async () => {
166+
const one = await boot(stack([app([master('cbp_master'), detail('cbp_detail', 'cbp_master')])]), lateReadGate());
167+
expect(one.err).toBeUndefined();
168+
const star = await boot(stack([app([master('cbp_master'), detail('cbp_detail', 'cbp_master')])]), lateReadGate({ object: '*' }));
169+
expect(star.err).toBeUndefined();
170+
});
171+
172+
it('a detail and its master bound explicitly to the same second datasource boot', async () => {
173+
const { err } = await boot(stack(
174+
[app([master('cbp_master', { datasource: 'archive' }), detail('cbp_detail', 'cbp_master', { datasource: 'archive' })])],
175+
['primary', 'archive'],
176+
));
177+
expect(err).toBeUndefined();
178+
});
179+
180+
it('a master on another datasource under a detail that is NOT controlled_by_parent boots', async () => {
181+
const { err } = await boot(stack(
182+
[app([master('cbp_master', { datasource: 'archive' }), { ...detail('cbp_detail', 'cbp_master'), sharingModel: 'private' }])],
183+
['primary', 'archive'],
184+
));
185+
expect(err).toBeUndefined();
186+
});
187+
});

0 commit comments

Comments
 (0)