How is audit built?¶
One installation belongs to one application. Concepts defines the vocabulary. Direct mode and stream mode show each shape as run.
The bucket contract and the capabilities matrix state what exists per platform.
The parts¶
flowchart TB
E["emit library<br/>validates against<br/>the catalogue"]
R["receiver<br/>audit-writer, front door"]
NATS[("JetStream<br/>stream mode only")]
W["writer<br/>audit-writer"]
S3[("the environment's bucket<br/>THE RECORD")]
PG[("database<br/>dedupe, registry, index")]
O["indexer<br/>audit-observe"]
Q["query service<br/>audit-query"]
UI["Audit page"]
E -- "Connect,<br/>ack = durable" --> R
R -- "direct: put, then ack" --> W
R -- "stream: publish" --> NATS
NATS --> W
W -- "locked objects" --> S3
W --> PG
O -- "lists from a cursor" --> S3
O -- "index rows, cursors" --> PG
UI -- "console's own token" --> Q
Q --> PG
Q --> S3
| part | runs as | holds | never holds |
|---|---|---|---|
| emit | library in the application | compiled-in catalogue, bounded in-memory queue | bucket, index or stream credentials |
| receiver | audit-writer, one or two pods |
stream credentials in stream mode | none |
| writer | consumer mode, N pods; the receiver in direct mode | write on its prefix, a dedupe and registry role | the index, any way to hand a record back |
| indexer | audit-observe, one pod |
read on the archive, the index role | write on the archive, the dedupe table |
| notary | audit-notary, hourly CronJob |
read on the prefix, put under seals/, the seal key |
write on records/ |
| verify | nightly CronJob | read on the prefix | write on the archive |
| query service | audit-query, one or two pods |
read-only index role, read on the prefix | write on the archive or index |
| Audit page | React component in the console | nothing | credentials |
| usage consumer | small Deployment, quotas only | the counter cache | none |
The application holds no credentials for the trail. A compromised application pod can reach the archive only by emitting records. Nothing in the write path can read a record back. The query service is the only way in, and it records every read.
The bucket is one per environment, Object Lock in compliance mode where a profile demands it. The index is one database in the application's Postgres: rows, facet counts, cursors, rollups, and the writer's dedupe table under another role. Nothing in it is unrebuildable.
The catalogue¶
One YAML file lives with the application code, with a JSON Schema per action that carries data. It names each action's operation, framework categories, profile copies, subject, actors, data schema, delivery and sentence template.
- A catalogue is one per application. It names framework profiles and never defines them.
- Every record names its catalogue version. The archive keeps every version.
- The catalogue reaches the receiver at start-up over
RegisterCatalogue. A malformed catalogue is refused and the application does not start. An unreachable receiver is retried. - Use one constructor per action, so
check-emitterscan hold the code to the file.
CI runs audit validate and check-emitters. The emit library refuses a mismatching record. The receiver and writer validate again. The query service renders sentences from it. See the catalogue reference.
What an acknowledgement means¶
Delivery is set per action in the catalogue.
| delivery | the call returns | receiver down | for |
|---|---|---|---|
block |
when the receiver acknowledges durability | the action fails | privileged sign-in, key destruction, billable operations |
async (default) |
at once | the record waits in a bounded queue and retries with backoff | everything else |
The acknowledgement states durability. Archived is the object in the bucket, which the receiver puts before answering in direct mode. Queued is a replicated queue's acknowledgement. Logged is a log line. The start-up guard sink.Require refuses a chain that can never give what the deployment needs.
What each mode can lose¶
A block record is never lost. For async:
| event | direct mode | stream mode |
|---|---|---|
| application pod dies with records queued | unacknowledged records: one flush interval (default one second) plus the batch in flight | the same, shorter |
| writer dies holding stream records | not applicable | nothing, because the stream redelivers |
| receiver or writer crashes | nothing | nothing |
| long receiver outage overflows the queue | oldest dropped and counted | the same |
| container restarts, pod intact | the queue is gone, as in row one | the same |
The emitter logs each record the queue gives up, with its identifier and reason. Alert on audit.emit.records.dropped. Watch audit.emit.queue.pending.
The life of one record¶
- The emitter fills id, time, source, sequence, client address and ids. It validates against the catalogue and delivers as declared.
- The receiver stamps
recorded_at, the observer from the caller's verified token, andorigin_hash. In stream mode it stamps here, because writers have no caller to verify. They keep a stamp whose hash matches and stamp afresh otherwise. - The writer splits the record into one copy per profile, keeps only the allowed fields, and treats each identity by category. See the writer.
- The writer rolls copies into objects per ingest batch, profile and tenant, keyed by the hour of ingest. It puts each under an Object Lock retention computed from the profile. A record it cannot take goes to the dead-letter prefix.
- The writer marks the id as written. The indexer lists the bucket once an object passes the settle window and writes the rows and facet counts a couple of minutes later.
- The verify job checks the previous day's objects nightly. The notary makes seals hourly, checked with
--root. - A reader asks the query service. Their grant becomes one term of the query, and the read is recorded.
The writer and every job record their own activity in the archive.
The two shapes¶
| direct | stream | |
|---|---|---|
| for | internal service, low volume, no stream | product with many pods, metering, quotas |
| receiver | is the writer and puts to the bucket | publishes to JetStream |
| writers | the receiver's pods | N consumers, scaled apart |
| needs | bucket, database | bucket, database, NATS account |
Switching from direct to stream changes only the receiver's configuration. The archive layout is the same, so installations on different versions can share a bucket.
Extensions¶
Both are projections switched on per installation and add nothing to the request path.
- Billing: rollups at index time and an immutable monthly statement.
- Usage quotas: a second stream consumer counting into a cache, a decision point, and an hourly reconciler.
Receiver and writer never combine records. They batch many records into one object per profile, tenant and day per batch. Projections (index rows, facet counts, rollups, usage counters) rebuild from the archive with audit reindex, so none is backed up.
What is built¶
| state | |
|---|---|
| record, catalogue, framework profiles, emitter, writer, query service, v1 bucket layout, verify, legal holds, retention addenda | built |
| searchers: Postgres, archive scan, memory | built |
| signers: key file, AWS KMS, OpenBAO transit | built |
key providers local, OpenBAO transit and AWS KMS |
built, off by default |
| one validated configuration file per binary | built |
| chart per application, golden per shape | built |
@truvity/audit, @truvity/audit-react |
built; published to GitHub Packages at each release tag |
| TypeScript emitter | designed, not built |
| billing statement, usage consumer, reconciler | designed, not built |
| exporters (OCSF, ECS, Parquet), adapters | designed, not built |