Audit

The audit trail is the record of what happened in a tenant: every event any module publishes becomes one row saying what happened, who caused it and when, kept for as long as the deployment is obliged to keep it and read by whoever holds audit:read. The module promises that a module is audited by having emitted an event, wherever it sits in the composition; that a row is never updated and nothing but the retention job removes one; and that recording publishes nothing, because an audit of audits is a loop. The module is modules/audit; this page states nothing that tree does not.

The map

Open the map full screen · source features/audit in the record

What it promises

Entity

Event in contracts/audit.go: an id, occurredAt (when the state changed, which is when the outbox row was written), the event’s name, an actor that is absent for a job, a handler or the bootstrap, the eventId of the outbox event it records, and the payload as its module published it. It is not a crud.Entity: nothing updates a row, nothing soft-deletes one, and the tenant comes from the transaction, never from the envelope. Query filters a page by name, by actor and by time, Since inclusive and Until exclusive.

Events

none: the manifest declares Events: nil, and recording publishes nothing.

Permissions

audit:read — the list, the read and the navigation entry (contracts/permissions.go); there is no second permission, because nothing writes through a route.

Service

contracts.Service: Record, List, Get; the fake and the conformance suite live in contracts/audittest/.

Routes and screens

Method Path Does Authorization Publishes

GET

/api/v1/audit/events

List the audit trail

audit:read

GET

/api/v1/audit/events/{id}

Read one audit event

audit:read

  • GET /api/v1/audit/events — the trail, newest first, filterable by name, actor, since (inclusive) and until (exclusive), 50 rows a page and 200 at most; GET /api/v1/audit/events/{id} — one row. Both are written by hand with httpx.Register in internal/handler.go, behind httpx.Permission("audit:read"), as audit-event-list and audit-event-read. There is no rest.Spec and no command: a Spec is five routes and three of them write.

  • Screens: none. The routes register no httpx.Resource, so the shell generates nothing and the resource catalog does not list the trail. The manifest’s navigation entry "Audit" points at /admin/audit/events, a path no route serves: the shell reports it once at boot and never renders it (modules/admin/internal/mount.go, ui/page/navigation.go). Hand-written pages: none.

Authorization

Authorization Who passes Routes

audit:read

a member of the tenant

event-list, event-read

Events, jobs and subscriptions

  • Publishes: nothing.

  • Jobs: audit-retention (internal.Retention in internal/retention.go), hourly on 0 * * * *, through jobs.PerTenant: it deletes each tenant’s rows older than RetentionDays, a thousand a transaction until fewer than a thousand remain, with the cutoff computed by the database so two workers' clocks cannot disagree.

  • Subscriptions: one, with no name. The manifest in module.go sets SubscribeAll, and the kernel’s module.Expand (kit/module/module.go) turns that one subscription into one per event every module declares, after every manifest has been read; each goes to Service.Record, which inserts ON CONFLICT (tenant_id, event_id) DO NOTHING — the key migrations/000015_audit_event_unique.up.sql gave it — so a redelivered or replayed event leaves one row. Main composes it next to last in apps/platformkit/modules.go, and nothing depends on that: a module listed after it is audited all the same.

What it needs

Deps field

Interface

Supplied by

Tenants

jobs.TenantLister

the tenant module’s Active lister, passed in apps/platformkit/modules.go; the retention job walks it

RetentionDays

int

config.Audit.RetentionDays, passed in the same file; zero means a year

Configuration: audit.retention_days in config.yaml (config.example.yaml; config.Audit in kit/config/config.go): zero means the default of 365, and a value below one is refused at load.

Who uses it

  • No module takes its contracts: nothing in platformkit, the catalog or the clients imports modules/audit/contracts; the compositions import the manifest, not the contracts.

  • The admin and the native shell do not reach it: it registers no httpx.Resource, so neither the generated screens nor GET /api/v1/admin/resources know the trail. A caller with audit:read reads the two routes.

Verification

  • go test ./modules/audit/…​ — the fake (TestTheFakeIsAService) and the real service (TestServiceConforms, on a real Postgres in a rolled-back tenant transaction) pass one conformance suite: a row says what happened, who caused it and when; a job’s event is nobody’s; three deliveries of one event leave one row; the filters by name, actor and time, newest first and paged; an unknown id is not found; and nothing is published. TestTheTrailIsTenantOwned proves another tenant sees nothing, by the policy in migrations/000010_audit.up.sql; TestRetentionForgetsOnlyWhatIsOldEnough proves the boundary and the loop’s exit.

  • No browser journey: e2e/admin-tasks.spec.ts does not touch the trail.

  • Not proven: the two routes, which have no handler test; the batching of the retention delete, whose fixture is smaller than a batch; the expansion of SubscribeAll against a worker and a live transport, which is the kernel’s to prove.