Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/25144-cbp-unservable-master-boot-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/objectql': patch
---

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

Clause-②: yes (narrowing)

Metadata-protocol: none

<!-- 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). -->

**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.

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:

- **(a)** a master bound to another datasource than the detail. No driver evaluates a subquery across two datasources.
- **(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.

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.

- 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.
- 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.
- New engine method: `ObjectQL#findUnservableControlledByParentMasters()` lists the offending details.

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.

### Remedy

Pick one, for each detail the refusal names:

- bind the detail and its master to one datasource (the same `datasource`, mapping rule or package default);
- or choose a master that no object-scoped read middleware narrows;
- or declare the detail with a sharing model other than `controlled_by_parent`, and grant its access directly.
82 changes: 75 additions & 7 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
readableIdsRefusalError,
type ComposableReadableIdsSite,
} from './filter-comparand-shape.js';
import { ReadableIds, RLS_DENY_FILTER, markFilterSubtreeProvenance } from '@objectstack/spec/data';
import { ReadableIds, RLS_DENY_FILTER, markFilterSubtreeProvenance, isControlledByParentMasterField } from '@objectstack/spec/data';
import type { ISecurityService } from '@objectstack/spec/contracts';
import {
assertHavingResolvedTemporalTokensInRange,
Expand Down Expand Up @@ -4845,6 +4845,24 @@ interface ReadableIdsChain {
*/
const READABLE_IDS_MAX_CHAIN_DEPTH = 8;

/**
* [ADR-0139 D5.2 / D5.3] A `controlled_by_parent` detail whose master the
* engine composes no `ReadableIds` leaf over —
* `ObjectQL#findUnservableControlledByParentMasters`, refused at boot by
* `ObjectQLPlugin`.
*/
export type UnservableControlledByParentMaster = {
/** The `controlled_by_parent` detail. */
readonly detail: string;
/** Its one required `master_detail` field. */
readonly field: string;
/** The object that field names. */
readonly master: string;
} & (
| { readonly class: 'object-scoped-read-middleware' }
| { readonly class: 'other-datasource'; readonly objectDatasource: string | undefined; readonly parentDatasource: string | undefined }
);

/**
* [ADR-0139 D2] The engine slot where `ObjectQLPlugin` puts the SOURCE the
* `ReadableIds` composition reads the registered `ISecurityService` from: a
Expand Down Expand Up @@ -6543,27 +6561,77 @@ export class ObjectQL implements IObjectQLEngine {
`such leaf ${why}. The query was NOT run and no driver was asked. Read this class through its bounded ` +
'probe, chosen before the leaf is written.',
);
const outer = this.resolveDatasourceBinding(object)?.datasource;
const composable: string[] = [];
for (const [parent, site] of firstSite) {
// A parent the registry does not hold has no record anyone may read:
// its leaf is answered `$in: []` without asking anything, and it is in
// none of D5's classes.
if (!this._registry.getObject(parent)) continue;
if (this.hasObjectMiddleware(parent)) {
const uncomposable = this.uncomposableReadableIdsParent(object, parent);
if (uncomposable?.class === 'object-scoped-read-middleware') {
throw refuse(site, `over '${parent}', whose rows an object-scoped read middleware narrows: that WHERE is ` +
`built at read time and is no part of its read scope, so the composed leaf would be wider than a read of '${parent}'`);
}
const bound = this.resolveDatasourceBinding(parent)?.datasource;
if (bound !== outer) {
throw refuse(site, `over '${parent}', which is bound to datasource '${String(bound)}' while '${object}' is ` +
`bound to '${String(outer)}': no driver evaluates a subquery across two datasources`);
if (uncomposable?.class === 'other-datasource') {
throw refuse(site, `over '${parent}', which is bound to datasource '${String(uncomposable.parentDatasource)}' while '${object}' is ` +
`bound to '${String(uncomposable.objectDatasource)}': no driver evaluates a subquery across two datasources`);
}
composable.push(parent);
}
return composable;
}

/**
* [ADR-0139 D5.2 / D5.3] Which of D5's two remaining classes a leaf over
* `parent` in a read of `object` falls in, or `undefined` when it falls in
* neither — the ONE predicate behind both the read-time refusal
* ({@link refuseUncomposableReadableIdsClasses}) and the boot-time refusal of
* a `controlled_by_parent` detail over such a master
* ({@link findUnservableControlledByParentMasters}), so the two rules cannot
* drift. D5.2 is judged first, as the read path always has.
*/
private uncomposableReadableIdsParent(object: string, parent: string):
| { class: 'object-scoped-read-middleware' }
| { class: 'other-datasource'; objectDatasource: string | undefined; parentDatasource: string | undefined }
| undefined {
if (this.hasObjectMiddleware(parent)) return { class: 'object-scoped-read-middleware' };
const objectDatasource = this.resolveDatasourceBinding(object)?.datasource;
const parentDatasource = this.resolveDatasourceBinding(parent)?.datasource;
if (parentDatasource !== objectDatasource) return { class: 'other-datasource', objectDatasource, parentDatasource };
return undefined;
}

/**
* [ADR-0139 D5.2 / D5.3 · ADR-0049] Every registered `controlled_by_parent`
* detail whose master the engine composes no `ReadableIds` leaf over — a
* master an object-scoped read middleware narrows, or one bound to another
* datasource than the detail — judged by the read path's own predicate
* ({@link uncomposableReadableIdsParent}). A detail's access derives from its
* one required `master_detail` (ADR-0055 §1), so such a declaration is one
* the runtime cannot honour: `ObjectQLPlugin` refuses the boot on any entry.
*
* Both facts are deployment facts (the datasource mapping, a package's
* default datasource, lifecycle routing, the middleware registrations), so
* the answer is final only once every plugin has registered its middleware —
* which several do inside their own `kernel:ready` handler. Asked earlier, it
* is short. A detail with no single required master, or whose master the
* registry does not hold, is no entry: other rules own those.
*/
findUnservableControlledByParentMasters(): UnservableControlledByParentMaster[] {
const found: UnservableControlledByParentMaster[] = [];
for (const object of this._registry.getAllObjects() as Array<{ name?: string; sharingModel?: unknown; fields?: Record<string, unknown> }>) {
if (object?.sharingModel !== 'controlled_by_parent' || typeof object.name !== 'string') continue;
const masters = Object.entries(object.fields ?? {}).filter(([, f]) => isControlledByParentMasterField(f));
if (masters.length !== 1) continue;
const [field, def] = masters[0];
const master = (def as { reference?: unknown }).reference;
if (typeof master !== 'string' || !this._registry.getObject(master)) continue;
const uncomposable = this.uncomposableReadableIdsParent(object.name, master);
if (uncomposable) found.push({ detail: object.name, field, master, ...uncomposable });
}
return found;
}

/**
* [ADR-0139 D2 steps 1-2] The scope a leaf over `parent` is composed with,
* or `null` for `$in: []`. Fails closed on every answer that is not a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0139 D5.2 / D5.3 · ADR-0049] A `controlled_by_parent` detail whose
* master the engine composes no `ReadableIds` leaf over fails the BOOT, naming
* the detail, the master, the class and the remedy — never admitted to answer
* 500 at read time:
*
* - (a) a master bound to another datasource than the detail;
* - (b) a master an object-scoped read middleware narrows.
*
* Booted through a real `ObjectKernel` and `ObjectQLPlugin`. The middleware is
* registered the way the platform's own gates are (mailbox, audit, attachment):
* from the owning plugin's `kernel:ready` handler, which runs AFTER the
* engine's — so a check made at the engine's `kernel:ready` would not see it.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectKernel, type PluginContext } from '@objectstack/core';
import { ObjectQLPlugin } from './plugin.js';
import type { ObjectQL } from './engine.js';

interface ManifestService {
register(artifact: unknown): Promise<void> | void;
}

const kernels: ObjectKernel[] = [];
afterEach(async () => {
while (kernels.length) {
const kernel = kernels.pop()!;
try { if (kernel.getState() === 'running') await kernel.shutdown(); } catch { /* noop */ }
}
});

