Skip to content

fix(plugin-audit): an activity row serves a parent field's value only to a reader the security service serves that field (#21081) - #21152

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21081-activity-field-values
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21081-activity-field-values

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21081
Clause-②: no

An activity row now serves a parent record's field value only to a reader the security service serves that field unmasked. The full row stays at rest; the redaction is applied at read time, keyed on the reading caller.

Measured first, on a real boot

The measurement used bootStack with the real SecurityPlugin, ObjectQL, SQL driver, REST and auth layers, plus AuditPlugin. One synthetic object carried one field of each class, each tracked on the timeline and each named by a milestone template. Three more synthetic objects each had one label-candidate field, one per class. Rows were written by the CRUD mirror from REST writes. One reader per class was granted exactly what makes its own class apply, and the unmasking reader was the control. Readings stay private to the dispatch. This table gives classes only.

Reader class Data plane (reference) Activity rows before (list and by-id doors) Activity rows after
Field served masked replaced stored value in summary, record label and recorded change absent
Capability-gated field (no mask) key absent stored value in summary, record label and recorded change absent
Field not granted by the reader's sets key absent stored value in summary, record label and recorded change absent
Unmasking reader (control) stored stored stored

In the "after" column, each reader is still served the values of the two other classes. A summary composed only from served fields is kept.

So the card's suspicion is confirmed: the value is served, for all three classes and at every position (tracked-change summary, milestone summary, record label and recorded change). Under the triage's raise rule this is the seat's p1 call; the measurement is in the dev report.

Why read time, not composition time

  • The writer composes once, as the system. It cannot know the reader.
  • A permission set that withholds a field is runtime state, so composition time cannot even see one of the three classes.
  • Read time through the security service's own answer is the shape the approval payload snapshot took for this class (plugin-approvals/src/payload-redaction.ts).
  • The fields served unmasked come from the security contract. They are getReadableFields intersected with getQueryableFields, which the contract defines as differing by exactly the masked-for-this-caller fields. Nothing in plugin-audit derives masking.

What changed

  • packages/plugins/plugin-audit/src/activity-field-redaction.ts (new) holds installActivityFieldRedaction, a read middleware on the activity stream. It runs on find and by-id reads, under a non-system context.
    • Recorded change: every key the reader is not served is dropped, key by key.
    • Summary and record label: a text cannot be split back into fields, so each one is served whole or dropped whole. The basis is the writer's declaration of which parent fields that text was composed from.
    • The declaration itself: it is bookkeeping and is never served.
    • Projections: a projection that names a text column gets the parent and declaration columns for the read, and they are removed from what is served.
    • Fail closed: an unexpected failure strips the value-bearing columns. A reader the service cannot answer masking for is served no field. The service's own "no answer" passes rows through, as the approval snapshot and the data plane do.
  • packages/plugins/plugin-audit/src/audit-writers.ts: each row now declares which parent fields its summary and record label carry.
    • The tracked-change and milestone renderers return the fields they actually rendered, from the same loop that renders the text, so the declaration cannot drift from it.
    • A milestone token can name a field the update never changed, so the change itself cannot answer this; only the renderer can.
    • The record label also returns the field it was read from.
  • packages/plugins/plugin-audit/src/activity-read-visibility.ts: parseActivityParentObject is extracted, so both read seams share one definition of the parent a row names.
  • packages/plugins/plugin-audit/src/audit-plugin.ts: the redaction is mounted beside the read gate. The security service is resolved on every read, because the security plugin may register later.
  • Rows without a declaration:
    • A row in the mirror's earlier shape has text of unknown provenance. Its text is served only to a reader who is served every field of the parent (as the service enumerates them for the system). These rows age out with the stream's retention.
    • Any other row was written by an app, and its text is served as written.
  • .changeset/21081-activity-field-values.md: patch.

