Skip to content

feat(audit): every data export writes one run-level sys_audit_log row, and the export action comes back with its writer - #31000

Merged
objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/project-thread-y3qc8b
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 15 commits into
mainfrom
claude/project-thread-y3qc8b

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

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 当初在 audit-log (C): retire export / import / permission_change from the sys_audit_log action enum and its in-repo consumer surfaces (ADR-0087 registration) #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:

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 audit: an action run through MCP leaves no audit row naming its surface; ruling 6513869839 parameter 3 (#27681) needs an execution-context surface key, the audit writer and the forward headers #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).

  • 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 / audit-log (C): retire export / import / permission_change from the sys_audit_log action enum and its in-repo consumer surfaces (ADR-0087 registration) #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 feat(plugin-auth): read the AI assistants a person has authorized, and when each was last used #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 (audit: an action run through MCP leaves no audit row naming its surface; ruling 6513869839 parameter 3 (#27681) needs an execution-context surface key, the audit writer and the forward headers #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).
Measured on main 1b3e22f (the PR's source files put back to main, tests kept, rest + plugin-audit rebuilt)
  • 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).
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)
  • 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.

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.

…and the `export` action comes back with its writer

The REST export door (GET /data/:object/export) hands the `audit` slot one
event per export it streamed; plugin-audit's new data-export writer turns it
into an `export` row (record_id null, the object in object_name) carrying the
format, row count, row limit, truncation, the filter as field names and
operators only, whether a search term applied, and the outcome. The `export`
value returns to sys_audit_log.action and to the config_changes view filter.

Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP
Co-authored-by: Claude <noreply@anthropic.com>
…xport, none for a refused one

Pins the door half of #30955 on a real ObjectQL + sqlite boot: one event per
csv/json/xlsx export, the ledger's own export, truncation evidence, a failed
read after streaming began, and no event for EXPORT_NOT_PERMITTED, a 400 parse
refusal or the template. A throwing or empty audit slot leaves the file as is.

Also: the `audit` provider row in the slot-lookups pin, the
persistDataExportAuditRow durability-critical callee, and the `export` option
label in every plugin-audit locale bundle.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP
…s one row and no cell value

Composes the #30955 halves on a real boot (SecurityPlugin + AuditPlugin):
an ordinary export, the ledger's own export, a truncated export, a filtered
and a searched export each leave one run-level row; the seeded value is in
the file and in no row; EXPORT_NOT_PERMITTED leaves none.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP
…site

Regenerated by node scripts/tenant-audit-census.mjs --write; the page's
hand-written figures move with it (261 -> 262, 184 -> 185, 144 -> 145).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP
…low census copies the new durability callee

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-audit, @objectstack/rest, touching 57 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

60 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 bd1b7685fc686eb2c4d5c4701a5b32762d41c7e7.

⛔ 16 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
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 57 pages)
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 224 client-bound route-ledger rows — the other 161 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 161: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 57 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 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 — 25 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 bd1b7685fc686eb2c4d5c4701a5b32762d41c7e7 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 bd1b7685fc686eb2c4d5c4701a5b32762d41c7e7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… census re-measured

