Repository navigation
feat(audit): every data export writes one run-level sys_audit_log row, and the export action comes back with its writer - #31000
Conversation
…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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP
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
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
📓 Docs Drift CheckThis PR changes 2 package(s): 60 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
… 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
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract reviewServed-tier: ① Contract: the door, the writer, the enumThe door hands one event per export that passed its gates and began reading. After the response ends. The success site at The writer writes one run-level row. Truncation. The enum and the view. Is restoring a retired served enum value a correct Clause-② widening? Yes.
Findings:
② Security and privacyNo filter VALUE or search term reaches the row. Tenant scoping. The event carries the request's resolved Forge / suppress. The caller controls 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 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:
③ The two reds
Module-top import. Census. Findings:
④ Open question 3: the unknown-object exportAs built and now pinned ( Reachability on a real stack: 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 Findings:
⑤ Tests, ablations, dogfood, census lines, commits
Findings:
⑥ Check-runs on
|
…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
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH 维护者速读
席位意见:可以落地。 席位核对ACCEPT · seat
|
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.tsis an addition the seat accepted for review. The two tenant-audit census files and the oneWRITE_SHAPED_CALLEESline inscripts/measure-durability-swallow-family.mjsare 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
维护者速读
GET /data/:object/export,CSV / JSON / XLSX)时,每次导出都会在审计日志里留一条export记录,写明谁导出、导出的对象、格式、行数、是否被行数上限截断、用了哪些筛选字段和运算符、是否带了搜索,以及成功还是失败。导出审计日志本身也会记。export重新回到审计日志的动作列表,「配置变更」视图也会列出这些记录。export当初在 audit-log (C): retireexport/import/permission_changefrom thesys_audit_logaction enum and its in-repo consumer surfaces (ADR-0087 registration) #8147 被下线,是因为没有任何代码写它;这次先有写入方再恢复这个值,符合「审计面宁窄勿谎」。企业版的审计导出(cloud#3665)在等这张卡。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/exportstreamed up to 50,000 rows of any object the caller may export,sys_audit_logincluded, and wrote nothing to the compliance ledger.sys_audit_log.actionhad noexportvalue: #8147 retired it because no code wrote it. Measured on main1b3e22f02c:export(sys-audit-log.object.ts:244"exportremoved (audit-log (C): retireexport/import/permission_changefrom thesys_audit_logaction enum and its in-repo consumer surfaces (ADR-0087 registration) #8147)", theconfig_changesfilter at:250is['config_change', 'import'], the retirement note is at:289);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, newdata-export-audit.ts).createDataExportAuditWriteraddsrecordDataExportto the existingauditservice slot, besiderecordAuthEventandrecordActionInvocation, 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_valuenull. This is the run-level shape of the incumbentimportrow (plugin-auth'sadmin-import-users.ts).user_id,actor(when the column exists) andtenant_id, taken from the request's execution context. An agent-performed export addsperformed_by/on_behalf_of, and an impersonated one addsimpersonated_by, as the other ledger rows do.metadata:format,row_count;limit,requested_limit,limit_cap, andlimit_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, plussearch_fields(names) when a search applied;outcome(succeeded/failed), plusstatus/error_codefor a failed one.The value scrub (
summarizeExportFilter). It walks both filter dialects (the object form with$and/$or/$not/$norand nested relation paths, and the array form[field, op, value]withand/orlists). It keeps only: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 infilter_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 lastfindData's ownhasMore/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 recordsoutcome: 'failed'with the status and machine coderesolveThrownHttpErrorreads off the error, never the error text. The event carries the query as read underreadQuery, notquery. It is no query handed to the engine, andrest-server-canonical-query-ast.test.tscensuses everyquery:line indata-action-routes.tsas a server-built QueryAST slot: spelledquery, the event's type line and its literal read as two new slots (8 against the pinned 6, the Test Core 6/8 red on63e2ee7f). The pin stays 6, re-pinned nowhere.The reach (
rest-server.ts,rest-api-plugin.ts).rest-api-plugin.tsadds an environment-scopedauditprovider, using the same helper assecurityandi18n.RestServertakes it as the LAST positional constructor argument ([decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring underisolated— the wall compares against the caller's own unvetted claim #15256) and handsresolveAuditServicetoDataActionRouteHost.rest-api-plugin-slot-lookups.test.tspins the new PROVIDERS row (index 21) and the environment-scoped list. It also imports@objectstack/objectqlat module top. The realRestServerit extends reaches that package only through two route handlers' dynamic imports (meta-object-state-routes.ts,meta-record-currency-backfill-route.ts), andcheck: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 afterImport.config_changesview filter is['config_change', 'import', 'export'].export/import/permission_changefrom thesys_audit_logaction 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_changestays retired, and a writer-first paragraph names the writer and its views.sys-audit-log-retired-actions.test.tsmovesexportfromRETIRED_ACTIONStoACTIONS_WITH_WRITERS, naming the writer, and the stored-values list insys-audit-log-action-labels.test.tsgainsexport.check-i18n-bundlesandcheck:i18n-coverageOK).Tenant-audit census (regenerated). The writer's
engine.insertintosys_audit_logis 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 --writeregeneratedcontent/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.mdfrom main's side of both files. Eight hand-written figures on the page, whichcheck-tenant-audit-censusholds 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 the2 / Ncitation), 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) joinsDURABILITY_CRITICAL_CALLEES, so its loss report cannot be downgraded belowerrorwithout the gate going red. Proven by downgrading it towarn: exit 1.scripts/measure-durability-swallow-family.mjskeeps a deliberate copy of that list (WRITE_SHAPED_CALLEES, origingate-vocabulary).check:swallow-census-controlsrequires 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 unparseablefilterororderby(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 asobject_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_invocationrule (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 aterroronce per cause (the cause key is the error's code or name), then atdebugfor 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 ownauditsink 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 realObjectQLPlugin+AuditPluginon sqlite, reachesrecordDataExportthrough theauditslot, and reads every row back:limit_capped;$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: trueandsearch_fieldsare recorded;?for a non-machine-name key and a non-operator token;actorkey without the column;erroronce thendebug, with the value in no log line;rest/src/export-integration.test.ts, new describe (13 cases), on the real streaming door over a real ObjectQL + sqlite, with theauditprovider passed asrest-api-plugin.tspasses it:400,INVALID_FIELD);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);qa/dogfood/test/data-export-audit-row.dogfood.test.ts(new, 8 cases). It bootsbootStackwith the realSecurityPluginandAuditPlugin, with an exporter (read + export) and a reader (read only):EXPORT_NOT_PERMITTEDleaves no row, with an exporter control on the same door;bootStackthe signed-in admin is refused export ofsys_audit_log(EXPORT_NOT_PERMITTED). So the exporter's fixture set grantsallowExportonsys_audit_logfor the ledger case. That is a fixture grant, not a change to the gate; the gate is untouched.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, andsys-audit-log-pointer-pair.test.ts(itsconfig_changescontrol pins the filter literal, now withexport).Measured on main 1b3e22f (the PR's source files put back to main, tests kept, rest + plugin-audit rebuilt)
exportabsent fromsys_audit_log.action(grep "value: 'export'": no match).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.git checkout HEAD -- packages, then checked:git statusempty,git diff HEADempty, 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 byvoid 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.termswas replaced by the rawquery.filter, and the preflight shows the marker present in the dist. Unit: 1 of 15 red, exactlythe 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.searchwas 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 inexport-integration.test.ts, exactly the unknown-object case (expected [ { context: { …(2) }, …(11) } ] to deeply equal []). Restored: blob == HEAD (cc48b9986d34),git diff HEADempty.Acceptance notes
sys_audit_logexport: all three;sys_audit_log.actionis declared only by plugin-audit's object, andpackages/specdeclares no audit-action enum. The semantic migration entrypackages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.tsstays as written. It is the 17.x record and says "no export feature ever wrote an audit row", which was true then. That ispackages/spec(⛔ for this card), so it is noted here, not edited.check-adr-0087-registration --base origin/mainis green.ui/packages/plugin-detail/src/HistoryTimeline.tsxhas a comment sayingexportis retired (⛔ui/**). For an action it has no style for, the timeline falls back to an outline badge.plugin-audit'saudit-log-read-visibility.tsandaudit-log-field-redaction.tsdo not name the export writer. Their behaviour already covers it: run-level rows (norecord_id, a non-record action) are served under the ledger grant.recentlists export rows except an export of an object that view already leaves out (AUDIT_LOG_DEFAULT_VIEW_EXCLUDED_OBJECTS, the settings stores).config_changesandall_eventslist every export row.tenant_idis the exporter's tenant. A row withtenant_idnull (a platform-scope export) is served to platform admins only, as every such ledger row is.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.res.end()), so the dogfood cases read the ledger until the row lands (bounded) rather than immediately.Verification
Take-over rounds, head
35743e5a(④-1 on53bec3a5, thenorigin/mainbd1b7685merged clean;pnpm --filter @objectstack/rest teston35743e5a: 393 files, 7486 passed / 332 skipped). Round 1, heade1d50d59(origin/maine1dac192merged, #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):63e2ee7fand their causes: Test Core 6/8 (rest-server-canonical-query-ast.test.ts:459, 8 ≠ 6) was the event'squerymember counted as two QueryAST slots, fixed at the source byreadQuery; reverse-verified by puttingqueryback (expected 8 to be 6) and restoring (git diff HEADempty).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/objectqlimport; fixed by the module-top import the gate names.4addb47f:pnpm --filter @objectstack/rest test393 files, 7486 passed / 332 skipped;pnpm --filter @objectstack/plugin-audit test76 files, 1194/1194;typecheckexit 0 for both. (The second merge brought no change topackages/restorpackages/plugins/plugin-audit.)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.ts15/15;check-tenant-audit-censusOK (265 sites, 23 prose figures held).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-auditand@objectstack/rest:testandtypecheck;node scripts/pm/dispatch-gates.mjs --commandsderives from this diff, reconciled with--ran;pnpm lint.CI's required checks are the authority for everything else.