Skip to content

Commit b1d701b

Browse files
fix(plugin-security,spec): explain decides whether a record story discloses a record by the asker's read, not the explained principal's (#22847)
Fixes #22792 Clause-②: no This PR delivers item 2 of the card, built to ruling A of the explain-asker decision (ruling record `6107185582` on #22795, maintainer 「同意」; director pointer `6107202712`). Item 1 landed as #22813, so this PR closes the card. Claim `6108485513`. ## What this changes Class: explain's disclosure of a record's existence. Positions: the record pass of the explain engine (`packages/plugins/plugin-security/src/explain-engine.ts`), the caller-authorized explain (`explainAccessForCaller` in `security-plugin.ts`), and two description strings in `packages/spec/src/security/explain.zod.ts`. - **The asker gate.** Every record-grained explanation first asks the read door's own by-id read, `recordAbsentToCaller` (the question the write doors ask), with the ASKER's context. An asker the door does not return the record to gets, for every operation, exactly what an id no row carries gets: `record` with no decider, and every layer's per-record attribution the missing record's, through the one missing-record path #22791 built. There is no exemption beside the door's answer: a system asker is asked too, and the door returns it every row. - **An asker who can read the record** gets the explained principal's full record story: - `read` and `export`: the principal's own verdict, decider included (item 1's master-leg verdict among them), unchanged. - a by-id `update`, `delete` or `transfer` (through `rlsOp`) of a row the principal cannot read: `visible: false`, because the principal's write answers what a nonexistent id answers, with the decider of the principal's READ. Each row-scoped layer carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`, so the decider and the layer trace agree (the spec defines the decider as the layer whose `record.outcome` excluded the record). - The ruling's confidence gap, measured: one write-operation pass cannot yield the read's decider. It composes the write's row-level security, the write gate, the write depth and the modify bypass, and none of them is the read's (shown below, where the write composition admits the row and the read excludes it). So a second, read-operation pass of the engine yields it, asked only in that arm. - **Self-explanation** (the asker is the explained principal): an unreadable row is the missing record for every verb. On the write verbs that is what #22791 already answered. On `read` and `export` it is new: the card's item 2. - **Batch** (`recordIds`): the REST route answers each id through the singular service call, so it follows by its own definition. Pinned below. - **Spec text**: the `record` verdict's description and the `recordIds` "Missing records" clause now say that an id the asker cannot read answers as missing, alongside filtered, deleted and never-existed ids. Description text only; the reference page is regenerated by the spec's own tooling (`check:generated --fix`, which ran `gen:docs`). ### No exported type moves (Clause-② no) - The asker reaches the engine through a module-internal entry, `explainAccessForAsker` in `explain-engine.ts`, which the package index does not export. The published `explainAccess(deps, input)` keeps its signature and answers a self-explanation. `ExplainEngineDeps`, `ExplainInput` and `SecurityPlugin.explainAccessForCaller` keep their signatures (comment-only diffs there); `src/index.ts` is untouched. - Measured on the built declaration (the package rebuilt at `842f1ca03`): `dist/index.d.ts` still declares `explainAccess(deps: ExplainEngineDeps, input: ExplainInput)`, its export list carries no new name, and `explainAccessForAsker` occurs 0 times in `dist/index.d.ts` and `dist/index.d.mts`. - Why not a per-call binding of `recordAbsentToCaller`: the engine asks that question of the explained principal too (the by-id write guard, item 1's master leg), so rebinding it to the asker would judge the principal's own outcome by the asker's read. The engine needs both contexts at once, and the internal entry carries the second one without widening a published type. ## Pins New file `packages/plugins/plugin-security/src/explain-asker-gate.test.ts`, 42 cases. The real `SecurityPlugin` over a real `ObjectQL`, the SQL driver and the platform grant tables, on one `public_read_write` object. The explained member's read policy hides one row and its write-class policies admit every row, so the write composition alone narrows nothing the read does. - Armed: who reads which row, and the member's three write doors answer `404 RECORD_NOT_FOUND` for the hidden row and for a nonexistent id. - An asker who cannot read the row (the member itself; a `manage_users` administrator held to the member's read; a D12 delegated administrator held to it, whose boundary check is stubbed): for `read`, `export`, `update`, `delete` and `transfer`, the hidden row's whole explanation equals the nonexistent id's, layers included. - A `manage_users` administrator who reads the row: `read` is the member's verdict with its decider; each write verb is `visible: false` decided by the member's READ (`rls`), the `rls` layer carries the read policy's predicate (never the write composition's), every row-scoped layer carries the read preface, and the only status any layer names is the door's `404`. Both equal the system asker's answer. - Controls: a nonexistent id is the missing record for every asker; a row every asker reads gets one answer whoever asks. - Engine level: which contexts the question is asked with, and how often (once per context per explanation); a system asker is asked like any other; a rejection of the asker's question fails the explanation (an outage is neither absence nor a row); and the write arm's decider is the READ's where the write composition admits the row. Also: `explain-controlled-by-parent-write.test.ts` gains the write arm on a `controlled_by_parent` record (decided by the principal's read on the master leg, the master write check never reached, no layer naming its `403`; control: a record the principal reads keeps the check's refusal and its `403`). `cbp-explain-master-write.dogfood.test.ts` (the family's REST parity pin) gains, beside the GET door, an administrator who reads the record (the steward's verdict, decider included) and the batch case (each `records[i]` equals the singular verdict, for the steward and for the administrator). ## Pin sweep Whole-repo grep for the same semantics (record-grained explain answers for a row the asking principal cannot read, every `decidedBy` assertion beside an explain call) over plugin-security, plugin-sharing, rest, client, spec and the dogfood suite. Flipped, each to a load-bearing assertion of the new answer: - `explain-controlled-by-parent-write.test.ts`, the read block: item 1's verdict cells now explain to an asker who reads the record, so the decider stays pinned where ruling A keeps it; new self-explanation cells pin the missing record's whole answer (equal to the no-row explanation), and the read-question cells pin which contexts are asked. - `explain-cross-class-refusal.test.ts` and `explain-json-column-refusal.test.ts`, the controls: a self-explained read of a row the caller cannot read now pins the missing record (no decider, the `rls` record not evaluated, the whole explanation equal to a nonexistent id's), matching the write side those cells already pinned. - `cbp-explain-master-write.dogfood.test.ts`, the read row: the steward explaining itself the unreadable record pins the missing record, whole. Kept verbatim: every refusal-shape assertion (the `INVALID_FILTER` refusals, the door statuses), #22791's record-story pins (they are self-explanations and hold as written), and `explain-write-door-parity.dogfood.test.ts` (self-explanations; its object-gate cells keep `decidedBy: 'object_crud'`, which precedes the missing-record branch). ## Ablation (one-time, no permanent file) Each leg through `scripts/ablation-replace.mjs` in wrap mode (the anchor hit 1 then 0, the blob changed, restore proven by blob equality with HEAD and an empty `git diff HEAD`). The plugin-security suites import `src/`; the dogfood suite reads `dist/`, so its legs rebuilt the package, read the marker in `dist/` with `ablation-dist-preflight.mjs` before the run, and after the restore rebuilt and read the marker absent with a clean tree. | leg | mutation | red | green | |---|---|---|---| | A | the asker gate off | 48 of 192 in five suites (every cannot-read cell, the engine read-question cells, the flipped controls, #22791's self-explanation pins) and 2 of 20 dogfood (the self-explained read, the batch) | every control and every can-read cell | | B | the write arm's read pass off | 10 of 141 (the write arm's decider, layer and status cells, plugin and engine, and the `controlled_by_parent` write arm) | everything else | | C | the caller passes no asker | 7 of 42 (every can-read cell) and 2 of 20 dogfood (the administrator's read, the batch) | every cannot-read cell and control | Leg A ran on `842f1ca03`, after the asker check lost its system-asker exemption (a system asker is asked like any other, and the read door returns it every row); B and C ran on `46dcaa54a`; between it and the head the engine changed only by that exemption, and the `explainAccessForCaller` call is unchanged. Quoted, record ids elided: `… to deeply equal { Object (recordId, visible) }` (A, a cannot-read cell, told the stored row's verdict with its decider) and `explain read for the steward: expected { recordId: ID, …(1) } to deeply equal { recordId: ID, …(2) }` (C, the dogfood administrator cell, told the missing record). ## Verification All at `842f1ca03` unless named (the branch merged `origin/main` up to `d8c7d3864`; a full workspace build ran after the merge). - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: 198 files passed, 4057 tests passed, 45 skipped, exit 0. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 (tsc, scripts, and the test layer, `check:test-typecheck: OK`; `tsconfig.test.json` includes `src/**/*`). - `pnpm --filter @objectstack/dogfood exec vitest run test/cbp-explain-master-write.dogfood.test.ts test/explain-write-door-parity.dogfood.test.ts`: 2 files, 43 passed, against the package's rebuilt `dist/`. `pnpm --filter @objectstack/dogfood typecheck`: exit 0 at `8cf36e560` (the dogfood file is unchanged since). - At `8cf36e560` (the spec and rest sides are unchanged since): `@objectstack/spec` `src/security/explain.test.ts` and `explain-zero-rows-sentinels.pin.test.ts`, 38 passed; `@objectstack/rest` `cross-class-refusal-remedy-on-the-wire`, `security-explain-envelope` and `security-routes`, 47 passed. - `pnpm --filter @objectstack/spec check:generated` named one stale artifact (the explain reference page); `--fix` rebuilt the spec, ran `gen:docs` and re-checked it green. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 115; all 115 ran, every one exit 0, each exit code captured before any pipe (`check:generated`, `check:docs`, `check:api-surface`, `check:nul-bytes`, `check:system-context-census`, `check:empty-changeset`, `check:dual-build-cjs-loads`, `check:i18n` and the `check:type-check-debt` re-measure among them). `--ran` over the `command :: exit code` record: 115 derived, 115 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. - Lint, narrowed and proved: (1) population, read from eslint's own config: all 8 touched TypeScript files resolve under `eslint --print-config`, and the changeset and the generated `.mdx` match no configuration; (2) `eslint --no-inline-config --format json` over those 8 files: 8 files, 0 errors, 0 warnings; (3) invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. Repo-wide `pnpm lint` is declared to CI. - Control-byte self-scan over the changed files: 0 hits. - Not run locally, declared to CI: repo-wide lint, the full dogfood suite, the path-scheduled jobs. ## Acceptance notes - **The read question is now asked for every record-grained explanation**, of the asker (and, in the write arm, of the principal), memoized per explanation: one caller-context by-id read more per explanation than before on most verbs. A second engine pass runs only in the write arm for an asker who reads the record. - **A principal refused at the object gate** whose row is absent to it now gets the missing record's layers too. Its verdict is unchanged (the capability and CRUD deciders precede the missing-record branch). - **The published `explainAccess`** (a self-explanation, for a direct embedder that wires `recordAbsentToCaller`) answers a `read` or `export` of a row the principal cannot read as missing, as the security service now does. - **Drift case, NOT MEASURED as reachable:** if the principal's read story admits a row its read door withholds (a leg explain does not model), the write arm's verdict carries no decider, and the layers show that read's admitting attribution under the preface. - **Item 1's pending release note** (`.changeset/22792-explain-read-master-leg.md`) says a withheld `controlled_by_parent` record "is reported not visible, decided by the `sharing` layer". Under ruling A that holds for an asker who can read the record. This PR leaves that note untouched (correcting a pending note is a release decision, and the empty-changeset gate keeps such a PR red) and its own note states the qualification. If the seat prefers the pending note corrected in place, that is the gate's deliberate-correction path. - **Open question for the seat (not built): a refused asker.** Under the ruled notion, `recordAbsentToCaller` answers `true` only for absence; a declared 4xx refusal of the asker's own read (for example, no read grant on the object at all) answers `false`, so that asker is answered as one who can read. Its read door answers the same refusal for a stored row and a nonexistent id, while explain answers them differently. The ruling's headline and its D12 bullet read the other way; its sub-question 4 names this exact question. Built as ruled; the options are in the report. ### Patch round: ruling C of #22851 (decision record `6110031776`) - **What changed.** The asker gate classifies the asker's caller-context by-id read by asker kind, on that one read: - An asker explaining another user (a `manage_users` administrator, a D12 delegate): no row, or a declared 4xx refusal of its own read, is "cannot read", and it gets, for every operation, the answer an id no row carries gets. Position: `unreadableUnderCallerRead` in `security-plugin.ts`, beside `absentUnderCallerRead`, over the shared `readUnlessRefused` classification; a store fault still fails the explanation. - A principal explaining itself keeps the write path's notion (`recordAbsentToCaller`: absent means no row), so a refused read leaves its explanation as its own doors answer. - The engine carries the asker as `{ context, cannotRead }` on the module-internal `explainAccessForAsker` (its `ExplainAsker` type is not exported from the package index). `explainAccess`, `ExplainEngineDeps`, `ExplainInput` and `SecurityPlugin.explainAccessForCaller` keep their signatures. Measured on the built `dist/index.d.ts` at `026209205`: none of the new names occurs, and `explainAccess` is declared as before. `Clause-②: no` holds. - **Spec text and changeset.** The `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids each asker cannot read (explaining another principal: no row, or a refusal; a principal explaining itself: no row, a refused read not counting). The reference page is regenerated with the spec's own `gen:docs`; the changeset says the same. - **Pins** (`explain-asker-gate.test.ts`, now 62 cases): - a `manage_users` administrator and a D12 delegate whose own reads of the object are refused, for `read`, `export`, `update`, `delete` and `transfer`: a stored row's whole explanation equals a nonexistent id's, layers included (a row the explained user cannot read, and one it reads); - CONTROL: a principal that may edit the object but not read it, explaining itself, keeps the stored row's story (equal to a system asker's account of that principal) and, on the write verbs, `visible: true` beside its own door's admission; - CONTROL: a reading administrator and a nonexistent id are unchanged; - engine level: the third-party classification decides for another principal's explainer, a self-explanation is never asked it, and a store fault in it fails the explanation. - **A CI regression in this round, its cause and fix.** `explain-enforce-parity.test.ts` went red at `6cc56c243` (7 of 98). Cause: that rig's explaining administrator held `manage_users` and no read grant on the probed tenant object, so its own by-id read of the record is refused (measured: a declared 4xx). Under ruling C that asker is told the nonexistent id's answer, while those cells hold explain's record verdict against the explained user's door. The classification is right: a third-party asker, read with its own context, genuinely refused. Fix, fixture only: the rig's administrator also holds the reader set the explained users hold, so it reads what it explains and the record cells keep measuring the explained user's story. The five failing record cells are byte-identical. The two `single`-posture record rows for the other organization's row (a measured divergence, asserted as a disagreement) turned to parity, because the asker's own read is scoped to the context organization the same way; per the file's own rule those rows joined the invariant (their divergence marker removed), and the `readFilter` divergence row stays. - **Ablation** (`scripts/ablation-replace.mjs`; restore proven by blob equality with HEAD and an empty `git diff HEAD`; the subject resolves through `src/`, so no `dist/` leg): the plugin's refused-asker classification put back to the absence notion turned 10 of 161 red (every refused-asker cell) with every control green; the engine's third-party branch switched to the write path's question turned 12 of 161 red (those 10 and the two engine classification cells) with the controls green. Both at `6cc56c243`; the mutated lines are unchanged at the head. - **Hand-written page re-verified** (`content/docs/permissions/explain.mdx`, listed by the Docs Drift Check): read "What a decision looks like" (lines 15-45), "Calling it" (47-84), "Who may ask" (86-100) and "What it powers" (102-112). It names no record verdict, no `recordId` or `recordIds`, and no missing-record definition; its "another user" rule is the request's authorization, which rulings A and C leave as it is. Every sentence holds, so it is not edited. - **Verification at `026209205`** (the branch merged `origin/main` to `0253192f7` through `scripts/pm/os-regen-merge.sh`, then a full workspace build): the plugin-security suite, 197 files, 4045 passed, 45 skipped; plugin-security typecheck exit 0; dogfood `cbp-explain-master-write` and `explain-write-door-parity`, 43 passed, and dogfood typecheck exit 0; gates, 115 derived by `dispatch-gates --commands`, all 115 run and exit 0 (`check:docs` and `check:generated` among them), `--ran` 0 NOT-MEASURED and 0 UNRUN; narrowed lint, 9 files, 0 errors and 0 warnings (population read from eslint's own config, no type-aware linting); control-byte scan 0. - **Acceptance note update:** the body's "Open question for the seat: a refused asker" is answered by ruling C and built in this round. --- _Generated by [Claude Code](https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 40bc51f commit b1d701b

11 files changed

Lines changed: 1062 additions & 63 deletions
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+
'@objectstack/spec': patch
4+
---
5+
6+
`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.
7+
8+
Clause-②: no
9+
10+
- **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.
11+
- 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).
12+
- 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.
13+
- 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.
14+
- **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.
15+
- A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does.
16+
- `@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.
17+
- Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged.

‎content/docs/references/security/explain.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
108108
| **principal** | `{ userId: string \| null; positions?: string[]; permissionSets?: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | |
109109
| **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; … }[]` | ✅ | |
110110
| **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. |
111-
| **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. |
111+
| **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. |
112112
| **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. |
113113

114114
### Nested Shape: `ExplainDecision.principal`

0 commit comments

Comments
 (0)