Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6eef23c
wip(plugin-security): explain's record story is gated on the asker's …
claude Oct 11, 2026
3f562d9
wip: asker gate pins, flipped self-explain read pins, spec descriptio…
claude Oct 11, 2026
9bae302
wip: discriminating pins for the write arm's read decider
claude Oct 11, 2026
4ae3a27
wip: the rig's write composition admits every board
claude Oct 11, 2026
c92fbf1
wip: changesets; dogfood read row asks who is asking; batch case
claude Oct 11, 2026
46dcaa5
wip: regenerate the explain reference page (gen:docs via check:genera…
claude Oct 11, 2026
ca39319
Merge remote-tracking branch 'origin/main' into claude/issue-22792-ex…
claude Oct 11, 2026
8cf36e5
Merge remote-tracking branch 'origin/main' into claude/issue-22792-ex…
claude Oct 11, 2026
4335f94
wip: leave item 1's pending release note as it is; this PR's note car…
claude Oct 11, 2026
842f1ca
fix(plugin-security): no system exemption beside the read door's own …
claude Oct 11, 2026
21df91d
Merge remote-tracking branch 'origin/main' into claude/issue-22792-ex…
claude Oct 11, 2026
349d8fc
wip(plugin-security): a third-party asker's refused read counts as ca…
claude Oct 11, 2026
6cc56c2
wip: ruling C pins (refused third-party askers; refused self-explanat…
claude Oct 11, 2026
781573f
test(plugin-security): the parity rig's explaining administrator read…
claude Oct 11, 2026
308bbb0
wip(spec): explain's record and Missing records text say which ids ea…
claude Oct 11, 2026
2de8d8a
wip: regenerate the explain reference page (gen:docs)
claude Oct 11, 2026
f64673b
Merge remote-tracking branch 'origin/main' into claude/issue-22792-ex…
claude Oct 11, 2026
0262092
Merge remote-tracking branch 'origin/main' into claude/issue-22792-ex…
claude Oct 11, 2026
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
17 changes: 17 additions & 0 deletions .changeset/22792-explain-asker-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-security': patch
'@objectstack/spec': patch
---

`security/explain` decides whether a record-grained explanation may say a record exists by the read access of whoever asked for it, not by the explained principal's.

Clause-②: no

- **An asker who cannot read the record** gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's.
- An asker explaining another user (`manage_users`, or a delegated administrator's scope) cannot read a record its own by-id read finds no row for, or refuses (a declared 4xx, such as no read grant on the object).
- A principal explaining itself cannot read a record its own by-id read finds no row for. A refused read does not count, so the explanation shows what that principal's own doors answer: a principal that may edit a record it may not read is told what its edit does.
- Before, a principal explaining itself a `read` of a row its read finds no row for was told the row's verdict and why, and an administrator with no read grant on the object was told any row's story; the by-id write verbs already answered a self-explanation of such a row as missing.
- **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer.
- A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does.
- `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids an asker cannot read, by asker, and that those answer as missing. Description text only; no schema shape changes.
- Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged.
2 changes: 1 addition & 1 deletion content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
| **principal** | `{ userId: string \| null; positions?: string[]; permissionSets?: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | |
| **layers** | `{ layer: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` | ✅ | |
| **readFilter** | `any` | optional | The composed row filter the caller would be served with — the machine artifact behind the prose (null = unrestricted). Two shapes mean zero rows — `{ id: "__deny_all__" }` and the fail-closed RLS denial (`__rls_deny__` plus a colon and a UUID-shaped suffix, which can also ride inside an $and composite) — so a consumer pattern-matching this payload alone must match both; the decision itself is allowed plus the rls layer verdict. |
| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. |
| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted. Explaining another principal, the asker cannot read an id its own by-id read finds no row for or refuses; a principal explaining itself cannot read an id its read finds no row for. |
| **records** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }[]` | optional | Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds. |

### Nested Shape: `ExplainDecision.principal`
Expand Down
Loading
Loading