Skip to content

Commit 1ecb871

Browse files
fix(plugin-audit,plugin-approvals)!: refuse a query over activity text, ledger snapshots or the approval snapshot for a reader withheld a field of its record (#21154) (#21179)
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
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-audit': minor
3+
'@objectstack/plugin-approvals': minor
4+
---
5+
6+
fix(plugin-audit,plugin-approvals)!: a query over the activity stream's value-bearing columns, the compliance ledger's before/after snapshots, or an approval request's snapshot is refused for a reader withheld a field of the objects the query can reach — the one parent object it names, or every object when it names none (#21154)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling, type or shape: no packages/spec schema, object definition, export or stored metadata moves, and neither package's public exports change. What changes is which READ queries three engine middlewares answer: a filter, sort, search, grouping or aggregation that names a value-bearing column of sys_activity or sys_audit_log, or the snapshot column of sys_approval_request, is refused with 403 PERMISSION_DENIED for a non-system reader withheld a field of the objects the query can reach (the one parent object it names, or every registered object when it names none). objectstack migrate meta has nothing to rewrite: which object a caller meant to query is the caller's decision. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers a query refusal and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
11+
12+
**BREAKING**: an accept-set narrowing on three engine read middlewares, shipped as `minor` under the launch-window convention.
13+
14+
**What was wrong.** An activity row carries field values of the record it is about in three columns: its one-line summary, its record label and its recorded change. A compliance-ledger row carries them in its before and after snapshots. An approval request carries the submitted record's snapshot. A read-time redaction narrows such values on the rows a reader is SERVED, after the driver has answered. A filter over the same columns was evaluated at rest, before that, so row presence answered whether the stored text held a value the reader is served masked or not at all, one guess at a time. A grouping by one of them handed the stored text back as the group key.
15+
16+
**What is refused now.** For a non-system caller, on every door that reaches these objects through the engine (the list and query doors, record export, and any other `find` / `count` / `aggregate`), a query that filters, searches, sorts, groups or aggregates by one of those columns is refused with `403 PERMISSION_DENIED`, in the engine's own words for a field the caller may not query, unless the caller is served every field, as the security service answers it, of the objects the query can reach:
17+
18+
- when the query names exactly one parent object, by equality on the column that names it, at the root of its filter (or inside a root `$and`): that object;
19+
- when it names none: every object registered in the deployment, the set these rows can concern, read from metadata and never from the rows.
20+
21+
A grouping or aggregation by such a column answers the engine's aggregate refusal; every other position answers its predicate refusal. Both are followed by one sentence naming the remedy.
22+
23+
**Who is affected.** A caller withheld any field of the parent object (served masked, gated by a capability it does not hold, or not granted by its permission sets) can no longer filter, search, sort or group by those columns of that object's activity rows, ledger rows or approval requests. A caller withheld any field of any registered object can no longer do so in a query that names no parent object, or names one only inside an alternative — that includes a free-text search over the activity stream or the compliance ledger, whose searched sets include those columns. A caller served every field of the parent it names, or, for a query naming none, of every object (an administrator in a stock deployment), queries as before; the latter costs three security-service calls per registered object per such query.
24+
25+
**One-line fix:** a caller withheld some field names one parent object it is served in full, by equality in the query's filter; for a parent it is withheld a field of, it reads the rows unfiltered by those columns.
26+
27+
**Unchanged.** A query that names none of those columns answers as before, for every caller. System-context reads are not judged. A deployment without the security service answers as before: the columns are served whole there, so a filter over them discloses nothing the rows do not. The approvals service door's own search keeps its own rule for the snapshot.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
1010
service self-write, a migration.
1111

1212
This page is **the authority** for what that flag actually does. It exists
13-
because the flag is not one concept: it is a single boolean read at **111
13+
because the flag is not one concept: it is a single boolean read at **113
1414
distinct sites across 19 packages**, and knowing three of those behaviours gives
1515
no hint that the other hundred-and-four exist. Every documented app-side bug
1616
traced to `isSystem` had the same shape — the metadata was complete and correct,
@@ -113,7 +113,7 @@ that silently does not happen.
113113
| 14 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `packages/plugins/plugin-security/src/permission-set-overlay-discard.ts#assertTenantAdmin` |
114114
| 15 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `packages/mcp/src/stdio-data-bridge.ts#enforceApiExposure` |
115115
| 16 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `packages/plugins/plugin-audit/src/read-audit.ts#installReadAuditWriter` |
116-
| 17 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware` |
116+
| 17 | Approval snapshot payload redaction skipped, and so is the snapshot query guard | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel — and a filter, sort or grouping by it on any read. Lose: field-visibility redaction over approval payloads, and the refusal of a query over the snapshot for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-approvals/src/payload-redaction-middleware.ts#bindSnapshotRedactionMiddleware`, `packages/plugins/plugin-approvals/src/payload-predicate-guard.ts#bindSnapshotPredicateGuard` |
117117
| 18 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `packages/rest/src/rest-server.ts#enforceAuth` |
118118

119119
### 2. Write pipeline and data integrity
@@ -138,7 +138,7 @@ that silently does not happen.
138138

139139
### 3. Sharing (`plugin-sharing`)
140140

141-
The largest single consumer — **17 of the 111 sites**.
141+
The largest single consumer — **17 of the 113 sites**.
142142

143143
| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
144144
|:--|:---|:---|:---|
@@ -161,7 +161,7 @@ The largest single consumer — **17 of the 111 sites**.
161161
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` |
162162
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor`, `#resolveActor`, `#sendBack`, `#resubmit`, `#reassign`, `#remind`, `#requestInfo`, `#comment` |
163163
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` |
164-
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity read gate (its read AST), the activity field redaction and the audit-log field redaction (the rows a read serves) | plugin-audit | Get: the whole activity row on `find` / `findOne` — the audit writer and the read gate's own pre-scan — and the whole `sys_audit_log` row, its before/after snapshots included. Lose: comment visibility scoping, the narrowing of `sys_activity` to rows whose parent record the caller can read, and the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction` |
164+
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity read gate (its read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row on `find` / `findOne` — the audit writer and the read gate's own pre-scan — and the whole `sys_audit_log` row, its before/after snapshots included, and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` |
165165
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` |
166166

167167
### 5. Actions, metadata plane, provenance, the organization wall
@@ -281,7 +281,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
281281
independent decisions, and a seed loader plausibly wants the first two but not
282282
the third. The concept is nevertheless **staying as one boolean**:
283283

284-
- **Shipped semantics.** `isSystem` is a published contract with 111 read sites
284+
- **Shipped semantics.** `isSystem` is a published contract with 113 read sites
285285
in 19 packages. Splitting it is a breaking contract change across all of them.
286286
(The ruling was taken when the census read 80 sites in 18 packages; the count
287287
has grown, which strengthens rather than weakens the argument.)
@@ -353,18 +353,18 @@ still holds equal to the census on every pull request:
353353
| — in tests | 1013 | — |
354354
| — in non-test sources | 798 | — |
355355
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
356-
| — parsed as a declaration | 22 | ✅ |
356+
| — parsed as a declaration | 23 | ✅ |
357357
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
358-
| — parsed as a property **read** | 117 | ✅ |
358+
| — parsed as a property **read** | 119 | ✅ |
359359
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
360360
| — the remainder: text inside comments and string literals | 358 | — |
361361
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
362-
| Of those reads: reads of `ExecutionContext.isSystem` | **111** | ✅ |
363-
| — behaviour-bearing (rows 1–61 above) | 108 | ✅ |
362+
| Of those reads: reads of `ExecutionContext.isSystem` | **113** | ✅ |
363+
| — behaviour-bearing (rows 1–61 above) | 110 | ✅ |
364364
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
365365
| Packages containing at least one elevation read | **19** | ✅ |
366-
| Files containing at least one elevation read | 49 | ✅ |
367-
| — the distinct symbols those reads live in — what this page anchors | 94 | ✅ |
366+
| Files containing at least one elevation read | 51 | ✅ |
367+
| — the distinct symbols those reads live in — what this page anchors | 96 | ✅ |
368368
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
369369

370370
The six rows marked — are a **dated decomposition, not a live claim**: they were
@@ -428,7 +428,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
428428
Renaming a symbol is now a loud red instead of a silent misdirection.
429429

430430
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
431-
cannot say WHICH read inside a function it means, and **8** of the **49**
431+
cannot say WHICH read inside a function it means, and **8** of the **51**
432432
anchored files hold more than one read inside a single symbol. So the population
433433
check runs per file at symbol granularity: every file the census finds a read in
434434
must be anchored, and the set of symbols this page cites into that file must

‎packages/plugins/plugin-approvals/src/approvals-plugin.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
unbindAllHooks,
2828
} from './lifecycle-hooks.js';
2929
import { bindSnapshotRedactionMiddleware } from './payload-redaction-middleware.js';
30+
import { bindSnapshotPredicateGuard } from './payload-predicate-guard.js';
3031
import type { FieldVisibilitySource } from './payload-redaction.js';
3132
import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
3233

@@ -280,6 +281,10 @@ export class ApprovalsServicePlugin implements Plugin {
280281
// `payload-redaction-middleware.ts` on why one door covered alone is
281282
// worse than none.
282283
bindSnapshotRedactionMiddleware(engine as any, fieldVisibility, ctx.logger);
284+
// [#21154] …and its query half: the redaction narrows only what a read
285+
// hands back, so a filter or a group key over the snapshot would select
286+
// on a value the reader is not served. See `payload-predicate-guard.ts`.
287+
bindSnapshotPredicateGuard(engine as any, fieldVisibility, ctx.logger);
283288
} catch (err: any) {
284289
ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
285290
}

0 commit comments

Comments
 (0)