Repository navigation
Commit 1ecb871
Fixes #21154
Clause-②: no (narrowing)
A filter, search, sort, grouping or aggregation over a column that
carries another record's field values is now refused, in the engine's
own refusal shape, for a non-system reader whose security answer
withholds any field of an object the query can concern: the one parent
object it names, or, when it names none, any object registered in the
engine. Before this, the read-time redactions narrowed only what a row
SERVES, so the predicate selected on the stored value at rest: row
presence answered a probe one guess at a time, and a group key handed
the stored text back. Triage ruling `5929903631` (option A), applied to
every member the measurement reached (claim `5930307463`, amended
`5931964383`); the unpinned rule per the seat's decisions `5933122330`
(patch round 1).
## The family, by member
| Member | Columns (by class) | Measured before | Folded here |
|:--|:--|:--|:--|
| Activity stream | the text, the record label, the recorded change |
predicate answers by row presence; grouping returns the stored value |
yes (the card) |
| Approval request, generic data door | the submitted-record snapshot |
predicate answers by row presence; grouping returns the stored snapshot
| yes (H4) |
| Compliance ledger | the before and after snapshots | predicate answers
by row presence; grouping returns the stored value | yes (amendment,
seat 2's note `5931903481`) |
## Per-class table — generic data door, real boot
Readers of #21081's three field classes (a field served masked, a
capability-gated field with no mask, a field the reader's sets do not
grant), one reader per class, plus the unrestricted reader as the
control. Every restricted class read the same in every row, so the
classes share a row; each class has its own pins. Status and code only.
| Reader | Column class | Probe | Before | After |
|:--|:--|:--|:--|:--|
| each restricted class | activity text / label / change | matching
filter, parent named | 200, rows | 403 `PERMISSION_DENIED` |
| each restricted class | activity text / label / change | non-matching
filter, parent named | 200, no rows | 403 `PERMISSION_DENIED` |
| each restricted class | activity text / label / change | filter naming
no parent | 200, rows or none | 403 `PERMISSION_DENIED` (withheld a
field of a registered object) |
| each restricted class | activity text / label / change | grouping
(query door) | 200, stored value as the group key | 403
`PERMISSION_DENIED` (aggregate words) |
| each restricted class | activity text / label / change | sort | 200 |
403 `PERMISSION_DENIED` |
| each restricted class | activity (searched set) | free-text search,
matching or not | 200, rows or none | 403 `PERMISSION_DENIED` |
| each restricted class | ledger before / after | matching and
non-matching filter, parent named or not | 200, rows or none | 403
`PERMISSION_DENIED` |
| each restricted class | ledger before / after | grouping; sort; search
| 200 (grouping returns the stored value) | 403 `PERMISSION_DENIED` |
| each restricted class | approval snapshot | matching and non-matching
filter, parent named or not | 200, rows or none | 403
`PERMISSION_DENIED` |
| each restricted class | approval snapshot | grouping | 200, stored
snapshot as the group key | 403 `PERMISSION_DENIED` (aggregate words) |
| restricted reader, on a parent it is served in full | activity label,
ledger snapshot | matching and non-matching filter | 200, rows or none |
200, rows or none (unchanged) |
| control (unrestricted) | every column above, parent named | matching
and non-matching filter, grouping, sort, search | 200, rows or none |
200, rows or none (unchanged) |
| control (unrestricted) | every column above, no parent named | filter,
search | 200, rows or none | 200, rows or none (unchanged: withheld
nothing on any registered object, see H2) |
| any reader | a query naming no value-bearing column | list | 200 | 200
(unchanged) |
| any restricted class | activity columns | analytics door, grouping
dimension or filter | 200, stored value | 403 `PERMISSION_DENIED` after
#21080 (merged here, measured) |
H5, the refusal matched: the engine's own answer for a non-queryable
field of the parent object, measured on the same boot, is 403
`PERMISSION_DENIED` with its predicate words (a filter or sort) or its
aggregate words (a grouping). The new refusals carry the same code,
status (`status` and `statusCode`) and first sentence, naming the
guarded column, followed by one sentence naming the remedy. They name
nothing about the parent's fields.
## H2 — which parent, and why
Rule: a query that pins ONE parent object, by equality on the
parent-object column at the root of its filter (or inside a root
`$and`), is answered when the reader is served every field of it. A
query that pins none is answered only when the reader is served every
field of every object registered in the engine (the declared set, patch
round 1), and is refused otherwise, whenever the security service is
wired.
Why not the parents the query reaches: the only per-query knowledge of
parents is the activity read gate's pre-scan, which runs the caller's
own filter. Its parents are those of the rows a query MATCHES, so a
matching probe would reach the withholding parent and be refused while a
non-matching probe reached none and answered an empty list: the refusal
itself would be the oracle, and the ruling's "matching and non-matching
both refused" pin fails. The unpinned set is therefore read from
metadata, never from rows: every object registered in the engine, the
writers' reach and a superset of it (the CRUD mirror skips only its own
exclusion list, which has grown over time, so rows about an object it
now skips can be at rest). A reader withheld nothing anywhere is
answered; any other reader is refused, matching probe or not.
"Served every field" is read from the serve seams themselves, never
derived a second way: the activity redaction's served set (also used for
the ledger) and the approval redaction's served set, asked as the
caller, compared with the security service's read projection as the
system. A serve seam's "no answer" (no security service) passes, since
the rows are then served whole.
## Mechanism
- `plugin-audit`: `parent-field-query-guard.ts`, one engine read
middleware per object (activity stream, compliance ledger), registered
with ONE call in `AuditPlugin`, ahead of the activity read gate so a
refused query pays no pre-scan. `activity-field-redaction.ts`,
`audit-writers.ts` and `index.ts` are not changed.
- `plugin-approvals`: `payload-predicate-guard.ts`, registered beside
the snapshot redaction middleware.
- Clauses judged: the engine predicate guard's set — `where` and
`having` (keys and cross-field comparands), `orderBy`, `groupBy`,
`aggregations` (field and filter). A free-text search arrives expanded
into `where`. The projection is not judged.
- Operations: `find`, `findOne`, `count`, `aggregate`. System-context
and context-less reads are not judged.
## Pins (synthetic fixtures; no title states a value or a column)
- `plugin-audit` `activity-predicate-guard.test.ts` (41) and
`audit-log-predicate-guard.test.ts` (28): real kernel, engine, driver
and plugin, security double; per class a matching and a non-matching
probe refused, count, grouping, sort and search refused, the control and
the served-in-full parent unchanged, a reader served every field of
every registered object filtering and searching with no parent named as
before, no-security unchanged, fail-closed, and the guard's column list
held equal to the redaction's own strip.
- `plugin-approvals` `payload-predicate-guard.test.ts` (20): real engine
and driver, guard mounted by the plugin's own `start`; a reader served
every field of every registered object filters with no subject named as
before.
- dogfood `activity-text-predicate.dogfood.test.ts` (22): real boot with
the real security plugin, one reader per class plus the control, all
three members, armed before measuring; the platform admin, granted the
fixture's two capability sets, filters all three members with no parent
named as before. dogfood `audit-log-admin-search.dogfood.test.ts` (3):
stock showcase boot with the audit and approvals plugins; the seeded
admin's unpinned ledger list search and activity search, matching and
not, and an unpinned approval-snapshot filter answer as before.
## Security order and ablation (predictions written before each run)
| Step | Commit | Reading |
|:--|:--|:--|
| pins red (activity, approvals) | `931c596892` | audit 29 failed / 8
passed; approvals 9 / 8; dogfood 13 / 4 — as predicted |
| fix | `bc50d08de9` | 40 / 19 / 17 passed |
| ablation 1: both refusals removed, `plugin-audit` rebuilt, removal
proven in `dist/` | — | 29 / 11; 9 / 10; 13 / 4 failed / passed — as
predicted |
| restore 1 | — | both files' blob equals `HEAD`, `git diff HEAD` empty,
porcelain empty; rebuilt, ablation marker absent from `dist/`; 40 / 19 /
17 passed |
| ledger pins red | `dd178fcfd3` | ledger 19 failed / 8 passed; dogfood
4 / 17 — as predicted |
| ledger fix, one guard over both objects | `dcb051ea7c` | 40 / 27 / 19
/ 21 passed |
| ablation 2 | — | 29 / 11; 19 / 8; 9 / 10; 17 / 4 failed / passed — as
predicted |
| restore 2 | — | blob equals `HEAD` for both, diff and porcelain empty;
rebuilt, marker absent; 40 / 27 / 19 / 21 passed |
## Local gates at `dcb051ea7c`
One locked script: the 68 families `dispatch-gates --commands` derives
from this diff, reconciled with `--ran` (68 derived, 68 run, exit codes
recorded, 0 not measured), plus every touched package's tests and
typecheck and the dogfood suite.
- 67 of 68 families exit 0; `check:system-context-census` exits 1 (see
Acceptance notes).
- `plugin-audit` 478 / 478 and typecheck 0; `plugin-approvals` 823 / 823
and typecheck 0; dogfood typecheck 0; dogfood pins 38 / 38; dogfood
suite 1302 passed, 3 skipped.
## Acceptance notes
- **`check:system-context-census` is green.** The census page carries
the two guards' registration anchors (the plugin-audit row and the
approvals row) and its counts are regenerated with the page's own
tooling (patch round 1, the seat's Q2).
- **Analytics door, closed by #21080.** Before #21080 the ad-hoc
analytics query over the activity stream grouped by a value-bearing
column and returned the stored value, because its native-SQL strategy
ran no engine read middleware. #21080 routes objects carrying a read
middleware through the engine, and it is merged here: re-measured, each
restricted class's analytics grouping and filter answer 403
`PERMISSION_DENIED`, the control 200.
- **The no-parent rule follows the declared set.** A query naming no
parent object is answered for a reader served every field of every
registered object (the stock admin, measured: withheld nothing on any of
86) and refused for a reader withheld any field anywhere; it costs 3
security-service calls per registered object per such query (258 on the
stock showcase, 2 to 4 ms in process).
- **PR #21171 landed and is merged here.** The served-set composition is
imported from `served-fields.ts`, its new home; `audit-plugin.ts` keeps
one registration; the pins were re-run green.
- Filter-array spelling: a single-condition array reaches the guard
(refused); a compound array spelling is refused earlier as malformed.
- A pin by membership (`$in`) or by more than one object is not
accepted; that would be a widening.
- The clause walk is restated in the two plugins because neither depends
on the security plugin's predicate guard. Carrier: none.
- The no-middleware-seam warning in `AuditPlugin` is unchanged
(one-registration constraint); without the seam the guard is not
installed, like the read gate and the redaction.
- The approvals service door's own free-text rule (#11040) is unchanged.
- The branch merged `main` twice in patch round 1 (no rebase), last as
`ee558beae2`; `main` commits after that touch no file of this diff and
merge cleanly with it.
## Patch round 1
The seat decided both open questions (comment `5933122330`): Q1, a query
naming no parent object is admitted for a reader withheld nothing on any
object those rows can concern, and refused otherwise; Q2, the two
guards' census anchors land here. Commits `e8ca01c861` (pins),
`0232077761` (fix), `8907accb83` (census), `d0e1c4d8ba` (pin
correction), merges `89fc4580a2` and `ee558beae2`; pushed together after
the ablation.
### Q1 — a query naming no parent object
- **Rule.** A query that pins one parent object keeps the round-0 rule.
A query that pins none is judged against the **declared set**: every
object registered in the engine, read from metadata and never from rows,
so the set is the same whatever the query and whatever rows it would
match (not the read gate's pre-scan). The pinned query's own per-object
question (the redaction's served set asked as the caller, against the
security service's read projection asked as the system) is asked of each
object in turn; the first object that withholds a field refuses the
query, in the same words as before. An engine that cannot list its
objects refuses.
- **Why every registered object.** The CRUD mirror is registered on
every object but its own exclusion list, and that list has grown over
time, so rows about an object it now skips can still be at rest; an
approval snapshot can concern any object an approval process names. The
set is the writers' reach and a superset of it, which can only refuse
more.
- **Which fields count.** Every field: a create or delete snapshot
records the whole stored record, and rows written before the mirror
began masking credential fields are still at rest. The per-object answer
is the serve seam's own; masking is not derived a second time.
- **(D) before (B), measured first** on a stock showcase boot with its
seeded admin, before the fix: the engine registers 86 objects (80 inside
the CRUD mirror's scope), and the admin is withheld no field on any of
the 86. (D) keeps the admin's unpinned search, so (B) was not built.
Before the fix, that admin's unpinned ledger search, activity search and
approval-snapshot filter each answered 403.
- **Cost**, same boot, after the fix, 20 requests per door after one
warm-up: an unpinned query naming a value-bearing column adds 258
security-service calls (3 per registered object) for a reader withheld
nothing; a restricted reader stops at the first object that withholds.
In process the 258 calls take 4 ms cold and 2 ms warm. End to end per
request: unpinned ledger search 75.5 ms, ledger list naming no such
column 78.7 ms, pinned ledger filter 64.5 ms, unpinned activity search
107.6 ms, unpinned approval filter 77.5 ms; the admission sits inside
request-to-request variation. A pinned query is unchanged (3 calls); a
query naming no value-bearing column asks the guard nothing.
### Pins, security order and ablation (prediction written before each
run)
| Step | Commit | Reading |
|:--|:--|:--|
| pins red: 7 admin/control pins (a reader served every field of every
object filters and searches each member with no parent named, in the
activity, ledger and approvals suites and for the dogfood fixture's
platform admin over all three; and
`audit-log-admin-search.dogfood.test.ts`, the stock showcase admin's
unpinned ledger list search and activity search, matching and not, and
approval-snapshot filter) | `e8ca01c861` | audit 1 failed / 40 passed;
ledger 1 / 27; approvals 1 / 19; dogfood fixture 1 / 21; showcase 3 / 0
— as predicted |
| fix | `0232077761` | 41 / 28 / 20 passed, showcase 3 / 3; the dogfood
fixture's admin pin stayed red (1 / 21): that admin was not granted the
fixture's two capability sets, so it is withheld a field and the rule
refused it correctly. The pin's premise was wrong, not the fix |
| pin correction: the fixture grants its admin those two sets;
prediction re-recorded before the run | `d0e1c4d8ba` | 41 / 28 / 20 / 22
/ 3 passed |
| ablation: the admission arm replaced by an unconditional refusal in
both guards (`scripts/ablation-replace.mjs`, anchor 1 to 0 in each),
`plugin-audit` rebuilt, the marker proven present in `dist/` and the arm
absent | — | exactly the 7 admin/control pins red (1 / 1 / 1 / 1 / 3);
every other pin green — as predicted |
| restore, by absolute path | — | both blobs equal `HEAD`, `git diff
HEAD` empty, porcelain empty; rebuilt, marker absent from `dist/`, arm
present; 41 / 28 / 20 / 22 / 3 passed |
Each restricted class stays refused, pinned and unpinned, on every
member: the round-0 pins, unchanged and green at every step (the
ablation refuses everyone, so they stay green under it, as predicted).
### Q2 — the census anchors
The plugin-approvals row (17) now names
`payload-predicate-guard.ts#bindSnapshotPredicateGuard` and the
plugin-audit row (45)
`parent-field-query-guard.ts#installParentFieldQueryGuard`, in the
page's anchor spelling; the counts are regenerated by `pnpm
gen:system-context-census` (113 reads, 51 files, 96 symbols), not
hand-edited. `check:system-context-census` exits 0.
### Merges (no rebase)
- `89fc4580a2` merges `main` with #21171: the served-set composition is
imported from `served-fields.ts`, its new home.
`activity-field-redaction.ts` and `audit-writers.ts` are not changed;
`audit-plugin.ts` keeps one registration.
- `ee558beae2` merges `main` with #21080, which routes these objects off
the analytics native-SQL path. Re-measured on the real boot: each
restricted class's analytics grouping and filter over an activity
value-bearing column answers 403 `PERMISSION_DENIED`; the control
answers 200.
### Local gates at `ee558beae2`
One locked script, 108 commands. `dispatch-gates --commands` derives 95
families from the final diff; reconciled with `--ran`: 95 run, 0 not
measured. Two of them, `check:skill-examples` and
`check:dual-build-cjs-loads`, first answered PREREQUISITE NOT MET
(packages unbuilt in this worktree); built under the lock and re-run,
both exit 0. Every other command exits 0.
- `plugin-audit` 510 / 510 (33 files), typecheck 0; `plugin-approvals`
824 / 824 (53 files), typecheck 0; dogfood typecheck 0; dogfood pins 50
/ 50 (6 files); dogfood suite 1327 passed, 3 skipped.
- Roster families `check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity` and `check-changeset-fixed` exit 0.
`AGENTS.md` was not written.
### CI at `ee558beae2`
Converged green on this head: 12 workflow runs, 11 success and 1 skipped
(the opt-in pack smoke); 35 check runs, 33 success and 2 skipped
(Console Pin Gate, the opt-in packed-tarball smoke), 0 failed — among
them Test Core 6 / 6 shards, Dogfood Regression Gate 3 / 3 shards,
Dogfood Verify CLI, Lint & Repo Gates, the four Type Check lanes, Build
Core, Build Docs, Check Changeset and Temporal Conformance.
### Acceptance notes (round 1)
- The admission's cost grows with the number of registered objects: 3
security-service calls per object per unpinned query over these columns,
not cached across queries. Carrier: none.
- `main` moved after the last merge; its new commits touch no file of
this diff and merge cleanly with it.
_The seat appended this section from the dev's patch-round report
(`5937092567` on #21154), and replaced the round-0 lines it made stale._
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbcd20c commit 1ecb871
11 files changed
Lines changed: 2336 additions & 12 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- plugins
- plugin-approvals/src
- plugin-audit/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
113 | 113 | | |
114 | 114 | | |
115 | 115 | | |
116 | | - | |
| 116 | + | |
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
| |||
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
141 | | - | |
| 141 | + | |
142 | 142 | | |
143 | 143 | | |
144 | 144 | | |
| |||
161 | 161 | | |
162 | 162 | | |
163 | 163 | | |
164 | | - | |
| 164 | + | |
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
| |||
281 | 281 | | |
282 | 282 | | |
283 | 283 | | |
284 | | - | |
| 284 | + | |
285 | 285 | | |
286 | 286 | | |
287 | 287 | | |
| |||
353 | 353 | | |
354 | 354 | | |
355 | 355 | | |
356 | | - | |
| 356 | + | |
357 | 357 | | |
358 | | - | |
| 358 | + | |
359 | 359 | | |
360 | 360 | | |
361 | 361 | | |
362 | | - | |
363 | | - | |
| 362 | + | |
| 363 | + | |
364 | 364 | | |
365 | 365 | | |
366 | | - | |
367 | | - | |
| 366 | + | |
| 367 | + | |
368 | 368 | | |
369 | 369 | | |
370 | 370 | | |
| |||
428 | 428 | | |
429 | 429 | | |
430 | 430 | | |
431 | | - | |
| 431 | + | |
432 | 432 | | |
433 | 433 | | |
434 | 434 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
| |||
280 | 281 | | |
281 | 282 | | |
282 | 283 | | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
283 | 288 | | |
284 | 289 | | |
285 | 290 | | |
| |||
0 commit comments