Skip to content

Commit 022b4e7

Browse files
feat(audit): every data export writes one run-level sys_audit_log row, and the export action comes back with its writer (#31000)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DWeF7nbxqGwBWoay6h9xZio)_ domain:server#1 · os-elon-musk · session_016WV4cxfxbKV82MKKQzizpP Fixes #30955 Claim: 6687338710 (full re-post of 6687178882 with this branch; file surface amended to the writer's reach). The dogfood file `packages/qa/dogfood/test/data-export-audit-row.dogfood.test.ts` is an addition the seat accepted for review. The two tenant-audit census files and the one `WRITE_SHAPED_CALLEES` line in `scripts/measure-durability-swallow-family.mjs` are what two gates require for the writer's new write call site and durability callee, reported to the seat. Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:通过 REST 导出数据(`GET /data/:object/export`,CSV / JSON / XLSX)时,每次导出都会在审计日志里留一条 `export` 记录,写明谁导出、导出的对象、格式、行数、是否被行数上限截断、用了哪些筛选字段和运算符、是否带了搜索,以及成功还是失败。导出审计日志本身也会记。`export` 重新回到审计日志的动作列表,「配置变更」视图也会列出这些记录。 - 为什么改:此前导出完全不留痕。有导出权限的人一次能下载最多 5 万行(审计日志本身也能导),审计日志里却没有任何记录。`export` 当初在 #8147 被下线,是因为没有任何代码写它;这次先有写入方再恢复这个值,符合「审计面宁窄勿谎」。企业版的审计导出(cloud#3665)在等这张卡。 - 风险与回滚:只新增审计行,导出文件的内容和导出权限判断都不变。审计行在文件发完之后才写,写失败只记一条 error 日志,导出照常成功。记录里从不出现导出的单元格值:筛选只记字段名和运算符,搜索只记「有搜索」,测试埋了特征值并断言它不在行里。被拒绝的导出(没有导出权限、参数错误)不写记录。可整个 PR 回滚。 - 先看:`packages/plugins/plugin-audit/src/data-export-audit.ts` 的 `summarizeExportFilter` 和 `buildDataExportAuditRow`(只保留字段名和运算符);`packages/rest/src/data-action-routes.ts` 导出处理里 `exportRun` 的赋值点,以及文件结束后对 `recordDataExport` 的两处调用;`sys-audit-log.object.ts` 恢复的 `export` 值和 `config_changes` 视图筛选。 - 两分钟验证:`pnpm --filter @objectstack/plugin-audit exec vitest run src/data-export-audit.test.ts`,预期 15 个用例全部通过,其中 `the row never carries an exported cell value` 会埋入一个特征值,并断言整条记录里没有它。 - 需要你做的:无,席位落地。 ## What changes `GET /data/:object/export` streamed up to 50,000 rows of any object the caller may export, `sys_audit_log` included, and wrote nothing to the compliance ledger. `sys_audit_log.action` had no `export` value: #8147 retired it because no code wrote it. Measured on main `1b3e22f02c`: - the enum has no `export` (`sys-audit-log.object.ts:244` "`export` removed (#8147)", the `config_changes` filter at `:250` is `['config_change', 'import']`, the retirement note is at `:289`); - the export door (`data-action-routes.ts:1096`) has no ledger write. Run against main's source (rebuilt), the new dogfood file sees zero export rows after each of six exports. This PR adds the writer first, then restores the value. - **The writer (`plugin-audit`, new `data-export-audit.ts`).** `createDataExportAuditWriter` adds `recordDataExport` to the existing `audit` service slot, beside `recordAuthEvent` and `recordActionInvocation`, the #27908 pattern. The door hands it an EVENT; plugin-audit owns the row shape. Both sides declare the event structurally, so neither package depends on the other. The row: - `action: 'export'`, `object_name` = the exported object, `record_id: null`, `old_value` / `new_value` null. This is the run-level shape of the incumbent `import` row (`plugin-auth`'s `admin-import-users.ts`). - `user_id`, `actor` (when the column exists) and `tenant_id`, taken from the request's execution context. An agent-performed export adds `performed_by` / `on_behalf_of`, and an impersonated one adds `impersonated_by`, as the other ledger rows do. - `metadata`: - `format`, `row_count`; - `limit`, `requested_limit`, `limit_cap`, and `limit_capped` (whether the 50,000 cap clipped the request); - `truncated`: the file stopped at its limit and the read did not say the result set ended there; - `filter`: a summary of field paths and operator tokens, never a comparand; - `search`: a boolean, never the term, plus `search_fields` (names) when a search applied; - `outcome` (`succeeded` / `failed`), plus `status` / `error_code` for a failed one. - **The value scrub (`summarizeExportFilter`).** It walks both filter dialects (the object form with `$and` / `$or` / `$not` / `$nor` and nested relation paths, and the array form `[field, op, value]` with `and` / `or` lists). It keeps only: - field paths that match a machine-name pattern (at most 4 dotted segments); - operator tokens that match an operator shape. Anything else is recorded as `?`, so a value placed in a key position cannot pass through. Terms are de-duplicated and bounded at 32; the remainder is counted in `filter_terms_omitted`, and recursion depth is capped at 16. Comparands are never read into the summary. - **The door (`rest`, `data-action-routes.ts`).** An export is recorded once every gate has passed and the read begins: auth, export permission, parameter parsing. It is recorded after the response has ended, completed or failed. The truncation reading uses the last `findData`'s own `hasMore` / `total`; at its limit, a read that answers neither cannot rule out more rows, so the file reads as truncated. An export that fails after reading began records `outcome: 'failed'` with the status and machine code `resolveThrownHttpError` reads off the error, never the error text. The event carries the query as read under `readQuery`, not `query`. It is no query handed to the engine, and `rest-server-canonical-query-ast.test.ts` censuses every `query:` line in `data-action-routes.ts` as a server-built QueryAST slot: spelled `query`, the event's type line and its literal read as two new slots (8 against the pinned 6, the Test Core 6/8 red on `63e2ee7f`). The pin stays 6, re-pinned nowhere. - **The reach (`rest-server.ts`, `rest-api-plugin.ts`).** - `rest-api-plugin.ts` adds an environment-scoped `audit` provider, using the same helper as `security` and `i18n`. - `RestServer` takes it as the LAST positional constructor argument (#15256) and hands `resolveAuditService` to `DataActionRouteHost`. - `rest-api-plugin-slot-lookups.test.ts` pins the new PROVIDERS row (index 21) and the environment-scoped list. It also imports `@objectstack/objectql` at module top. The real `RestServer` it extends reaches that package only through two route handlers' dynamic imports (`meta-object-state-routes.ts`, `meta-record-currency-backfill-route.ts`), and `check:test-source-alias` ("The source reach") asks every test file a change edits to pay that load during collection. - **The enum (`sys-audit-log.object.ts`).** - `{ label: 'Export', value: 'export' }` is back after `Import`. - The `config_changes` view filter is `['config_change', 'import', 'export']`. - The two ADR-0087 / #8147 comments are updated in the same diff: the retirement note now says "at the time", `permission_change` stays retired, and a writer-first paragraph names the writer and its views. - `sys-audit-log-retired-actions.test.ts` moves `export` from `RETIRED_ACTIONS` to `ACTIONS_WITH_WRITERS`, naming the writer, and the stored-values list in `sys-audit-log-action-labels.test.ts` gains `export`. - The option label is in all 11 plugin-audit locale bundles (`check-i18n-bundles` and `check:i18n-coverage` OK). - **Tenant-audit census (regenerated).** The writer's `engine.insert` into `sys_audit_log` is a new write call site (elevated, against a tenancy-enabled object). The census moves from main's 264 (after #30946) to 265 sites. `node scripts/tenant-audit-census.mjs --write` regenerated `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` from main's side of both files. Eight hand-written figures on the page, which `check-tenant-audit-census` holds to the census, move with it: the site count 264 to 265 in five places (the unreadable-share population, the deviation-table row, the decidable-share population, the elevated-share population and the `2 / N` citation), decidable 187 to 188 once, and decidably elevated 147 to 148 twice (still 56%). The only new census row is this writer's. - **Durability gate (`scripts/check-durability-degradation-log-level.mjs`).** `persistDataExportAuditRow` (the writer's named persist callee) joins `DURABILITY_CRITICAL_CALLEES`, so its loss report cannot be downgraded below `error` without the gate going red. Proven by downgrading it to `warn`: exit 1. `scripts/measure-durability-swallow-family.mjs` keeps a deliberate copy of that list (`WRITE_SHAPED_CALLEES`, origin `gate-vocabulary`). `check:swallow-census-controls` requires the new name there too, so it is added there. ### A refused export writes no row, and why A request the door refuses before it reads anything writes no row. That covers `EXPORT_NOT_PERMITTED` (403, the gate is unchanged), an unknown or unreadable parameter (400), and an unparseable `filter` or `orderby` (400). The import template is not an export of data and writes none either. - An object name the registry does not know (`404 OBJECT_NOT_FOUND`), even past an export gate that admitted the name: there is no object, so no data moved, and the name is only the caller's path text, which the ledger would otherwise store unverified as `object_name`. It is the one read refusal special-cased in the export catch (`if (code === 'OBJECT_NOT_FOUND') return;`). - Nothing was read, so no data left the system; the row records data that moved. - The caller already has a definitive refusal from the door. - A refusal row could be minted at request rate by any caller, unprivileged ones included, which would flood the compliance ledger with noise. - It is the `action_invocation` rule (#27908): a row once the call has cleared the door's admission gates, whether it then succeeds or fails, and none for a refusal at the gates. - A refusal row would need its own shape and its own card; if one is wanted, it should arrive writer-first like this one. An export that fails after reading began DOES write a row (`outcome: 'failed'`), because part of the stream may already have reached the caller. That includes a read the protocol refuses: an unknown field in the filter (`400 INVALID_FIELD`, pinned in the door describe). ### When the audit write fails The row is written after `res.end()`, so the ledger write cannot change, delay or cut the file. The writer never throws. A failed insert is reported at `error` once per cause (the cause key is the error's code or name), then at `debug` for the same cause. The message names the object and the cause, never the query or any value: `Data-export audit write FAILED (...)`. The export's answer is unchanged: 200 and the full file. The door also catches anything a host's own `audit` sink throws. A kernel without plugin-audit has no ledger and exports exactly as before. ## Tests - `plugin-audit/src/data-export-audit.test.ts` (new, 15 cases). It boots the real `ObjectQLPlugin` + `AuditPlugin` on sqlite, reaches `recordDataExport` through the `audit` slot, and reads every row back: - an ordinary object gives one row of the full shape; - the ledger's own export gives one row; - truncation and `limit_capped`; - a failed outcome with its status and code; - **no cell value**: a distinctive value is placed in every comparand position (`$eq`, `$in`, a nested relation `$contains`, under `$not`) and as the search term. `JSON.stringify(row)` must not contain it, while the field and operator terms, `search: true` and `search_fields` are recorded; - the array dialect; - `?` for a non-machine-name key and a non-operator token; - the 32-term bound with the omitted count; - de-duplication; - agent attribution and service-actor attribution, and no `actor` key without the column; - a refused insert logs `error` once then `debug`, with the value in no log line; - no engine: silent. - `rest/src/export-integration.test.ts`, new describe (13 cases), on the real streaming door over a real ObjectQL + sqlite, with the `audit` provider passed as `rest-api-plugin.ts` passes it: - csv / json / xlsx each hand ONE event with format and row count; - the ledger's own export hands one event, and its file holds the rows that were there before; - truncation from the read's page markers, and the cap clipping the request; - the query is handed as read (the writer reduces it); - a read refused after the gates hands one failed event (`400`, `INVALID_FIELD`); - no event for `EXPORT_NOT_PERMITTED`, for a 400 parameter or filter refusal, for the template, or for an object name the registry does not know past an admitting export gate (`404 OBJECT_NOT_FOUND`, with a known-object export on the same boot as its control); - a throwing sink and an empty slot leave the 200 and the file unchanged. - `qa/dogfood/test/data-export-audit-row.dogfood.test.ts` (new, 8 cases). It boots `bootStack` with the real `SecurityPlugin` and `AuditPlugin`, with an exporter (read + export) and a reader (read only): - an ordinary export, a csv export, the ledger's own export and a truncated export each leave one row; - a filtered export and a searched export carry the seeded value in the FILE and in no row; - `EXPORT_NOT_PERMITTED` leaves no row, with an exporter control on the same door; - no export row anywhere in the ledger carries the value. - On `bootStack` the signed-in admin is refused export of `sys_audit_log` (`EXPORT_NOT_PERMITTED`). So the exporter's fixture set grants `allowExport` on `sys_audit_log` for the ledger case. That is a fixture grant, not a change to the gate; the gate is untouched. - Updated: `rest-api-plugin-slot-lookups.test.ts` (the PROVIDERS row and the environment-scoped list), `sys-audit-log-retired-actions.test.ts`, `sys-audit-log-action-labels.test.ts`, and `sys-audit-log-pointer-pair.test.ts` (its `config_changes` control pins the filter literal, now with `export`). <details><summary>Measured on main 1b3e22f (the PR's source files put back to main, tests kept, rest + plugin-audit rebuilt)</summary> - `export` absent from `sys_audit_log.action` (`grep "value: 'export'"`: no match). - Dogfood file: 7 failed / 1 passed. Six exports each left zero rows (`expected [] to have a length of 1 but got +0`), and the ledger file held zero export rows. The one pass is the ledger-wide "no row carries the value" case, which is vacuous with no rows. - Door describe: 7 failed / 5 passed. Every positive case saw no event; the five no-event cases pass on main by construction. - Restored with `git checkout HEAD -- packages`, then checked: `git status` empty, `git diff HEAD` empty, and the writer's blob equals HEAD's blob (`307d2c3064`). </details> <details><summary>Ablations (scripts/ablation-replace.mjs, WRAP mode: anchor 1 → 0, blob changed, restored blob == HEAD and git diff HEAD empty; dist reach checked with ablation-dist-preflight.mjs)</summary> - **A. The writer dropped from the slot.** `audit-plugin.ts`'s `...createDataExportAuditWriter(...)` was replaced by an empty object. The preflight marker `...createDataExportAuditWriter(` is present in the pristine build and absent after. Unit: 6 of 15 red, all six slot cases. Dogfood: 7 of 8 red, every row case; the 8th is the vacuous ledger-wide one. - **A′. The door's call dropped.** `await sink.recordDataExport(event)` was replaced by `void event`. Door describe: 7 of 12 red, every positive case; the five no-event cases stay green. - **B1. The filter scrub dropped.** `metadata.filter = summary.terms` was replaced by the raw `query.filter`, and the preflight shows the marker present in the dist. Unit: 1 of 15 red, exactly `the row never carries an exported cell value`. Dogfood: 2 of 8 red, the filtered case and the ledger-wide case. - **B2. The search scrub dropped.** `metadata.search` was set to the raw term. Unit: 1 of 15 red, the same no-cell-value case. Dogfood: 2 of 8 red, the searched case and the ledger-wide case. The preflight marker was a comment, which esbuild strips (exit 1). The dogfood red on the searched case alone is the evidence that the mutation reached the dist. - A first round of A and B1 ran the dogfood file against a rest dist that was still built from main's source, left over from the re-measure. Those dogfood reds were discarded. HEAD was rebuilt (preflight hits in both packages, dogfood 8/8 green) and A and B1 were re-run; the figures above are from the re-run. - **C. The unknown-object refusal dropped.** `if (code === 'OBJECT_NOT_FOUND') return;` deleted from the export catch. Door describe: 1 of 39 red in `export-integration.test.ts`, exactly the unknown-object case (`expected [ { context: { …(2) }, …(11) } ] to deeply equal []`). Restored: blob == HEAD (`cc48b9986d34`), `git diff HEAD` empty. </details> ## Acceptance notes - Every Acceptance bullet has a test: - one row with format and count: unit, door and dogfood; - the `sys_audit_log` export: all three; - truncation: all three; - no exported cell value: unit and dogfood, with ablation B. - Seat addition: the search term never reaches the row, only "search present" (unit, dogfood, ablation B2). - **Spec.** No spec enum is needed: `sys_audit_log.action` is declared only by plugin-audit's object, and `packages/spec` declares no audit-action enum. The semantic migration entry `packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts` stays as written. It is the 17.x record and says "no export feature ever wrote an audit row", which was true then. That is `packages/spec` (⛔ for this card), so it is noted here, not edited. `check-adr-0087-registration --base origin/main` is green. - **Not edited, outside the surface.** - `ui/packages/plugin-detail/src/HistoryTimeline.tsx` has a comment saying `export` is retired (⛔ `ui/**`). For an action it has no style for, the timeline falls back to an outline badge. - The writer lists in the docblocks of `plugin-audit`'s `audit-log-read-visibility.ts` and `audit-log-field-redaction.ts` do not name the export writer. Their behaviour already covers it: run-level rows (no `record_id`, a non-record action) are served under the ledger grant. - **Views.** - `recent` lists export rows except an export of an object that view already leaves out (`AUDIT_LOG_DEFAULT_VIEW_EXCLUDED_OBJECTS`, the settings stores). - `config_changes` and `all_events` list every export row. - A row's `tenant_id` is the exporter's tenant. A row with `tenant_id` null (a platform-scope export) is served to platform admins only, as every such ledger row is. - **Not recorded.** The exported COLUMNS (`fields=`) are not recorded, only the filter's field names. The card did not ask for them; they could be a follow-up. Filter field names are visible to anyone who can read the ledger row; they are names, not values. - The row lands just after the caller has the complete file (it is written after `res.end()`), so the dogfood cases read the ledger until the row lands (bounded) rather than immediately. ## Verification **Take-over rounds, head `35743e5a`** (④-1 on `53bec3a5`, then `origin/main` `bd1b7685` merged clean; `pnpm --filter @objectstack/rest test` on `35743e5a`: 393 files, 7486 passed / 332 skipped). Round 1, head `e1d50d59` (`origin/main` `e1dac192` merged, #30946 included; delta against main 32 files, +1503 / -42), after a closure rebuild (`turbo run build --filter='./packages/**' --filter='@objectstack/dogfood^...'`, 81/81, lock VERDICT command-exit 0): - Reds on `63e2ee7f` and their causes: Test Core 6/8 (`rest-server-canonical-query-ast.test.ts:459`, 8 ≠ 6) was the event's `query` member counted as two QueryAST slots, fixed at the source by `readQuery`; reverse-verified by putting `query` back (`expected 8 to be 6`) and restoring (`git diff HEAD` empty). `Lint · CI, release & engine gates` (`check:test-source-alias`, `rest-api-plugin-slot-lookups.test.ts:96`) was the edited test file reaching a dynamic `@objectstack/objectql` import; fixed by the module-top import the gate names. - On the pre-merge-2 head `4addb47f`: `pnpm --filter @objectstack/rest test` 393 files, 7486 passed / 332 skipped; `pnpm --filter @objectstack/plugin-audit test` 76 files, 1194/1194; `typecheck` exit 0 for both. (The second merge brought no change to `packages/rest` or `packages/plugins/plugin-audit`.) - On `e1d50d59`: the dogfood file 8/8 (`NODE_OPTIONS=--max-old-space-size=6144`); the census, door and slot-lookup files 162 passed / 22 skipped; `data-export-audit.test.ts` 15/15; `check-tenant-audit-census` OK (265 sites, 23 prose figures held). - Derived gates and `pnpm lint`: in the os-dev-report comment for this round. Local runs on the final head are quoted, with that head, in the report comment on #30955: - `@objectstack/plugin-audit` and `@objectstack/rest`: `test` and `typecheck`; - the dogfood file; - every family `node scripts/pm/dispatch-gates.mjs --commands` derives from this diff, reconciled with `--ran`; - `pnpm lint`. CI's required checks are the authority for everything else. <!-- signed: domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78d4fe9 commit 022b4e7

32 files changed

Lines changed: 1504 additions & 42 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
'@objectstack/rest': patch
4+
---
5+
6+
feat(audit): every data export writes one run-level `sys_audit_log` row, and the `export` action comes back with its writer
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
`GET /data/:object/export` used to leave the compliance ledger nothing to say: a user could download up to 50,000 rows of any object they may export, `sys_audit_log` included, and no row recorded who, what or how much. Each export the door streams now writes one row with `action: "export"`, the object, `record_id: null` and the exporter (user, actor, tenant, and the agent or impersonator when there is one). Its `metadata` carries the format, the row count, the effective and requested limit and whether the 50,000 cap clipped it, whether the file was truncated at the limit, a bounded summary of the filter (field names and operators, at most 32 terms, never a comparand), whether a search term narrowed it (never the term) and the outcome. An export whose read fails after the checks records `outcome: "failed"` with the status and machine code it answered.
12+
13+
A request refused before anything is read (no export permission, a bad parameter, an unparseable filter) writes no row; neither does the import template. The row is written after the file is finished, so the audit write never changes or delays the download; a ledger write that fails is logged at `error` once per cause and the export is unaffected.
14+
15+
`export` is declared again on `sys_audit_log.action` (it was retired under #8147 because nothing wrote it) and is shown by the `config_changes` list view beside `config_change` and `import`. The writer lives on the `audit` service slot as `recordDataExport`, beside `recordAuthEvent` and `recordActionInvocation`; a kernel without the audit plugin exports exactly as before.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **55 of the 264 sites are spelled that way**. A
135+
forwarding shim cannot, and **55 of the 265 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **264** |
200+
| 175 write call sites | quoted in the merged changeset | **265** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **187 of 264** decidable, **77** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 147 decidably elevated, 0 decidably not, 109 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **188 of 265** decidable, **77** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 148 decidably elevated, 0 decidably not, 109 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
147 of 264 (56%) as decidably elevated, with 109 more whose elevation is a
220+
148 of 265 (56%) as decidably elevated, with 109 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 264`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 265`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **264** |
237-
| …whose object name is statically decidable | 187 |
236+
| write call sites on the application surface | **265** |
237+
| …whose object name is statically decidable | 188 |
238238
| …whose object name is chosen at run time | 77 |
239-
| …against an object with tenancy ENABLED | 181 |
239+
| …against an object with tenancy ENABLED | 182 |
240240
| …against an object that declares tenancy off | 6 |
241-
| threading a tenant context | 201 |
241+
| threading a tenant context | 202 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 55 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 147 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 148 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 109 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 202 |
252+
| receiver carried a readable engine type | 203 |
253253
| receiver erased, placed by the object NAME | 52 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 118 |
257+
| object name spelled inline | 119 |
258258
| object name spelled through a `const` | 69 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 70 |
@@ -307,11 +307,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-11-06 at `19cadde19`.
310+
Measured on 2026-11-06 at `4addb47fd`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 1044 |
314+
| tracked non-test sources scanned | 1045 |
315315
| engine-shaped types recognised | 112 |
316316
| declared objects in the registry | 130 |
317317
| same-named calls subtracted as non-engine | 234 |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 264 |
37-
| Object name statically decidable | 187 |
36+
| Write call sites | 265 |
37+
| Object name statically decidable | 188 |
3838
| Object name chosen at run time | 77 |
39-
| Against a tenancy-enabled object | 181 |
39+
| Against a tenancy-enabled object | 182 |
4040
| Against an object declaring tenancy off | 6 |
41-
| Threading a tenant context | 201 |
41+
| Threading a tenant context | 202 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 55 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 147 |
46+
| Threading a decidably elevated context | 148 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 109 |
4949

@@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-11-06 at `19cadde19`.
93+
Measured on 2026-11-06 at `4addb47fd`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 1044 |
97+
| tracked non-test sources scanned | 1045 |
9898
| engine-shaped types recognised | 112 |
9999
| declared objects in the registry | 130 |
100100
| same-named calls subtracted as non-engine | 234 |
@@ -120,6 +120,7 @@ Measured on 2026-11-06 at `19cadde19`.
120120
| `packages/plugins/plugin-audit/src/activity-changed-fields-backfill.ts` | `update` | `sys_activity` | enabled | elevated | 1 |
121121
| `packages/plugins/plugin-audit/src/auth-event-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |
122122
| `packages/plugins/plugin-audit/src/comment-parent-pair-backfill.ts` | `update` | `sys_comment` | enabled | elevated | 1 |
123+
| `packages/plugins/plugin-audit/src/data-export-audit.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 |
123124
| `packages/plugins/plugin-audit/src/mail-log-body-migration.ts` | `update` | `object` | undecidable | elevated | 1 |
124125
| `packages/plugins/plugin-audit/src/mail-log-body-migration.ts` | `update` | `object` | undecidable | options unreadable | 1 |
125126
| `packages/plugins/plugin-audit/src/read-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 |

‎packages/plugins/plugin-audit/src/audit-plugin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface }
3030
import { installReadAuditWriter, type ReadAuditWriterHandle } from './read-audit.js';
3131
import { createAuthEventAuditSink } from './auth-event-audit.js';
3232
import { createActionInvocationAuditWriter } from './action-invocation-audit.js';
33+
import { createDataExportAuditWriter } from './data-export-audit.js';
3334
import { installCommentAccessHooks, installCommentReadVisibility } from './comment-access-hooks.js';
3435
import {
3536
COMMENT_REACTION_REMOVE_ROUTE,
@@ -320,6 +321,11 @@ export class AuditPlugin implements Plugin {
320321
// (`recordActionInvocation`): the two action doors in `@objectstack/runtime`
321322
// resolve it per request and hand it one event per invocation, naming the
322323
// door it came through (ruling 6513869839 on #27681, parameter 3).
324+
//
325+
// [#30955] And the data-export writer (`recordDataExport`): the REST
326+
// export door resolves it per request and hands it one event per export
327+
// it streamed, so every export of every object, this ledger included,
328+
// leaves a run-level `export` row (`data-export-audit.ts`).
323329
const getLedgerEngine = (): IDataEngine | undefined => {
324330
try {
325331
return ctx.getService<IDataEngine>('objectql');
@@ -336,6 +342,7 @@ export class AuditPlugin implements Plugin {
336342
ctx.registerService('audit', {
337343
...createAuthEventAuditSink({ getEngine: getLedgerEngine, logger: ctx.logger }),
338344
...createActionInvocationAuditWriter({ getEngine: getLedgerEngine, logger: ctx.logger }),
345+
...createDataExportAuditWriter({ getEngine: getLedgerEngine, logger: ctx.logger }),
339346
});
340347

341348
// ADR-0029 D8 — contribute this plugin's object translations to the i18n

0 commit comments

Comments
 (0)