Outside the claimed file surface, and why

  • packages/qa/dogfood/test/activity-field-values.dogfood.test.ts is the per-class pin on a real boot. Only the real security plugin tells a capability-gated field from a field no set grants; a double cannot. This follows the approval precedent's dogfood pin.
  • content/docs/permissions/system-context.mdx: the new middleware skips system-context reads, which is one more elevation read site. check-system-context-census requires its row, so it joins the sibling read gate's row, and the counts are regenerated by gen:system-context-census.

Pins

  • packages/plugins/plugin-audit/src/activity-field-redaction.test.ts has 19 cases. It uses a real engine, a real SQLite driver and the real AuditPlugin wiring. A security-service double answers both contract members per reader.
    • Masked and not-served readers: values, the summary and record-label drop, and key-by-key narrowing.
    • Preservation: a summary built from served fields is kept, and the other class is still served.
    • Bookkeeping: the declaration is never served.
    • Doors: a projection naming only a text column, and the by-id door.
    • Row shapes: the earlier-shape row, and an app's row served as written.
    • Fail-closed reader, and system reads unredacted.
    • resolveServedFields: its fail-closed branches.
  • packages/qa/dogfood/test/activity-field-values.dogfood.test.ts has 10 cases: three per class plus the control.
    • The scene is armed in beforeAll before anything is believed. The rows at rest carry every class, and the data plane serves each reader its class as the class says.
  • No test title states a value.

Ablations (lane commitment: every negative pin)

  • Tooling: each leg goes through scripts/ablation-replace.mjs. The anchor must hit once, and the mutation is proven landed by anchor count and blob hash. The restore is proven by blob equal to HEAD and an empty git diff HEAD.
  • Unit suite (it reads plugin-audit from source). The red/total counts below are identical at the final head eec3115f.
    • Mount removed: 10/19 red.
    • Query-side intersection removed: 7 red, the masked class only.
    • Text check disabled: 8 red.
    • Key-by-key narrowing disabled: 7 red.
    • Earlier-shape recognition disabled: 1 red.
    • Projection augmentation removed: 1 red.
    • Milestone provenance removed: 4 red.
    • Tracked-change provenance removed: 6 red.
    • Label provenance removed: 1 red.
    • Fail-closed branch falls open: 2 red.
    • Declaration strip removed: 1 red.
    • Each leg's other cases stayed green.
  • Dogfood suite (it resolves plugin-audit through dist/). Each leg rebuilt and proved the marker live in dist/ with scripts/ablation-dist-preflight.mjs. The restore leg rebuilt and proved it absent with --absent, on a clean whole tree.
    • Mount removed: 6/10 red, all six per-class negatives; the control and the preservation cases stayed green.
    • Query-side intersection removed: 2/10 red, the masked class only.
    • A first attempt at the mount leg was a void leg and is not counted. Its mutation left an import unread, the declaration build refused it, and nothing ran. It was re-run with a buildable mutation, with the result above.

Verification (at eec3115f, after merging current main)

  • pnpm --filter @objectstack/plugin-audit test: 29 files, 411 tests passed.
  • pnpm --filter @objectstack/plugin-audit typecheck: clean, including check:test-typecheck OK.
  • Dogfood: activity-field-values and the sibling activity-parent-read-gate pin, 16/16. pnpm --filter @objectstack/dogfood typecheck is clean.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 95 families on this head, and all 95 exited 0. The --ran reconciliation reads 95 derived, 95 run, 0 NOT-MEASURED, with every exit code recorded.
    • On the way, two gates were red on this diff and were fixed, not waived. check-system-context-census wanted the new row. check:query-options-erasure had grown by four cast sites in the new test; the options are now typed and the ratchet is back at its ceiling.
  • Lint: a proven narrowing; the repo-wide run is CI's.
    • Population: eslint --no-inline-config --format json over the six changed TypeScript files reported all six, none ignored, with 0 errors and 0 warnings.
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project or projectService), so this diff cannot move a verdict on an untouched file.
  • Integration layer: none touched here. The kernel boot paths exercised are the dogfood suite's own.

