Repository navigation
fix(plugin-audit): the compliance ledger's before/after snapshots serve a parent field only to a reader the security service serves that field (#21155) - #21171
Conversation
…ve a parent field only to a reader the security service serves that field A read-time middleware on the ledger narrows each record-write row's snapshots key by key to the parent fields the security service serves the reader unmasked. The served-fields composition moves into one helper that the activity stream's redaction now shares. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…s on the real engine Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…a real boot Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…edaction's early return Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…dger-snapshot-fields Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 72527786f31e4fef6fcd38183735da2ca6ff618f && git checkout 72527786f31e4fef6fcd38183735da2ca6ff618f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d34aa58a2affc87ded426dc6a326edb03534cb62 f5289158c7e1cf6d76254f469e5f8511629c07a6 && git checkout -B drift-repro d34aa58a2affc87ded426dc6a326edb03534cb62 && git merge --no-ff f5289158c7e1cf6d76254f469e5f8511629c07a6
node scripts/docs-audit/affected-docs.mjs --json d34aa58a2affc87ded426dc6a326edb03534cb62
|
…cord the admin's decisions, never a value of a user field (objectstack-ai#21174) (objectstack-ai#21195) Fixes objectstack-ai#21174 Clause-②: no The compliance-ledger rows that `plugin-auth`'s admin identity endpoints write themselves now record the admin's decisions and a reference to the user, never a value of a field of that user. The values those calls write into the user's fields stay where the CRUD mirror already records them: on its `create` and `update` rows for the same writes, in the before/after snapshot columns that PR objectstack-ai#21171 narrows per reader. This follows triage's direction on the card (`5932908541`): the row records the decision or a reference to the record, never a parent field value in free metadata, and there is no read-time mapping of decision names back to fields. ## Measured first, on a real boot Re-measured privately on `main` at `b9087d77` (PR objectstack-ai#21171 in). The stack was `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and auth layers (the admin plugin on), plus `AuditPlugin`. The seeded platform admin called the create-user and set-user-password doors. Readers read the ledger through the generic list door. Readings stay private to the dispatch; this table gives classes only. | Reader class | Mirror rows about the user (PR objectstack-ai#21171) | Explicit admin create row, before | After | |---|---|---|---| | A user field it is not granted (one reader per written field, four fields) | that field absent | that field's value in `metadata` | absent | | A user field served masked (layered by an object extension) | absent | value in `metadata` | absent | | A capability-gated user field, no mask (layered the same way) | absent | value in `metadata` | absent | | Platform read-only wildcard plus a withholding set | every class absent | every class's value in `metadata` | absent | | Unrestricted reader (control) | every value | every value in `metadata` | every value, through the mirror rows only | The set-user-password row carried the value of one written field the same way. The data plane answered every reader `404` for the created user, the control included (see the acceptance notes). ## What changed - `packages/plugins/plugin-auth/src/admin-user-endpoints.ts`: - `runAdminCreateUser` no longer copies the four values it writes into the user's fields into the row's `metadata`. `runAdminSetUserPassword` no longer copies the one it writes. - `writeAdminAudit` takes a closed `AdminAuditDecisions` type: the operation, whether the password was generated, whether the account's address is a generated placeholder, whether the membership was bound, and the bound organization as a reference. Each member is either a decision no field of the user stores, or a reference to another record. The row's reference to the user is its own `object_name` and `record_id`. - The header that justifies the explicit row, and the durability warning that lists its decisions, now say the same. - `.changeset/21174-admin-audit-metadata.md`: `patch` for `@objectstack/plugin-auth`, with the migration line for a reader that took a value from these rows. The type refuses a field value written as a literal key at compile time. A conditional spread passes TypeScript's excess-property check, so the type does not stop that spelling; the unit pins do. The ablations below measured both. ## Census: every non-mirror ledger writer in `domain:services` Every `sys_audit_log` insert outside the CRUD mirror, found by grepping the lane's packages (`plugin-auth`, `plugin-security`, `plugin-sharing`, `plugin-approvals`, `plugin-audit`, `plugin-webhooks`, `plugin-email`, `embedder-openai`, `knowledge-*`, `services/*`, `connectors/*`, `triggers/*`) for the ledger's name and for its constants. `plugin-sharing`, `plugin-approvals`, the connectors, the triggers and every other service name the ledger in comments only. | Writer | Row | What `metadata` carries | Verdict | |---|---|---|---| | `plugin-auth` `admin-user-endpoints.ts#writeAdminAudit`, create-user | `create` on the user | the decisions, plus four values written into the user's fields | **fixed here** | | same, set-user-password | `update` on the user | the decisions, plus one value written into the user | **fixed here** | | `plugin-auth` `admin-import-users.ts`, run-level row | `import`, `record_id` null | the run's mode, match key name and password policy, and counts | already clean: no record value | | `plugin-auth` `auth-session-audit.ts` events, written by `plugin-audit` `auth-event-audit.ts` | `login` / `logout` on the session | the endpoint path; on an impersonation session, the impersonating admin's id | clean of a field value: the id is a reference and is the row's own `actor` column. The client fingerprint rides the ledger's own columns, not `metadata` | | `plugin-audit` `read-audit.ts` | record-view rows | nothing | already clean | | `service-settings` `config-change-audit.ts` | `config_change`, `record_id` null | the setting's composite identity (its reference), a flag derived from the setting's declaration, the request id | already clean: the value rides the snapshot column as a digest only | | `plugin-security` `platform-admin-standing-audit.ts` | `platform_admin_standing_change` | the event and two counts | already clean; `plugin-security` is outside this card's file surface and untouched | No in-lane sibling needed an edit, so no edit outside `plugin-auth` was made beyond the tests and the changeset. ## Downstream readers of the dropped values - `packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts` asserted one dropped value on the explicit create row. That case now asserts the explicit row does not carry it, and that the mirror's `update` row for the same write does. - The console's audit-log browser (objectui `apps/console/src/pages/system/AuditLogPage.tsx`, read at the pinned `.objectui-sha`) renders `metadata` as pretty-printed JSON. It reads no key by name. - ADR-0093's two membership decisions on this row are kept. - No other reader in this repository names any dropped key. ## Pins - `packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts`, 4 new cases. For each row it asserts: - the row's reference to the user; - the exact decision key set; - that no key names a field this call wrote, and no value equals a string the call wrote. The cases cover create-user with every optional input, phone-only create, a create that binds a membership, and set-user-password. Each case first checks that the call really wrote those fields. - `packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts`, 8 cases, on a real boot. - The platform declares no mask and no capability gate on the user fields these endpoints write. So an object extension layers one written field masked and one capability-gated. A permission set withholds a third. - There is one reader per class, the read-only-wildcard reader, and the control. The seeded admin drives both doors. - `beforeAll` arms it with `assertArmed`. The explicit rows exist at rest, and the mirror rows at rest carry every class. The mirror rows served to each reader withhold exactly its class, and the control is served every class. - Per class, through the list, by-id and projected doors: both explicit rows are served to the reader, and no row served to it carries a value of its class. Separate cases show the two other classes still reach it through the mirror. - The control is served every class through the mirror, and the explicit rows carry only the closed decision set. - No test title states a value. ## Ablations: every negative pin, put back and shown red All legs went through `scripts/ablation-replace.mjs`. Each anchor hit once and landed by count and blob. Each restore was proven by blob equal to HEAD and an empty `git diff HEAD`. The first unit attempt was a no-op. Its replacement text contained its own anchor, so the tool refused before running anything. Every leg was re-spelled and run again. Unit legs, `src`-resolved, over the 41-case file: | Put back | Red | |---|---| | the created account's sign-in identifier, create row | 3 of the 4 new cases | | its phone identifier, create row | 2 | | its legacy role scalar, create row | 2 | | its force-password-change flag, create row | 3 | | the same flag, password-set row | 1 | | a written value smuggled inside a declared decision key | 4; the value detector names the leak in the phone-only case | | the identifier as a literal key, under `tsc --noEmit` | TS2353 on the closed type | Every other case stayed green, and the restore run read 41/41. Dogfood legs, resolved through `dist`. Each leg rebuilt `plugin-auth`, and `scripts/ablation-dist-preflight.mjs` proved the marker present in `dist`. Three legs used a literal key, and their declaration build failed on the closed type: the JS emitted, the preflight proved it, and the suite ran on it. The spread leg built clean. | Put back | Red, of 11 (the new 8 plus the audit-trail file's 3) | |---|---| | the not-granted class's value, create row | 3: that class, the wildcard, the control | | the masked class's value, create row (a spread) | 3: that class, the wildcard, the control | | the gated class's flag, create row | 4: that class, the wildcard, the control, and the audit-trail case | | the gated class's flag, password-set row | 3: that class, the wildcard, the control | In every leg the preservation cases and the other classes' cases stayed green. The restore leg rebuilt `plugin-auth` and proved all four markers absent with `--absent` on a clean whole tree. It then went 11/11 green. The same 11 cases were also run against the pre-fix build. Red: the 5 negative cases and the audit-trail case. Green: the 3 preservation cases and the 2 untouched audit-trail cases. ## Verification at head `0f314432` `origin/main` was merged once, with no overlap with this diff; then a reinstall and a rebuild of the dogfood closure. - `pnpm --filter @objectstack/plugin-auth test`: 116 files, 2484 tests passed. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. The test-typecheck debt is held unchanged: 10 files, 94 errors, 23 signatures. - Dogfood `vitest --project isolated`, 9 files and 59 tests passed: - the new pin and the audit-trail file; - the five other suites that drive the admin endpoints (`admin-credential-lifecycle`, `admin-platform-admin-standing`, `admin-route-nonadmin-refusal`, `bearer-lane-password-change`, `membership-reconciler`); - the ledger readers `audit-log-field-values` and `auth-session-audit-trail`. - Dogfood typecheck: exit 0. - Gates: `dispatch-gates --repo objectstack-ai/objectstack --commands` derived 67 families, with no paths. All 67 were run with exit codes captured before any pipe, and all exited 0. `--ran` reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) because eight unrelated packages had no `dist`. Those packages were built and it re-ran to exit 0. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 4 changed TS files reported 4 files, none ignored, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting, so no untouched file's verdict can move. The repo-wide lint is CI's. - Size: 594 changed lines (+581 / -13) across 5 files against merge base `2c1cef33`. No governed surface. ## Acceptance notes - **Rows written before this release.** They keep the values they copied. The ledger is append-only and the fix is producer-side, so nothing rewrites them, and a read-time narrowing of `metadata` is ruled out. Whether those stored rows are scrubbed is a decision for the maintainer. It is reported to the seat. - **The parent-record gate.** Every reader in the pin, the control included, is answered `404` by the data plane for the created user, and is still served the ledger rows about it. That is the class of objectstack-ai#21175 (the ledger's parent-record gate), which this seat holds separately. objectstack-ai#21175 remains open and is not addressed here. - **Judged, not changed:** - The create row's placeholder decision states whether the admin created a phone-only account. No field stores it, and the generated address itself is no longer copied. - The login row's impersonating admin's id is a reference that the same row's `actor` column carries. - The settings row's encrypted flag is derived from the setting's declaration, not from stored data. Each stays. - **The fixture's object extension** replaces two platform field definitions on the user object. `content/docs/data-modeling/object-extensions.mdx` documents that replace semantics ("A name the target already has is replaced, not merged"). This is how a masked and a capability-gated class reach those fields on a real deployment; the platform declares neither. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…t, ledger snapshots or the approval snapshot for a reader withheld a field of its record (objectstack-ai#21154) (objectstack-ai#21179) Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#21080.** Before objectstack-ai#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. objectstack-ai#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 objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
…ows about records the caller can read (objectstack-ai#21175) (objectstack-ai#21194) Fixes objectstack-ai#21175 Clause-②: no A read of the compliance ledger (`sys_audit_log`) now returns only the rows about records the caller can read. This implements triage's ruling A on the card (`5932888473`): the ledger takes the activity stream's parent-record read gate (objectstack-ai#20833, PR objectstack-ai#21069), and that gate reads the engine's own answer. **Status.** Draft. The product consequence for admins (see Acceptance notes) is awaiting the maintainer's decision on objectstack-ai#21175; this PR's behaviour is unchanged by patch round 1. ## Patch round 1: what changed since the first push - **Merged `origin/main` at `1ecb871b`** (merge commit `77d3cc05`; no rebase, no force-push). PR objectstack-ai#21179 (objectstack-ai#21154) is in that merge. Its `audit-plugin.ts` mount and this PR's mount sat in different hunks of the same block and merged without a conflict. The census page (`content/docs/permissions/system-context.mdx`) did conflict: row 45 now names both the query guard and the ledger read gate, and the declared counts were regenerated by `gen:system-context-census` (114 sites). - **Mount order.** Engine middleware runs in registration order. On `sys_audit_log` the order is now: (1) the query guard from objectstack-ai#21154, (2) the ledger field redaction from objectstack-ai#21155, (3) this PR's parent-record read gate. On `sys_activity` it is: (1) the query guard, (2) the activity read gate, (3) the activity field redaction. Each guarantee holds: - the guard judges a query before any read gate runs its pre-scan, so a refused query never pays it; - the read gate ANDs its WHERE before the read executes; - the redaction narrows the rows that come back, which are only the rows the gate kept. The mount comment in `audit-plugin.ts` states this order. - **Changeset, now in the activity gate's form** (objectstack-ai#20833 / PR objectstack-ai#21069): `minor`, a **BREAKING** paragraph, and `Clause-②: no (narrowing)`. The arm is there because this narrows what a read returns and no accept set widens; the PR line above keeps the claim's `no`. It carries the ADR-0087 `not-required (no-migration-prescription)` marker (`check-adr-0087-registration`: 1 declared-breaking changeset, carrying its disposition) and a Migration paragraph. - **CI red on the previous head.** The red was `Dogfood Regression Gate (3/3)`; reproduced locally on that head. The failing case was `admin-ledger-decision-metadata.dogfood.test.ts`, a pin objectstack-ai#21174 landed on `main` after this branch's first merge. Its readers could open only their own user row through the data door (measured: each reader got 404 on the subject user). Under the parent-record rule they are served none of the subject's ledger rows, so its armed check disarmed. **Old to new:** the fixture's reader sets now add view-all on the user object, a row-scope grant only. A new armed control asserts that every reader opens the subject through the data door (measured 200 for all five). No assertion changed: its armed check still measures that each reader is withheld exactly its field class, and all 8 cases pass. - **Docs.** `content/docs/permissions/record-view-auditing.mdx`, "Reading the trail": the sentence saying ledger queries go through the data service "like any other object" now states the read rule. A view row is served outside system context only to a caller who can read its record. Views of a record that has since been deleted stay stored and are served only to system-context reads. ## Measured first, on a real boot (classes only) The re-measure was private and its readings stay in the dispatch's scratch. It ran on `main` at `b9087d77` (PR objectstack-ai#21171 in), and again at this branch's first head. The stack was `bootStack`, org-bound, with the real `SecurityPlugin`, auth, REST and `AuditPlugin`. The rows were written by the CRUD mirror and the auth-event sink. The member holds the ledger read through one explicit permission set; the admin is the seeded admin. "Parent" means the same caller's read of the row's record through the data door. | door | caller | parent, through the data door | before | after | |:--|:--|:--|:--|:--| | list `GET /data/sys_audit_log` | member | 404: a private record's create, update and delete rows | returned | absent | | list | member | 404: other users' sessions (their `login` rows) | returned | absent | | list | member | 200 | returned | returned | | by id `GET /data/sys_audit_log/:id` | member | 404 | 200 | 404 | | list, by id | admin | 200: every existing record | returned | returned | | list | admin, member | the record no longer exists (`delete` rows, a deleted record's other rows, `logout` rows) | returned | absent | | list `total` | member | (any) | 51 | 42, the rows returned | | list `total` | admin | (any) | 51 | 47, the rows returned | ## What changed - `parent-record-read-gate.ts` (new): the activity gate's mechanism, moved out of `activity-read-visibility.ts` and parameterized per gate. It still asks `resolveReadableParentIds`, the one readability answer the comment and activity gates share. Nothing derives row scope a second way. - `activity-read-visibility.ts`: now a thin declaration over the shared module. Its exports (`parseActivityParentObject`, which the objectstack-ai#21154 guard imports, included), scan options, sentinel and log lines are unchanged, and its unit and integration pins pass unedited. - `audit-log-read-visibility.ts` (new): `installAuditLogReadVisibility`, the ledger's declaration, with the row classes below. - `audit-plugin.ts`: one mount with its order comment, plus the no-middleware-seam warning now names the ledger read gate. - `content/docs/permissions/system-context.mdx`, row 45: the census anchor for the new early return on system context. ## Row classes: the stated answer for rows the gate cannot judge Measured per writer: - **About a record** (`object_name` + `record_id`): the CRUD mirror's create, update and delete rows; record-view `read` rows; plugin-auth's administrative create and update on a user; and `login` / `logout`, which name the session. Judged by the record gate. - **About a record that no longer exists:** no caller can read the record, so these rows are excluded for every caller that is not system context, admins included. That covers every `delete` row, every `logout` row (sign-out deletes the session, measured), and a sign-in row whose session has since been removed. The rows stay stored. - **About no record** (no `record_id`, and an action that is not a record action): the run-level `import`, `config_change` and `platform_admin_standing_change` rows, and an auth event without a session id. These are outside the gate's class and are served under the ledger's own grant, as before. **Why this differs from the activity gate**, which excludes every row that names no parent: - the activity stream has no platform producer of such rows; - these rows have three producers, and a shipped consumer reads them through the data door (the `config_changes` view, pinned by the settings dogfood test as the admin); - none of them carries a record's field values (the per-writer measurement in `audit-log-field-redaction.ts`). - **Excluded, fail closed:** a record action (`create`, `read`, `update`, `delete`) that names no record, a record under an object the engine does not know, and a row naming the ledger itself. ## Pins - `audit-log-read-visibility.integration.test.ts` uses a real kernel, the real `AuditPlugin`, the real CRUD mirror and a real SQLite driver. It covers find, findOne, count, aggregate, a scoped query, the batching count, every row class, and an admin control. 12 tests. - `audit-log-read-visibility.test.ts` covers the class rule, the fail-closed branches, the scan bound and its order pass-through, and the inert seam. 13 tests. - `packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts` covers the public doors above on a real boot. Each returned row's record is opened through the same door as the same reader. It also covers a row about no record from its real producer (a settings write), a deleted record's rows, and the admin control. 8 tests. - **Composition consequences on other cards' pins (adapted, not weakened):** - objectstack-ai#21155's unit test now pins the redaction of rows no non-system door serves on `redactAuditLogRows` over the row at rest. Its field-values dogfood pin checks the live record's rows. - objectstack-ai#21174's admin-ledger pin is described above. ## Ablation: put the forbidden behaviour back, red, restore Both mutations ran from committed state through `scripts/ablation-replace.mjs`, and each restore was proven: blob equals HEAD and `git diff HEAD` is empty. - **Mount removed** (the one `installAuditLogReadVisibility(...)` call in `audit-plugin.ts`; anchor 1 → 0). - Leg A, source-resolved: the integration pin gave 8 failed, 4 passed of 12. - Leg B, dogfood, resolved from `dist/`: plugin-audit was rebuilt, and `ablation-dist-preflight --absent` proved the call gone. The dogfood pin then gave 5 failed, 3 passed of 8. - Restore: a rebuild, the preflight proved the call present and the tree clean, and the pin re-ran 8 of 8. - **The class rule widened** (a record action naming no record treated as outside the gate): the unit and integration pins gave 6 failed, 19 passed of 25. - **objectstack-ai#21174's pin without the reader view-all grant** (the previous head's fixture), on the merged build: its armed check disarmed. All five readers were served no mirror row about the subject. ## Verification at HEAD `d376985f` Every exit code was captured before any pipe. - `pnpm --filter @objectstack/plugin-audit test`: 35 files, 535 tests passed. `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, test layer included. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. `plugin-approvals` `payload-predicate-guard.test.ts`: 20 passed. - **Dogfood shard 3/3** (`OS_TEST_SHARD=3/3`, CI's slice): 54 files passed, 1 skipped; 523 tests passed, 2 skipped. - Ledger and neighbour pins, 12 files: this PR's dogfood pin; objectstack-ai#21155's `audit-log-field-values`; objectstack-ai#21081's `activity-field-values`; objectstack-ai#21154's `activity-text-predicate` and `audit-log-admin-search`; `activity-parent-read-gate`; `auth-session-audit-trail`; `settings-config-change-audit`; `admin-identity-audit-trail`; objectstack-ai#21174's `admin-ledger-decision-metadata` (8 of 8 after the fixture change); `comments-permission-matrix`; `membership-actor-attribution`. All green: eleven ran at `b415f4d7`, whose tree differs from this head only in the admin-ledger pin, and that pin ran at this head. - `dispatch-gates --commands`: 94 families derived; 93 ran with exit 0 and 1 is NOT MEASURED. `spec check:skill-examples` exited 0 after the client SDK it reads was built. `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: it needs a whole-repo build, and CI runs it. `dispatch-gates --ran`: 94 accounted for, 0 unrun. `main` moved again after `1ecb871b`; this round merges `main` once, as dispatched. - Lint, a declared narrowing: `eslint --no-inline-config --format json` over the 10 changed TypeScript files reports 10 files linted, 0 errors and 0 warnings. An ignored file would report a warning. `eslint.config.mjs` sets no `parserOptions.project` and no typed rule, so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` runs in CI. ## Acceptance notes - **Admins lose the trail of deleted records and of sign-outs** on every door that is not system context, including Setup's Audit Logs. The rows stay stored. This is the consequence awaiting the maintainer's decision on objectstack-ai#21175. - **Interim mitigation.** `domain:engine` reports (`5936411631` on objectstack-ai#21175) that this gate shrinks objectstack-ai#21197's reach to admins: the gate stops serving a member the ledger rows about a key-material record that member cannot open. The admin half of objectstack-ai#21197 stands, and that card's own mechanism is separate. - **Scan bound.** The shared 2,000-row pre-scan bound now applies to the ledger, which is a larger table than the activity stream. A broad read past the bound fails closed and logs a warning: rows outside the window, and the `total` they would add, are omitted. A record timeline scopes by `object_name` and `record_id` and never reaches the bound. - Not measured: doors onto the ledger that answer outside the engine's middleware chain (`GET /search`, the analytics dataset over the ledger, export), and a boot with more than one organization. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…mpts its holder from the parent-record read gate; platform administrators hold it by default (objectstack-ai#21260) (objectstack-ai#21296) Fixes objectstack-ai#21260 Clause-②: yes (widening) This executes ruling B on objectstack-ai#21175 (comment `5942331027`, maintainer 「同意264」). The compliance ledger gets an audit capability, `view_all_audit_log`. A caller who holds it is exempt from the ledger's parent-record read gate. Field-level narrowing (PR objectstack-ai#21171) still applies to that caller. Platform administrators hold it by default. Every other position holds it only by explicit grant. objectstack-ai#21175 is the parent card. This PR does not close it (it is already closed). **Status.** Draft. `Clause-②: yes`, so an isolated contract-review-tier review is owed before enqueue. The PM runs it. ## Cross-lane surfaces, named before the change list - **`packages/spec/src/security/capabilities.ts`** (`domain:spec`). One new `PLATFORM_CAPABILITIES` entry. This is cross-lane, as the claim declares. - **`packages/spec/src/identity/eval-user.zod.ts`** (`domain:spec`). This file holds the default grant to platform administrators. - The claim expected the admin grant path to live in `plugin-security`. Measured: it lives here. `ADMIN_FULL_ACCESS_CAPABILITIES` is the one list that two consumers read: - plugin-security's `admin_full_access` set spreads it; - core's configured-owner envelope (`resolve-authz-context.ts`) reads it. - Adding the capability anywhere else would fork that list. It is reported here as the claim requires: "If that path lives outside `plugin-security`, the build reports it before touching it." - **`packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts`**. Test only, no source change in `plugin-security`. - Its pin on the admin set's exact capability list goes red on any capability change, by design. The literal now names the new capability, and its docblock says why. - A new pin says that no other shipped set carries the capability. ## What changed 1. **The declaration.** `PLATFORM_CAPABILITIES` gains `view_all_audit_log`: - label "View All Audit Log", `scope: 'org'` (see "Scope" below); - its description names exactly what it lifts and what still applies. - `PLATFORM_CAPABILITY_NAMES` derives from the list, so the seeder (`bootstrapSystemCapabilities`), the authoring lint and the anchor floor pick it up without a second edit. 2. **The default holder.** `ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions` lists it. Platform administrators hold it both ways: - through the `admin_full_access` grant; - through the configured-owner envelope. - There is no role-name test anywhere. 3. **The exemption.** `parent-record-read-gate.ts` is the shared mechanism. It gains one declared field, `ParentRecordGate.exemptCapability`, and one check at the top of `computeParentRecordFilter`, before anything is scanned or ANDed in. - The check reads the caller's resolved `systemPermissions`: the capability set that the request's authorization resolver (`resolveAuthzContext`, `assembleExecutionContext`) stamps on the execution context. - Every other runtime capability check outside plugin-security reads that same set, for example the `sys_record_share` read scope and the object-schema mask exemption. No new resolver is added. - Only the ledger gate declares the field (`LEDGER_AUDIT_CAPABILITY` in `audit-log-read-visibility.ts`). The activity stream's gate declares none and says so. 4. **The anchor floor (`high-privilege.ts`).** No edit. That file's platform floor is `PLATFORM_CAPABILITY_NAMES` itself, so declaring the capability puts it on the floor. - Reading: it unlocks ledger rows that the parent-record gate otherwise withholds. So the anchor-laundering argument applies, and a set carrying it must never bind to `everyone` or `guest`, even when an app declares a capability of the same name. - A pin in `high-privilege.test.ts` measures this. It refuses for `everyone` and for `guest`, with the name declared. 5. **Docs.** `content/docs/permissions/record-view-auditing.mdx`, the ledger reader sentence, which PR objectstack-ai#21194 wrote. It now names the capability, who holds it by default, the field-level narrowing, and the organization bound. Old and new text are in the report. 6. **Pins made false by this ruling.** - `audit-log-parent-read-gate.dogfood.test.ts` said the admin is not served a deleted record's rows. The admin is now a default holder: that pin now asserts the reader is served none of them and the admin is served both. - The comment in `audit-log-field-values.dogfood.test.ts` is corrected the same way. 7. **Changeset.** `@objectstack/spec` minor and `@objectstack/plugin-audit` minor, carrying `Clause-②: yes (widening)`. ## Measured before the change (the gate as landed) Real boot through the public data doors: `bootStack`, the real SecurityPlugin, the SQL driver, REST, auth, and AuditPlugin's CRUD mirror and auth-event sink. - **At base `d2bc644f2`, with `dist` built from that tree.** A member holding the ledger grant was served: - none of the deleted record's rows; - `404` on the sign-out row; - `404` on a private record's row; - a total of 2 against 2,053 at rest on a broad read past the 2,000-row bound. - The platform administrator was served none of the deleted record's rows. Its resolved capability set held the seven existing capabilities. - **With only the new exemption line removed, so the gate is exactly as objectstack-ai#21194 landed it.** Platform administrator and ledger-grant member, both: - deleted-record list 0; - `delete` row `404`, sign-out row `404`, ended session's sign-in row `404`; - broad read total 2 against 2,054 at rest. - This is A, as the dispatch expected. ## Scope: `org`, measured rather than assumed - The dispatch default was `platform` unless the build measured that the ledger is org-scoped. It is. - The registry provisions `organization_id` on `sys_audit_log`. - The CRUD mirror stamps each row with the record's organization. - Measured on a real `isolated` boot, with two organizations each created by its owner and the first owner granted the capability (the readings below are row counts): | Caller | Rows about the deleted record in its own org | Rows about the other org's deleted record | |---|---|---| | Holder (org A owner) | 2 | 0 | | Non-holder (org B owner) | 0 | 0 | | Platform administrator | 2 | 2 | - The capability lifts only the parent-record gate. The tenant wall still bounds a holder to its own organization's rows. The platform administrator reaches every organization through its own wall bypass, not through this capability. - This is now a pin, in the second block of the new dogfood file. ## Pins | Pin | Where | |---|---| | A holder is served the deleted-record rows (list and by id), the sign-out row, the ended session's sign-in row, a record it cannot open, and a broad read past the bound whole. | dogfood | | Field narrowing still applies to the holder: a withheld field's value is absent from the deleted record's served snapshots, a plain field's value is present, and the value is present at rest. | dogfood | | A non-holder (ledger grant only) gets exactly A: none of these rows, and a broad-read total below the bound. Its own record's rows are the control. | dogfood | | A platform administrator holds the capability by default, read through `resolveAuthzContext`, and is served the deleted-record, sign-out and broad-read rows. | dogfood | | The activity stream's gate is not exempted for a holder or for the admin. | dogfood; unit `activity-read-visibility.test.ts`; integration `audit-log-read-visibility.integration.test.ts` (real SQLite driver) | | The holder is bounded by its organization. | dogfood, second block | | On every read operation the holder is not narrowed and is not pre-scanned. Its broad read never meets the bound and logs no warning. A caller with other capabilities, a non-list or none gets the gate unchanged. The constant is pinned against `PLATFORM_CAPABILITIES` and `ADMIN_FULL_ACCESS_CAPABILITIES`. | unit `audit-log-read-visibility.test.ts` | | The holder is served every row, deleted and unjudgeable rows included, and the count matches. | integration | | The capability is on the anchor floor. | `high-privilege.test.ts` | | The admin grant carries it, declared `org`. | `platform-admin-capabilities.test.ts` | | Only `admin_full_access` among the shipped sets carries it. | `default-permission-sets.test.ts` | The pre-scan bound is pinned with a real fixture past it: 2,050 inserted rows plus the real ones. The constant is not shrunk. ## Ablations Each leg: mutate through `scripts/ablation-replace.mjs` (anchor must hit), run the src-aliased plugin-audit suites, rebuild `@objectstack/plugin-audit`, prove the mutation reached `dist/` with `scripts/ablation-dist-preflight.mjs`, run the dogfood file, restore with `git checkout HEAD -- ABSOLUTE_PATH` (blob hash equals HEAD and `git diff HEAD` is empty), rebuild, and prove the restore in `dist/`. A trap restores on EXIT, INT and TERM. Every leg turned red, as predicted. | Leg | Unit and integration | Dogfood | |---|---|---| | Exemption removed | 3 red: 2 unit holder pins, 1 integration holder pin | 8 red: every holder and admin pin, including the org-bound holder and admin pins. Non-holder and activity pins stayed green. | | Exemption applied to the activity gate | 2 red: unit activity pin, integration activity pin | 1 red: the activity pin | | Capability held by everyone | 19 red: the non-holder pin, plus every pre-existing ledger narrowing case | 2 red: both non-holder pins | The first run of the activity leg was a no-op. The replacement contained its own anchor, and the tool refused it with "anchor count moved 1 to 1". Its readings are void. It was re-run with an anchor the replacement does not contain, and the readings above are from that run. ## Gates and tests, at `d0a1903b7` - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 110 families. All 110 exited 0. - Two first answered `PREREQUISITE NOT MET` (exit 3) because sibling packages had no `dist/`: `check:skill-examples` and `check:dual-build-cjs-loads`. Both were re-run after building those packages and exited 0. - `--ran` reconciliation: "110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED (a DERIVED zero — all 110 recorded an exit code and none of them is 3)". - **Spec artifacts.** `pnpm --filter @objectstack/spec check:generated`: "All 15 generated artifacts are up to date". Nothing was regenerated, because no artifact moves for one new list entry. - **Tests.** - `@objectstack/spec`: 645 files, 18,316 passed. - `@objectstack/plugin-security`: 157 files, 3,407 passed. - `@objectstack/plugin-audit`: 35 files, 542 passed. - `@objectstack/core` `resolve-authz-context.platform-admin-config.test.ts`: 31 passed. - Dogfood, 4 ledger files at this head: 37 passed. - **Typecheck.** `typecheck` passed for spec, plugin-audit, plugin-security and dogfood, including each package's test-layer typecheck. - **Lint.** ESLint on the 14 touched TS files with `--no-inline-config --format json`: 14 files linted, 0 errors, 0 warnings. - The repo's `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`). So this diff cannot change the verdict on any untouched file. - The full `pnpm lint` is left to CI. ## Acceptance notes - **Agent principals (ADR-0090 D10).** The exemption reads `systemPermissions` as the shared resolver stamped them. An OAuth agent principal carries the delegating user's capabilities only when the user consented to `actions:execute`, and none otherwise (`assemble-execution-context.ts`). The `sys_record_share` read scope and the object-schema mask exemption read the same set and follow the same rule. This is a reading, recorded for the contract reviewer. It is not a defect filed by this PR. - **What a holder is served.** A holder is served rows about records of objects it holds no object-level read on. Field-level security still narrows the snapshots. This is the breadth "exempt from the parent-record gate" means, and the capability's description and the docs page both say so. - **`PLATFORM_ADMIN_ONLY_CAPABILITIES` in plugin-security is deliberately untouched.** The new capability is not a platform-admin marker: it is org-scoped, and an explicit grant must not confer platform standing. - **Base.** `origin/main` has moved since `d2bc644f2`. The incoming commits touch none of this PR's files, so they were not merged here. CI and the queue validate the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21155
Clause-②: no
The compliance ledger's create, update and delete rows now serve a parent record's field value only to a reader the security service serves that field unmasked. The full row stays at rest. The narrowing is applied at read time, keyed on the reading caller, through the same served-fields answer the activity stream's redaction uses. This implements triage's ruling on the card (
5929919761): ledger readers are not field-unrestricted by default, and an auditor who must see every field is granted it by a set that unmasks those fields. The audit-capability question stays out of scope, as ruled.Measured first, on a real boot
Re-measured on
mainatbafb8c94, with PR #21152 in, privately. The stack wasbootStackwith the realSecurityPlugin,ObjectQL, SQL driver, REST and auth layers, plusAuditPlugin. One synthetic object carried one field of each class. Rows were written by the CRUD mirror from REST writes: a create, an update of all three fields, and the delete of a second record. One reader per class held exactly what makes its own class apply, plus the ledger read. The unmasking reader was the control. A fifth reader took its ledger read from the platform read-only set's wildcard, beside a set withholding one field: that is the card's measured door. Readings stay private to the dispatch. This table gives classes only.In the "after" column each reader is still served the values of the classes that do not apply to it. The card named the create and update snapshots. The delete row's before-snapshot is the same column on the third record-write action and carried the same values, so it is narrowed too.
What changed
packages/plugins/plugin-audit/src/audit-log-field-redaction.ts(new):installAuditLogFieldRedaction, asys_audit_logread middleware onfind/findOneunder a non-system context. On a row of a record-write action, each snapshot key the reader is not served is dropped, key by key. The record-write actions are create, update and delete: the vocabulary the mirror'sactionForreturns, which is now typed by the same exported list. A reader served every key gets the stored bytes untouched.packages/plugins/plugin-audit/src/served-fields.ts(new): the served-unmasked composition, now one helper both read seams use.resolveServedFieldsis the read projection intersected with the query-side answer, failing closed when the query-side answer is missing. The helper also holds the per-read memo, the key-by-key drop and the projection augmentation. ⛔ Nothing inplugin-auditderives masking.packages/plugins/plugin-audit/src/activity-field-redaction.ts: moved onto that helper. Its exported names (resolveServedFieldsand its types) are unchanged. Behaviour is unchanged: its 19-case unit pin and 10-case dogfood pin stay green, and the log line names its seam.packages/plugins/plugin-audit/src/audit-plugin.ts: mounts the ledger seam beside the activity one. One resolver looks up the security service on every read, for both seams. The no-middleware-seam warning names both.packages/plugins/plugin-audit/src/audit-writers.ts:actionForis typed by the shared record-write list, so a new record-write action does not compile until the read side judges it.content/docs/permissions/system-context.mdx: row 45 names the new system-context early return. The census counts move 110 to 111, whichcheck-system-context-censusrequires..changeset/21155-ledger-snapshot-fields.md:patchfor@objectstack/plugin-audit.Fail closed
Every ledger column judged, by writer
read,login,logoutandimportrows: written empty. Nothing to narrow.config_changerows (service-settings): a digest of the setting, never its value. Served as written.platform_admin_standing_changerows (plugin-security): the deployment's administrator roster, not a parent record's field map. Served as written.plugin-auth's admin create/update rows: the admin's decisions, which include values of the created user's fields. Not narrowed here; see the acceptance notes (measured; producer-side; outside this card's file surface).login/logoutrows: the ledger's own columns, governed by field-level security on the ledger object itself.Read doors
Every product door onto the ledger reads through the engine's generic data path:
apps/console/src/pages/system/AuditLogPage.tsx);packages/app-shell/src/views/RecordDetailView.tsx).Both objectui files were read at the pinned
.objectui-sha. No server route in this repository reads the ledger outside the engine. All three door shapes were measured: list, by-id and projected list.Pins
packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts, 15 cases. It uses the realObjectKernel,ObjectQLPlugin, SQLite driver and the realAuditPluginmount. The security-service double answers both contract members per reader. Rows are written by the real mirror (create, update, delete), plus rows in the other writers' shapes. The suite covers each class, the control's byte identity, the by-id and projected doors, the other actions served as written, each fail-closed branch, the no-answer pass-through and a system read.packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts, 8 cases, on a real boot with the real security plugin, which is the only thing that distinguishes capability-gated from not granted. It has one reader per class plus the read-only-wildcard reader and the control. It is armed inbeforeAllbyassertArmed: the rows at rest carry every class, and the data plane serves each reader its class as declared. Each negative case reads the list, by-id and projected list doors. No test title states a value.Ablations: every negative pin, put back and shown red
All legs went through
scripts/ablation-replace.mjs. Each anchor hit once and landed by count and blob. Each restore was proven by blob equal to HEAD and an emptygit diff HEAD. Every leg turned only its own cases red; all others stayed green.Unit legs, on the ledger file (15 cases) plus the activity file (19 cases):
Dogfood legs, resolved through
dist. Each leg rebuiltplugin-audit.scripts/ablation-dist-preflight.mjsproved the marker present indist. The restore leg rebuilt again and proved the marker absent with--absenton a clean whole tree, then went 18/18 green:Verification at head
f5289158pnpm --filter @objectstack/plugin-audit test: 30 files, 426 tests passed (411 before this change, plus 15).pnpm --filter @objectstack/plugin-audit typecheck: exit 0, andcheck:test-typecheckOK.vitest --project isolated, the new pin plus the activity sibling: 2 files, 18 tests passed. Dogfood typecheck: exit 0.admin-identity-audit-trail,auth-session-audit-trail,membership-actor-attribution,settings-config-change-auditandactivity-parent-read-gate(dogfood, 24 tests), plusservice-settingsconfig-change-audit.test.ts(16 tests). All green.dispatch-gates --repo objectstack-ai/objectstack --commands, with no paths, derived 95 families. All 95 were run with exit codes captured before any pipe, and all exited 0.--ranreads 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. Two families first answered PREREQUISITE NOT MET because packages had nodist:check:skill-examplesandcheck:dual-build-cjs-loads. The packages they named were built, and both re-ran to exit 0.check-system-context-censuswas red until the census row landed.eslint --no-inline-config --format jsonover the 7 changed TS files reported 7 files, none ignored, 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (0parserOptions.project/projectService), so no untouched file's verdict can move. The repo-wide lint is CI's.e35c40a5. No governed surface.Acceptance notes
plugin-auth's admin create-user row (measured). Its decision metadata carries the created user's field values. A reader withheld one of those fields (and refused the record itself) was served that value through the row's metadata. After this change, the mirror's own create row about the same user no longer carries it. The producer isplugin-auth/src/admin-user-endpoints.ts, outside this card's surface. Narrowing it here would need a second derivation of which decision keys are field values. It is reported to the seat.plugin-approvals/src/payload-redaction.ts) still carries its own copy of the served-unmasked composition. Hoisting it besideserved-fields.tsis security(plugin-audit): an activity row composed at write time may carry a changed field's values to a reader who can read the parent record but not that field (unmeasured; measure first) #21081's noted follow-up, not this card's.plugin-approvalsis untouched.Generated by Claude Code