const driver = (name: string): any => ({
name, version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
async find() { return []; }, async findOne() { return null; }, async count() { return 0; },
});

/** Registers the drivers and the artifacts during init, the way a host composes a stack. */
function stack(artifacts: unknown[], drivers: string[] = ['primary']) {
return {
name: 'test.stack',
dependencies: ['com.objectstack.engine.objectql'],
init: async (ctx: PluginContext) => {
const ql = ctx.getService<ObjectQL>('objectql');
drivers.forEach((name, i) => ql.registerDriver(driver(name), i === 0));
const manifest = ctx.getService<ManifestService>('manifest');
for (const artifact of artifacts) await manifest.register(artifact);
},
};
}

/** Registers a read middleware from its own `kernel:ready` handler — as late as the platform's gates register. */
function lateReadGate(options?: { object?: string }) {
return {
name: 'test.late-read-gate',
dependencies: ['com.objectstack.engine.objectql'],
init: async () => {},
start: async (ctx: PluginContext) => {
ctx.hook('kernel:ready', async () => {
ctx.getService<ObjectQL>('objectql').registerMiddleware(async (_op, next) => next(), options);
});
},
};
}

async function boot(...plugins: any[]): Promise<{ kernel: ObjectKernel; err: any }> {
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
kernels.push(kernel);
await kernel.use(new ObjectQLPlugin());
for (const p of plugins) await kernel.use(p);
const err = await kernel.bootstrap().then(() => undefined, (e) => e);
return { kernel, err };
}

const master = (name: string, extra: Record<string, unknown> = {}) => ({
name, ...extra, fields: { title: { name: 'title', type: 'text' } },
});
const detail = (name: string, masterName: string, extra: Record<string, unknown> = {}) => ({
name,
sharingModel: 'controlled_by_parent',
...extra,
fields: { parent: { name: 'parent', type: 'master_detail', reference: masterName, required: true } },
});
const app = (objects: unknown[]) => ({ id: 'com.test.cbp', name: 'cbp', objects });