Acceptance notes

  • Legibility: a restricted reader's dropped summary leaves the row with its type, actor and time, and no text. A value-free rendering, such as "N fields changed" or naming only served fields, would read better. It would need new localized vocabulary and is not part of this fix.
  • One composition, two consumers: the served-unmasked composition (read projection intersected with the query-side answer, failing closed) now exists in two consumers: the approval snapshot and this one. Hoisting both into one shared helper would remove a drift seam. Carrier: none.
  • Adjacent mechanisms: two were measured on the same boot and are outside this change. One is query-side, over the stored activity text; the other is on the compliance ledger. Both are reported to the seat with their measurements for filing. This PR closes what an activity row serves, not what a query over it can infer.
  • Gate runs touched a governed file: with the dev-dependency group bump now on main, a turbo-backed command run by an agent writes a managed block into AGENTS.md. It happened once in this worktree during the gate union, and was restored to HEAD and proven, not committed. It is reported to the seat.

Generated by Claude Code

claude added 5 commits October 1, 2026 08:41
… to a reader the security service serves that field

The CRUD mirror composes each activity row once, as the system, and its
summary, record label and recorded change can carry parent field values.
The read gate keeps a row for every reader who can read the parent record,
so a reader served a field masked, gated off by a capability, or not
granted it by its sets was served the stored value through the row.

Read time, keyed on the reading caller: the recorded change is narrowed
key by key, and each text column is served whole or dropped by the
writer's declaration of which parent fields it was composed from. The
fields served unmasked are the security contract's read projection
intersected with its query-side answer; nothing derives masking here.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…g reader as the control, on the activity stream

A real boot (security, REST, auth and the audit plugin) writes activity
rows through the REST door about a record holding one field of each class,
and reads them back through the list and by-id doors as a reader of each
class. The scene is armed before anything is measured: the rows at rest
carry every class, and the data plane serves each reader its own class as
the class says.

Plus the patch changeset for the plugin-audit fix.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… redaction's elevation read; test options typed

The activity field redaction skips system-context reads, which is one more
elevation read site; its row on the census page joins the sibling read
gate's, and the page's counts are regenerated from the census.

The redaction test's engine query options are typed instead of erased, so
the query-options erasure ratchet's test surface does not grow.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl 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 34 documentable anchor(s).

20 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 e952cff578cc936daab3241570c4c2c36804bbd6.

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

What this run could not see
  • 3 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 e952cff578cc936daab3241570c4c2c36804bbd6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9e004d49db904b26de8de3c1db377345de480659 — the merge of head eec3115faa14f9dbbca11aca3cbf3488b1031cd2 into base e952cff578cc936daab3241570c4c2c36804bbd6, 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 9e004d49db904b26de8de3c1db377345de480659 && git checkout 9e004d49db904b26de8de3c1db377345de480659
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e952cff578cc936daab3241570c4c2c36804bbd6 eec3115faa14f9dbbca11aca3cbf3488b1031cd2 && git checkout -B drift-repro e952cff578cc936daab3241570c4c2c36804bbd6 && git merge --no-ff eec3115faa14f9dbbca11aca3cbf3488b1031cd2

node scripts/docs-audit/affected-docs.mjs --json e952cff578cc936daab3241570c4c2c36804bbd6

⚠️ 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 e952cff578cc936daab3241570c4c2c36804bbd6 → 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 10:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 10:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 2488b98 Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21081-activity-field-values branch October 1, 2026 10:49
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ve a parent field only to a reader the security service serves that field (objectstack-ai#21155) (objectstack-ai#21171)

Fixes objectstack-ai#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 objectstack-ai#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
objectstack-ai#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 objectstack-ai#21081's noted follow-up, not this card's.
`plugin-approvals` is untouched.

---
_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