Skip to content

Commit cff39e9

Browse files
wip(objectql,plugin-security,adr): the inherited pins and the ADR-0139 revision draft, taken over unverified
The delegated-compose pins beside the leaf tests (objectql) and the real-stack test in plugin-security, the three plugin-security pins respelled for a delegator who does not exist, and the ADR-0139 draft (O5 answered, D5.1 amended). Committed as found in the interrupted run's worktree; verified in the commits that follow. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ffe7d2 commit cff39e9

6 files changed

Lines changed: 619 additions & 44 deletions

‎docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
D2's position sentence names the executor boundary — after the middleware chain and the before-hooks, immediately
66
before each driver call — in place of the seam where `lowerRelationConditions` runs, which precedes the chain D4's
77
emitters write into (ruling [`6121741984`](https://github.com/objectstack-ai/objectstack/issues/22861#issuecomment-6121741984)
8-
on [#22861](https://github.com/objectstack-ai/objectstack/issues/22861), letter A). **This record declares; it
9-
implements nothing.** It became **Accepted** on the maintainer's approval (`docs/adr/**` is Tier H, Prime
8+
on [#22861](https://github.com/objectstack-ai/objectstack/issues/22861), letter A). **Amended** (2026-10-21): O5 is
9+
answered — `controlled_by_parent` becomes the seventh consumer, in the end-state form — and D5.1 is amended from a
10+
refusal to a composition: the engine composes the leaf for a delegated (on-behalf-of) caller, the scope being the
11+
ADR-0090 D10 delegator intersection `getReadFilter` now answers for that context (ruling
12+
[`6303721609`](https://github.com/objectstack-ai/objectstack/issues/24932#issuecomment-6303721609) on
13+
[#24932](https://github.com/objectstack-ai/objectstack/issues/24932), letter B; the engine card
14+
[#25009](https://github.com/objectstack-ai/objectstack/issues/25009)). D6 and the execution plan carry the rest of that
15+
ruling. **This record declares; it implements nothing.** It became **Accepted** on the maintainer's approval (`docs/adr/**` is Tier H, Prime
1016
Directive #14); the execution cards under [Execution plan](#execution-plan-after-acceptance) are cut after acceptance
1117
and ⛔ never before it. Until then the platform stayed in the ruling's letter D state, and letter C (a located 400 at
1218
the bound, after objectui#12081 item 8) is the `domain:services` lane's interim on
@@ -26,7 +32,8 @@ keys on), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) D10 (del
2632
D5 (principal-less refusal), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove: why no
2733
capability bit returns), [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (the refusal envelope),
2834
[ADR-0057](./0057-system-data-lifecycle-and-retention.md) (the `telemetry` datasource split that makes a parent federated).
29-
**Leaves unchanged**: ADR-0055 §2's chosen mechanism (b) for `controlled_by_parent`, its `rlsMembership` IN-form and
35+
**Leaves unchanged**: ADR-0055 §2's chosen mechanism (b) for `controlled_by_parent` (until the plugin-security switch
36+
the 2026-10-21 amendment of D6 orders lands, in its own Tier H edit of that record), its `rlsMembership` IN-form and
3037
the fail-closed RLS compiler and its closed pushdown form set (historically four, broader today); ADR-0053 D-D1
3138
(drivers receive lowered input) for every form but this one leaf.
3239
**Consumers**: `@objectstack/spec` (the leaf's declaration), `@objectstack/objectql` (composition and push-down),
@@ -198,16 +205,31 @@ restriction".
198205
`managedBy: 'append-only'`, so the backfill goes through the migration door, never the CRUD write path). A row the
199206
backfill cannot judge keeps the fail-closed answer it has today.
200207

201-
### D5 — Three classes stay on the bounded fail-closed probe, by name
202-
203-
Each is recognised by the engine, which **refuses to compose a leaf for it** (loud, as D3's envelope), and routed by the
204-
consumer to today's probe before the leaf is written. ⛔ No silent fallback: the probe is chosen up front, never
205-
reached by catching a refusal.
206-
207-
1. **A delegated (on-behalf-of) caller** (ADR-0090 D10). `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter`
208-
answers the deny sentinel for such a context because the delegator intersection is not threaded through the
209-
read-scope path, while the CRUD middleware intersects it; a composed leaf would serve a delegated reader nothing.
210-
The probe, which reads through the middleware, stays until the intersection reaches `getReadFilter`.
208+
### D5 — Two classes stay on the bounded fail-closed probe, by name; the delegated caller composes (amended 2026-10-21)
209+
210+
Each of the two remaining classes is recognised by the engine, which **refuses to compose a leaf for it** (loud, as D3's
211+
envelope), and routed by the consumer to today's probe before the leaf is written. ⛔ No silent fallback: the probe is
212+
chosen up front, never reached by catching a refusal.
213+
214+
1. **A delegated (on-behalf-of) caller** (ADR-0090 D10) — **composed, not refused** (amended 2026-10-21; as accepted
215+
this class was refused "until the intersection reaches `getReadFilter`", and it has). The engine's boundary
216+
(`packages/objectql/src/engine.ts#composeReadableIdsAtBoundary`) asks both halves — `canReadObject` and
217+
`getReadFilter` — with the delegated context exactly as it stands, `onBehalfOf` intact, and composes what it is
218+
answered; it reads no delegation link of its own
219+
(`packages/objectql/src/engine.ts#refuseUncomposableReadableIdsClasses` judges the two classes below and nothing
220+
about the caller). The security service answers each half for the delegated principal, which under ADR-0090 D10 is
221+
the INTERSECTION of the principal's leg and the delegator's: `canReadObject` already intersected the object half;
222+
`packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` now answers the row half through
223+
`packages/plugins/plugin-security/src/security-plugin.ts#computeDelegatedReadFilter`, the six layers the engine
224+
middleware chain ANDs into a delegated `find` of P — the principal's RLS and `controlled_by_parent` derivation, the
225+
delegator's two (over the context `packages/plugins/plugin-security/src/explain-engine.ts#resolveDelegatorContext`
226+
rebuilds), and plugin-sharing's visibility filter under each identity at its own depth. So a delegated read through
227+
the leaf serves exactly the rows a delegated direct read of P serves (measured: `find`, `count`, one statement per
228+
read, the parent inside the subquery). The closed doors are the chain's: a delegator who does not exist, one the
229+
parent object does not admit, a principal that resolves no set, and any resolution failure answer `false` or the
230+
deny sentinel, and the leaf is `$in: []`. ⛔ The engine derives no second intersection: it has one context, the
231+
delegated one, and the delegator's grants are the security service's to resolve. The six consumers' up-front
232+
`'delegated-caller'` probe routing (D4) is now a residue of the refusal, retired per consumer on its own card.
211233
2. **A parent whose rows are narrowed by an object-scoped read middleware** (`packages/objectql/src/engine.ts#registerMiddleware`
212234
with an `object`): `sys_attachment`, `sys_comment`, `sys_comment_reaction`, `sys_approval_request`, and the two
213235
gated objects themselves. Their WHERE is built at read time and is no part of `getReadFilter`, so a leaf over them
@@ -226,11 +248,28 @@ compiled output of the RLS compiler, whose closed pushdown form set (historicall
226248
IN-form and deliberate lack of a subquery form are unchanged
227249
(`packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler`, `#compileExpression`: a subquery shape compiles to
228250
`null` and fails closed). §2's mechanism (b) for
229-
`controlled_by_parent` stands, set-size ceiling included; whether that id set becomes a seventh consumer is ⛔ not
251+
`controlled_by_parent` stands, set-size ceiling included; whether that id set becomes a seventh consumer was ⛔ not
230252
decided here. ADR-0056's non-goal stands as written for the compiler and is revisited only as the engine-side leaf.
231253
Each record carries one pointer to this one, landed with this record — ADR-0055's status line, ADR-0056's non-goal
232254
bullet; nothing else in either moves.
233255

256+
**Amended 2026-10-21 — O5 is decided, by the maintainer's ruling
257+
[`6303721609`](https://github.com/objectstack-ai/objectstack/issues/24932#issuecomment-6303721609) on
258+
[#24932](https://github.com/objectstack-ai/objectstack/issues/24932) (letter B), recorded here verbatim:**
259+
260+
> O5 is answered yes, in the end-state form. `controlled_by_parent` becomes the seventh consumer of the engine-internal
261+
> semi-join leaf, once for every shape: the engine first composes the leaf for a delegated (on-behalf-of) caller (the
262+
> exit condition D5.1 itself names) and for a nested master chain; then plugin-security switches the caller, the
263+
> delegator intersection and `getReadFilter` in one change, and the id-list pre-scan retires.
264+
265+
The order the ruling binds: the delegated composition (D5.1 as amended, this revision's card), then the nested master
266+
chain (a leaf inside a composed scope is composed; its own `domain:engine` card), then the plugin-security switch,
267+
"which in the same Tier H edit corrects ADR-0055's 'cached within the request' sentence to what the code does". Two
268+
things the ruling ⛔ does not take: an interim with two spellings of one scope, and "interim removal of the predicate for
269+
an unrestricted master scope: it would expose rows with a null or dangling foreign key (measured)". ADR-0055 §2's
270+
mechanism (b) therefore stands until the switch lands, and ADR-0055's own text moves only in that switch's edit, never
271+
here.
272+
234273
## Consequences
235274

236275
- **Positive.** A reader gets every row they may see and a `total` that is true, on any table size; the pre-scan,

‎packages/objectql/src/engine-readable-ids-composition.test.ts‎

Lines changed: 148 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@
1515
*
1616
* Refused by name, `INTERNAL_ERROR` / 500, before any driver is asked: a write
1717
* verb, `having`, an aggregation's filter, any position but a `$in`
18-
* comparand, a composed leaf the engine did not compose, and D5's three
19-
* classes (a delegated caller, a middleware-gated parent, a federated parent).
18+
* comparand, a composed leaf the engine did not compose, and D5's two
19+
* remaining classes (a middleware-gated parent, a federated parent). A
20+
* delegated (on-behalf-of) caller composes like any other (D5.1 as amended):
21+
* both halves are asked with the delegated context as it stands, and what the
22+
* security service answers for that context — under ADR-0090 D10 the
23+
* intersection of the two legs — is the scope.
2024
*
2125
* The recording driver is this package's witness shape (the in-memory
2226
* driver's test consumers are a closed census); evaluating the composed leaf
@@ -87,18 +91,27 @@ const leafOf = (object = 'rid_parent') => ReadableIds.of(object);
8791
const branch = (object = 'rid_parent') => ({ object_name: object, record_id: { $in: leafOf(object) } });
8892
const COUNT_AGG = [{ function: 'count', alias: 'n' }];
8993

90-
/** A security service double that records which half it was asked, per object. */
94+
/** A security service double that records which half it was asked, per object, and the context each half received. */
9195
function makeSecurity(opts: {
92-
canRead?: (object: string) => boolean | Promise<boolean>;
93-
scope?: (object: string) => unknown;
96+
canRead?: (object: string, context?: unknown) => boolean | Promise<boolean>;
97+
scope?: (object: string, context?: unknown) => unknown;
9498
} = {}) {
9599
const asked: string[] = [];
100+
const contexts: unknown[] = [];
96101
const svc: any = {
97-
async canReadObject(object: string) { asked.push(`canReadObject:${object}`); return opts.canRead ? opts.canRead(object) : true; },
98-
async getReadFilter(object: string) { asked.push(`getReadFilter:${object}`); return opts.scope ? opts.scope(object) : undefined; },
102+
async canReadObject(object: string, context?: unknown) {
103+
asked.push(`canReadObject:${object}`);
104+
contexts.push(context);
105+
return opts.canRead ? opts.canRead(object, context) : true;
106+
},
107+
async getReadFilter(object: string, context?: unknown) {
108+
asked.push(`getReadFilter:${object}`);
109+
contexts.push(context);
110+
return opts.scope ? opts.scope(object, context) : undefined;
111+
},
99112
async explain() { asked.push('explain'); return { allowed: false }; },
100113
};
101-
return { svc, asked };
114+
return { svc, asked, contexts };
102115
}
103116

104117
/** The `$in` comparand the driver received at `where.record_id`, under any `$and` the chain wrapped around it. */
@@ -344,6 +357,133 @@ describe('[ADR-0139 D2] the executor boundary composes the ReadableIds leaf', ()
344357
});
345358
});
346359

360+
describe('[ADR-0139 D5.1, as amended] a delegated (on-behalf-of) caller composes — both halves asked with the delegated context as it stands', () => {
361+
let engine: ObjectQL;
362+
let calls: SeenCall[];
363+
/** The shape the OAuth door assembles: `userId` stays the human, the link names them as the delegator. */
364+
const DELEGATED = { userId: 'usr_owner', principalKind: 'agent', onBehalfOf: { userId: 'usr_owner', principalKind: 'human' } };
365+
const delegated = () => ({ ...DELEGATED, onBehalfOf: { ...DELEGATED.onBehalfOf } }) as any;
366+
367+
beforeEach(async () => {
368+
const rec = makeRecordingDriver();
369+
const farRec = makeRecordingDriver('far');
370+
calls = rec.calls;
371+
engine = new ObjectQL();
372+
engine.registerDriver(rec.driver, true);
373+
engine.registerDriver(farRec.driver);
374+
await engine.init();
375+
for (const def of [CHILD, PARENT, OTHER, GATED, FAR]) engine.registry.registerObject(def as any, 'test');
376+
engine.registerMiddleware(async (_ctx, next) => next(), { object: 'rid_gated' });
377+
calls.length = 0;
378+
});
379+
380+
const delegatedReads = (where: Record<string, unknown>) => [
381+
['find', () => engine.find('rid_child', { where, context: delegated() })],
382+
['findOne', () => engine.findOne('rid_child', { where, context: delegated() })],
383+
['count', () => engine.count('rid_child', { where, context: delegated() })],
384+
['aggregate', () => engine.aggregate('rid_child', { where, aggregations: COUNT_AGG as any, context: delegated() })],
385+
] as const;
386+
387+
it('each read verb asks both halves with the delegation link intact and hands its driver ONE call carrying the composed leaf — scope by reference, marked policy', async () => {
388+
// What the service answers for the delegated context stands for the
389+
// ADR-0090 D10 intersection; the engine composes it, it derives nothing.
390+
const scope = { $and: [{ owner: 'usr_owner' }, { title: { $ne: 'secret' } }] };
391+
const { svc, asked, contexts } = makeSecurity({ scope: () => scope });
392+
wire(engine, () => svc);
393+
for (const [verb, run] of delegatedReads(branch())) {
394+
calls.length = 0;
395+
asked.length = 0;
396+
contexts.length = 0;
397+
await run();
398+
expect(calls.map((c) => c.verb), verb).toEqual([verb]);
399+
expect(asked, verb).toEqual(['canReadObject:rid_parent', 'getReadFilter:rid_parent']);
400+
// Both halves received the caller's context as it stands: the link the
401+
// service intersects on is there, and the principal is the delegated one
402+
// — never a context the engine rebuilt for the delegator.
403+
for (const ctx of contexts as Array<Record<string, unknown>>) {
404+
expect(ctx.onBehalfOf, verb).toEqual(DELEGATED.onBehalfOf);
405+
expect(ctx.userId, verb).toBe(DELEGATED.userId);
406+
expect(ctx.principalKind, verb).toBe('agent');
407+
}
408+
const composed = inComparandOf(calls[0].ast.where) as any;
409+
expect(isComposedReadableIds(composed), verb).toBe(true);
410+
expect(composed.object, verb).toBe('rid_parent');
411+
expect(composed.scope, verb).toBe(scope);
412+
}
413+
expect(filterSubtreeProvenanceOf(scope)).toBe('policy');
414+
});
415+
416+
it('a leaf written by an object-scoped middleware on the child is composed for a delegated caller too', async () => {
417+
const scope = { owner: 'usr_owner' };
418+
const { svc, contexts } = makeSecurity({ scope: () => scope });
419+
wire(engine, () => svc);
420+
engine.registerMiddleware(async (ctx, next) => {
421+
if (ctx.ast && ['find', 'findOne', 'count', 'aggregate'].includes(ctx.operation)) {
422+
ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, branch()] } : branch();
423+
}
424+
await next();
425+
}, { object: 'rid_child' });
426+
for (const [verb, run] of delegatedReads({ note: 'x' })) {
427+
calls.length = 0;
428+
contexts.length = 0;
429+
await run();
430+
const composed = inComparandOf(calls[0].ast.where) as any;
431+
expect(isComposedReadableIds(composed), verb).toBe(true);
432+
expect(composed.scope, verb).toBe(scope);
433+
expect(contexts.map((c) => (c as any)?.onBehalfOf?.userId), verb).toEqual(['usr_owner', 'usr_owner']);
434+
}
435+
});
436+
437+
it('the closed doors stay closed under a delegated context: canReadObject false, the deny sentinel, a throw on either half ⇒ $in: []', async () => {
438+
const doors: ReadonlyArray<readonly [string, ReturnType<typeof makeSecurity>]> = [
439+
['canReadObject false', makeSecurity({ canRead: () => false, scope: () => ({ owner: 'usr_owner' }) })],
440+
['the deny sentinel, by identity', makeSecurity({ scope: () => RLS_DENY_FILTER })],
441+
['canReadObject throws', (() => {
442+
const s = makeSecurity({ scope: () => ({ owner: 'usr_owner' }) });
443+
s.svc.canReadObject = async () => { throw new Error('canReadObject is down'); };
444+
return s;
445+
})()],
446+
['getReadFilter throws', (() => {
447+
const s = makeSecurity();
448+
s.svc.getReadFilter = async () => { throw new Error('getReadFilter is down'); };
449+
return s;
450+
})()],
451+
];
452+
for (const [label, { svc }] of doors) {
453+
wire(engine, () => svc);
454+
for (const [verb, run] of delegatedReads(branch())) {
455+
calls.length = 0;
456+
await run();
457+
expect(calls.map((c) => c.verb), `${label}: ${verb}`).toEqual([verb]);
458+
expect(inComparandOf(calls[0].ast.where), `${label}: ${verb}`).toEqual([]);
459+
}
460+
}
461+
});
462+
463+
it('D5.2 and D5.3 keep their refusal under a delegated context: code and status, no driver call, the service unasked', async () => {
464+
const { svc, asked } = makeSecurity();
465+
wire(engine, () => svc);
466+
const gated = await refusalOf(engine.count('rid_child', { where: branch('rid_gated'), context: delegated() }));
467+
expect(envelopeOf(gated)).toEqual(REFUSED);
468+
expect(gated!.message).toContain("'rid_gated', whose rows an object-scoped read middleware narrows");
469+
const far = await refusalOf(engine.aggregate('rid_child', { where: branch('rid_far'), aggregations: COUNT_AGG as any, context: delegated() }));
470+
expect(envelopeOf(far)).toEqual(REFUSED);
471+
expect(far!.message).toContain("'rid_far', which is bound to datasource 'far'");
472+
expect(calls).toHaveLength(0);
473+
expect(asked).toEqual([]);
474+
});
475+
476+
it('CONTROL — the same read without the delegation link composes the same way, so the link is not what the engine keys on', async () => {
477+
const scope = { owner: 'usr_owner' };
478+
const { svc, asked, contexts } = makeSecurity({ scope: () => scope });
479+
wire(engine, () => svc);
480+
await engine.find('rid_child', { where: branch(), context: { userId: 'usr_owner', principalKind: 'human' } as any });
481+
expect(asked).toEqual(['canReadObject:rid_parent', 'getReadFilter:rid_parent']);
482+
expect(contexts.map((c) => (c as any)?.onBehalfOf)).toEqual([undefined, undefined]);
483+
expect((inComparandOf(calls[0].ast.where) as any).scope).toBe(scope);
484+
});
485+
});
486+
347487
describe('[ADR-0139 D1, D5] what the engine never composes is refused by name — no driver call', () => {
348488
let engine: ObjectQL;
349489
let calls: SeenCall[];
@@ -365,17 +505,6 @@ describe('[ADR-0139 D1, D5] what the engine never composes is refused by name
365505
calls.length = 0;
366506
});
367507

368-
it('D5 — a delegated (on-behalf-of) caller', async () => {
369-
const err = await refusalOf(engine.find('rid_child', {
370-
where: branch(),
371-
context: { userId: 'usr_agent', onBehalfOf: { userId: 'usr_owner' } } as any,
372-
}));
373-
expect(envelopeOf(err)).toEqual(REFUSED);
374-
expect(err!.message).toContain('on-behalf-of');
375-
expect(calls).toHaveLength(0);
376-
expect(asked, 'D5 is decided before the security service is asked').toEqual([]);
377-
});
378-
379508
it('D5 — a parent an object-scoped read middleware narrows', async () => {
380509
const err = await refusalOf(engine.count('rid_child', { where: branch('rid_gated') }));
381510
expect(envelopeOf(err)).toEqual(REFUSED);

0 commit comments

Comments
 (0)