Chronicle is a production-grade audit trail library that records every event into a SHA-256 hash chain. Key that chain with HMAC and it cannot be recomputed without material your database never holds. It is designed for multi-tenant SaaS applications that need SOC2, HIPAA, or GDPR compliance out of the box.
- Hash chain integrity — Every event is linked by SHA-256 hashes, optionally keyed with HMAC. Read Tamper evidence for what each mode detects, and what neither one does.
- GDPR crypto-erasure — Per-subject AES-256-GCM encryption of the personal payload. Destroy the key and it is irrecoverable, while the operational record and the hash chain stay intact and verifiable.
- Multi-tenant scoping — Events are automatically scoped to app + tenant from context. Cross-tenant queries are impossible.
- Compliance reports — Generate SOC2 Type II, HIPAA, EU AI Act, and custom reports. Export to JSON, CSV, Markdown, or HTML.
- Pluggable stores — Postgres (pgx), Grove ORM, SQLite, Redis (cache layer), and in-memory (testing).
- Pluggable sinks — Fire-and-forget event outputs (stdout, file, S3, custom). Sinks never block the pipeline.
- Plugin system — BeforeRecord enrichment, AfterRecord notification, SinkProvider, AlertHandler, and more.
- Retention policies — Automatic archival and purge with configurable schedules.
- Admin HTTP API — 21 endpoints for events, verification, erasure, retention, compliance, and stats, guarded per operation class (read / write / admin).
- Forge integration — Drop-in extension for the Forge framework with DI-injected Emitter.
- Type-safe IDs — TypeID-based identifiers (
audit_01h2x...,stream_01h2x...).
package main
import (
"context"
"fmt"
"log"
"github.com/xraph/chronicle"
"github.com/xraph/chronicle/audit"
"github.com/xraph/chronicle/scope"
"github.com/xraph/chronicle/store"
"github.com/xraph/chronicle/store/memory"
)
func main() {
ctx := context.Background()
ctx = scope.WithAppID(ctx, "myapp")
ctx = scope.WithTenantID(ctx, "tenant-1")
mem := memory.New()
adapter := store.NewAdapter(mem)
c, err := chronicle.New(chronicle.WithStore(adapter))
if err != nil {
log.Fatal(err)
}
// Record an event.
err = c.Info(ctx, "login", "session", "sess-001").
Category("auth").
UserID("user-42").
Meta("provider", "okta").
Record()
if err != nil {
log.Fatal(err)
}
// Query events.
result, err := c.Query(ctx, &audit.Query{Limit: 10, Order: "desc"})
if err != nil {
log.Fatal(err)
}
for _, ev := range result.Events {
fmt.Printf("[%s] %s %s/%s\n", ev.Severity, ev.Action, ev.Resource, ev.ResourceID)
}
}go get github.com/xraph/chronicleContext (AppID, TenantID, UserID, IP)
|
v
EventBuilder --> Chronicle.Record()
|
1. Apply scope from context
2. Assign ID + timestamp
3. Validate required fields
4. Resolve or create stream (app+tenant)
5. Compute SHA-256 hash chain
6. Store.Append
7. Update stream head
|
Plugins (BeforeRecord / AfterRecord)
|
Sinks (stdout, file, S3, ...)
Package Index:
| Package | Import Path | Purpose |
|---|---|---|
| chronicle | github.com/xraph/chronicle |
Root engine, EventBuilder, Emitter, Storer |
| audit | .../audit |
Event type, query types, Store interface |
| stream | .../stream |
Hash chain stream per app+tenant |
| hash | .../hash |
SHA-256 chain computation |
| verify | .../verify |
Chain integrity verification |
| store | .../store |
Composite Store interface, Adapter |
| crypto | .../crypto |
AES-256-GCM for GDPR erasure |
| erasure | .../erasure |
GDPR subject erasure service |
| compliance | .../compliance |
Report generation and export |
| retention | .../retention |
Policy-based archival and purge |
| sink | .../sink |
Fire-and-forget output targets |
| plugin | .../plugin |
Extensibility hooks |
| batcher | .../batcher |
Batched event writing |
| scope | .../scope |
Context-based tenant isolation |
| id | .../id |
TypeID-based entity identifiers |
| handler | .../handler |
Admin REST endpoints (21 routes) |
| extension | .../extension |
Forge framework extension |
| Backend | Package | Use Case |
|---|---|---|
| PostgreSQL (pgx) | store/postgres |
Production — direct pgx driver |
| Grove ORM | store/grovestore |
Production — Grove ORM |
| SQLite | store/sqlite |
Single-node / embedded |
| Redis | store/redis |
Cache layer (read-through) |
| Memory | store/memory |
Testing and examples |
All backends implement the composite store.Store interface and are wrapped with store.NewAdapter() to satisfy chronicle.Storer.
Generate compliance reports from your audit data:
engine := compliance.NewEngine(auditStore, verifyStore, reportStore, logger)
// SOC2 Type II
report, _ := engine.SOC2(ctx, &compliance.SOC2Input{
Period: compliance.DateRange{From: from, To: to},
AppID: "myapp",
GeneratedBy: "admin",
})
// HIPAA
report, _ := engine.HIPAA(ctx, &compliance.HIPAAInput{...})
// EU AI Act
report, _ := engine.EUAIAct(ctx, &compliance.EUAIActInput{...})
// Custom
report, _ := engine.Custom(ctx, &compliance.CustomInput{...})
// Export
engine.Export(ctx, report, compliance.FormatMarkdown, os.Stdout)Supported formats: json, csv, markdown, html.
Chronicle supports GDPR Article 17 (right to erasure) through crypto-erasure:
- Each data subject's events are associated with a per-subject encryption key.
- On erasure request, the key is destroyed — making encrypted data irrecoverable.
- Events are marked as erased in the store.
- The hash chain remains structurally valid (hashes are computed over event metadata, not encrypted payloads).
keyStore := crypto.NewInMemoryKeyStore()
service := erasure.NewService(store, keyStore)
result, _ := service.Erase(ctx, &erasure.Input{
SubjectID: "user-alice",
Reason: "GDPR Article 17",
RequestedBy: "dpo@company.com",
}, appID, tenantID)
// result.KeyDestroyed == true
// result.EventsAffected == 3Plugins implement any subset of hook interfaces. The registry discovers capabilities at registration time for O(1) dispatch:
type MyPlugin struct{}
func (p *MyPlugin) Name() string { return "my-plugin" }
// Enrich events before they are stored.
func (p *MyPlugin) OnBeforeRecord(ctx context.Context, event *audit.Event) error {
event.Metadata["enriched"] = true
return nil
}
// React after events are stored.
func (p *MyPlugin) OnAfterRecord(ctx context.Context, event *audit.Event) error {
fmt.Println("Event recorded:", event.ID)
return nil
}
registry := plugin.NewRegistry(logger)
registry.Register(&MyPlugin{})| Interface | When |
|---|---|
OnInit |
Chronicle starts |
OnShutdown |
Chronicle stops |
BeforeRecord |
Before event is persisted (enrichment, filtering) |
AfterRecord |
After event is persisted (notifications, alerts) |
SinkProvider |
Provides a custom Sink |
Exporter |
Provides a custom export format |
AlertHandler |
Fires when events match alert rules |
The handler package provides 21 REST endpoints mounted under /chronicle/:
Events: GET /events, GET /events/{id}, GET /events/user/{user_id}, POST /events/aggregate
Verification: POST /verify
Erasure: POST /erasures, GET /erasures, GET /erasures/{id}
Retention: GET /retention, POST /retention, DELETE /retention/{id}, POST /retention/enforce, GET /retention/archives
Reports: GET /reports, POST /reports/soc2, POST /reports/hipaa, POST /reports/euaiact, POST /reports/custom, GET /reports/{id}, GET /reports/{id}/export/{format}
Stats: GET /stats
Chronicle ships as a Forge extension with full lifecycle management:
ext := extension.New(
extension.WithBatchSize(100),
extension.WithCryptoErasure(false), // see note below: not implemented yet
extension.WithRetentionInterval(24 * time.Hour),
// Required: the admin API can purge audit history.
extension.WithAuth("jwt",
[]string{"chronicle:read"}, // observe events, reports, policies
[]string{"chronicle:write"}, // save policies, generate reports
[]string{"chronicle:admin"}, // enforce retention, delete policies, erase
),
)
ext.Init(ctx, store) // runs migrations, wires components
ext.Start(ctx) // starts background retention scheduler
defer ext.Stop(ctx)
// DI: other extensions receive the Emitter interface
emitter := ext.Emitter()
emitter.Info(ctx, "login", "session", "sess-1").Category("auth").Record()
// Mount admin API
mux.Handle("/", ext.Routes())The admin API exposes operations that permanently destroy audit data:
POST /v1/retention/enforce purges events, DELETE /v1/retention/:id disables
retention, and POST /v1/erasures cannot be undone. Chronicle's per-request
scope check keeps tenants out of each other's data, but it does not gate these
operations, so the extension refuses to start until you decide who may call them.
Pick one:
chronicle:
auth:
provider: jwt # a registered forge auth provider
read_scopes: [chronicle:read]
write_scopes: [chronicle:write]
admin_scopes: [chronicle:admin]chronicle:
auth:
allow_unauthenticated: true # something upstream already authenticateschronicle:
disable_routes: true # do not mount the API at allRoutes are graded by blast radius. read observes; write creates records that
destroy nothing (saving a policy, generating a report); admin covers the
irreversible operations. A token that can read events cannot purge them.
Note that forge's WithAuth route options only annotate the OpenAPI spec — they
are not enforced at request time in forge v1.9.5. Chronicle enforces with the
auth registry's middleware and uses those options for documentation only.
The Forge dashboard pages are read-only by default. Chronicle cannot authenticate the dashboard route (Forge's dashboard extension owns it), so creating and deleting retention policies, running enforcement, and generating reports are disabled until you assert that the route is already protected:
chronicle:
dashboard_mutations: trueDashboard reads are always tenant-scoped, and dashboard enforcement only runs the viewing tenant's own policies.
c, _ := chronicle.New(
chronicle.WithStore(adapter), // required: backing store
chronicle.WithLogger(logger), // slog.Logger (default: slog.Default)
chronicle.WithBatchSize(100), // max events before flush
chronicle.WithFlushInterval(time.Second), // max time between flushes
chronicle.WithCryptoErasure(false), // see note below
)The default digest is an unkeyed SHA-256. It catches corruption and accidental
edits. It does not catch tampering by anyone who can write to your database,
because the algorithm ships in this repository, so rewriting an event and
recomputing every digest after it is about twenty lines of work.
TestPlainChainDoesNotDetectARewrite in tamper_test.go asserts that directly,
so the limit is pinned by a test rather than left to inference.
Keying the digest changes who can do that:
chronicle:
tamper_evidence:
digest: hmac # plain (default) | hmac
keys:
provider: file
path: /etc/chronicle/hmac.jsonOr programmatically:
c, _ := chronicle.New(
chronicle.WithStore(adapter),
chronicle.WithDigestScheme(hash.SchemeHMACV5),
chronicle.WithKeyProvider(kp),
)Set digest: hmac without a key source and Chronicle refuses to start. You
configured a keyed chain, so writing unkeyed digests while you believed
otherwise is the worse failure. A keyset that parses but has no active hmac
key in it, because the use is misspelled or the only entry is "active": false, is caught at startup as well. Otherwise the process boots green and then
fails on every event you record.
The keyset is a JSON file holding every key you have used, with one marked
active per use. Rotating means adding a key and moving the active flag. Retired
keys stay, because events written under them still have to verify, and
Provider.ByID is what resolves them.
Rotating is not enough when a key leaks. Whoever holds it can rewrite every
event, label each row with that key's ID, recompute, and get a clean report,
because the digests genuinely check out. Set "revoked": true on the entry and
ByID refuses to resolve it at all, so verification errors instead of passing.
Leave the material in the file when you do, so a row naming that key is reported
as revoked, not as an ID nobody recognises. It cuts both ways. Events you signed
honestly with that key stop verifying too, because nobody can tell them apart
from the forged ones. That stretch of the log no longer proves anything, and
saying so beats a green tick that does not mean what it looks like.
What keying buys you. An event's digest can no longer be produced from the stored row alone. Each event records the scheme that wrote it and each stream records the scheme it is pinned to from which sequence, so an event claiming a weaker scheme than its stream promises is reported as tampering rather than read as history.
Turn hmac on for a database that already holds events and the pin moves on its
own. The first time you record into a stream, Chronicle advances that stream to
the keyed scheme from the next sequence, and writes the pin before the event.
Everything below the new boundary keeps verifying under the scheme it was
written with. Chronicle only ever moves a pin up. Point a plain-configured
process at a stream already pinned to hmac and it refuses to record, because a
pin that drops on its own looks exactly like an attacker lowering it.
Upgrading a deployment that already writes hmac needs a stop then start, not
a rolling restart. An older process does not recognise chronicle/v5, and an
unrecognised scheme ranks below every one it does know, so it reads the pin the
new process just set as weaker than its own and pulls it back down to
chronicle/v3. The two versions then take turns moving the pin while events
land under both. The same goes for any scheme change, including turning hmac
on for the first time.
Every event records which scheme produced it, so upgrading never invalidates what is already stored. There are five:
| scheme | digest | content | status |
|---|---|---|---|
chronicle/v1 |
SHA-256 | legacy field set | verify-only |
chronicle/v2 |
SHA-256 | delimiter-joined | verify-only |
chronicle/v3 |
HMAC-SHA256 | delimiter-joined | verify-only |
chronicle/v4 |
SHA-256 | length-prefixed | written when digest: plain |
chronicle/v5 |
HMAC-SHA256 | length-prefixed | written when digest: hmac |
v2 and v3 joined their fields with a bare | and escaped nothing, so content
could move across a separator without changing the bytes being hashed. A user ID
of alice beside an address of 10.0.0.9|attacker-note hashes to exactly the
same digest as a user ID of alice|10.0.0.9 beside an address of
attacker-note. Same digest, different answer to who acted and from where.
Keying did not help there. The MAC covered those same ambiguous bytes, so that one rewrite was the single tampering move that worked without the key. v4 and v5 length-prefix every field, so a reader can find where each one ends without trusting what is inside it.
Events written before v4 keep their recorded scheme and keep verifying under it.
They also keep that weakness, and there is no fixing it after the fact. You
cannot re-digest them without the key, and re-digesting them with the key would
destroy whatever the old digests were worth as evidence. Read a chronicle/v2
or chronicle/v3 row as evidence that something was recorded, not as proof of
precisely what. TestDelimiterJoinedContentCollides in hash/ holds the
collision open so nobody has to take that on trust.
Chronicle will not write v1, v2 or v3. Configure one and it fails at startup naming the replacement, rather than accepting a setting that promises evidence it cannot deliver.
What it does not buy you. Someone who can write to your database can still
rewrite every event to the unkeyed scheme and rewrite the stream's pin to
match. Verification accepts that, because both halves of the evidence live in
the same database and whoever controls it controls both.
TestFullStreamDowngradeIsNotDetectedWithoutSignedCheckpoints pins that limit
too. Closing it needs a signature held somewhere Chronicle cannot reach.
Signed checkpoints, covered below, are the first half of that, and they exist
now. On their own they only get you so far: a local checkpoint still lives in
the same database as the events, so an attacker with that same write access
can delete it too. External anchoring is what reaches the rest, and that part
isn't built yet.
So read keying as narrowing who can carry the attack out, not ending it. It
defends against someone who can write chronicle_events but not
chronicle_streams, and who holds no key your provider has ever known. Both
halves have to hold. Reach the stream row too and the pin moves with the events,
which is the case above. Get hold of a key and every digest recomputes cleanly,
which is what revoking it is for.
Turn checkpointing on and Chronicle periodically signs a statement about where a stream's chain stood. That statement is what makes a later rewrite of anything before it provable, even when the events and the stream's pin get rewritten together.
chronicle:
checkpoints:
enabled: true
every_events: 1000 # checkpoint once a stream gains this many events
every_interval: 1h # or at least this often, whichever comes first
signer:
provider: file
path: /etc/chronicle/checkpoint-signer.jsonLeave signer.provider empty and Chronicle takes the signing key from
whatever keys.Provider you already passed to WithKeyProvider in code, as
long as it resolves UseCheckpointSig. Give it neither and checkpointing
refuses to start rather than silently signing nothing.
every_interval matters more than every_events looks like it should. The
gap between two checkpoints is exactly how long an attacker can rewrite
without a signature standing in the way, so a quiet stream that never hits
the event count still needs the interval to bound that window.
Sign a checkpoint over a range and rewriting any event inside it stops
passing verification, but only once the range you verify actually reaches
the checkpoint's ToSeq. The checkpoint already asserted, under a key
nobody but the signer holds, what the chain hashed to at that point, and
recomputing the row after it's been changed can't reproduce that hash.
Verify a narrower range that stops short of ToSeq and that comparison
never runs at all: HashChecked stays false, and a relinked rewrite inside
the part you skipped passes clean. TestRewriteAfterACheckpointIsProvable
pins the ordinary case, where the range does reach it.
Checkpoints chain to each other too, each one carrying the digest of the one
before it. Delete one from the middle of a run and the next checkpoint can no
longer show a clean line back to the one before the gap, which is how a
missing checkpoint gets caught. TestDeletingAMiddleCheckpointIsDetected
pins that.
The range checks don't reach past the head you claim, and the reason is more specific than "a checkpoint can be deleted too." Verification asks the checkpoint store for what falls inside the range you're checking, and the top of that range comes from the head you claim. A checkpoint whose starting sequence sits past that head gets excluded before its signature or its hash is looked at, on every backend. Rewrite the stream's own head to hide a truncated tail and every checkpoint covering the removed events drops out of that comparison, whether its row survives or not.
One check runs outside the range, and it catches exactly that. Chronicle
reads the stream's latest checkpoint directly, confirms its signature still
verifies, and compares its to_seq against the head you claimed. A
checkpoint asserting the chain once reached sequence 15, against a claimed
head of 10, fails the verification: checkpoint_head_ok comes back false,
and checkpoint_head_checked tells you the comparison ran rather than
leaving you to guess. It runs before the range is resolved at all, so
deleting every event and zeroing the head doesn't get past it either.
TestTruncationIsDetectedWhileTheCheckpointSurvives pins the truncation and
TestTotalWipeIsCaughtByTheLatestCheckpoint pins the wipe.
Retention won't trip it. Purging works from the front of a stream and never lowers the head, so a checkpoint ending past the head you claim means events left the tail.
Deleting every checkpoint a stream has is milder. Nothing gets tampered
with, so Valid stays true. But no span of the coverage ladder can claim
LevelSigned anymore, because nothing survived to have signed it.
TestDeletingEveryCheckpointDropsCoverageNotValidity pins that too.
What still gets through is deleting the covering checkpoint along with the events it covers. The newest surviving checkpoint then ends exactly where the rewritten head says it should, the comparison agrees, and it's right to, given what's left to compare against.
You don't have to delete the row to get that, either. Blanking or corrupting
its signature column does the same job for less work. The head comparison
only lets a checkpoint contradict you if its signature still verifies, so a
corrupted one drops straight out of it, and the row already sits past the
rewritten head, which keeps it out of the range checks too. Nothing reports a
signature failure, because nothing fetched the row to find one. Valid comes
back true either way.
Closing this needs a signature held somewhere write access to Chronicle's own
database can't reach. External anchoring, publishing a checkpoint or just its
hash somewhere an attacker with a SQL shell can't also edit, is what does it,
and it's the next piece of work.
TestTruncationBeyondADeletedCheckpointIsNotDetected pins the deletion half.
Enabling it requires a key store, because the key store's durability decides whether sealed events can ever be read again:
c, _ := chronicle.New(
chronicle.WithStore(adapter),
chronicle.WithCryptoErasure(true),
chronicle.WithSealer(crypto.NewSealer(keyStore)),
)Under the Forge extension, extension.WithKeyStore(...) supplies it and the
store is wrapped automatically so reads come back decrypted.
What is encrypted: Metadata, Reason and IP, keyed per subject, for any
event carrying a SubjectID.
What is not, and why: SubjectID stays readable because it is the lookup key
used to find a subject's events in order to erase them and to prove afterwards
that they were erased. UserID stays readable because it identifies the actor
rather than the data subject, and ByUser depends on it. Action, Resource,
Category, Outcome, Severity, ResourceID and the timestamps stay readable
because they are the operational record that must outlive an erasure, and queries
and compliance reports group on them.
So an erasure destroys what was recorded about a subject, not the fact that an event involving them occurred. Read the guarantee as exactly that.
Why the chain survives: the digest is computed over the encrypted bytes, not
the plaintext. Destroying a key therefore changes nothing the verifier reads. Had
the hash covered plaintext, every erased event would have reported as tampered.
The corollary is that verification paths (EventRange, VerifyEvent) read the
stored form while display paths (Get, Query, ByUser) return decrypted
copies. Retention archives the sealed bytes, so cold storage never holds
plaintext an erasure was meant to destroy.
Once a key is gone, sealed fields read back as [ERASED], metadata is dropped,
and the event reports Erased: true.
| Option | Default |
|---|---|
| BatchSize | 100 |
| FlushInterval | 1s |
| ShutdownTimeout | 30s |
| CryptoErasure | false |
| RetentionCheckInterval | 24h |
See the _examples/ directory:
| Example | Description |
|---|---|
basic |
Record, query, and verify events |
plugins |
Custom plugins with enrichment and sinks |
compliance |
SOC2 and HIPAA report generation |
gdpr |
Crypto-erasure with key destruction |
hash-chain |
Hash chain structure and verification |
forge |
Forge extension lifecycle |
go run ./_examples/basic/Part of the Forge ecosystem.