Main moved the census to 263 while this branch carried 262. Took main's
side of both census files, re-ran node scripts/tenant-audit-census.mjs
--write (264 with the export writer's site), and restated the page's six
hand-written figures to match.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro
…dQuery, and two test files meet their gates

The rest slot census (rest-server-canonical-query-ast.test.ts) counts every
`query:` line in data-action-routes.ts as a server-built QueryAST slot. The
export audit event's `query` member (its type and its literal) read as two
new slots, 8 against the pinned 6, though neither reaches the engine. The
member is now `readQuery` in the door, the plugin-audit writer and their
tests; the census is back to 6 with nothing re-pinned.

rest-api-plugin-slot-lookups.test.ts, edited by this branch, reaches
`@objectstack/objectql` through two route handlers' dynamic imports; a
module-top side-effect import pays that load during collection
(check:test-source-alias, "The source reach").

The export of an object name the registry does not know, past an export
gate that admitted it, is pinned as built: one failed event, 404
OBJECT_NOT_FOUND.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro
The history line added there brought the file into check:test-source-alias's
source reach; the explanation for the `readQuery` spelling lives at the
event's declaration in data-action-routes.ts instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro
…the tenant-audit census re-measured

#30946 moved main's census to 264. Took main's side of both census files,
re-ran node scripts/tenant-audit-census.mjs --write (265 with the export
writer's site) and restated the page's six hand-written figures.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e1d50d59acf2d6a69ed05c08dc638e97c12cf315
Local-runs: none
Scope: card #30955 (body; comments 6687178882, 6687338710, 6690573399, 6690609596, 6694803777, 6697018696); the PR body; the body delta /mnt/project-files/server-seat-2/r2026-11-06/out/31000-fix-body-delta.md; the fix order dispatch-31000-fix.md; the full diff e1dac192...pr-31000 (32 files, +1503 / -42) read file by file in the local clone (git show/git diff, no checkout); the PR-owned commits and merges e1dac192..pr-31000; on the head: data-action-routes.ts, rest-server.ts, rest-api-plugin.ts, rest-api-plugin-slot-lookups.test.ts, rest-server-canonical-query-ast.test.ts, export-integration.test.ts, plugin-audit data-export-audit.ts + test, audit-plugin.ts, index.ts, sys-audit-log.object.ts and its three tests, audit-log-read-visibility.ts, audit-log-field-redaction.ts, auth-event-audit.ts, action-invocation-audit.ts, the 13 translation bundles, the dogfood file, the two census files, check-tenant-audit-census.mjs (PROSE_COUNTS), check-durability-degradation-log-level.mjs, measure-durability-swallow-family.mjs, check-test-source-alias.mjs, check-adr-0087-registration.mjs, packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts, packages/types/src/thrown-http-error.ts, plugin-security canExport / enforceApiAccess / enforceExportPermission, ui/packages/plugin-detail/src/HistoryTimeline.tsx; the changeset; AGENTS.md (review tiers, Clause-② / Metadata-protocol rules); check-runs on the head.

① Contract: the door, the writer, the enum

The door hands one event per export that passed its gates and began reading. packages/rest/src/data-action-routes.ts:1252 declares exportRun unset; the gates run first — enforceApiAccess(... 'export') at :1276, enforceExportPermission at :1279 (403 EXPORT_NOT_PERMITTED), refuseUnknownQueryParams at :1303, the template branch returns at :1317, readExportFormat at :1329, the filter / orderby JSON refusals after it — and exportRun is only assigned at :1599, immediately before the read loop. Every return above it leaves exportRun undefined, so the two recording sites (:1792 failed, :1808 succeeded) are guarded by if (exportRun) and a refusal writes no row — pinned by the door describe (export-integration.test.ts:707: 403, 400 parameter, 400 filter, template → events empty) and by the dogfood case "a refused export (no export permission) writes no row".

After the response ends. The success site at :1804-1811 runs after res.end() / xlsx.finalize(); the failure site at :1776-1798 runs after handleRouteError has answered. The helper recordDataExport (:429-445) swallows a missing slot and a throwing sink; the writer's recordDataExport never throws (data-export-audit.ts:441-465). Pinned: "a sink that throws changes nothing about the file" (export-integration.test.ts:895), "no audit writer on the slot" (:902).

The writer writes one run-level row. data-export-audit.ts:356-376: action: 'export' (DATA_EXPORT_AUDIT_ACTION, :70), record_id: null, old_value/new_value null, object_name, user_id, tenant_id from the envelope, actor only when the column exists (:371-375, via createFieldPresenceProbe). Metadata (:318-343): format, row_count, limit, requested_limit, limit_cap, limit_capped, truncated, filter (terms, bounded at EXPORT_FILTER_SUMMARY_MAX_TERMS = 32, :133, with filter_terms_omitted), search as a boolean (:335), search_fields names only, outcome, and status / error_code on a failure. The persist callee persistDataExportAuditRow (:436-438) inserts with { context: { isSystem: true } }, the same call shape as auth-event-audit.ts:351 and action-invocation-audit.ts:275. The slot merge is audit-plugin.ts:345 beside recordAuthEvent / recordActionInvocation; the index re-exports at index.ts:27-43.

Truncation. exportTruncatedAtLimit (:414-420): below the limit → false; at the limit hasMore decides, else total > rowCount, else true (a protocol with no page markers cannot rule more rows out). exportLastPage is taken from the last findData (:1646-1648). Pinned on the door ("records truncation at the row limit from the read's own page markers, and the cap clipping the request") and in the dogfood file.

The enum and the view. sys-audit-log.object.ts:410 { label: 'Export', value: 'export' } after Import; :257 the config_changes filter is ['config_change', 'import', 'export']; the ADR-0087 / #8147 comments at :250-256 and :296-319 are rewritten in the same diff ("at the time", permission_change stays retired, a writer-first paragraph naming the writer, the door and the views). sys-audit-log-retired-actions.test.ts moves export from RETIRED_ACTIONS to ACTIONS_WITH_WRITERS (:91-96, the entry at :92) and its set-equality case "the action enum declares exactly the actions that have a writer" (:183-195) is the detector in both directions; sys-audit-log-action-labels.test.ts:28-31 and sys-audit-log-pointer-pair.test.ts:197-198 pin the stored order and the view literal. The option label is in all 11 locale bundles plus the two source-hashes files (e.g. zh-CN "导出", ja-JP "エクスポート").

Is restoring a retired served enum value a correct Clause-② widening? Yes.

  • packages/spec/src declares no audit-action enum (no hit for action_invocation / config_change / platform_admin_standing_change outside migrations/); sys_audit_log.action is declared only by plugin-audit's object. No published spec key moves: 17.audit-log-action-enum-retired.ts:38-42 itself records that the retirement was an enum-VALUE change with "nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets ... byte-identical", and the restore is the inverse of the same class. Spec property liveness and Governed Surface Queue Guard are green on the head; check-adr-0087-registration gates only a declared-breaking changeset (scripts/check-adr-0087-registration.mjs:4-60), which this is not.
  • A consumer who followed the 17.x entry's acceptance criteria (:64-68: "the arms for these two values are now unreachable and should go") now meets export rows its switch falls through on — a value gained, nothing taken away: a widening, not a break. The writer-first rule the retirement cited (审计面宁窄勿谎) is satisfied in the same diff.
  • Metadata-protocol: none is right: sys_audit_log is platform-owned, every field readonly, nobody authors the enum or a row (the 17.x entry's own classification, :43-50); the only authorable surface that could name the value is a list-view filter on a system object, and the enum was never enforced there (validateRecord skips readonly fields, :51-56).
  • Readers of the enum, checked: the retired-actions test (moved), the labels test (moved), the pointer-pair test (moved), the read-visibility gate (audit-log-read-visibility.ts:160-183: a row with no record_id and a non-record action is "about no record" — export is not in LEDGER_RECORD_ACTIONS, so it is served under the ledger grant as import is), the field redaction (audit-log-field-redaction.ts:177-185: only record-write actions and settings config_change are redacted; an export row is served as written, and it carries names only), the UI HistoryTimeline.tsx:109-120 (comment calls export retired; ACTION_VARIANT[action] ?? 'outline' at :250 is the fallback, so the badge renders with the served option label), and the spec history entry 17 (stale prose for an 18.x reader; packages/spec/** is ⛔ for this card). The two non-edited readers degrade to stale comments, not to wrong behaviour.

Findings:

  • must-fix: none.
  • should-fix: none in this section.
  • nit ①-1: the 17.x semantic entry (17.audit-log-action-enum-retired.ts:18-22, :64-68) and HistoryTimeline.tsx:109-115 now state a retirement that no longer holds. Both are outside the surface; the seat should file them as follow-ups (a docs-only spec amendment naming the restore and its PR, and a ui/** card for the comment plus an export variant) rather than leave them as "noted, not filed".
  • nit ①-2: the writer lists in the docblocks of audit-log-read-visibility.ts:51-56 and audit-log-field-redaction.ts:59-61 do not name the export writer; behaviour covers it (measured above). Same follow-up.

② Security and privacy

No filter VALUE or search term reaches the row. summarizeExportFilter (data-export-audit.ts:196-273) reads only keys and operator tokens: a comparand position is never read (:217-219, :247-249), a key that is not a machine-name path becomes ? (FIELD_PATH, :146, :169-171), an operator token of no operator shape becomes ? (OPERATOR, :149, :173-175), a $-key with no field above it is recorded as ('?', key) (:262-264), depth is capped at 16 (:136) and terms at 32 (:206-209). search is typeof query.search === 'string' && trim !== '' (:335); search_fields passes through recordedField (:293-299, max 32). A failure records status and error_code off resolveThrownHttpError (thrown-http-error.ts:225-258: the error's declared status / code, never its message). The failure log line names the object and the cause (:392-429); the unit test pins SECRET absent from the row (data-export-audit.test.ts:175), from the ? terms (:211) and from every log call (:262); the dogfood cases pin the value in the FILE and in no row (:184-200, :213-214). Ablations B1 / B2 (PR body) red exactly those cases.

Tenant scoping. The event carries the request's resolved ExecutionContext (data-action-routes.ts:1599-1600, from resolveExecCtx, which the caller cannot set); the writer reads tenantId off it and stamps tenant_id explicitly (data-export-audit.ts:368), then inserts under isSystem (:437) — the pattern of the incumbent writers (action-invocation-audit.ts:203 / :275). Readers are scoped by the sys_audit_log_org row policy on that column; a null tenant_id row is served to platform administrators alone (audit-log-read-visibility.ts:96-99). The audit slot is resolved for the REQUEST's environment through environmentScopedProvider('audit') (rest-api-plugin.ts:453-468, :549) and resolveAuditService (rest-server.ts:5974-5984, the resolveSecurityService shape), so an environment's export lands in that environment's ledger.

Forge / suppress. The caller controls objectName (judged by two gates and the registry), format (closed set, :1329), limit (read as a declared number and capped), filter / search / searchFields (reduced as above). It cannot set user_id, actor, tenant_id, performed_by or impersonated_by (envelope only, :310-312, :347-354). It cannot suppress the row: the write runs in the handler after the response, unconditionally once exportRun is set, and a client abort does not reach it.

Abort, mid-stream error, truncation. A throw after the read began (a refused read, a driver fault, a write to a destroyed socket) lands in the catch at :1776-1798 → one failed row with the status / code. Pinned for 400 INVALID_FIELD and 404 OBJECT_NOT_FOUND on the door. Truncation is computed on both sites from the same markers. One limitation, pre-existing to the door: it has no abort listener (no aborted / 'close' handling anywhere in the file), so a client that disconnects while a chunked write still succeeds locally gets a succeeded row with the server-side row_count — the row records what the server served, which over-reports what the client received. Not introduced by this PR; nit below.

The ledger's own read visibility and redaction — measured in ①: served as a "row about no record" under the ledger grant, metadata not narrowed (names only). Filter field names are visible to anyone who may read the ledger row; the PR body says so.

Findings:

  • must-fix: none.
  • should-fix: none.
  • nit ②-1: a client-side abort mid-stream is recorded as succeeded with the server-side count (the door has no abort awareness; pre-existing). Worth a sentence in the body's "What changes" or a follow-up, not a change here.

③ The two reds

query → readQuery. rest-server-canonical-query-ast.test.ts:286-290 censuses /^\s*query:\s/ lines; on 63e2ee7f the event's interface line and its literal (data-action-routes.ts:389, :1607 on the head) both matched, giving 8 against the pinned 6 (:459). Neither is a QueryAST handed to the engine — the only engine query in that path stays query: { at :1626 — and had they been counted, the slot-body check would also have flagged the event's filter key as a wire alias. So re-pinning at 8 would have certified two false slots; the rename is a genuine fix at the cause, not a dodge, and the reason is written at the declaration (:377-389). The census test is byte-identical to main (git diff e1dac192 pr-31000 -- rest-server-canonical-query-ast.test.ts empty), as commit 4addb47f says. Reverse-verified by the dev (query put back → expected 8 to be 6).

Module-top import. rest-api-plugin-slot-lookups.test.ts:63-70 adds import '@objectstack/objectql'; with the gate's own prescription (check-test-source-alias.mjs:2169: "Add a module-top side-effect import so the transform is paid during COLLECTION"); @objectstack/objectql is a declared dependency of @objectstack/rest (packages/rest/package.json:46). The PROVIDERS row index: 21, slot: 'audit', scope: 'environment' (:136) and the scoped list (:437-440) match rest-api-plugin.ts:591 (appended last, #15256) and rest-server.ts:2165-2168.

Census. tenant-audit-census.mdx and the .counts.md are regenerated at 265 (main after #30946: 264); the only new row is data-export-audit.ts | insert | sys_audit_log | enabled | elevated | 1 (.counts.md:123). Every enforced PROSE_COUNTS pattern (check-tenant-audit-census.mjs:289-400) resolves on the head; Lint · repo & tooling gates is green. The hand-written figures that moved, outside the generated region (mdx:230-319): 264→265 at :135, :200, :202, :220, :224 (five places), 187→188 at :202, 147→148 at :203 and :220 — eight figures, not six (see Body-delta accuracy).

Findings:

  • must-fix: none.
  • should-fix: none.

④ Open question 3: the unknown-object export

As built and now pinned (export-integration.test.ts:872-893): an export gate that admits no_such_object → 404 OBJECT_NOT_FOUND from the read, one failed event with status: 404, rowCount: 0, truncated: false.

Reachability on a real stack: enforceApiAccess passes an unregistered name through by design (rest-server.ts:2730-2732, "the data path's registry gate 404s it"), so the case is decided by canExport (security-plugin-object-admission.ts:363-416): it needs resolved permission sets granting export on that name with getObjectSecurityMeta not unresolved — a wildcard set — or no security service at all (enforceExportPermission returns false without one, rest-server.ts:2847). So only an export-privileged caller reaches it, as the hand-off says.

Judgement: acceptable to land as pinned, with a should-fix the seat may take now or as a follow-up. The arguments for a no-row refusal are the PR's own: no object exists, so no data could have moved (the row is defined as "data that moved"); and the row's object_name is caller-chosen path text that no registry verified — String(req.params.object || '') at :1260, no pattern check in the door, and the column's maxLength: 255 (sys-audit-log.object.ts:453) is not validated on this readonly object (the 17.x entry, :51-56), so on a driver that enforces the column width the insert fails and is logged once per cause with that text in the line. The INVALID_FIELD precedent differs: there the object exists and the row names a real object. The one-line special case the dev offered (treat OBJECT_NOT_FOUND as a refusal in the catch, flip the pin) closes both.

Findings:

  • should-fix ④-1: make 404 OBJECT_NOT_FOUND a no-row refusal (one line in the catch at data-action-routes.ts:1787-1797, and flip the pin at export-integration.test.ts:872); or, if the seat prefers the one-rule reading, bound object_name to a machine-name shape before it reaches the ledger. Either is small; neither changes what the card asked for. Not a landing blocker: the behaviour is pinned, privileged-only and bounded by URL length.

⑤ Tests, ablations, dogfood, census lines, commits

  • Unit (data-export-audit.test.ts, 15): real ObjectQLPlugin + AuditPlugin on sqlite through the audit slot; ordinary object, the ledger itself, truncation + cap, failed outcome, no cell value, array dialect, ? terms, the 32 bound, de-duplication, agent / service-actor attribution, no actor without the column, error-then-debug with no value in any log line, no engine.
  • Door (export-integration.test.ts:707-908, 13 = it.each × 3 formats + 10): the audit provider is handed as the 22nd positional (:775), exactly as rest-api-plugin.ts does; the ledger export is measured against a sink that writes into the ledger (:746-751), so "its file holds the rows that were there before" is a real pin.
  • Dogfood (data-export-audit-row.dogfood.test.ts, 8): real SecurityPlugin + AuditPlugin on bootStack, exporter / reader sets (:77-90), bounded poll for the post-res.end() row (:111-119, :116-117: 100 × 20 ms, then a 150 ms settle so a second row would count). The allowExport on sys_audit_log is a fixture grant (:88); the gate is untouched (enforceExportPermission unchanged in the diff).
  • Ablations A / A′ / B1 / B2 and the main re-measure are in the PR body with restore evidence; the hand-off's dist-staleness trap was caught and the affected round discarded. The 404 pin is a presence pin (no ablation owed).
  • Durability: DURABILITY_CRITICAL_CALLEES gains persistDataExportAuditRow (check-durability-degradation-log-level.mjs:409-412), proven red by a warn downgrade; WRITE_SHAPED_CALLEES gains the copy (measure-durability-swallow-family.mjs:331). Both gates green in Lint · repo & tooling gates.
  • Changeset .changeset/30955-data-export-audit-row.md: patch for @objectstack/plugin-audit and @objectstack/rest, both declaration lines, no breaking banner — the level the AGENTS.md rule gives a widening with Metadata-protocol: none.
  • Commits (10 PR-owned + 3 merges, e1dac192..pr-31000): every trailer is Co-Authored-By: Claude <noreply@anthropic.com> + Claude-Session:; no model name, no Fixes line in any commit message (grep over all bodies: no hit). Fixes #30955 is in the PR body only.

Findings:

  • must-fix: none.
  • should-fix: none.

⑥ Check-runs on e1d50d59

50 check-runs, all completed; none failed, none pending. Success: Test Core (8/8 shards), TypeScript Type Check, Lint & Repo Gates, Lint · ESLint, Lint · CI, release & engine gates, Lint · scoped · slot lookup & corpus, Lint · scoped · query-options erasure, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · tooling self-tests, Type Check · workspace / source gates / consumer gates / debt ledger, Build Core, Build Docs, Live E2E UI, Test UI, Dogfood Regression Gate, Governed Surface Queue Guard, Check Changeset, PR-body guards, Check PR Size, Check Documentation Links, Spec property liveness, Flag docs affected, Auto Label, filter, Plan Test Core shards. Skipped by the PR filter: the UI lanes (Build UI Deps, Lint UI, Type Check UI, Test UI shards, UI Published Artifacts, Build & E2E UI), Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance, the two opt-in harnesses and the schedule gate. Last completion 2026-11-06T21:53Z. mergeable_state: clean, draft.

Body-delta accuracy

Items 1, 2, 4, 5 and 6 match the head: readQuery at the declaration and literal; the module-top import and its two dynamic-import sources; the 404 pin with status: 404, errorCode: 'OBJECT_NOT_FOUND', rowCount: 0; 13 door cases; 32 files +1503 / -42. Item 3 undercounts: the page's hand-written figures that moved are eight — 264→265 in five places (:135, :200, :202, :220, :224, the 2 / N citation among them), 187→188 once, 147→148 twice — not "six ... (three places ...)". Replace that sentence with the five/one/two count (nit). Item 4's promise that "making it a no-row refusal is a one-line special case" stays accurate and is the ④-1 option.

Implemented-by: claude/project-thread-y3qc8b
Reviewed-by: session_01L9qibyvxbrLP4jm4PargoH

VERDICT: PASS — landing waits on nothing in the diff: the seat applies the body delta (with the item-3 figure count corrected), decides ④-1 (land as pinned, or take the one-line no-row refusal before the queue), and files the three ⛔-surface follow-ups (spec entry 17 prose, HistoryTimeline.tsx, the two docblock writer lists).

…s no audit row

Seat decision on the contract review of the export audit row: an object name
the registry does not know means no object and no data moved, and the name
is only the caller's path text, so the export catch hands no event for
OBJECT_NOT_FOUND. The door pin flips to 404 with no event, beside a control
export of a known object on the same boot.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro
No conflict; the tenant-audit census still certifies 265 sites.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

维护者速读

  • 改了什么:每次数据导出(GET /data/:object/export)通过权限等关卡、开始读取后,审计插件写一条运行级 sys_audit_log 记录:action: 'export',记格式、行数、上限、是否截断、筛选摘要(只记字段名和运算符,最多 32 项,不记值),搜索只记"有/无",以及成功或失败。被拒绝的导出(无导出权限、参数错误、模板、对象不存在)不写。export 这个动作值恢复使用。
  • 为什么改:卡片 Data export writes no sys_audit_log row and the export action value is retired, so no export of any object (the audit ledger included) can be audited #30955,以前任何对象的导出(包括审计日志本身)都没有审计记录。
  • 风险与回滚:只新增审计记录,导出文件本身不变;记录在响应结束后写入,写失败不影响导出。回滚=撤销本 PR。
  • 先看:packages/rest/src/data-action-routes.ts 的导出路由(exportRun 在所有关卡之后才赋值),packages/plugins/plugin-audit/src/data-export-audit.ts。
  • 两分钟验证:pnpm --filter @objectstack/plugin-audit exec vitest run src/data-export-audit.test.ts,预期 15 条全绿。
  • 需要你做的:无,席位落地。

席位意见:可以落地。

席位核对

ACCEPT · seat domain:server#2 · head 35743e5a · 2026-11-06T22:53Z

  1. Shape: Fixes #30955, draft, base main, 32 files, +1504/−42 (1,546 changed lines; check-governed-merges --pr 31000: not governed). Clause-②: yes (widening) (a retired audit action value comes back with its writer; packages/spec declares no audit-action enum), Metadata-protocol: none; changeset patch for @objectstack/plugin-audit and @objectstack/rest. Take-over claim 6694803777 (hand-off 6690573399; original claim 6687338710).
  2. Premise: measured in the PR: on main an export wrote no sys_audit_log row and the export value was retired, so no export was auditable.
  3. Review: CONTRACT_REVIEW_TIER review 6697209231 (PASS on e1d50d59, 0 must-fix). It confirms: no row on any refusal (exportRun set only after every gate); the row is written after the response ends and a writer failure is swallowed; no filter value or search term reaches the row; tenant scoping holds; restoring export is a correct widening. The two reds were fixed at the cause. The query → readQuery rename stops a census that miscounted the event's type line as QueryAST slots, so it is not a dodge. Should-fix ④-1 (seat decision) is in 53bec3a5, read by the seat: an unknown object name is a no-row refusal (if (code === 'OBJECT_NOT_FOUND') return; in the export catch), the pin flips to 404 with no event beside a known-object control, and the ablation is exactly 1 red and was restored to HEAD. The merge of bd1b7685 is clean (its tree equals a fresh merge-tree of its parents).
  4. Gates / check-runs: on head 35743e5a: 38 success, 12 skipped, 0 failed. Dev: rest 7,486 passed; plugin-audit 1,194/1,194; dogfood file 8/8; dispatch-gates 117/117 exit 0, --ran 0 not measured; pnpm lint 0. Tenant-audit census 265 (after feat(plugin-auth): read the AI assistants a person has authorized, and when each was last used #30946), eight hand-stated figures moved, check OK. The seat applied the body delta.
  5. Accepted deviations / follow-ups: the spec 17.x migration entry and ui/packages/plugin-detail/src/HistoryTimeline.tsx still call export retired (both outside the surface; the UI falls back to a neutral badge). The seat files them. A client abort mid-stream is recorded succeeded with the server-side count; that is pre-existing door behaviour and is noted only.
  6. Landing: not governed: the seat readies the PR and arms auto-merge on the maintainer's word in the seat thread 「你应该使用 pm-dispatch 技能审核和落地pr」(2026-11-06T16:49Z).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 22:56
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 22:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit 022b4e7 Nov 6, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-y3qc8b branch November 6, 2026 23:17
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data export writes no sys_audit_log row and the export action value is retired, so no export of any object (the audit ledger included) can be audited

2 participants