Skip to content

docs(plugin-audit): the published README documents the record-view audit surface that shipped - #9541

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-9517-readme-read-audit
Aug 18, 2026
Merged

docs(plugin-audit): the published README documents the record-view audit surface that shipped#9541
os-project-manager merged 1 commit into
mainfrom
claude/issue-9517-readme-read-audit

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#9517

This is the residual half of the card. PR #9531 (53fc09922) landed the urgent half — the SOC 2 / HIPAA / GDPR claim, the 12 fabricated auditService methods, the wrong row shape, the wrong object name and the 6 nonexistent REST routes are all gone from main, and two dependency boundaries were annotated. It correctly refused to document record-view auditing because that work was still an unmerged draft.

Premise re-verified before editing anything

git merge-base --is-ancestor 5126e795d origin/main && echo landed

prints landed. 5126e795d is feat(plugin-audit): record-view auditing — who viewed which record (#8992) (#9515), so the blocker named in the previous round is cleared and the surface is now describable. Everything below was measured against origin/main at 53fc09922, not against the card's description of it.

What the README now documents

  • The read action, in the action table with its writer and trigger, and the record_views list view in the views table.
  • The per-object opt-in as an install-time list, with the constructor spelling and the three settings the plugin forwards.
  • Off-the-request-path batched writes — enqueue-and-return, the size and timer flush thresholds, the destroy() tail drain, and why created_at holds the view instant rather than the flush instant.
  • The record-detail discriminator — one materialized record plus a primary-key pin, the AND-composed predicate the security middleware leaves behind, the $or / $not refusal, and the depth bound. This is what keeps list and search reads out of scope, so it is stated as the scope rule rather than as trivia.
  • Both declared boundaries — a system-elevated read and a read with no principal each write no row.
  • That no field values are recorded, and the consequence: the ledger cannot answer what a viewer actually saw.

Two things only reading the code shows, both now on the page:

  1. maxBufferedEvents (default 10000) is a writer knob the plugin does not forward. Documenting it as a plugin option would have been a small instance of exactly this card's defect.
  2. 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. Filed separately as record_views lists an ip_address column that no read-path writer ever stamps — a declared-but-unwritten column on a shipped compliance view #9539; documented here rather than left to surprise a reader.

The opt-in is an install-time list, not a metadata key

The README says so explicitly, and says why. enable.auditReads appears on the page exactly once, in a sentence stating it does not exist — and the verification below asserts that, so a later edit cannot quietly turn the mention into a documented API.

Scope 4 re-checked: record-view auditing introduces no enterprise boundary

packages/spec/src/kernel/platform-capabilities.ts:143 declares audit: { package: '@objectstack/plugin-audit', edition: 'open' }. The read writer lives in this package, the opt-in is ordinary plugin configuration, and nothing about the capability degrades on an open build. ⇒ Nothing new is annotated under the access-recipes.mdx pattern; the page says so in one sentence rather than inventing a dependency. The two existing annotations (archive datasource fails closed to retention, hierarchy resolver fails closed to own) are untouched, and the second is noted as applying to read rows the same way it applies to every other row.

Evidence: a set-equality check anyone can re-run

Matching the bar PR #9531 set. Save and run from the repo root:

cat > /tmp/check-audit-readme.mjs <<'EOF'import { readFileSync } from 'node:fs';const P = 'packages/plugins/plugin-audit/';const readme = readFileSync(P + 'README.md', 'utf8');const object = readFileSync(P + 'src/objects/sys-audit-log.object.ts', 'utf8');const index = readFileSync(P + 'src/index.ts', 'utf8');const plugin = readFileSync(P + 'src/audit-plugin.ts', 'utf8');const writer = readFileSync(P + 'src/read-audit.ts', 'utf8');let bad = 0;const eq = (label, doc, src) => { const d = [...new Set(doc)].sort(), s = [...new Set(src)].sort(); const missing = s.filter((x) => !d.includes(x)), invented = d.filter((x) => !s.includes(x)); const ok = !missing.length && !invented.length; if (!ok) bad++; console.log(`${ok ? 'OK ' : 'FAIL'} ${label}: documented ${d.length} / declared ${s.length}` + (ok ? '' : `\n omitted: ${JSON.stringify(missing)}\n invented: ${JSON.stringify(invented)}`));};const section = (from, to) => { const a = readme.indexOf(from), b = to ? readme.indexOf(to, a + from.length) : readme.length; if (a < 0 || b < 0) throw new Error('section not found: ' + from); return readme.slice(a, b);};const firstCol = (text) => { const sep = text.search(/^\|[-\s|:]+\|\s*$/m); const body = sep < 0 ? text : text.slice(text.indexOf('\n', sep) + 1); return [...body.matchAll(/^\|\s*`([a-z_]+)`\s*\|/gm)].map((m) => m[1]);};eq('action enum', firstCol(section('## What lands on the ledger', '## `sys_audit_log` fields')), object.match(/action:\s*Field\.select\(\s*\[([^\]]*)\]/)[1] .split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter(Boolean));eq('sys_audit_log fields', firstCol(section('## `sys_audit_log` fields', '**Secret masking.**')), [...object.matchAll(/^ {4}(\w+):\s*Field\./gm)].map((m) => m[1]));eq('list views', firstCol(section('| View | Shows |', '\nIndexes are declared')), [...object.matchAll(/^ {4}(\w+):\s*\{\n\s*type: 'grid'/gm)].map((m) => m[1]));const documented = [...section('## Exports', '## License').matchAll(/^export (?:type )?\{([^}]*)\}/gms)] .flatMap((m) => m[1].split(',').map((s) => s.trim()).filter(Boolean));const unresolved = documented.filter((s) => !new RegExp(`(^|[\\s,{])${s}([\\s,}]|$)`, 'm').test(index));if (unresolved.length) bad++;console.log(`${unresolved.length ? 'FAIL' : 'OK '} exports: ${documented.length} documented symbols` + (unresolved.length ? `\n not exported by src/index.ts: ${JSON.stringify(unresolved)}` : ''));const optRows = [...section('| Option | Default | Meaning |', '\n⚠️ The writer itself') .matchAll(/^\|\s*`readAudit\.(\w+)`\s*\|\s*`([^`]*)`\s*\|/gm)].map((m) => [m[1], m[2]]);eq('readAudit options', optRows.map((r) => r[0]), [...plugin.slice(plugin.indexOf('interface AuditPluginReadAuditOptions'), plugin.indexOf('interface AuditPluginOptions')) .matchAll(/^\s{2}(\w+)\??:/gm)].map((m) => m[1]));const defs = Object.fromEntries([...writer.matchAll( /^\s{4}(maxBatchSize|flushIntervalMs|maxBufferedEvents) = ([\d_]+),/gm)] .map((m) => [m[1], m[2].replace(/_/g, '')]));for (const [name, doc] of [...optRows, ['maxBufferedEvents', '10000']]) { if (!(name in defs)) continue; const ok = defs[name] === doc.replace(/[^\d]/g, ''); if (!ok) bad++; console.log(`${ok ? 'OK ' : 'FAIL'} default ${name}: README ${doc} / source ${defs[name]}`);}const claimsKey = /`enable\.auditReads`/.test(readme) && !/There is no `enable\.auditReads`/.test(readme);if (claimsKey) bad++;console.log(`${claimsKey ? 'FAIL' : 'OK '} enable.auditReads is named only to say it does not exist`);console.log(bad === 0 ? '\nALL CHECKS PASSED' : `\n${bad} CHECK(S) FAILED`);process.exit(bad === 0 ? 0 : 1);EOF
node /tmp/check-audit-readme.mjs

Output at 4435acf66:

OK action enum: documented 8 / declared 8
OK sys_audit_log fields: documented 13 / declared 13
OK list views: documented 6 / declared 6
OK exports: 28 documented symbols
OK readAudit options: documented 3 / declared 3
OK default maxBatchSize: README 50 / source 50
OK default flushIntervalMs: README 2000 / source 2000
OK default maxBufferedEvents: README 10000 / source 10000
OK enable.auditReads is named only to say it does not exist
ALL CHECKS PASSED

Nothing is documented that the source does not declare, and nothing declared is left out: 8 of 8 action values, 13 of 13 fields, 6 of 6 list views, 28 of 28 export symbols resolving in src/index.ts, 3 of 3 plugin options with their defaults matching the writer's.

The check is proven able to fail

A green check nobody has seen go red is an assurance, not evidence. Three ablations, each run against the committed tree and each restored to byte identity afterwards:

AblationResult
delete the read row from the action tableFAIL action enum: documented 7 / declared 8 — omitted: ["read"]
delete the record_views row from the views tableFAIL list views: documented 5 / declared 6 — omitted: ["record_views"]
document maxBatchSize as 25FAIL default maxBatchSize: README 25 / source 50

After restore, git diff --stat HEAD is empty and the check passes again.

Gates

Derived from git merge-base origin/main HEAD (53fc09922) per #9320, not from a two-dot range: node scripts/pm/dispatch-gates.mjs .changeset/mighty-ducks-repeat.md packages/plugins/plugin-audit/README.md gives 8 path-derived plus 1 convention-triggered. All run at 4435acf66, which is the branch tip and the tree every gate saw.

GateResult
check:changeset-gate-self-testsOK (118 + 206 + 116 assertions)
check:objectui-changesetOK
check:test-source-aliasOK (72 packages)
check:type-source-resolutionOK (76 packages)
check-adr-0087-registration.mjsOK (1 non-breaking changeset seen)
check-changeset-no-major.mjsOK
check-empty-changeset.mjsOK (1 declaring changeset added)
check-affected-docs.mjsOK (220 self-test cases)
check:i18n (convention-triggered)OK (9 packages, all bundles in sync)
check:nul-bytes (any edit)OK (6151 files, 0 control bytes)

check:i18nrefused the unbuilt tree first (PREREQUISITE NOT MET, exit 1) and only went green after turbo run build --filter=@objectstack/cli (55 tasks). Reported green here is green, not skipped.

Package scope, after building the dependency closure (pnpm --filter '@objectstack/plugin-audit^...' build):

Test Files 17 passed (17)
Tests 268 passed (268)

and pnpm --filter @objectstack/plugin-audit typecheck clean — the script name is echoed in the output, so this is not a zero-match silent pass. All heavy runs were serialized through flock -E 99 -w 240 /tmp/os-heavy-verify.lock; no queue timeouts.

⚠️ No dogfood ablation is claimed and none applies: the diff is one Markdown file plus a changeset, with zero executable code. The test and typecheck runs are a no-regression control, not evidence about README content — the set-equality check above is that evidence.

Changeset

Owed, patch. PR #9531's reasoning is the precedent and it holds here: README.md is in this package's published files array with private unset, so a docs-only correction with no version bump never reaches the npm package page at all. The changeset is the mechanism that publishes the correction, not paperwork — which is why skip-changeset would be the wrong call.

Findings filed, not fixed here


Generated by Claude Code

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-audit/README.md) — pages documenting those are invisible to this run

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 02ebb6f5b3e43f3878edcdfdb6533aff99c19c4apackageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mtooling

Projects

None yet

2 participants

@os-project-manager@claude