Skip to content

Commit 605b3e2

Browse files
fix(plugin-security): an on-behalf-of by-id write never admits a row its delegator's own by-id write refuses (ADR-0090 D10) (#24792)
Fixes #24746 Clause-②: no ## What was wrong ADR-0090 D10 says an on-behalf-of (delegated) principal never exceeds its delegator. The by-id write's pre-image check (`security-plugin.ts`, step 2.7) already composes the delegator's own write filter beside the principal's. But it composed that filter differently from the filter the delegator's own direct write of the same row gets. Where an app-authored row-level write policy bounds the delegator, an OAuth agent acting for that delegator could therefore be admitted for a row that the delegator's own by-id write refuses. Since #24745 (the #24646 card), explain's on-behalf-of write verdict composes the same delegated path, so it gave the same admit. ## The fix, in rule terms Every row gate that judges a delegated by-id write now also composes the delegator's filter as the delegator's own direct write of that row gets it, and requires both compositions to pass: - **Step 2.7, the pre-image check** of a by-id `update` or `delete`. This is the card's gate. - **Step 3.6, the post-image check** of the row an `update` stores. - **Step 2.8, the master gate** of a `controlled_by_parent` detail write, on the delegator's pass. `delegatorOwnWriteFloorOptions` is the one helper. It returns the composition knobs the delegator's own direct by-id write of the row would get, asked as the delegator and not as the delegated context. The three gates each AND that composition beside the one they already used. **The change only narrows.** A write is admitted only where today's composition admits it AND the delegator's own composition admits it. No row refused before is admitted. Writes that are not delegated, bulk writes, and every other gate are unchanged. **Explain.** `security.explain`'s on-behalf-of record verdict composes the same two filters (`delegatorRecordWriteFilter` in `explain-engine.ts`). Its master leg already runs the door's own `assertControlledByParentWrite` for both principals, so the step 2.8 change reaches explain without a second edit. Explain's record verdict still matches the door: every rig 4 row of `explain-enforce-parity.test.ts` passes, and so do 14 new rig 4 rows for this card. ### Bounded in-place fix: steps 3.6 and 2.8 The claim's file surface names step 2.7. I measured on `origin/main` (`795ff708d8`), in both postures: the same rule broke at two more gates of the same delegated write, for the same reason, in the same file: - the post-image check; - the master gate's delegator pass, for detail `update` and `delete`. All four conditions of the bounded in-place exemption hold: 1. It is the same defect class. 2. The fix is the identical mechanical construction, from one helper. 3. `security-plugin.ts` is held by this claim alone. 4. It uses the same test family, with no new verification surface. The evidence is the pins below and ablations B and C. The claim's declared file surface needs the same amendment. That is the seat's write, and it is named in the report. ## Not changed, deliberately There are shapes where the delegator's own direct write admits a row that the delegated write has always refused. Those rows stay refused. Two such rows are pinned as unchanged in the new file, and rig 4 already pins two more. Making the delegated write match the delegator's own composition exactly there would WIDEN a security boundary. That is the maintainer's call, so it is not done here. It goes to the seat as an open question. This PR does not touch `plugin-sharing`, `rls-compiler.ts`, `packages/spec/**` or the shipped permission sets. ## Evidence **Measured first, on `origin/main` `795ff708d8`.** This was a real stack: ObjectQL with the SQL driver, `SecurityPlugin`, the sharing service and middleware, and contexts from `resolveAuthzContext` / `assembleExecutionContext` with the `mcp_agent_data_write` OAuth ceiling. Under both `isolated` and `single`, the delegator's own write was refused with 403 `PERMISSION_DENIED`, and the write on its behalf was admitted, for: - the by-id `update` and `delete`; - the stored-row check; - the master-gated detail `update` and `delete`. Explain admitted those writes as well. After the fix, every one of them is refused for both callers. Explain matches the door on each, and the controls are unchanged. **Tests, at head `e6f4cff0ce`:** - `pnpm --filter @objectstack/plugin-security test`: 191 files, 3972 passed, 45 skipped. Exit 0. - `pnpm --filter @objectstack/plugin-security typecheck`: `tsc --noEmit` for `src`, scripts and the test layer (`check:test-typecheck`: 0 files, 0 errors). Exit 0. - New `src/delegated-write-never-exceeds-delegator.test.ts`: 22 passed. Each case asserts both callers' outcomes with the ADR-0112 envelope (`code` and `status`), and explain's `record.visible` beside the door. It covers both postures, and every refusal has the delegator's own request as its control. - `src/explain-enforce-parity.test.ts`: 164 passed. The 14 new rig 4 rows are labelled `#24746`. **Ablations.** Each removes one leg's new composition through `scripts/ablation-replace.mjs`. The anchor must hit once, and the blob must change on disk. Each run was restored with `git checkout HEAD`, and the restore was proven by blob equal to HEAD and an empty `git diff HEAD`. | Leg removed | Result | |---|---| | A: step 2.7 pre-image | 4 red: the delegated `delete` cells, in the new file and in rig 4. The `update` cells stay green because the step 3.6 leg refuses them too, so `update` has two legs. | | B: step 3.6 post-image | 2 red: the stored-row cells. | | C: step 2.8 master gate | 6 red: the detail `update` and `delete` cells, door and explain. | | D: explain's second composition | 10 red: explain's verdict for the delegated `update` and `delete` cells. The door still refuses. | | All four at once (the old composition) | 18 red: every breach pin in both files. Every control and every unchanged-refusal row stays green. | **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff derived 69 commands. Each was run, and its exit was recorded before any pipe. `--ran` reconciliation: 69 derived, 67 run with exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED gates are `check:dual-build-cjs-loads` and `check:i18n`. Both exited 3, PREREQUISITE NOT MET, because they read a whole-repo build that this container does not have. CI runs them. The changeset is `.changeset/24746-delegated-write-never-exceeds-delegator.md`, a `@objectstack/plugin-security` patch. ## Acceptance notes - The delegator's pass in the master gate is recognised as the pass where the gate's `context` carries no delegation link while the operation context does. This is the same reading the existing floor exclusion uses there, which reads the link off the operation context. The method's only caller is the chain walk in `assertControlledByParentWrite`, which passes both through unchanged. - Explain does not model the step 3.6 post-image check, because its record verdict has no payload. This is unchanged by this PR. - I did not run `test:repo` locally. It is CI's run (`Test Core`). --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12d1476 commit 605b3e2