const REMEDY = 'Bind the detail and its master to one datasource; or choose a master without an object-scoped read '
+ 'middleware; or declare the detail without `controlled_by_parent`.';

describe('[ADR-0139 D5.2 / D5.3] a controlled_by_parent detail over a master the engine cannot compose a read over', () => {
it('(a) a master bound to another datasource refuses the boot, naming both datasources and the remedy', async () => {
const { err } = await boot(stack(
[app([master('cbp_master', { datasource: 'archive' }), detail('cbp_detail', 'cbp_master')])],
['primary', 'archive'],
));
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
expect(err.message).toContain(
"'cbp_detail' (master field 'parent') over master 'cbp_master', which is bound to datasource 'archive' "
+ "while 'cbp_detail' is bound to 'primary' (ADR-0139 D5.3)",
);
expect(err.message).toContain(REMEDY);
});

it('(b) a master an object-scoped read middleware narrows — registered at kernel:ready — refuses the boot', async () => {
const { err } = await boot(
stack([app([master('cbp_master'), detail('cbp_detail', 'cbp_master')])]),
lateReadGate({ object: 'cbp_master' }),
);
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
expect(err.message).toContain(
"'cbp_detail' (master field 'parent') over master 'cbp_master', whose rows an object-scoped read middleware "
+ 'narrows (ADR-0139 D5.2)',
);
expect(err.message).toContain(REMEDY);
});

it('(b) a detail mastered by sys_attachment, gated as the storage plugin gates it, refuses the boot', async () => {
const { err } = await boot(
stack([app([master('sys_attachment'), detail('cbp_attachment_note', 'sys_attachment')])]),
lateReadGate({ object: 'sys_attachment' }),
);
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
expect(err.message).toContain("'cbp_attachment_note' (master field 'parent') over master 'sys_attachment'");
});

it('names every offending detail in one refusal', async () => {
const { err } = await boot(
stack(
[app([
master('cbp_gated'), detail('cbp_detail_b', 'cbp_gated'),
master('cbp_far', { datasource: 'archive' }), detail('cbp_detail_a', 'cbp_far'),
])],
['primary', 'archive'],
),
lateReadGate({ object: 'cbp_gated' }),
);
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
expect(err.message).toMatch(/^2 controlled_by_parent details derive their access/);
expect(err.message).toContain("over master 'cbp_gated'");
expect(err.message).toContain("over master 'cbp_far'");
});
});

describe('[ADR-0139 D5.2 / D5.3] controls: a servable controlled_by_parent master boots unchanged', () => {
// HotCRM's six controlled_by_parent shapes (e6d5d3e): every master a crm_* object on the one datasource, with no
// object-scoped read middleware on any of them — while the platform gates its own system objects.
const HOTCRM = [
master('crm_account'), master('crm_opportunity'), master('crm_activity'),
detail('crm_contact', 'crm_account'),
detail('crm_quote', 'crm_opportunity'),
detail('crm_contract', 'crm_account'),
detail('crm_quote_line', 'crm_quote'), // a two-level chain: its master is controlled_by_parent too
detail('crm_opportunity_line', 'crm_opportunity'),
detail('crm_activity_participant', 'crm_activity'),
];

it("HotCRM's six details boot, with an object-scoped read middleware on a system object no detail is mastered by", async () => {
const { kernel, err } = await boot(stack([app([...HOTCRM, master('sys_comment')])]), lateReadGate({ object: 'sys_comment' }));
expect(err).toBeUndefined();
expect(kernel.getService<ObjectQL>('objectql').findUnservableControlledByParentMasters()).toEqual([]);
});

it('a global middleware (no object, or "*") is keyed to no master and does not refuse', async () => {
const one = await boot(stack([app([master('cbp_master'), detail('cbp_detail', 'cbp_master')])]), lateReadGate());
expect(one.err).toBeUndefined();
const star = await boot(stack([app([master('cbp_master'), detail('cbp_detail', 'cbp_master')])]), lateReadGate({ object: '*' }));
expect(star.err).toBeUndefined();
});

it('a detail and its master bound explicitly to the same second datasource boot', async () => {
const { err } = await boot(stack(
[app([master('cbp_master', { datasource: 'archive' }), detail('cbp_detail', 'cbp_master', { datasource: 'archive' })])],
['primary', 'archive'],
));
expect(err).toBeUndefined();
});

it('a master on another datasource under a detail that is NOT controlled_by_parent boots', async () => {
const { err } = await boot(stack(
[app([master('cbp_master', { datasource: 'archive' }), { ...detail('cbp_detail', 'cbp_master'), sharingModel: 'private' }])],
['primary', 'archive'],
));
expect(err).toBeUndefined();
});
});
Loading
Loading