Skip to content

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

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21155-ledger-snapshot-fields
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21155-ledger-snapshot-fields

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 main at bafb8c94, with PR #21152 in, privately. The stack was bootStack with the real SecurityPlugin, ObjectQL, SQL driver, REST and auth layers, plus AuditPlugin. 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.

Reader class Data plane (reference) Ledger rows before (list, by-id, projected list) Ledger rows after
Field served masked replaced stored value in the create, update and delete snapshots absent
Capability-gated field (no mask) key absent stored value in all three absent
Field not granted by the reader's sets key absent stored value in all three absent
Platform read-only wildcard plus a withholding set (the card's door) replaced, key absent, key absent stored value of every class absent
Unmasking reader (control) stored stored stored, byte-identical to the row at rest

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, a sys_audit_log read middleware on find / findOne under 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's actionFor returns, 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. resolveServedFields is 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 in plugin-audit derives masking.
  • packages/plugins/plugin-audit/src/activity-field-redaction.ts: moved onto that helper. Its exported names (resolveServedFields and 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: actionFor is 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, which check-system-context-census requires.
  • .changeset/21155-ledger-snapshot-fields.md: patch for @objectstack/plugin-audit.

Fail closed

  • A record-write row's snapshot that is not a JSON object is dropped whole.
  • A record-write row that names no parent object loses both snapshots.
  • A row whose action cannot be read loses both snapshots.
  • An unexpected failure strips both snapshots from every row of the read.
  • A reader the service cannot answer masking for is served no field of the parent.
  • The security service's own "no answer" passes rows through, as the activity stream, the approval snapshot and the data plane do. That covers no service wired, and an object the service cannot resolve.

Every ledger column judged, by writer

  • Before/after snapshots on create, update and delete rows (the CRUD mirror): the parent record's field map. Narrowed.
  • Snapshots on read, login, logout and import rows: written empty. Nothing to narrow.
  • Snapshot on config_change rows (service-settings): a digest of the setting, never its value. Served as written.
  • Snapshots on platform_admin_standing_change rows (plugin-security): the deployment's administrator roster, not a parent record's field map. Served as written.
  • The metadata column on mirror rows: the delegated-write attribution only. Served as written.
  • The metadata column on 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).
  • The client-fingerprint columns on login / logout rows: the ledger's own columns, governed by field-level security on the ledger object itself.
  • Pointer, actor and tenant columns: ids, the ledger's own.

Read doors

Every product door onto the ledger reads through the engine's generic data path:

  • the Setup "Audit Logs" object view;
  • the console's audit-log browser, which calls the generic list door (objectui apps/console/src/pages/system/AuditLogPage.tsx);
  • a record page's history tab, which calls the generic list door projected to the snapshot columns (objectui 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 real ObjectKernel, ObjectQLPlugin, SQLite driver and the real AuditPlugin mount. 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 in beforeAll by assertArmed: 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 empty git 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):

Mutation Red
mount removed 9 of 15 ledger; 0 activity
query-side intersection removed (shared helper) 3 ledger (masked class) + 7 activity
key narrowing off 6
action discriminator opened 1 (other actions served as written)
non-object snapshot kept 1
parentless row kept 1
failure catch strips nothing 1
missing query-side answer read as "nothing masked" (shared helper) 1 ledger + 2 activity
projection augmentation off 1
added-column removal off 1
untouched bytes re-serialised 1 (control byte identity)
no-answer pass-through closed 1

Dogfood legs, resolved through dist. Each leg rebuilt plugin-audit. scripts/ablation-dist-preflight.mjs proved the marker present in dist. The restore leg rebuilt again and proved the marker absent with --absent on a clean whole tree, then went 18/18 green:

Mutation Red
mount removed 4 of 8 (the three per-class negatives and the wildcard negative); the 3 preservation cases and the control stay green; the 10 activity cases stay green
query-side intersection removed 2 of 8 (masked class and the wildcard) + 2 activity (masked class)

Verification at head f5289158

  • pnpm --filter @objectstack/plugin-audit test: 30 files, 426 tests passed (411 before this change, plus 15).
  • pnpm --filter @objectstack/plugin-audit typecheck: exit 0, and check:test-typecheck OK.
  • Dogfood vitest --project isolated, the new pin plus the activity sibling: 2 files, 18 tests passed. Dogfood typecheck: exit 0.
  • Consumer suites that read the ledger as callers: admin-identity-audit-trail, auth-session-audit-trail, membership-actor-attribution, settings-config-change-audit and activity-parent-read-gate (dogfood, 24 tests), plus service-settings config-change-audit.test.ts (16 tests). All green.
  • Gates: 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. --ran reads 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. Two families first answered PREREQUISITE NOT MET because packages had no dist: check:skill-examples and check:dual-build-cjs-loads. The packages they named were built, and both re-ran to exit 0. check-system-context-census was red until the census row landed.
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the 7 changed TS files reported 7 files, none ignored, 0 errors and 0 warnings. eslint.config.mjs enables no type-aware linting (0 parserOptions.project / projectService), so no untouched file's verdict can move. The repo-wide lint is CI's.
  • Size: 1155 changed lines (+1032 / -123) across 9 files against merge base e35c40a5. No governed surface.

Acceptance notes

  • Parent-record visibility on the ledger (measured, a policy question). A reader whose sets grant the ledger read was served the ledger rows about a record the data plane answers 404 to that reader. After this change, the snapshots in those rows still carry every field the reader is not withheld at field level. The activity stream has a parent-record read gate; the ledger has none. Whether ledger reads take the parent's record gate, or the separate audit-capability narrowing triage named, is a ruling, not this card. It is reported to the seat.
  • 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 is plugin-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.
  • Predicate over the stored snapshot text (measured, after this change). As the withheld reader, a filter on a snapshot column answers by row presence: one row on a hit, none on a miss. The predicate is evaluated at rest, before any read-time redaction. This is the family of security(plugin-audit): a filter over the stored activity text answers by row presence: the generic list door evaluates the predicate at rest, before #21081's read-time redaction, so a reader can probe a field value it is not served #21154, the activity stream's same question, and it is reported for folding there. A group or aggregate over the snapshot columns is the same family and was not measured.
  • The cross-plugin hoist. The approval snapshot (plugin-approvals/src/payload-redaction.ts) still carries its own copy of the served-unmasked composition. Hoisting it beside served-fields.ts is 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-approvals is untouched.

Generated by Claude Code

claude added 6 commits October 1, 2026 11:59
…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>
…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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-audit, touching 31 documentable anchor(s).

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d34aa58a2affc87ded426dc6a326edb03534cb62.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d34aa58a2affc87ded426dc6a326edb03534cb62 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 72527786f31e4fef6fcd38183735da2ca6ff618f — the merge of head f5289158c7e1cf6d76254f469e5f8511629c07a6 into base d34aa58a2affc87ded426dc6a326edb03534cb62, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d34aa58a2affc87ded426dc6a326edb03534cb62 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 13:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 13:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit fbcc05f Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21155-ledger-snapshot-fields branch October 1, 2026 13:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants