Skip to content

feat(plugin-audit): record-view auditing — who viewed which record (#8992) - #9515

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-8992-read-view-audit-mvp
Aug 18, 2026
Merged

feat(plugin-audit): record-view auditing — who viewed which record (#8992)#9515
os-project-manager merged 3 commits into
mainfrom
claude/issue-8992-read-view-audit-mvp

Conversation

@os-project-manager

@os-project-manageros-project-manager commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes#8992

sys_audit_log covered writes only — actionFor() maps exactly
afterInsert/afterUpdate/afterDelete, and the shipped list views confirmed the
scope — so the platform could not answer the question every regulated-industry
security review opens with: who viewed this customer record, and when?

Both premises re-measured on origin/main before any code was written; both
still hold (e3a86e390).

What lands

The read action, writer-first: the emission point, its tests, and the
record_views list view that surfaces it, in one stroke. That ordering is not
style — sys-audit-log-retired-actions.test.ts pins set-equality between the
enum and a writer inventory, precisely because every field on this object is
readonly and validateRecord skips readonly fields, so the enum validates
nothing in either direction.

Packaging split — where the enterprise half is, and why it is not in this diff

The ruling splits this per the AI-governance precedent: neutral audit-write seam
in plugin-audit (open runtime), policy/enforcement in the enterprise security
package. This diff is the open-runtime seam only, because the enterprise
package has no source in this repository.

Measured, not assumed: @objectstack/security-enterprise has no directory under
packages/, and no workspace package.json declares it. Every in-repo reference
treats it as an externally-installed package — platform-capabilities.ts lists it
as edition: 'enterprise' with the note that it ships via plugins[], and the
hierarchy-security precedent works the same way. The same answer was reached one
card over: #8993's landing note records the split it applied as "everything in the
open runtime, enterprise composes on top".

So the split is honoured by shape, and here is precisely what the enterprise
side composes on top:

  • What it calls:installReadAuditWriter(engine, { objects, ... }), exported
    from the package root. Its policy engine computes the object list from whatever
    licensed source it owns and hands it over. There is no second entry point to
    wire and no ordering constraint beyond having the engine.
  • What it does not need to re-derive: the ledger row shape, the record-detail
    discriminator, the batching, the exclusion subtraction, the tenant stamp. All of
    those are mechanism and stay here — an enterprise package re-implementing any of
    them would be a second de-facto definition of the sys_audit_log row, which is
    the drift auth-event-audit.ts already argues against for the auth rows.
  • What is deliberately absent from the open runtime: any default, any built-in
    object list, any "audit everything unless" posture. The opt-in has no default
    value; an empty list registers no hook at all.

To be explicit about the boundary rather than let it read as compliance: the seam
does fix three things the enterprise side cannot vary — the record-detail-only
scope, the audit exclusion list, and the system-elevated skip. Those are the
ruling's own scope pins and the plugin's existing exclusions, not per-deployment
policy. The per-deployment policy is exactly one thing, which objects, and it is
a pure input.

The ruling's scope pins, as code rather than as prose

Maintainer ruling 2026-08-16, ruled jointly with #8993.

PinWhere it livesWhat proves it
Record-detail views onlyextractDetailReadIda list read, a single-row list read, and a findOne with no key pin all produce zero rows
Per-object opt-in, closedthe narrow afterFind registration targeta non-opted-in object never buffers anything; an empty opt-in registers no hook at all
Async batched writes off the request pathcreateReadAuditBatcherthe read resolves with the ledger still empty and the view buffered

A read qualifies as a record-detail view when it materialized one record and
its predicate pinned the primary key — the shape getData produces for the
record-detail route. The predicate walk tolerates what the security middleware
leaves behind (it AND-composes RLS predicates onto the where clause before the
driver runs) and refuses $or / $not, under which an id equality no longer
proves the read was for that record.

Declare-and-enforce, in this PR

There is no declarable surface here that enforcement could lag behind, by
construction: the opt-in is one input, used as both the hook registration
target and the recorded set. A registration target plus a separate runtime
predicate would be two surfaces that can disagree, and the disagreement is silent
in the worst direction — a policy naming an object the registration never targeted
would declare coverage that produces no row, which on an audit surface a compliance
reviewer reads as coverage. Both directions are pinned:

  • an opted-in object produces a read row on a record-detail view;
  • an object that is not opted in produces none, and buffers nothing;
  • an object named in the opt-in but sitting on the audit exclusion list is dropped
    with a warning, so a configuration cannot silently claim coverage.

Off the request path — what was measured, not what is annotated

The assertion is behavioural: after await engine.findOne(...) resolves, the
ledger table holds zero rows and the writer reports exactly one buffered view.
Only then does an explicit flush produce the row. A synchronous write creeping
back in fails that test — demonstrated, not assumed: ablating the hook to await
its own flush turns it red along with two others (see the ablation table below).

Deferred, and actually deferred

List/search-result auditing, sampling modes and sensitivity-label triggering are
not built. The record-detail discriminator is what makes the first of those a
real deferral rather than a stated one.

Two things worth reviewing closely

The row records no field values. Read auditing runs at afterFind, which is
inside the security middleware and ahead of its field masking (that runs
after next()). So the record this writer sees is pre-mask plaintext, including
the #8993maskingRule channel's partial masks. Copying values in would mint a
plaintext copy of exactly what field-level security withholds, inside the one
table compliance staff are granted broad access to. Both value columns stay
null, and a test asserts the sensitive fixture value appears nowhere in the
serialized row.

This is also why the trail does not derive "what did this viewer actually
see". That answer has one implementation — SecurityPlugin.computeReadPartialMaskRules,
which #9127 lifted so explain's fls layer, result masking and getReadableFields
all read it. A second derivation minted here would drift from it. Recording
masking state is a follow-up that must read the lifted one.

Each row keeps the VIEW instant, not the flush instant. Batching is what
moves the write off the request path, and it is also what makes the NOW()
default wrong: it would stamp a whole batch with the moment its buffer drained,
producing a ledger that answers "when did they look?" with its own scheduling.
created_at is engine-owned and stripped from ordinary writes (#4447); a
system-context write is the declared exemption, which is the context the ledger
insert already uses.

Declared boundaries, not silent ones

  • A system-elevated read writes no row. sudo() keeps the caller's user id,
    so that flag is the only thing separating "a person opened this record" from
    "the platform read it while recomputing a formula". Without the skip, every
    roll-up would land in the ledger as a user view.
  • A read with no principal writes no row — there is no "who" to record.
  • An object on the audit exclusion list is refused from the opt-in with a
    warning
    , rather than silently producing nothing.

Both boundaries are pinned in tests, so they are narrowings a reviewer can see.

Documentation

No hand-written page states an audit scope this change makes false. The five
pages under content/docs/** that mention sys_audit_log were read: the audit
service page documents the settings sink (action: 'set' | 'reset'), a different
surface; the others cover nav, object ownership and metadata reads. No page
enumerates the action enum, and none claims audit is write-only.

packages/plugins/plugin-audit/README.mdis badly drifted — it documents an
auditService API with no implementation and a row shape the object does not
have — but that drift predates this change and is unrelated to it, so it is filed
as a separate finding (#9517) rather than fixed as a rider here.

Verification

Every gate below run at 23b2f70b6, which is HEAD, with the workspace closure
built (check:i18n and check:type-check-debt both refuse to judge an unbuilt
tree, and say so rather than reporting green).

plugin-audit test 17 files / 268 tests passed
plugin-audit typecheck clean
downstream typecheck 6 of 78 projects (plugin-audit + its 5 CONSUMERS) clean
check:query-options-erasure OK — test surface 240, FLAT (see below)
check:type-check-debt OK — 33 ledger entries re-measured, none above its number
check:nul-bytes OK check:durability-log-level OK
check:i18n OK check:cross-package-test-inputs OK
check:test-source-alias OK check:type-source-resolution OK
check:engine-double-contract OK check:type-check-coverage OK
check:changeset-gate-self-tests OK check:objectui-changeset OK
check-adr-0087-registration OK check-changeset-no-major OK
check-empty-changeset OK check-affected-docs OK

The query-options-erasure ratchet (patch round)

The first push grew the test surface 240 to 262 — 22 erasures in the new suite.
Fixed at the source, ratchet left flat at 240: every find / findOne /
insert options bag is now passed typed, and the shared read context is a named
ReadContext alias off EngineQueryOptions['context']. No baseline number was
raised and no assertion was weakened.

Worth noting against the gate's own #8210 caveat, which is that typing these
often buys nothing today because most counted packages exclude their tests from
tsc: plugin-audit does not, so the guard here is live. Positive control —
planting the exact #4674 defect (orderBy: [{ field: 'id', direction: 'asc' }],
direction being IReportService's vocabulary rather than SortNodeSchema's
order) now fails compilation:

error TS2353: Object literal may only specify known properties, and 'direction'
does not exist in type '{ field: string; order?: "desc" | "asc" | undefined; }'.

Under the old as any that same key was silently dropped. Control reverted.

Reverse verification. Four ablations, direction predicted before each run,
all four red and each on exactly the test that names the property; the fix was
committed first, so every restore was byte-identical.

AblationResult
widen the hook registration to a wildcardthe not-opted-in test goes red
make the hook await its flushthe off-the-request-path test plus 2 more go red
drop the view-instant stampthe view-instant test goes red
remove the system-elevated skipthe declared-boundary test goes red

Plus a cross-package type check: a consumer (dogfood) fed a misspelled
readAudit key is rejected by name against the rebuilt declaration file —
'object' does not exist in type 'AuditPluginReadAuditOptions'. Did you mean to write 'objects'? — so the new option type is genuinely visible downstream
rather than read from a cache. Probe deleted.

The tests run against a real engine over a stub driver, not a mocked hook
dispatcher: all three pins are properties of the engine's own behaviour, and a
fake engine would let this suite assert them against its own mock of the thing
under test.


Generated by Claude Code

…ter, and its view (#8992)
`sys_audit_log` covered writes only: `actionFor()` maps exactly
afterInsert/afterUpdate/afterDelete, and the shipped list views confirmed the
scope. "Who viewed this customer record, and when?" was unanswerable.
Adds the `read` action WRITER-FIRST — the emission point, its tests, and the
`record_views` list view that surfaces it, in one stroke, which is the only way
a value is allowed onto this enum (#8147 / #8315).
Scope is the maintainer's 2026-08-16 ruling, and each pin is code:
- record-detail views only — `extractDetailReadId` requires one materialized
record AND a primary-key pin, so list/search reads produce nothing;
- per-object opt-in, closed — one input, used as the narrow `afterFind`
registration target, so a non-audited read costs no dispatch;
- batched off the request path — the hook enqueues and returns; each row keeps
the VIEW instant via the system-context `created_at` exemption (#4447).
The row carries no field values: `afterFind` runs ahead of the security
middleware's field masking, so `ctx.result` is pre-mask plaintext.
Co-Authored-By: Claude <noreply@anthropic.com>
`registry.registerObject(schema, packageId)` requires the owner; the one-arg
call ran fine but failed `tsc --noEmit`.
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

76 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive: node scripts/docs-audit/affected-docs.mjs --json origin/main.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/index.ts) — pages documenting those are invisible to this run
  • 2 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 29 pages), created_at (literal, 33 pages)
  • 16 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 4 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 origin/mainpackageMentionDocs.

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 origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
…them to `any`
The new read-audit suite added 22 sites to the `query-options-erasure`
test-surface ratchet (240 -> 262). Fixed at the source: every find/findOne/
insert options bag is now passed typed, and the shared read context is a named
`ReadContext` alias off `EngineQueryOptions['context']`. The ratchet returns to
240 — flat, not raised.
This is not count-satisfying hygiene here. The gate's #8210 caveat is that in a
package whose tsconfig excludes test files, typing these buys no compiler guard
today. plugin-audit does NOT exclude them, so `tsc` reads this file and a wrong
options key is a real compile error rather than a silently dropped one (#4674).
Co-Authored-By: Claude <noreply@anthropic.com>
@os-project-manager
os-project-manager marked this pull request as ready for review August 18, 2026 07:40
@os-project-manager
os-project-manager added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 5126e79Aug 18, 2026
28 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-8992-read-view-audit-mvp branch August 18, 2026 07:58
os-project-manager pushed a commit that referenced this pull request Aug 18, 2026
…dit surface that shipped (#9517)
PR #9531 corrected this README against the shipped surface while record-view
auditing was still an unmerged draft, so it correctly refused to describe it.
That work has since landed (#8992 via PR #9515), which made two of the page's
statements false: "reads and views are not on the ledger", and that the plugin
takes no configuration.
The page now documents the surface that exists, each point measured against the
source rather than against a description of it: the `read` action and its writer
in the action table, the `record_views` list view, the record-detail
discriminator (one materialized record plus a primary-key pin, `$or`/`$not`
refused) that keeps list and search reads out of scope, the batched
off-request-path writes with the view-instant `created_at` and the two loud
once-only failure postures, and the two declared boundaries — a system-elevated
read and a read with no principal both write no row.
The opt-in is documented as what it is: an INSTALL-TIME list on the plugin
constructor, explicitly not an `enable.auditReads` object-metadata key. That
spelling was ruled against on #8992 for the reason this card exists — a
declarable key can be set on an object in a deployment that never installs the
plugin, producing metadata that reads as audited and writes nothing.
Two things the page now says that only reading the code shows: `maxBufferedEvents`
is a writer knob the plugin does not forward, and the shipped `record_views` view
carries an `ip_address` column that is always empty on a `read` row because no
read-path writer stamps it.
Record-view auditing adds no enterprise dependency — this package's declared
edition is `open` — so nothing new is annotated under that pattern; the two
existing annotations are unchanged.
A changeset is owed because the README ships in the package's `files` array: a
docs-only correction with no version bump never reaches the npm package page.
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

audit: no read/view audit — "who viewed which record" is unanswerable, blocking regulated-industry deals

2 participants

@os-project-manager@claude