Skip to content

Commit c11b758

Browse files
fix(plugin-security): explain's read verdict on a controlled_by_parent record takes the read door's answer on the master leg (#22813)
Part of #22792 This PR delivers item 1 of the card; item 2 (ruling A on #22795) stays on the card, and #22792 remains open for item 2. Clause-②: no ## What this changes Class: explain-versus-door parity, the `read` verb. The family's triage record on #22530 pinned every write verb; this widens that pin to `read`. Position: `applyRecordAttribution`'s `read` branch in `packages/plugins/plugin-security/src/explain-engine.ts`. For a `controlled_by_parent` record, the read door scopes the find by the record's master as well as by its own row-level security (ADR-0055). explain's `read` branch modelled the record's own row-level security only, so its record verdict could disagree with the read door. It now asks the read door's own by-id read for that leg, and its verdict equals the door's answer: - **Asked when:** a record-grained `read` (or `export`, which streams the same find) of a record that exists, on a `controlled_by_parent` object, past the capability and object gates, where every leg the report already models (the tenant wall, the record's business row-level security, the sharing read filter) admits the record. - **Asked how:** through the existing `ExplainEngineDeps.recordAbsentToCaller`, the caller-context by-id read the write doors already ask, with the EXPLAINED context. No second copy of the master derivation. - **The answer:** a record the door withholds is `visible: false`, decided by the `sharing` layer (the layer the write verbs' master check names), with that layer's record `excluded`. A record the door returns leaves the report byte-identical. A rejection is reported fail-closed (`not_evaluated`, no predicate), as every other dependency fault on the record path is. - **Unchanged:** every object that is not `controlled_by_parent`, every write verb (the read-absent path of the by-id writes is not touched), object-level reports, `allowed`, and the answer for an id no row carries. The verdict keeps a decider; whether it should take the nonexistent-id shape is item 2's round, not this PR. ### Why the existing dependency and not a new one The PM route pointed at the read door's master-aware read (the security plugin's master-derived read filter). Reaching it directly would add an optional key to the exported `ExplainEngineDeps` type. On this family's own precedent (the update half, which added `checkControlledByParentWrite`) that is a widening of a published type, `Clause-②: yes (widening)` with a `minor` changeset. The claim and this dispatch declare `Clause-②: no` with a `patch`. `recordAbsentToCaller` is the read door itself, so asking it is parity by construction, and no public type moves. Only its docblock changes, to say it is now asked for such a read too. ## Pins | pin | where | what it holds | |:--|:--|:--| | **Family enumeration, REST** (the pin the #22530 triage record named, widened in place) | `packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts` | The per-verb door table now gives `read` a by-id read door (`GET`) on the fixture's private-master detail object. Three cells, each beside that door: a record under a master the principal cannot read (door `404 RECORD_NOT_FOUND`, explain `visible: false`, decided by `sharing`); CONTROL, a record under a master the principal reads (door `200`, explain `visible: true`); CONTROL, an id no row carries (door `404 RECORD_NOT_FOUND`, explain unchanged, no decider). The table stays total against `ExplainOperationSchema`. | | **Engine, per verb** (the engine's verb classification, widened in place) | `packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts` | `read` and `export` are classified `read_door_asked`. For each: a withheld record is not visible on `sharing`; a returned one is byte-identical to the report without the question; a rejection is fail-closed with no predicate; asked once with the explained context, and the write check is not asked; not asked where the record's own RLS or the tenant wall already decides; not asked on a `private` or `public_read_write` object, nor for an object-level request or a missing record; `create` is not asked. | No new test file. The registered-service enumeration (`controlled-by-parent-write-member.test.ts`) is left as it was; see Acceptance notes. ## Measurement and ablation - **Before** (a temporary, uncommitted plugin-level harness over a real `ObjectQL`, SQL driver and `SharingService`, on `179f7bf6c`, which equals `680a86b4c` for `plugin-security`): the read door withheld the record and explain's `read` verdict disagreed with it. PM assumption 1 holds. **After** (same harness, the changed source): the verdict equals the door for the subject and both controls. - **Ablation** (one leg, `scripts/ablation-replace.mjs` in wrap mode, anchor 1 to 0, blob changed, under an outer `trap` restoring to `HEAD` on EXIT, INT and TERM): the read master leg switched off behind a marker. Then `pnpm --filter @objectstack/plugin-security build`, then `scripts/ablation-dist-preflight.mjs` read the marker in 2 built files. Predicted: the engine's withheld, rejection and asked-once cells red for `read` and `export`, and the REST subject cell red, with every control green. Observed: engine **6 red**, 67 green; REST **1 red** (the subject cell), 17 green. Restore leg: blob equals `HEAD`, `git diff HEAD` empty, rebuilt, and the preflight with `--absent` read the marker absent from all 6 built files with the tree clean. ## Local verification (head `eb4274251`, after merging `origin/main` `098481744`) - `pnpm --filter @objectstack/plugin-security exec vitest run`: 199 files, 4053 passed, 45 skipped (run on `5ad8ac3f5`, before the merge, which touched no file of this package). On `eb4274251`: the three explain and master-check files, 107 passed; the widened dogfood file, 18 passed. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. `pnpm --filter @objectstack/dogfood typecheck`: exit 0, after a full workspace build. - `node scripts/pm/dispatch-gates.mjs --commands`: 70 derived commands, all run on `eb4274251`, all exit 0. Two first answered `PREREQUISITE NOT MET` (exit 3, not a measurement) and were re-run green after the full build. `--ran`: 70 derived, 70 run, 0 NOT-MEASURED. - Lint, narrowed to the three touched TypeScript files and proved. All three are in eslint's population (`--print-config` resolves each), the changeset is outside its `files` globs, and `--format json` counts 3 files with 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting, so this diff cannot move the verdict on any file it does not touch. - Not run locally, declared to CI: the repository-wide `pnpm lint`, the full dogfood suite (only the touched file ran), and the path-scheduled CI jobs. ## Acceptance notes - **Delegated reads.** On an on-behalf-of read the door also composes the delegator's scope. The record path still does not model the delegator's row-level security on its own, so when the door withholds such a record the sharing layer names the master and says that the delegator's scope is composed in too. The visible verdict is the door's either way. Not filed: no measurement. - **The registered-service enumeration** (`controlled-by-parent-write-member.test.ts`, `VERB_ROWS`) still classifies `read` as `no_by_id_write`, which stays literally true. Its fixture has no private master, so a read-door row there needs a fixture change. The family's REST and engine enumerations carry `read`. Carrier: none. - **The object-level `readFilter`** on a `read` explanation is the `rls` layer's artifact (`computeRlsFilter`). The service's `getReadFilter` also ANDs the master-derived scope and the sharing filter. This is an observation from reading code, not measured at a door, and it is the object-level question, not this card's record verdict. Not filed. Carrier: none. - **Wiring comment.** The comment beside the `recordAbsentToCaller` wiring in `security-plugin.ts` still calls it the write path's read question. It is accurate, but it no longer covers every caller. Left alone to keep the diff inside the declared territory. --- _Generated by [Claude Code](https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e078506 commit c11b758

4 files changed

Lines changed: 249 additions & 18 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`security/explain`'s record-grained `read` verdict for a `controlled_by_parent` record now equals the read door's answer on the master leg (ADR-0055).
6+
7+
- **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way.
8+
- **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. Every write verb. The answer for an id no row carries. The `ExplainEngineDeps` type: the `recordAbsentToCaller` dependency keeps its signature and is now asked for such a read too.

‎packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts‎

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323
// record's write gate), naming the leg or the reason; a rejection fails the
2424
// request, so it is reported fail-closed; an outcome outside the vocabulary
2525
// refuses too.
26+
//
27+
// A READ of such a record meets a master leg as well: the read door scopes the
28+
// find by the master ids the principal can read. The write check does not
29+
// answer a read, so explain asks the read door's own by-id read
30+
// (`recordAbsentToCaller`) where every leg it models admits the record, and
31+
// takes its answer. The last block pins that half: a record the door withholds
32+
// is not visible on the `sharing` layer, a record the door returns leaves the
33+
// report exactly as before, and a rejection is reported fail-closed. The REST
34+
// answer beside the REST read door is pinned in the same dogfood suite.
2635
import { describe, it, expect, vi } from 'vitest';
2736
import { PermissionSetSchema } from '@objectstack/spec/security';
2837
import { ExplainOperationSchema, type ExplainDecision, type ExplainOperation } from '@objectstack/spec/security';
@@ -39,21 +48,26 @@ const CBP_SCHEMA = { name: OBJECT, sharingModel: 'controlled_by_parent' };
3948

4049
const EDITOR = PermissionSetSchema.parse({
4150
name: 'cbx_editor',
42-
objects: { [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true } },
51+
objects: {
52+
[OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true, allowExport: true },
53+
},
4354
});
4455

4556
/** The principal being EXPLAINED. Kept as one object so a cell can assert the check received this very context. */
4657
const EXPLAINED = { userId: 'u_member', tenantId: 'org1', positions: ['org_member'], permissions: ['cbx_editor'] };
4758
const RECORD = { id: 'r1', organization_id: 'org1', owner_id: 'u_somebody' };
4859

4960
type Master = ExplainEngineDeps['checkControlledByParentWrite'];
61+
type ReadDoor = ExplainEngineDeps['recordAbsentToCaller'];
5062

5163
function deps(opts: {
5264
schema?: Record<string, unknown>;
5365
master?: Master;
5466
canEdit?: boolean;
5567
record?: Record<string, unknown> | null;
5668
layer1?: Record<string, unknown> | null;
69+
/** The read door's own by-id read: does it withhold the record from the explained principal? */
70+
readDoor?: ReadDoor;
5771
} = {}): ExplainEngineDeps {
5872
return {
5973
ql: { getSchema: () => opts.schema ?? CBP_SCHEMA },
@@ -77,6 +91,7 @@ function deps(opts: {
7791
canEditRecord: async () => opts.canEdit ?? true,
7892
canDeleteRecord: async () => opts.canEdit ?? true,
7993
...(opts.master ? { checkControlledByParentWrite: opts.master } : {}),
94+
...(opts.readDoor ? { recordAbsentToCaller: opts.readDoor } : {}),
8095
};
8196
}
8297

@@ -100,17 +115,23 @@ const explainAs = (d: ExplainEngineDeps, operation: ExplainOperation, recordId:
100115
* are retired until the lifecycle batch returns them). The check is still
101116
* asked, so the report stays the door's on the day the verb is grantable,
102117
* and the object-level CRUD layer decides first.
103-
* - `not_asked`: a read (the check guards writes), and `create`, whose master
104-
* comes from the request body an explanation does not carry.
118+
* - `read_door_asked`: a read, and an export, which streams the same find.
119+
* The check guards writes and is not asked; the read door's master leg is,
120+
* through the read door's own by-id read (the last block below).
121+
* - `not_asked`: `create`, whose master comes from the request body an
122+
* explanation does not carry.
105123
*/
106-
const VERB_CLASS: Record<ExplainOperation, 'master_checked' | 'master_checked_object_gate_first' | 'not_asked'> = {
124+
const VERB_CLASS: Record<
125+
ExplainOperation,
126+
'master_checked' | 'master_checked_object_gate_first' | 'read_door_asked' | 'not_asked'
127+
> = {
107128
update: 'master_checked',
108129
delete: 'master_checked',
109130
transfer: 'master_checked',
110131
restore: 'master_checked_object_gate_first',
111132
purge: 'master_checked_object_gate_first',
112-
read: 'not_asked',
113-
export: 'not_asked',
133+
read: 'read_door_asked',
134+
export: 'read_door_asked',
114135
create: 'not_asked',
115136
};
116137
const MASTER_CHECKED = (Object.keys(VERB_CLASS) as ExplainOperation[]).filter((v) => VERB_CLASS[v] === 'master_checked');
@@ -243,7 +264,9 @@ describe('[ADR-0055] which verbs ask the check, and for whom', () => {
243264
});
244265
}
245266

246-
for (const verb of (Object.keys(VERB_CLASS) as ExplainOperation[]).filter((v) => VERB_CLASS[v] === 'not_asked')) {
267+
for (const verb of (Object.keys(VERB_CLASS) as ExplainOperation[]).filter(
268+
(v) => VERB_CLASS[v] === 'not_asked' || VERB_CLASS[v] === 'read_door_asked',
269+
)) {
247270
it(`a record-grained ${verb} does not ask it`, async () => {
248271
const master = answering({ outcome: 'deny', leg: 'object_permission' });
249272
await explainAs(deps({ master }), verb);
@@ -262,3 +285,83 @@ describe('[ADR-0055] which verbs ask the check, and for whom', () => {
262285
expect(master).not.toHaveBeenCalled();
263286
});
264287
});
288+
289+
const READ_DOOR_ASKED = (Object.keys(VERB_CLASS) as ExplainOperation[]).filter((v) => VERB_CLASS[v] === 'read_door_asked');
290+
291+
/** The read door's answer: `true` withholds the record from the explained principal, `false` returns it. */
292+
const readDoorAnswering = (absent: boolean): ReadDoor => vi.fn(async () => absent);
293+
294+
describe('[ADR-0055] a read of a controlled_by_parent record takes the read door\'s answer on the master leg', () => {
295+
for (const verb of READ_DOOR_ASKED) {
296+
it(`${verb}: a record the read door withholds is not visible, decided by the sharing layer`, async () => {
297+
const d = await explainAs(deps({ readDoor: readDoorAnswering(true) }), verb);
298+
expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' });
299+
expect(sharingRecordOf(d)?.outcome).toBe('excluded');
300+
});
301+
302+
it(`${verb}: a record the read door returns: byte-identical to the report without the question`, async () => {
303+
const without = await explainAs(deps(), verb);
304+
const withDoor = await explainAs(deps({ readDoor: readDoorAnswering(false) }), verb);
305+
expect(withDoor).toEqual(without);
306+
expect(withDoor.record).toMatchObject({ recordId: 'r1', visible: true });
307+
});
308+
309+
it(`${verb}: a rejection fails the find, so it is reported fail-closed with no predicate`, async () => {
310+
const fault = Object.assign(new Error('datasource unavailable'), { code: 'ERR_DATASOURCE_UNAVAILABLE', status: 503 });
311+
const readDoor: ReadDoor = vi.fn(async () => { throw fault; });
312+
const d = await explainAs(deps({ readDoor }), verb);
313+
expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' });
314+
const sharing = sharingRecordOf(d);
315+
expect(sharing?.outcome).toBe('not_evaluated');
316+
expect(sharing?.rowFilter).toBeUndefined();
317+
expect(sharing?.matchesRecord).toBeUndefined();
318+
});
319+
320+
it(`${verb}: asked once, for that record, with the EXPLAINED context, and the write check is not asked`, async () => {
321+
const readDoor = readDoorAnswering(false);
322+
const master = answering({ outcome: 'deny', leg: 'object_permission' });
323+
await explainAs(deps({ readDoor, master }), verb);
324+
expect(readDoor).toHaveBeenCalledTimes(1);
325+
expect(readDoor).toHaveBeenCalledWith(OBJECT, 'r1', EXPLAINED);
326+
expect((readDoor as ReturnType<typeof vi.fn>).mock.calls[0][2]).toBe(EXPLAINED);
327+
expect(master).not.toHaveBeenCalled();
328+
});
329+
330+
it(`${verb}: a leg this report models that withholds the record decides, and the read door is not asked`, async () => {
331+
const readDoor = readDoorAnswering(true);
332+
const byRls = await explainAs(deps({ readDoor, layer1: { id: 'not_this_record' } }), verb);
333+
expect(byRls.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'rls' });
334+
const byTenant = await explainAs(deps({ readDoor, record: { ...RECORD, organization_id: 'org2' } }), verb);
335+
expect(byTenant.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'tenant_isolation' });
336+
expect(readDoor).not.toHaveBeenCalled();
337+
});
338+
339+
for (const [label, schema] of [
340+
['public_read_write', { name: OBJECT, sharingModel: 'public_read_write' }],
341+
['private', { name: OBJECT, sharingModel: 'private' }],
342+
] as const) {
343+
it(`${verb}: not asked on a ${label} object, which derives nothing from a master: the report is unchanged`, async () => {
344+
const readDoor = readDoorAnswering(true);
345+
const without = await explainAs(deps({ schema }), verb);
346+
const withDoor = await explainAs(deps({ schema, readDoor }), verb);
347+
expect(withDoor).toEqual(without);
348+
expect(readDoor).not.toHaveBeenCalled();
349+
});
350+
}
351+
352+
it(`${verb}: an object-level request, and a record that does not exist, are not asked about`, async () => {
353+
const readDoor = readDoorAnswering(true);
354+
const objectLevel = await explainAs(deps({ readDoor }), verb, null);
355+
expect(objectLevel.record).toBeUndefined();
356+
const missing = await explainAs(deps({ readDoor, record: null }), verb, 'r_missing');
357+
expect(missing.record).toEqual({ recordId: 'r_missing', visible: false });
358+
expect(readDoor).not.toHaveBeenCalled();
359+
});
360+
}
361+
362+
it('a create, which addresses no stored record, is not asked about', async () => {
363+
const readDoor = readDoorAnswering(true);
364+
await explainAs(deps({ readDoor }), 'create');
365+
expect(readDoor).not.toHaveBeenCalled();
366+
});
367+
});

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,12 @@ export interface ExplainEngineDeps {
419419
* delete, read through {@link rlsOperationForVerb}: `update`, `delete`, and
420420
* the lifecycle verbs that map onto them (`transfer` and `restore` onto
421421
* update, `purge` onto delete).
422+
*
423+
* [ADR-0055] Asked for a record-grained read too, of a
424+
* `controlled_by_parent` record the rest of the read's row story admits: the
425+
* read door also scopes such a record by its master, a leg no other
426+
* dependency here answers, so the door's own answer is what the read verdict
427+
* takes ({@link applyRecordAttribution}).
422428
*/
423429
recordAbsentToCaller?: (object: string, recordId: string, context: any) => Promise<boolean>;
424430
}
@@ -942,6 +948,7 @@ const MASTER_UNRESOLVED_DETAIL: Record<ControlledByParentWriteUnresolvedReason,
942948
};
943949

944950
const MASTER_DERIVED = 'Write access to this record derives from its master (controlled_by_parent, ADR-0055)';
951+
const MASTER_READ_DERIVED = 'Read access to this record derives from its master (controlled_by_parent, ADR-0055)';
945952

946953
/**
947954
* [ADR-0055] The record-grained writes whose verdict on a `controlled_by_parent`
@@ -1516,6 +1523,41 @@ async function applyRecordAttribution(
15161523
const masterRefusal = masterOrFault === undefined || masterOrFault === DEPENDENCY_FAULT
15171524
? undefined
15181525
: masterWriteCheckRefusal(masterOrFault, engineOp);
1526+
// [ADR-0055] A record-grained read of a `controlled_by_parent` record meets
1527+
// a master leg too: the read door ANDs the record's master-derived read scope
1528+
// (the master ids the principal can read, the chain included) into the find,
1529+
// beside the row-level security above, and the sharing service imposes
1530+
// nothing of its own on such an object (its effective model is `public`).
1531+
// No other dependency answers that leg, so the read door's own by-id read is
1532+
// asked (`recordAbsentToCaller`, the question the by-id writes ask), with the
1533+
// explained context, and only where every leg this report models admits the
1534+
// record: there the master leg is the one left that withholds it. A record
1535+
// the door withholds is reported NOT visible, as the door answers, on the
1536+
// sharing layer, the layer the write verbs' master check names. A rejection
1537+
// fails the find, so it is reported fail-closed.
1538+
const readStoryAdmits = isRead && recordExists && owd.model === 'controlled_by_parent'
1539+
&& !capsDeny && crudAllowed && !layeredFault && !readFilterFault
1540+
&& tenantRecord.outcome !== 'excluded'
1541+
&& !(layer1 != null && (isDenyAll(layer1) || matches(layer1) === false));
1542+
const masterReadOrFault = readStoryAdmits && deps.recordAbsentToCaller
1543+
? await settle(deps.recordAbsentToCaller(object, recordId, context))
1544+
: undefined;
1545+
const masterReadFault = masterReadOrFault === DEPENDENCY_FAULT;
1546+
const masterReadRefusal: { outcome: ExplainRecordAttribution['outcome']; detail: string } | undefined =
1547+
masterReadOrFault === true
1548+
? {
1549+
outcome: 'excluded',
1550+
detail: `${MASTER_READ_DERIVED}: the read door, asked by this principal, does not return the record, though ` +
1551+
"every row-level layer above admits it, so its master is outside the principal's read scope" +
1552+
(context?.onBehalfOf?.userId != null
1553+
? " (on this on-behalf-of read, the delegator's read scope is composed in as well)"
1554+
: '') +
1555+
'. The record is reported NOT visible, as the read door answers.',
1556+
}
1557+
: undefined;
1558+
// One master leg per verb: the write check for a by-id write, the read
1559+
// door's master scope for a read. At most one of the two is ever asked.
1560+
const masterLegRefusal = masterRefusal ?? masterReadRefusal;
15191561
// The sharing call THIS operation's row verdict rests on: the read filter for
15201562
// a read (the sharing middleware ANDs it into every find), the per-record gate
15211563
// for a write (the middleware gates every by-id write on it). Either one
@@ -1525,7 +1567,10 @@ async function applyRecordAttribution(
15251567
? (readFilterFault
15261568
? "The sharing service's read filter could not be evaluated: it threw, and the find fails on the same " +
15271569
'call — so the record is reported NOT visible (fail closed), never admitted.'
1528-
: undefined)
1570+
: masterReadFault
1571+
? `${MASTER_READ_DERIVED}, and the read door's own by-id read of it could not be evaluated: it threw, and ` +
1572+
'the find fails on the same call — so the record is reported NOT visible (fail closed), never admitted.'
1573+
: undefined)
15291574
: (writeGateFault
15301575
? `The sharing service's per-record ${engineOp === 'delete' ? 'delete' : 'update'} gate could not be ` +
15311576
'evaluated: it threw, and the by-id write fails on the same call — so the record is reported NOT ' +
@@ -1560,8 +1605,8 @@ async function applyRecordAttribution(
15601605
sharingOutcome = 'not_evaluated';
15611606
} else if (sharingFaultDetail) {
15621607
sharingOutcome = 'not_evaluated';
1563-
} else if (masterRefusal) {
1564-
sharingOutcome = masterRefusal.outcome;
1608+
} else if (masterLegRefusal) {
1609+
sharingOutcome = masterLegRefusal.outcome;
15651610
} else if (owd.effect !== 'private') {
15661611
sharingOutcome = 'not_evaluated'; // baseline already grants the rows sharing would add
15671612
} else if (canEdit !== undefined) {
@@ -1585,8 +1630,8 @@ async function applyRecordAttribution(
15851630
matchesRecord: sharingFaultDetail ? undefined : sharingMatches,
15861631
rules: shareRules,
15871632
// A fault is only ever recorded against an existing record, and the
1588-
// master check [ADR-0055] is only ever asked of one.
1589-
detail: sharingFaultDetail ?? masterRefusal?.detail ?? (!recordExists
1633+
// master leg [ADR-0055] is only ever asked of one.
1634+
detail: sharingFaultDetail ?? masterLegRefusal?.detail ?? (!recordExists
15901635
? 'Record not found; sharing not evaluated.'
15911636
: owd.effect !== 'private'
15921637
? 'Baseline is not private — sharing adds nothing beyond it for this record.'
@@ -1778,8 +1823,10 @@ async function applyRecordAttribution(
17781823
else if (sharingFaultDetail) { visible = false; decidedBy = 'sharing'; }
17791824
// [ADR-0055] The master check refuses after the record's own row-level
17801825
// security (step 2.8 runs below the pre-image gate), and names its leg on
1781-
// the sharing layer, the record's write gate.
1782-
else if (masterRefusal) { visible = false; decidedBy = 'sharing'; }
1826+
// the sharing layer, the record's write gate. A read's master leg is asked
1827+
// only where the record's own row-level security admits it, and is named on
1828+
// the same layer.
1829+
else if (masterLegRefusal) { visible = false; decidedBy = 'sharing'; }
17831830
else if (!businessRowAdmits) { visible = false; decidedBy = owd.effect === 'private' ? 'sharing' : 'owd_baseline'; }
17841831
else {
17851832
visible = true;

0 commit comments

Comments
 (0)