5 files changed

Lines changed: 601 additions & 20 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): an on-behalf-of by-id write never admits a row its delegator's own by-id write refuses (ADR-0090 D10)
6+
7+
Clause-②: no
8+
9+
ADR-0090 D10 says a delegated principal, such as an OAuth agent acting for a user, never exceeds its delegator. The by-id write already asked every row gate as the delegator too. But it composed the delegator's row-level write filter differently from the filter the delegator's own direct write of the same row gets. Where an app-authored row-level write policy bounds the delegator, a write on the delegator's behalf could therefore be admitted for a row the delegator's own write is refused.
10+
11+
Each gate that judges a delegated by-id write now also composes the delegator's filter as the delegator's own direct write gets it, and requires both:
12+
13+
- the pre-image check of a by-id `update` or `delete`;
14+
- the post-image check of the row an `update` stores;
15+
- the master gate of a `controlled_by_parent` detail write, on the delegator's pass.
16+
17+
The change only narrows. A write is admitted only where it was admitted before AND the delegator's own write of that row is admitted. Nothing refused before is admitted now: where the delegator's own write admits a row that the delegated write has always refused, that row stays refused. `security.explain`'s record verdict for an on-behalf-of `update` or `delete` composes the same two filters, so it still gives the answer the door gives. Writes that are not delegated are unchanged.
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0090 D10] An on-behalf-of by-id write never admits a row its
5+
* delegator's own by-id write refuses.
6+
*
7+
* An OAuth agent acting for a user carries a ceiling of its own and is bounded
8+
* by that user: every row gate a by-id write meets asks the delegator as well.
9+
* The bound pinned here is the one the delegator's OWN direct write sets, at
10+
* each gate that judges a delegated by-id write:
11+
*
12+
* - the by-id `update` and `delete` of a row;
13+
* - the row an `update` would store;
14+
* - a `controlled_by_parent` detail's write, judged through its master.
15+
*
16+
* Every refusal stands beside the delegator's own request as its control, and
17+
* every row-level verdict beside explain's record verdict for the same caller,
18+
* under a walled (`isolated`) and a wall-less (`single`) posture.
19+
*
20+
* The bound only narrows. Two shapes the delegator's own write admits stay
21+
* refused on its behalf, exactly as they were: making the delegated write
22+
* match the delegator's own there would WIDEN it, which is not what this rule
23+
* says, so those rows pin the refusal as unchanged.
24+
*
25+
* Real stack: `ObjectQL` over better-sqlite3, the real `SecurityPlugin`, the
26+
* real sharing service and middleware, and contexts assembled by
27+
* `resolveAuthzContext` and `assembleExecutionContext` — the agent's with the
28+
* `mcp_agent_data_write` OAuth ceiling. `@objectstack/core` and
29+
* `@objectstack/plugin-sharing` resolve through their built `dist/`, as this
30+
* package's other suites read them.
31+
*/
32+
33+
import { describe, it, expect, vi, afterAll } from 'vitest';
34+
import { AsyncLocalStorage } from 'node:async_hooks';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core';
38+
import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing';
39+
import { PermissionSetSchema } from '@objectstack/spec/security';
40+
import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security';
41+
import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity';
42+
43+
import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js';
44+
import { SysUserPosition } from './objects/sys-user-position.object.js';
45+
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
46+
import { SecurityPlugin } from './security-plugin.js';
47+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
48+
49+
const SYS = { context: { isSystem: true } } as never;
50+
const ALPHA = 'org_alpha';
51+
52+
/** A delegator whose own writes app-authored row-level policies bound. */
53+
const BOUNDED = 'u_bounded_writer';
54+
/** A delegator with an `org` write grant and no authored policy. */
55+
const PLAIN = 'u_plain_writer';
56+
/** Owns and creates the rows nobody else here does. */
57+
const OTHER = 'u_someone_else';
58+
59+
/** An outcome with its ADR-0112 envelope: what a request answered. */
60+
type Outcome = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number };
61+
const ADMITTED: Outcome = { kind: 'admitted' };
62+
const DENIED: Outcome = { kind: 'refused', code: 'PERMISSION_DENIED', status: 403 };
63+
64+
const outcomeOf = (p: Promise<unknown>): Promise<Outcome> =>
65+
p.then(
66+
() => ADMITTED,
67+
(e: unknown) => {
68+
const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null;
69+
const status = x?.statusCode ?? x?.status;
70+
return {
71+
kind: 'refused' as const,
72+
code: x?.code == null ? undefined : String(x.code),
73+
status: status == null ? undefined : Number(status),
74+
};
75+
},
76+
);
77+
78+
/** See the explain-enforce-parity suite: a torn-down rig's async storages are released once its last row is judged. */
79+
const releaseAsyncContexts = (...owners: object[]): void => {
80+
for (const owner of owners) {
81+
for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable();
82+
}
83+
};
84+
85+
async function boot(posture: TenancyPosture) {
86+
const OBJ = 'qa_bounded_record';
87+
const MASTER = 'qa_bounded_master';
88+
const DETAIL = 'qa_bounded_detail';
89+
const engine = new ObjectQL();
90+
engine.registerDriver(
91+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
92+
true,
93+
);
94+
await engine.init();
95+
const fields = {
96+
id: { name: 'id', type: 'text', primaryKey: true },
97+
name: { name: 'name', type: 'text' },
98+
note: { name: 'note', type: 'text' },
99+
owner_id: { name: 'owner_id', type: 'text' },
100+
created_by: { name: 'created_by', type: 'text' },
101+
};
102+
engine.registerApp({
103+
id: `com.objectstack.qa.delegated-write-bound-${posture}`,
104+
name: 'An on-behalf-of write is bounded by its delegator',
105+
version: '1.0.0',
106+
type: 'plugin',
107+
scope: 'system',
108+
objects: [
109+
SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet,
110+
SysOrganization, SysUser, SysMember, SysRecordShare,
111+
{ name: OBJ, label: 'Record', sharingModel: 'private', fields },
112+
{ name: MASTER, label: 'Master', sharingModel: 'private', fields },
113+
{
114+
name: DETAIL,
115+
label: 'Detail',
116+
sharingModel: 'controlled_by_parent',
117+
fields: {
118+
id: { name: 'id', type: 'text', primaryKey: true },
119+
note: { name: 'note', type: 'text' },
120+
created_by: { name: 'created_by', type: 'text' },
121+
master: { name: 'master', type: 'master_detail', required: true, reference: MASTER },
122+
},
123+
},
124+
],
125+
} as never);
126+
await engine.syncSchemas();
127+
128+
const e = engine as unknown as {
129+
insert: (o: string, row: Record<string, unknown>, opts: never) => Promise<unknown>;
130+
getDriver: (o: string) => { create: (o: string, row: Record<string, unknown>) => Promise<unknown> };
131+
logger: { warn: () => void };
132+
};
133+
await e.insert('sys_organization', { id: ALPHA, name: ALPHA, slug: ALPHA }, SYS);
134+
for (const id of [BOUNDED, PLAIN, OTHER]) {
135+
await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS);
136+
await e.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: ALPHA, role: 'member' }, SYS);
137+
}
138+
const org = { allowRead: true, allowEdit: true, allowDelete: true, readScope: 'org', writeScope: 'org' } as const;
139+
const sets = [
140+
...defaultPermissionSets,
141+
PermissionSetSchema.parse({
142+
name: 'qa_bounded_writer',
143+
objects: { [OBJ]: org, [MASTER]: org, [DETAIL]: { allowRead: true, allowEdit: true, allowDelete: true } },
144+
rowLevelSecurity: [
145+
{ name: 'qa_bounded_updates', object: OBJ, operation: 'update', using: "name != 'held'" },
146+
{ name: 'qa_bounded_deletes', object: OBJ, operation: 'delete', using: "name != 'held'" },
147+
{ name: 'qa_bounded_master_updates', object: MASTER, operation: 'update', using: "name != 'held'" },
148+
],
149+
}),
150+
PermissionSetSchema.parse({ name: 'qa_plain_writer', objects: { [OBJ]: org } }),
151+
];
152+
for (const name of ['qa_bounded_writer', 'qa_plain_writer']) {
153+
await e.getDriver('sys_permission_set').create('sys_permission_set', {
154+
id: `ps_${name}`, name, label: name, organization_id: null, managed_by: 'admin', active: true,
155+
object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]),
156+
});
157+
}
158+
for (const [user, set] of [[BOUNDED, 'qa_bounded_writer'], [PLAIN, 'qa_plain_writer']] as const) {
159+
await e.insert('sys_user_permission_set', {
160+
id: `ups_${user}`, user_id: user, permission_set: set, organization_id: ALPHA,
161+
}, SYS);
162+
}
163+
const row = (id: string, name: string, owner: string, creator: string) =>
164+
({ id, name, note: '', owner_id: owner, created_by: creator, organization_id: ALPHA });
165+
for (const r of [
166+
row('held_1', 'held', BOUNDED, BOUNDED),
167+
row('held_2', 'held', OTHER, BOUNDED),
168+
row('free_1', 'free', BOUNDED, BOUNDED),
169+
row('held_del_direct', 'held', BOUNDED, BOUNDED),
170+
row('held_del_agent', 'held', BOUNDED, BOUNDED),
171+
row('free_del_direct', 'free', BOUNDED, BOUNDED),
172+
row('free_del_agent', 'free', BOUNDED, BOUNDED),
173+
row('store_direct', 'free', BOUNDED, BOUNDED),
174+
row('store_agent', 'free', BOUNDED, BOUNDED),
175+
row('plain_1', 'x', OTHER, OTHER),
176+
row('plain_2', 'x', PLAIN, OTHER),
177+
]) await e.insert(OBJ, r, SYS);
178+
await e.insert(MASTER, row('m_held', 'held', BOUNDED, BOUNDED), SYS);
179+
await e.insert(MASTER, row('m_free', 'free', BOUNDED, BOUNDED), SYS);
180+
for (const [id, master] of [
181+
['d_held', 'm_held'], ['d_free', 'm_free'], ['d_held_del_direct', 'm_held'], ['d_held_del_agent', 'm_held'],
182+
] as const) {
183+
await e.insert(DETAIL, { id, note: '', created_by: BOUNDED, master, organization_id: ALPHA }, SYS);
184+
}
185+
186+
let security: { explain: (input: unknown, ctx: unknown) => Promise<ExplainDecision> } | undefined;
187+
let sharing: SharingService | undefined;
188+
const services: Record<string, unknown> = {
189+
manifest: { register: vi.fn() },
190+
objectql: engine,
191+
metadata: {
192+
get: async (type: string, name: string) =>
193+
type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null,
194+
list: async () => sets,
195+
},
196+
tenancy: { posture },
197+
get sharing() { return sharing; },
198+
};
199+
const ctx = {
200+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
201+
registerService: (name: string, svc: unknown) => {
202+
if (name === 'security') security = svc as typeof security;
203+
else services[name] = svc;
204+
},
205+
hook: () => undefined,
206+
getService: (name: string) => {
207+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
208+
return services[name];
209+
},
210+
};
211+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
212+
await plugin.init(ctx as never);
213+
await plugin.start(ctx as never);
214+
sharing = new SharingService({ engine: engine as never, securityService: () => security as never });
215+
engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' });
216+
// The row gates log the writes they refuse.
217+
vi.spyOn(e.logger, 'warn').mockImplementation(() => undefined);
218+
219+
/** The caller's context as a request assembles it: the user's session, and for an agent its OAuth ceiling. */
220+
const contextOf = async (userId: string, onBehalf: boolean) => {
221+
const authz = await resolveAuthzContext({
222+
userAuthGates: undefined,
223+
apiKeyLifecycle: undefined,
224+
ql: engine,
225+
headers: {},
226+
getSession: async () => ({
227+
user: { id: userId },
228+
session: { id: `sess_${userId}_${posture}`, userId, activeOrganizationId: ALPHA },
229+
}),
230+
tenancyPosture: posture,
231+
});
232+
return assembleExecutionContext({
233+
authz,
234+
oauth: onBehalf
235+
? { userId, scopes: ['data:write'], clientId: 'qa_agent_client', scopePermissions: ['mcp_agent_data_write'], delegatesActions: false }
236+
: undefined,
237+
localization: undefined,
238+
requestLocale: undefined,
239+
accessToken: undefined,
240+
} as never)!;
241+
};
242+
const objects = { OBJ, DETAIL } as const;
243+
type Obj = keyof typeof objects;
244+
let n = 0;
245+
246+
return {
247+
/** Explain's record verdict for the caller's own by-id write of `object/id`. */
248+
explainVisible: async (userId: string, onBehalf: boolean, operation: 'update' | 'delete', object: Obj, id: string) =>
249+
(await security!.explain({ object: objects[object], operation, recordId: id }, { ...(await contextOf(userId, onBehalf)) }))
250+
.record?.visible,
251+
update: async (userId: string, onBehalf: boolean, object: Obj, id: string) =>
252+
outcomeOf(engine.update(objects[object], { note: `n_${++n}` }, {
253+
where: { id }, context: { ...(await contextOf(userId, onBehalf)) },
254+
} as never)),
255+
/** A by-id update whose stored row carries a value the delegator's own update may not store. */
256+
storeHeld: async (userId: string, onBehalf: boolean, id: string) =>
257+
outcomeOf(engine.update(OBJ, { name: 'held' }, {
258+
where: { id }, context: { ...(await contextOf(userId, onBehalf)) },
259+
} as never)),
260+
remove: async (userId: string, onBehalf: boolean, object: Obj, id: string) =>
261+
outcomeOf(engine.delete(objects[object], { where: { id }, context: { ...(await contextOf(userId, onBehalf)) } } as never)),
262+
teardown: async () => {
263+
try { await engine.destroy(); } catch { /* noop */ }
264+
releaseAsyncContexts(engine, plugin);
265+
},
266+
};
267+
}
268+
269+
type Rig = Awaited<ReturnType<typeof boot>>;
270+
const rigs = new Map<TenancyPosture, Promise<Rig>>();
271+
const rigFor = (posture: TenancyPosture): Promise<Rig> => {
272+
let rig = rigs.get(posture);
273+
if (!rig) {
274+
rig = boot(posture);
275+
rigs.set(posture, rig);
276+
}
277+
return rig;
278+
};
279+
afterAll(async () => {
280+
for (const rig of rigs.values()) await (await rig.catch(() => undefined))?.teardown();
281+
});
282+
283+
const word = (o: Outcome): string => (o.kind === 'admitted' ? 'admitted' : `refused ${o.code} / ${o.status}`);
284+
285+
/**
286+
* One by-id write, asked by the delegator itself and by an agent on its
287+
* behalf. `rows` names the row each asks about (a delete that lands removes
288+
* its row, so each delete asker has its own).
289+
*/
290+
interface Cell {
291+
delegator: string;
292+
verb: 'update' | 'delete';
293+
object: 'OBJ' | 'DETAIL';
294+
rows: { direct: string; agent: string };
295+
shape: string;
296+
direct: Outcome;
297+
agent: Outcome;
298+
}
299+
300+
const CELLS: Cell[] = [
301+
{
302+
delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'held_1', agent: 'held_1' },
303+
shape: 'a row the delegator\'s own update refuses', direct: DENIED, agent: DENIED,
304+
},
305+
{
306+
delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'held_2', agent: 'held_2' },
307+
shape: 'a second row the delegator\'s own update refuses', direct: DENIED, agent: DENIED,
308+
},
309+
{
310+
delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'free_1', agent: 'free_1' },
311+
shape: 'the control: a row the delegator\'s own update admits', direct: ADMITTED, agent: ADMITTED,
312+
},
313+
{
314+
delegator: BOUNDED, verb: 'delete', object: 'OBJ', rows: { direct: 'held_del_direct', agent: 'held_del_agent' },
315+
shape: 'a row the delegator\'s own delete refuses', direct: DENIED, agent: DENIED,
316+
},
317+
{
318+
delegator: BOUNDED, verb: 'delete', object: 'OBJ', rows: { direct: 'free_del_direct', agent: 'free_del_agent' },
319+
shape: 'the control: a row the delegator\'s own delete admits', direct: ADMITTED, agent: ADMITTED,
320+
},
321+
{
322+
delegator: BOUNDED, verb: 'update', object: 'DETAIL', rows: { direct: 'd_held', agent: 'd_held' },
323+
shape: 'a detail whose master refuses the delegator\'s own detail update', direct: DENIED, agent: DENIED,
324+
},
325+
{
326+
delegator: BOUNDED, verb: 'delete', object: 'DETAIL', rows: { direct: 'd_held_del_direct', agent: 'd_held_del_agent' },
327+
shape: 'a detail whose master refuses the delegator\'s own detail delete', direct: DENIED, agent: DENIED,
328+
},
329+
{
330+
delegator: BOUNDED, verb: 'update', object: 'DETAIL', rows: { direct: 'd_free', agent: 'd_free' },
331+
shape: 'the control: a detail whose master admits the delegator\'s own detail update', direct: ADMITTED, agent: ADMITTED,
332+
},
333+
// The bound only narrows: rows the delegator's own write admits and the
334+
// delegated write has always refused stay refused.
335+
{
336+
delegator: PLAIN, verb: 'update', object: 'OBJ', rows: { direct: 'plain_1', agent: 'plain_1' },
337+
shape: 'unchanged: a row the delegator\'s own update admits and the delegated update refuses', direct: ADMITTED, agent: DENIED,
338+
},
339+
{
340+
delegator: PLAIN, verb: 'update', object: 'OBJ', rows: { direct: 'plain_2', agent: 'plain_2' },
341+
shape: 'unchanged: a second row the delegator\'s own update admits and the delegated update refuses', direct: ADMITTED, agent: DENIED,
342+
},
343+
];
344+
345+
describe('an on-behalf-of by-id write never admits a row its delegator\'s own by-id write refuses (ADR-0090 D10)', () => {
346+
for (const posture of ['isolated', 'single'] as const) {
347+
for (const c of CELLS) {
348+
it(`\`${posture}\` · ${c.verb} · ${c.shape} → the delegator's own: ${word(c.direct)}; on its behalf: ${word(c.agent)}`, async () => {
349+
const rig = await rigFor(posture);
350+
const ask = (onBehalf: boolean, id: string) =>
351+
c.verb === 'update' ? rig.update(c.delegator, onBehalf, c.object, id) : rig.remove(c.delegator, onBehalf, c.object, id);
352+
// Explain first: a delete that lands removes the row it explains.
353+
const explainedDirect = await rig.explainVisible(c.delegator, false, c.verb, c.object, c.rows.direct);
354+
const explainedAgent = await rig.explainVisible(c.delegator, true, c.verb, c.object, c.rows.agent);
355+
const direct = await ask(false, c.rows.direct);
356+
const agent = await ask(true, c.rows.agent);
357+
expect(direct, 'the delegator\'s own write').toStrictEqual(c.direct);
358+
expect(agent, 'the write on its behalf').toStrictEqual(c.agent);
359+
// Explain's record verdict is the door's, for both callers.
360+
expect(explainedDirect, 'explain, the delegator\'s own write').toBe(c.direct.kind === 'admitted');
361+
expect(explainedAgent, 'explain, the write on its behalf').toBe(c.agent.kind === 'admitted');
362+
}, 60_000);
363+
}
364+
365+
it(`\`${posture}\` · update · a row the delegator's own update may not store → refused on its behalf as for the delegator`, async () => {
366+
const rig = await rigFor(posture);
367+
expect(await rig.storeHeld(BOUNDED, false, 'store_direct'), 'the delegator\'s own write').toStrictEqual(DENIED);
368+
expect(await rig.storeHeld(BOUNDED, true, 'store_agent'), 'the write on its behalf').toStrictEqual(DENIED);
369+
}, 60_000);
370+
}
371+
});

0 commit comments

Comments
 (0)