Conversation
ca4e2dd to
d35df83
Compare
adrian-lorenzo
left a comment
There was a problem hiding this comment.
Thanks for exploring this approach.
Before merging, please bound the matching-event query and make the test fixture seed its own role. With pageSize: 2, my probe still loaded 1,002 events in one query; the focused suite also fails on a fresh database because role_bundled_viewer is missing.
I’d also like us to agree on how operators will use this—the checker currently has no caller.
…lf-seeding fixture Addresses the two code findings on theam#309. - Bounded reads: `matchingActions` selects distinct (action, target id) pairs server-side and caps the result at actions × targets, the most distinct pairs there can be, so a page of n keys never loads more than 2n rows however often the log repeats a pair. `actionsFor` builds its map from it. Exported so the bound is observable in a test. - Self-seeding fixture: the suite calls `seed()` before inserting keys. The bundled roles the keys reference are written by the seed, never by a migration, so a fresh database has none. Same convention as budget-routes.integration.test.ts. Tests: no assertion changed in the existing suite. New suite audit-record-review.integration.test.ts (6 tests): 1,002 duplicate key.issued rows return one pair; n targets × k actions never exceed n × k rows; the pre-fix query shape run raw returns 1,002 rows; the report is unchanged at pageSize 2; and the archive route modelled both ways — DELETE on an existing project is clean, DELETE on an unknown id answers 200 and audits an archive it did not perform, which the report names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
d35df83 to
b332d3d
Compare
|
Thanks @adrian-lorenzo. Both addressed in |
…he state it narrates Read-only checks over the audit log, returning findings; nothing gates and nothing is surfaced. `audit_events` is an append log ordered by createdAt only, so on its own it cannot tell a removed event from a quiet hour. Where an event names its entity, the log and the state can be read against each other both ways: - keys: every API key has a key.issued event; every revoked key has a key.revoked event; every such event names a key that exists and is in that state. - projects: an archived project has a project.archived event, and that event names a project that is archived. - budgets: a project with a budget has a budget.updated event, and that event names a project that has one. Not evaluable and not claimed: actions whose target is the route rather than the entity (member.*, role.*, repo.*, project.created, org.updated), actions that leave no durable state (auth.logout), and state written by bootstrap paths outside audited routes. The module says so. Keyset-paginated over keys and over paired events; projects and budgets are org-scoped and read whole; every lookup is an IN over the page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lf-seeding fixture Addresses the two code findings on theam#309. - Bounded reads: `matchingActions` selects distinct (action, target id) pairs server-side and caps the result at actions × targets, the most distinct pairs there can be, so a page of n keys never loads more than 2n rows however often the log repeats a pair. `actionsFor` builds its map from it. Exported so the bound is observable in a test. - Self-seeding fixture: the suite calls `seed()` before inserting keys. The bundled roles the keys reference are written by the seed, never by a migration, so a fresh database has none. Same convention as budget-routes.integration.test.ts. Tests: no assertion changed in the existing suite. New suite audit-record-review.integration.test.ts (6 tests): 1,002 duplicate key.issued rows return one pair; n targets × k actions never exceed n × k rows; the pre-fix query shape run raw returns 1,002 rows; the report is unchanged at pageSize 2; and the archive route modelled both ways — DELETE on an existing project is clean, DELETE on an unknown id answers 200 and audits an archive it did not perform, which the report names. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
b332d3d to
45315f0
Compare
TL;DR — Read-only checks that the audit log agrees with the state it describes, in both directions: every API key has its
key.issued, every revocation itskey.revoked, every archived project itsproject.archived, every budget itsbudget.updated— and every such event names state that exists and is in that condition. One module that returns findings; it surfaces nothing, gates nothing, needs no migration. 16 tests. Opens with a question rather than a claim.The question first.
audit_eventscame through #289 as a plain append log —actor,action,target,payload,createdAt, indexed by time. It has no ordering of its own, so an event removed from the middle leaves no trace the log itself can show; a gap and a quiet hour look the same. That may well be the intended shape now that trust is architectural and the log is a record rather than an instrument — in which case this PR is a small read that costs nothing, and the question is answered. If the log is meant to stand as the account of who did what to the control plane, then what follows is the least that makes a gap visible without giving the log any machinery of its own.Sibling of #308 (the delivery-record checks), same shape, same rule: every check enumerates from a ledger other than the one it verifies. Here the second ledger is the state the log narrates.
What the log already gives us
Three explicit audits carry the entity as target —
key.issuedandkey.revokedwith{type: "key", id}(me-members-roles.ts),auth.logoutwith the principal. Fifteen more arrive through the genericonResponsehook on routes that declareauditAction; those carry the project as target when the route has one, otherwise the route path. So the log names an entity for keys, and a project forproject.archivedandbudget.updated. Those are the pairs this reads.api_keysrowkey.issuedfor that idkey.issuednames a key that existsapi_keys.revoked_atsetkey.revokedfor that idkey.revokednames a key that is revokedprojects.status = archivedproject.archivedfor that projectproject.archivednames a project that is archivedproject_budgetsrowbudget.updatedfor that projectbudget.updatednames a project with a budgetWhat it does not claim
member.*,role.*,repo.*,project.created,org.updatedaudit with the route as target, not the entity, so they cannot be paired by id. They are outside this read, stated rather than approximated.auth.logoutleaves no durable state (it clears a cookie); nothing to pair.createdAtonly. This PR does not add a sequence or a chain; it makes gaps visible from the state side, which is the only side that survives a deletion.Proof — every shape is "break the pair, watch the report"
services/api/test/audit-record.integration.test.ts(10 tests, scoped fixture orgs):keys: state-without-event(key.issued)keys: state-without-event(key.revoked)key.issuedin the log, key row deletedkeys: event-without-state— the deletion is visible from the log that still expects the rowkey.revokedin the log, key still activekeys: event-without-stateprojects: state-without-eventproject.archivedin the log, project is activeprojects: event-without-state— "the project is active"budgets: state-without-eventbudget.updatedin the log, no budgetbudgets: event-without-statepageSize: 2Review round — @adrian-lorenzo's findings
services/api/test/audit-record-review.integration.test.ts(6 tests). Fixtures are modelled on the writers:request.auditwrites onekey.issuedper issue with the key id as target; theonResponsehook writesauditActiononly on 2xx; bundled roles come from the seed, never from a migration.pageSize: 2the matching-event query still loaded 1,002 eventsmatchingActionsselects distinct(action, target id)pairs server-side and caps the result atactions × targets— the most distinct pairs there can be, so the cap never truncates; exported so the bound is observablekey.issuedrows → 1 pair; n targets × k actions never exceed n × k rows; the pre-fix query shape run raw returns 1,002 (kept in the suite as the red side); the report is unchanged atpageSize: 2role_bundled_viewermissingseed()before inserting keys — the conventionbudget-routes.integration.test.tsalready usesFound while enumerating the writers:
DELETE /v1/projects/:projectIdupdates by id without checking a row was hit and answers 200 either way, so the hook auditsproject.archivedfor an id that may not exist. The checker reports that asevent-without-state("no such project exists") — a signal about the route, kept and tested both ways (existing project: clean; unknown id: reported). The sibling PATCH route throwsnotFoundin the same situation; DELETE should too. Follow-up, not this PR.Both suites: 16/16, three consecutive runs;
tscandbiomeclean on the rebased branch.On the open question — who calls this. Proposal, not a claim: an
insightsread behindanalytics:readthat returns the report for the caller's org, with no scheduling and no surface in this PR. If a CLIdoctorsub-check fits the operator story better, that works just as well and I would rather follow the shape you prefer. Either caller would also justify an expression index ontarget->>'id'; today each page is a scan of the org's log, and DISTINCT bounds what is returned, not what is read.Bounded by construction
Read-only; keyset-paginated over
api_keysand over the paired events; projects and budgets are org-scoped and read whole; every lookup is anINover the page and returns distinct pairs capped at the page's size. Counts are reported over everything read —checkedKeys,checkedProjects,checkedBudgets,checkedEvents— never over survivors.Non-goals
No sequence column, no hash, no chain, no trigger, no gating, no surface, no migration. The log stays exactly what it is; this only reads it against the state beside it.
Files
services/api/src/insights/audit-record.ts—verifyAuditRecord,matchingActions(new)services/api/test/audit-record.integration.test.tsservices/api/test/audit-record-review.integration.test.tsVerify locally
🤖 Generated with Claude Code