Help us shape the future of Go! Take the 2026 Go Developer Survey

chronicle

package module
v1.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 21 Imported by: 5

README

Chronicle, a tamper-evident audit logging library for Go with a SHA-256 hash chain, GDPR erasure and compliance reports

Chronicle: tamper-evident audit logging for Go

Chronicle is an audit trail library for Go. Every event you record lands in a SHA-256 hash chain scoped to an app and tenant, and you can key that chain with HMAC, sign checkpoints over it, crypto-erase a data subject for GDPR, run retention policies and generate SOC 2, HIPAA and EU AI Act reports from it. It's built for multi-tenant SaaS backends, runs inside your own process against your own database, and plugs into Forge as an extension when you want config, DI and an admin API wired for you.

CI Go Reference GitHub Stars

Documentation · Quick start · Features · Stores · Tamper evidence · GDPR erasure · Forge · FAQ

Quick start

You need Go 1.26 or later.

go get github.com/xraph/chronicle@latest

Save this as main.go. It records two events into an in-memory store and then verifies the chain:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/xraph/chronicle"
	"github.com/xraph/chronicle/scope"
	"github.com/xraph/chronicle/store"
	"github.com/xraph/chronicle/store/memory"
	"github.com/xraph/chronicle/verify"
)

func main() {
	ctx := context.Background()
	ctx = scope.WithAppID(ctx, "billing")
	ctx = scope.WithTenantID(ctx, "acme")
	ctx = scope.WithUserID(ctx, "user-42")

	c, err := chronicle.New(chronicle.WithStore(store.NewAdapter(memory.New())))
	if err != nil {
		log.Fatal(err)
	}

	err = c.Info(ctx, "invoice.paid", "invoice", "inv-1001").
		Category("billing").
		Meta("amount_cents", 4999).
		Record()
	if err != nil {
		log.Fatal(err)
	}

	err = c.Critical(ctx, "role.granted", "role", "admin").
		Category("access").
		Reason("granted by support ticket 812").
		Record()
	if err != nil {
		log.Fatal(err)
	}

	report, err := c.VerifyChain(ctx, &verify.Input{AppID: "billing", TenantID: "acme"})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("valid=%v verified=%d tampered=%v\n", report.Valid, report.Verified, report.Tampered)
}

Run it:

go mod tidy
go run .
# valid=true verified=2 tampered=[]

You put the app, tenant and user on the context once, usually in middleware, and every event you record from that request picks them up. Info, Warning and Critical set the severity, and the builder takes Category, Reason, Outcome, SubjectID, Meta and the request fields (IP, UserAgent, RequestID, SessionID) before Record writes it. You read events back with c.Query, c.Aggregate or c.ByUser.

Swap memory.New() for a real backend from the table below and you have a persistent audit log.

Why Chronicle?

An audit log that anyone with database access can quietly edit is a log you can't show an auditor. Chronicle links each event to the one before it, so when you verify, a deleted or edited row shows up, and on top of that chain you get the pieces your compliance work keeps asking for.

You need Chronicle gives you
Evidence nobody edited the log A per-stream SHA-256 hash chain, optional HMAC keying, and ed25519-signed checkpoints, checked by VerifyChain
Tenant isolation Streams scoped to app and tenant from the context, with queries filtered the same way
GDPR right to erasure Per-subject AES-256-GCM sealing. Destroy the key and the personal payload is gone while the chain still verifies
Retention Per-category retention policies that archive to a sink and purge, recorded so verification can tell a purge from a deletion
Compliance reports SOC 2 Type II, HIPAA, EU AI Act and custom reports, exported as JSON, CSV, Markdown or HTML
An admin API Event, verification, checkpoint, erasure, retention, report and stats routes, guarded per operation class
Your existing database PostgreSQL, SQLite, MongoDB and Redis through Grove, plus an in-memory store for tests
Readable IDs TypeID identifiers such as audit_01h2x...

Storage backends

Every backend implements the composite store.Store interface. Wrap one with store.NewAdapter to pass it to chronicle.New.

Backend Package Constructor Notes
In-memory store/memory memory.New() Tests and examples
SQLite store/sqlite sqlite.New(db *grove.DB, opts...) Single node or embedded
PostgreSQL store/postgres postgres.New(db *grove.DB, opts...) Production, through Grove's pgdriver
MongoDB store/mongo mongo.New(db *grove.DB) Production, through Grove's mongodriver
Redis store/redis redis.New(kvStore *kv.Store, opts...) Through Grove KV. Doesn't support checkpoints

store/sealedstore wraps any of them so reads come back decrypted when crypto-erasure is on. The stores docs cover schema and migration for each one.

Tamper evidence

The default digest is plain SHA-256 (scheme chronicle/v4). It catches corruption and accidental edits. It will not stop someone who can write to your database, because the algorithm is public and recomputing every digest after a rewritten row takes a few lines of code. TestPlainChainDoesNotDetectARewrite in tamper_test.go pins that limit so you don't have to take it on trust.

If you key the chain, nobody can produce a digest from the stored row alone:

c, err := chronicle.New(
	chronicle.WithStore(adapter),
	chronicle.WithDigestScheme(hash.SchemeHMACV5),
	chronicle.WithKeyProvider(kp), // for example keys.NewFileProvider("/etc/chronicle/keys.json")
)

Ask for HMAC without a key provider and New returns ErrHMACKeyUnavailable. That refusal at startup is deliberate: writing unkeyed digests while you believe they are keyed would be the worse failure.

Each event records the scheme that wrote it, and each stream records the scheme it is pinned to and from which sequence. If you turn HMAC on for a database that already holds events, the pin moves forward from the next sequence you record, and your older events keep verifying under the scheme they were written with. A pin only moves up. Point a plain-configured process at a stream already pinned to HMAC and it refuses to record. An older Chronicle process doesn't know chronicle/v5, so when you change schemes, stop every instance and then start them. During a rolling restart the old and new processes would drag the pin back and forth.

Scheme Digest Status
chronicle/v1, chronicle/v2 SHA-256 Verify only
chronicle/v3 HMAC-SHA256 Verify only
chronicle/v4 SHA-256, length-prefixed fields Written by default
chronicle/v5 HMAC-SHA256, length-prefixed fields Written when you choose HMAC

v2 and v3 joined fields with a bare |, so content could slide across a separator without changing the hashed bytes. Chronicle will not write them any more, and TestDelimiterJoinedContentCollides in hash/ keeps the collision on record.

Keying narrows who can rewrite your log. Someone who can write both the events table and the streams table can still downgrade a whole stream and its pin together. Signed checkpoints close most of that gap. Turn them on and Chronicle periodically signs where each stream's chain stood, chains each checkpoint to the one before, and on verification compares the latest checkpoint against the head you claim, which catches a truncated tail or a wiped stream. If an attacker deletes a checkpoint together with the events it covers, verification still passes, because all of the evidence lives in the same database you are protecting. Anchoring checkpoints somewhere outside that database is not built yet.

The verification docs walk through each check and the test that pins it.

GDPR crypto-erasure

To use it, turn on crypto-erasure and give Chronicle a key store:

keyStore := crypto.NewInMemoryKeyStore() // tests only: losing the key store erases everyone

c, err := chronicle.New(
	chronicle.WithStore(adapter),
	chronicle.WithCryptoErasure(true),
	chronicle.WithSealer(crypto.NewSealer(keyStore)),
)

For every event with a SubjectID, the Metadata, Reason and IP fields are encrypted under a key that belongs to that subject in one app and tenant. The same subject in two tenants gets two keys. The digest covers the encrypted bytes, so destroying a key changes nothing the verifier reads, and the chain keeps verifying.

To erase a subject, call the erasure service with the subject and the scope:

svc := erasure.NewService(chronicleStore, keyStore)

result, err := svc.Erase(ctx, &erasure.Input{
	SubjectID:   "user-alice",
	Reason:      "GDPR Article 17 request",
	RequestedBy: "dpo@example.com",
}, "billing", "acme")
// result.KeyDestroyed, result.EventsAffected

After that, the sealed fields you read back say [ERASED], metadata is dropped and the event reports Erased: true. SubjectID, UserID, the action, resource, category, outcome, severity and timestamps stay readable on purpose: they are the operational record, and reports group on them. An erasure destroys what was recorded about a person, not the fact that something involving them happened. The GDPR erasure guide has the rest.

Compliance reports and retention

compliance.NewEngine(auditStore, verifyStore, reportStore, logger) builds reports with SOC2, HIPAA, EUAIAct and Custom, and Export writes one as compliance.FormatJSON, FormatCSV, FormatMarkdown or FormatHTML. Reports carry the chain verification result for their period.

Retention lives in retention/. A policy names a category, a duration and whether to archive. When it runs, the enforcer first writes a retention record into the chain, then archives the expired events through a sink.Sink if the policy asks for that (sink.NewFileSink, sink.NewS3Sink and sink.NewStdoutSink are built in), and then purges them. Verification reads those records, so a purged range shows up as retained and not as a gap.

Use it with Forge

The extension package turns Chronicle into a Forge extension. It reads extensions.chronicle from your config (falling back to a top-level chronicle key), builds the store, mounts the admin API under /chronicle, runs the retention and checkpoint schedulers, and registers chronicle.Emitter in the container so other extensions can record events with vessel.Inject[chronicle.Emitter].

go get github.com/xraph/chronicle@latest github.com/xraph/grove/extension@latest
import (
	chronicle "github.com/xraph/chronicle/extension"
	grove "github.com/xraph/grove/extension"
)

app := forge.NewApp(forge.AppConfig{
	Name: "billing-api",
	Extensions: []forge.Extension{
		grove.New(),     // extensions.grove: driver and dsn
		chronicle.New(), // extensions.chronicle: auth, tamper evidence, checkpoints
	},
})

List Grove first. Chronicle looks up the container's *grove.DB during Register and picks the PostgreSQL, SQLite or MongoDB store to match its driver. You can also name a database with grove_database, point at a Grove KV store with grove_kv for Redis, or skip discovery and pass chronicle.WithStore(memory.New()).

The admin API can purge history, so the extension won't start until you decide who may call it. Configure an auth provider, allow unauthenticated access explicitly, or turn the routes off. Requests also need an app scope on the context (Forge's scope, or scope.WithAppID from your own middleware), or they get a 401.

You get traces and metrics without wiring anything. Recording, verification, checkpoints, retention runs, reports and erasures each get a span on Forge's tracer provider, with the database spans from Grove nested under them, and the counters and histograms land on /_/metrics as forge_chronicle_*. No span or label carries a user, a subject or an event's payload. Outside Forge, pass chronicle.WithMetrics(m) with any go-utils metrics sink. Observability lists every span and metric.

Configuration

extensions:
  grove:
    driver: postgres
    dsn: postgres://app:secret@localhost:5432/app?sslmode=disable

  chronicle:
    base_path: /chronicle
    retention_interval: 24h        # 0 turns the retention scheduler off
    enable_crypto_erasure: false   # true also needs WithKeyStore
    auth:
      provider: jwt                # a forge auth provider; or allow_unauthenticated: true
      read_scopes: [chronicle:read]
      write_scopes: [chronicle:write]
      admin_scopes: [chronicle:admin]
    tamper_evidence:
      digest: hmac                 # plain (default) or hmac
      keys:
        provider: file
        path: /etc/chronicle/keys.json
    checkpoints:
      enabled: true
      every_events: 1000
      every_interval: 1h
      signer:
        provider: file
        path: /etc/chronicle/checkpoint-signer.json

Read scopes cover listing and verifying. Write scopes cover saving a retention policy and generating a report. Admin scopes cover the irreversible operations: enforcing retention, deleting a policy and requesting an erasure.

Key Option Default
disable_routes WithDisableRoutes() false
disable_migrate WithDisableMigrate() false
base_path WithBasePath(path) /chronicle
retention_interval WithRetentionInterval(d) 24h
enable_crypto_erasure WithCryptoErasure(true) plus WithKeyStore(ks) false
grove_database WithGroveDatabase(name) default *grove.DB
grove_kv WithGroveKV(name) unset
auth.* WithAuth(provider, read, write, admin) or WithUnauthenticatedAPI() none, so startup fails while routes are on
tamper_evidence.digest WithDigestScheme("hmac") plus WithKeyProvider(p) plain
checkpoints.* WithCheckpoints(cfg) off
dashboard.app_id, dashboard.tenant_id none unset

WithStore, WithArchiveSink, WithConfig and WithRequireConfig round out the options. batch_size and flush_interval still load, but Record writes each event synchronously, so they don't change how events are stored. The dashboard keys name the app a Forge dashboard session falls back to when its claims carry none; MIGRATION.md covers the move from the old server-rendered dashboard pages.

Documentation

Topic Where
Getting started Getting started, architecture
Recording and scoping Recording, multi-tenancy, configuration
Integrity Verification
Compliance work Erasure, retention, compliance reports
Storage PostgreSQL, SQLite, MongoDB, Redis, memory, custom store
Forge Forge extension guide, admin API, HTTP API reference
Running it Observability: spans, metrics and log lines

The Go API is on pkg.go.dev.

Examples

Example What it shows
basic Recording, querying and verifying events
hash-chain How the chain links events, and what verification reports
gdpr Crypto-erasure and key destruction
compliance SOC 2 and HIPAA reports
plugins The plugin hook registry and a custom sink
forge Registering the extension against a Forge app

Run one from the repository root with GOWORK=off go run ./_examples/basic.

FAQ

How do I make an audit log tamper-evident in Go?

Record events through Chronicle and verify with VerifyChain. For protection against someone with database write access, switch the digest to HMAC with a key the database never holds, and turn on signed checkpoints.

Does Chronicle support GDPR right to erasure?

Yes, through crypto-erasure. Each subject's personal fields are encrypted under their own key, and erasing the subject destroys the key. The hash chain still verifies afterwards because the digest covers the ciphertext.

Which databases can Chronicle store audit events in?

PostgreSQL, SQLite and MongoDB through Grove, Redis through Grove KV, and an in-memory store for tests. You can write your own by implementing store.Store.

Can I use Chronicle without Forge?

Yes. The root package, the stores and the subsystems are plain Go. The extension package is only there if you run a Forge app.

Does Chronicle write events in batches?

No. Record writes each event synchronously, because linking an event needs the previous event's hash. The batcher package exists for callers that drive batching themselves.

Contributing

Issues and focused pull requests are welcome on GitHub.

git clone https://github.com/xraph/chronicle.git
cd chronicle
GOWORK=off go test ./...
GOWORK=off go vet ./...
golangci-lint run ./...

The Postgres, MongoDB and Redis tests skip unless you set CHRONICLE_TEST_POSTGRES_DSN, CHRONICLE_TEST_MONGO_URI or CHRONICLE_TEST_REDIS_DSN, so a green run without them says nothing about those backends. Point them at throwaway containers on non-default ports. AGENTS.md has the container commands, the lint rules that bite and the release steps. Commits follow Conventional Commits.

License

MIT. See LICENSE.

Chronicle is part of the Forge forgery stack, built by Rex Raphael and contributors.

Documentation

Overview

Package chronicle provides a composable, immutable audit trail for Go.

Chronicle records tamper-proof audit events linked by SHA-256 hash chains, supports tenant-scoped queries, GDPR crypto-erasure, compliance reporting (SOC2, HIPAA, EU AI Act), and pluggable sinks and reporters.

It is designed as a library — import it into your application, configure a store backend, and start recording events. No separate daemon required.

c, err := chronicle.New(
    chronicle.WithStore(memStore),
    chronicle.WithBatchSize(50),
)

Chronicle integrates with the Forge ecosystem via forge.Scope for automatic tenant scoping and the Emitter interface for dependency injection.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoStore is returned when no store has been configured.
	ErrNoStore = errors.New("chronicle: no store configured")

	// ErrEventNotFound is returned when an audit event cannot be found.
	ErrEventNotFound = errors.New("chronicle: event not found")

	// ErrStreamNotFound is returned when a hash chain stream cannot be found.
	ErrStreamNotFound = errors.New("chronicle: stream not found")

	// ErrChainBroken is returned when hash chain verification detects tampering.
	ErrChainBroken = errors.New("chronicle: hash chain broken")

	// ErrSubjectNotFound is returned when a GDPR subject cannot be found.
	ErrSubjectNotFound = errors.New("chronicle: subject not found")

	// ErrPolicyNotFound is returned when a retention policy cannot be found.
	ErrPolicyNotFound = errors.New("chronicle: retention policy not found")

	// ErrReportNotFound is returned when a compliance report cannot be found.
	ErrReportNotFound = errors.New("chronicle: report not found")

	// ErrErasureNotFound is returned when an erasure record cannot be found.
	ErrErasureNotFound = errors.New("chronicle: erasure not found")

	// ErrErasureKeyNotFound is returned when a per-subject encryption key cannot be found.
	ErrErasureKeyNotFound = errors.New("chronicle: erasure key not found")

	// ErrInvalidQuery is returned when a query has invalid parameters.
	ErrInvalidQuery = errors.New("chronicle: invalid query")

	// ErrUnauthorized is returned when a caller lacks permission for the requested operation.
	ErrUnauthorized = errors.New("chronicle: unauthorized")

	// ErrStoreClosed is returned when an operation is attempted on a closed store.
	ErrStoreClosed = errors.New("chronicle: store closed")

	// ErrMigrationFailed is returned when database migrations fail.
	ErrMigrationFailed = errors.New("chronicle: migration failed")

	// ErrCryptoErasureUnavailable is returned when crypto-erasure is enabled
	// without a sealer to perform the encryption.
	//
	// Accepting the flag without one would store subject data as plaintext while
	// the operator believed a key deletion made it unrecoverable. Refusing to
	// start is the safer failure: a compliance guarantee that silently does not
	// hold is worse than one that is explicitly unavailable.
	ErrCryptoErasureUnavailable = errors.New(
		"chronicle: crypto-erasure is enabled but no sealer was provided; " +
			"pass chronicle.WithSealer(crypto.NewSealer(keyStore)) so subject " +
			"payloads are encrypted, or disable enable_crypto_erasure",
	)

	// ErrHMACKeyUnavailable is returned when a keyed digest scheme is selected
	// without a key provider to supply the key.
	//
	// The reasoning matches ErrCryptoErasureUnavailable: an operator who set
	// digest: hmac believes their chain cannot be recomputed from the stored
	// rows alone. Writing unkeyed digests under that belief is worse than
	// refusing to start.
	ErrHMACKeyUnavailable = errors.New(
		"chronicle: digest scheme hmac is selected but no key provider was given; " +
			"pass chronicle.WithKeyProvider(keys.NewFileProvider(path)) so digests " +
			"are keyed, or leave the digest scheme unset",
	)

	// ErrSchemeWeakeningRefused is returned when a stream is pinned to a
	// stronger digest scheme than the one this process is configured to write.
	//
	// Chronicle moves a stream's pin up on its own, because that is the only way
	// turning HMAC on takes effect on streams that already exist. It will not
	// move one down. A pin that drops on its own is indistinguishable from an
	// attacker lowering it, and once it has dropped every event written under
	// the weaker scheme verifies clean, so the operator sees green while the
	// guarantee they configured is gone. Refusing the write is loud, reversible,
	// and leaves the existing chain intact.
	ErrSchemeWeakeningRefused = errors.New(
		"chronicle: this stream is pinned to a stronger digest scheme than this " +
			"process writes; restore the stronger scheme (tamper_evidence.digest " +
			"and its key source, or chronicle.WithDigestScheme plus " +
			"chronicle.WithKeyProvider), or if the weaker scheme is genuinely " +
			"intended, record that decision and lower the stream's pin " +
			"deliberately through the store",
	)

	// ErrRetentionWithheld is returned when retention was asked to remove
	// events that no longer verify.
	//
	// Purging an event destroys the evidence of anything done to it, and a
	// retention record would then vouch for its hashes as if it had been
	// intact. So an event whose digest fails is kept, with its tampering still
	// on the record for verification to report, and the rest of the batch is
	// purged as usual. The same holds for an event that cannot be verified at
	// all, such as one digested under a key that can no longer be resolved.
	ErrRetentionWithheld = errors.New(
		"chronicle: retention withheld events that do not verify; they are kept as evidence",
	)

	// ErrBackfillUnkeyed is returned when a retention backfill is asked to
	// run against a stream that is not pinned to chronicle/v5, or by a
	// process that does not write chronicle/v5.
	//
	// A backfill turns gaps into retained sequences on the strength of
	// archived copies of the purged events. That is only evidence when
	// nobody but the chain writer could have produced those copies. Under an
	// unkeyed scheme anyone with write access to the store can compute a
	// valid digest, fabricate an archive to match, and have the backfill
	// excuse a deletion. The result would be an attestation dressed up as a
	// proof, so it is refused outright.
	ErrBackfillUnkeyed = errors.New(
		"chronicle: retention backfill needs a keyed chronicle/v5 chain; under an unkeyed " +
			"scheme an archived copy proves nothing, because anyone who can write the store " +
			"can fabricate one",
	)
)

Sentinel errors for Chronicle operations.

Functions

This section is empty.

Types

type BackfillInput

type BackfillInput struct {
	// AppID and TenantID select the stream. AppID is required.
	AppID    string
	TenantID string

	// Archive is where the policy that purged the events archived them.
	Archive sink.ArchiveReader

	// DryRun reports what would be recovered and writes nothing.
	DryRun bool
}

BackfillInput says which stream to backfill and where its purged events were archived.

type BackfillRecovered

type BackfillRecovered struct {
	Seq      uint64 `json:"seq"`
	Hash     string `json:"hash"`
	Location string `json:"location"`
}

BackfillRecovered is one gap an archived copy accounts for.

type BackfillRefused

type BackfillRefused struct {
	Seq    uint64 `json:"seq"`
	Reason string `json:"reason"`
}

BackfillRefused is one gap the backfill left alone.

type BackfillRejected

type BackfillRejected struct {
	Seq      uint64 `json:"seq"`
	Location string `json:"location"`
	Reason   string `json:"reason"`
}

BackfillRejected is one archived copy that failed a check.

type BackfillReport

type BackfillReport struct {
	StreamID id.ID `json:"stream_id"`
	DryRun   bool  `json:"dry_run,omitempty"`

	// Gaps are the sequences verification could not account for before the
	// backfill ran.
	Gaps []uint64 `json:"gaps"`

	// Recovered are the gaps an archived copy proved, in sequence order. On a
	// dry run nothing was written for them.
	Recovered []BackfillRecovered `json:"recovered,omitempty"`

	// Refused are the gaps left as gaps, each with the reason.
	Refused []BackfillRefused `json:"refused,omitempty"`

	// Rejected are archived copies that failed a check. A copy that claims to
	// be a purged event of this stream and does not verify under its key is
	// worth looking at on its own account: nothing the chain writer produced
	// looks like that.
	Rejected []BackfillRejected `json:"rejected,omitempty"`

	// Records are the sequences of the retention records written.
	Records []uint64 `json:"records,omitempty"`

	// After is the stream's verification once the records were written. Nil
	// on a dry run or when nothing was recovered.
	After *verify.Report `json:"after,omitempty"`
}

BackfillReport is what a backfill found and did.

type Chronicle

type Chronicle struct {
	// contains filtered or unexported fields
}

Chronicle is the root audit trail engine. It orchestrates event recording, hash chain computation, batching, sink fan-out, plugin hooks, and query execution.

func New

func New(opts ...Option) (*Chronicle, error)

New creates a new Chronicle instance with the given options.

func (*Chronicle) Aggregate

Aggregate returns grouped counts/stats, scoped to the caller's tenant.

func (*Chronicle) BackfillRetention

func (c *Chronicle) BackfillRetention(ctx context.Context, in *BackfillInput) (*BackfillReport, error)

BackfillRetention writes retention records for gaps left by purges that ran before retention records existed, where the purged events survive in an archive and can be proven to be the events that were removed.

It is an operator tool, run once per stream after an upgrade. Nothing calls it automatically, and there is deliberately no way to tell it to accept a gap without evidence: a tool that marks gaps as authorised on someone's say so cannot be told apart from one covering up a deletion.

A gap is recovered only when all of this holds:

  • The stream is pinned to chronicle/v5 and this process writes it. Anything else returns ErrBackfillUnkeyed before reading the archive.
  • The archive holds a copy of the event at that sequence, for this stream and scope, that is not itself a retention record.
  • The copy's digest recomputes under the stream's pin with the HMAC key it names, and it claims chronicle/v5. That is what shows the chain writer produced it and nobody has edited it since. Copies below the pin under an unkeyed scheme are refused, whatever the rest of the stream is.
  • Every sequence in the same run of consecutive gaps has such a copy, and the run links end to end: the first copy's PrevHash is the hash of the event before the run, each copy's PrevHash is the one before's Hash, and the last copy's Hash is the PrevHash of the event after the run. The neighbours can be present events that verify cleanly, sequences an existing record already accounts for, genesis, or the stream head.

A run is recovered whole or not at all, so the verifier can always link across what a backfilled record lists. One sequence nothing can prove leaves its whole run as gaps, and a sequence deleted by anything other than retention stays exactly what it is.

The records are written the same way RecordRetention writes them, marked with the archive's name, and verification reports those sequences as retained with RetainedRange.Backfill set. Verification never re-reads the archive. What it relies on afterwards is the record, which is as hard to forge as any other v5 event.

What this proves, and what it does not: a recovered sequence held an event the chain writer wrote, whose complete content is in the archive. It does not prove a retention policy removed it rather than someone who copied the row into the archive first. Either way nothing was hidden, since the content is still there to read, and that is the property a gap exists to protect. Keep the archive.

func (*Chronicle) ByUser

func (c *Chronicle) ByUser(ctx context.Context, userID string, opts audit.TimeRange) (*audit.QueryResult, error)

ByUser returns events for a specific user within a time range.

func (*Chronicle) Critical

func (c *Chronicle) Critical(ctx context.Context, action, resource, resourceID string) *EventBuilder

Critical creates an EventBuilder for a critical-severity event.

func (*Chronicle) Health

func (c *Chronicle) Health(ctx context.Context) error

Health checks the health of the Chronicle by pinging its store.

func (*Chronicle) Info

func (c *Chronicle) Info(ctx context.Context, action, resource, resourceID string) *EventBuilder

Info creates an EventBuilder for an info-severity event.

func (*Chronicle) Query

func (c *Chronicle) Query(ctx context.Context, q *audit.Query) (*audit.QueryResult, error)

Query returns events matching filters, scoped to the caller's tenant.

func (*Chronicle) Record

func (c *Chronicle) Record(ctx context.Context, event *audit.Event) error

Record persists an audit event through the Chronicle pipeline. Pipeline: apply scope → assign ID/timestamp → validate → resolve stream → compute hash chain → store.Append → update stream head

Each call is one "record event" span, with the store's own query spans under it, and one observation on the record metrics when WithMetrics was given.

func (*Chronicle) RecordOnce

func (c *Chronicle) RecordOnce(ctx context.Context, request acceptance.Request) (*acceptance.Receipt, error)

RecordOnce recovers or atomically accepts one source delivery. Your host must supply trusted producer/installation and authorized explicit event scope. Recovery does not seal, resolve keys, or change a stream. Unsupported stores fail explicitly. Record retains its separate, non-idempotent contract.

Each call is one "accept event" span and one observation on the record metrics, labelled record_once. A recovered receipt counts as ok.

func (*Chronicle) RecordRetention

func (c *Chronicle) RecordRetention(ctx context.Context, ref audit.RetentionRef, events []*audit.Event) ([]id.ID, error)

RecordRetention writes a retention record into the chain of every stream the given events belong to, and returns the IDs of the events it recorded. Only those may be purged.

This is what keeps retention from breaking verification. A policy removes events scattered through a stream, one category at a time, and without a record a verifier cannot tell those holes from an attacker's. The record lists each removed event's sequence and the hashes either side of it, so the chain still links end to end across the hole, and it is itself a chained event: forging one takes the same key forging any event does, and removing one leaves a gap of its own.

The record is written before anything is deleted. A crash between the two leaves a record naming events that are still present, which verifies cleanly, rather than deletions nothing explains.

Every event is verified in its stored form first, under its stream's pin. An event that fails is withheld: it stays in the store, its tampering stays visible, and the returned error wraps ErrRetentionWithheld. Purging it would erase the evidence and have the record vouch for hashes that no longer match their content. Events already gone are skipped silently, since there is nothing left to purge.

On error, the returned IDs are still exactly the events whose record was durably appended, and purging them is still correct.

func (*Chronicle) Store

func (c *Chronicle) Store() Storer

Store returns the underlying store for direct access.

func (*Chronicle) VerifyChain

func (c *Chronicle) VerifyChain(ctx context.Context, input *verify.Input) (*verify.Report, error)

VerifyChain verifies the integrity of a hash chain for a stream.

A caller that supplies AppID (the scope GetStreamByScope needs) gets HeadSeq/HeadHash and Pin filled in from the actual stream row for whichever of those it left entirely unset, so it gets head anchoring and pin-aware downgrade detection without having to resolve the stream itself. Three things keep that fill from doing the wrong thing silently:

  • It never mutates the caller's *Input. It fills a local copy and only verifies against that copy, so a caller reusing one Input across a loop of streams does not have it progressively overwritten by whichever stream happened to resolve first.
  • It only applies when the resolved stream's ID matches the StreamID being verified. A caller whose StreamID does not belong to the AppID/TenantID scope it also supplied must not get that scope's unrelated stream silently anchoring its tail -- a false "tampered" from an audit tool is an expensive thing to be wrong about.
  • HeadSeq and HeadHash fill as a pair, only when both are unset. A caller who already supplied one half (a HeadHash sourced out of band, from a checkpoint or an external notary) keeps both, rather than having the value it specifically wanted cross-checked silently replaced.

The StreamID fills from the resolved stream too, whenever the caller left it nil. That is the call every doc page shows -- scope in, nothing else -- and without it the range query ran against a nil ID and came back empty, which the head fill then turned into a confident report naming every sequence in the stream as a gap. Verifying a healthy five-event stream reported five deleted events.

A caller with only a StreamID in hand -- no scope, no stream row -- gets the old, narrower behaviour: no head anchoring, no downgrade detection, matching Input.Pin's documented contract for callers with no stream in hand.

func (*Chronicle) VerifyEvent

func (c *Chronicle) VerifyEvent(ctx context.Context, eventID id.ID) (bool, error)

VerifyEvent recomputes and verifies a single event's hash.

func (*Chronicle) Warning

func (c *Chronicle) Warning(ctx context.Context, action, resource, resourceID string) *EventBuilder

Warning creates an EventBuilder for a warning-severity event.

type Config

type Config struct {
	// BatchSize is the maximum number of events to accumulate before flushing.
	//
	// Not applied by [Chronicle.Record], which writes each event synchronously:
	// linking an event into the hash chain needs the previous event's hash, so
	// appends are inherently sequential. The batcher package implements batching
	// for callers that drive it directly.
	BatchSize int

	// FlushInterval is the maximum time to wait before flushing accumulated events.
	//
	// Not applied by [Chronicle.Record]. See BatchSize.
	FlushInterval time.Duration

	// ShutdownTimeout is the maximum time to wait for graceful shutdown.
	ShutdownTimeout time.Duration

	// EnableCryptoErasure encrypts each subject's personal payload under a
	// per-subject key, so destroying the key makes it irrecoverable.
	//
	// Requires a sealer from [WithSealer]; see [ErrCryptoErasureUnavailable].
	EnableCryptoErasure bool

	// RetentionCheckInterval is how often the retention enforcer runs.
	RetentionCheckInterval time.Duration

	// DigestScheme selects how each event's digest is computed. The default,
	// hash.SchemePlainV4, is unkeyed and reproducible by anyone who can read
	// the store, so it detects corruption rather than tampering.
	//
	// hash.SchemeHMACV5 requires a key provider; see [ErrHMACKeyUnavailable].
	//
	// hash.SchemePlain and hash.SchemeHMAC are verify-only. Setting either one
	// fails at New rather than at the first event, because their content
	// encoding is ambiguous and a digest written under them can be forged
	// without the key.
	DigestScheme hash.Scheme
}

Config holds the configuration for a Chronicle instance.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns a Config with sensible defaults.

type Emitter

type Emitter interface {
	Record(ctx context.Context, event *audit.Event) error
	Info(ctx context.Context, action, resource, resourceID string) *EventBuilder
	Warning(ctx context.Context, action, resource, resourceID string) *EventBuilder
	Critical(ctx context.Context, action, resource, resourceID string) *EventBuilder
}

Emitter is the interface Forge extensions use to emit audit events. Extensions receive this via DI — they don't import Chronicle directly.

type Entity

type Entity struct {
	CreatedAt time.Time `json:"created_at" bun:"created_at,notnull,default:current_timestamp"`
	UpdatedAt time.Time `json:"updated_at" bun:"updated_at,notnull,default:current_timestamp"`
}

Entity is the base type embedded by all Chronicle entities. It provides common timestamp fields.

func NewEntity

func NewEntity() Entity

NewEntity returns an Entity with both timestamps set to the current UTC time.

type EventBuilder

type EventBuilder struct {
	// contains filtered or unexported fields
}

EventBuilder provides a fluent API for constructing and recording audit events.

func (*EventBuilder) AppID

func (b *EventBuilder) AppID(aid string) *EventBuilder

AppID sets the application scope explicitly.

func (*EventBuilder) Category

func (b *EventBuilder) Category(cat string) *EventBuilder

Category sets the event category.

func (*EventBuilder) Event

func (b *EventBuilder) Event() *audit.Event

Event returns the underlying event without recording it. Useful for inspection/testing.

func (*EventBuilder) IP

func (b *EventBuilder) IP(ip string) *EventBuilder

IP sets the client address the action came from.

func (*EventBuilder) Meta

func (b *EventBuilder) Meta(key string, value any) *EventBuilder

Meta adds a key-value pair to the event metadata.

func (*EventBuilder) Outcome

func (b *EventBuilder) Outcome(o string) *EventBuilder

Outcome sets the event outcome.

func (*EventBuilder) Reason

func (b *EventBuilder) Reason(r string) *EventBuilder

Reason sets the event reason.

func (*EventBuilder) Record

func (b *EventBuilder) Record() error

Record persists the built event through the Chronicle pipeline.

func (*EventBuilder) RequestID

func (b *EventBuilder) RequestID(rid string) *EventBuilder

RequestID sets the HTTP request correlation id.

func (*EventBuilder) SessionID

func (b *EventBuilder) SessionID(sid string) *EventBuilder

SessionID sets the authenticated session the action ran under.

func (*EventBuilder) SubjectID

func (b *EventBuilder) SubjectID(sid string) *EventBuilder

SubjectID sets the GDPR subject ID for crypto-erasure.

func (*EventBuilder) TenantID

func (b *EventBuilder) TenantID(tid string) *EventBuilder

TenantID sets the tenant scope explicitly.

func (*EventBuilder) UserAgent

func (b *EventBuilder) UserAgent(ua string) *EventBuilder

UserAgent sets the client's User-Agent string.

func (*EventBuilder) UserID

func (b *EventBuilder) UserID(uid string) *EventBuilder

UserID sets the user who performed the action.

type EventSealer

type EventSealer interface {
	// Seal encrypts the event's personal payload in place.
	Seal(event *audit.Event) error
}

EventSealer encrypts an event's personal payload before the event is hashed and stored, and is what makes crypto-erasure possible: destroying the subject's key leaves the payload unrecoverable.

crypto.Sealer implements this. It is an interface here so the root package does not import crypto, which imports this package for its error sentinels.

type ID

type ID = id.ID

ID is the primary identifier type for all Chronicle entities.

type Option

type Option func(*Chronicle) error

Option configures a Chronicle instance.

func WithBatchSize

func WithBatchSize(n int) Option

WithBatchSize sets the maximum batch size before flushing.

Record writes each event synchronously, so this does not currently change how events are persisted; see Config.BatchSize.

func WithCheckpointSigner

func WithCheckpointSigner(s checkpoint.Signer) Option

WithCheckpointSigner supplies the signer VerifyChain checks signed checkpoints under. Pass the same signer the Checkpointer writes with.

It is a separate option rather than something derived from WithKeyProvider because the two key sources are genuinely independent. A deployment can checkpoint a plain, unkeyed chain, which has no key provider at all; another can digest with HMAC from one keyset and sign checkpoints from a different one. Deriving the signer from the digest's provider got both of those wrong: the first never checked a checkpoint, and the second failed every verification with "key not found" because the signature had been made under a key the digest's provider had never heard of.

Leave it unset and verification behaves exactly as it did before checkpoints existed: no checkpoint is fetched, and coverage tops out at keyed.

func WithCryptoErasure

func WithCryptoErasure(enabled bool) Option

WithCryptoErasure enables or disables per-subject encryption for GDPR.

Enabling it requires a sealer from WithSealer; without one, New returns ErrCryptoErasureUnavailable rather than accepting a flag that would promise an Article 17 guarantee nothing implements.

With it on, every event carrying a SubjectID has its metadata, reason and IP encrypted under a per-subject key before being hashed and stored. Destroying that key leaves the payload unrecoverable while the operational record and the hash chain stay intact. See crypto.Sealer for exactly which fields are covered.

func WithDigestScheme

func WithDigestScheme(s hash.Scheme) Option

WithDigestScheme selects how event digests are computed.

hash.SchemeHMACV5 requires WithKeyProvider; without one, New returns ErrHMACKeyUnavailable rather than accepting a flag that promises a guarantee nothing implements. The two options may be given in either order.

hash.SchemePlain and hash.SchemeHMAC are verify-only and rejected here. They hash an ambiguous content encoding, so a digest written under either can be forged without the key; use hash.SchemePlainV4 or hash.SchemeHMACV5.

func WithFlushInterval

func WithFlushInterval(d time.Duration) Option

WithFlushInterval sets the maximum time between flushes.

Record writes each event synchronously, so this does not currently change how events are persisted; see Config.BatchSize.

func WithKeyProvider

func WithKeyProvider(p keys.Provider) Option

WithKeyProvider supplies the key material for keyed digest schemes.

Rotation lives in the provider: retiring a key stops new digests using it while keeping old events verifiable through Provider.ByID.

func WithLogger

func WithLogger(l log.Logger) Option

WithLogger sets the logger for Chronicle.

func WithMetrics

func WithMetrics(m gometrics.Metrics) Option

WithMetrics records Chronicle's metrics on m: a counter and a latency histogram for every Record and RecordOnce call. In a Forge app pass app.Metrics(), and the series land on /_/metrics as forge_chronicle_events_recorded_total and forge_chronicle_record_duration_seconds. The Forge extension does this for you.

Spans need no option: Chronicle starts them on the global tracer provider, which forge.New installs. A nil m records nothing, which is the default.

func WithSealer

func WithSealer(s EventSealer) Option

WithSealer supplies the encryptor used when crypto-erasure is enabled.

Pass crypto.NewSealer(keyStore). The key store owns the per-subject keys, so its durability decides whether sealed events can be read back: losing it is indistinguishable from erasing every subject.

func WithStore

func WithStore(s Storer) Option

WithStore sets the backing store for Chronicle.

type Prefix

type Prefix = id.Prefix

Prefix identifies the entity type encoded in a TypeID.

type StoredReader

type StoredReader interface {
	// GetStored returns an event exactly as persisted, without decrypting.
	GetStored(ctx context.Context, eventID id.ID) (*audit.Event, error)
}

StoredReader is implemented by stores that decrypt on read, to expose the stored representation that hash verification requires.

type Storer

type Storer interface {
	// Audit event operations.
	Append(ctx context.Context, event *audit.Event) error
	AppendBatch(ctx context.Context, events []*audit.Event) error
	Get(ctx context.Context, eventID id.ID) (*audit.Event, error)
	Query(ctx context.Context, q *audit.Query) (*audit.QueryResult, error)
	Aggregate(ctx context.Context, q *audit.AggregateQuery) (*audit.AggregateResult, error)
	ByUser(ctx context.Context, userID string, opts audit.TimeRange) (*audit.QueryResult, error)
	Count(ctx context.Context, q *audit.CountQuery) (int64, error)
	LastSequence(ctx context.Context, streamID id.ID) (uint64, error)
	LastHash(ctx context.Context, streamID id.ID) (string, error)

	// Stream operations for hash chain (uses StreamInfo to avoid import cycle).
	CreateStreamInfo(ctx context.Context, s *StreamInfo) error
	GetStreamByScope(ctx context.Context, appID, tenantID string) (*StreamInfo, error)
	UpdateStreamHead(ctx context.Context, streamID id.ID, hash string, seq uint64) error
	UpdateStreamScheme(ctx context.Context, streamID id.ID, scheme string, since uint64) error

	// Verification operations.
	EventRange(ctx context.Context, streamID id.ID, fromSeq, toSeq uint64) ([]*audit.Event, error)
	Gaps(ctx context.Context, streamID id.ID, fromSeq, toSeq uint64) ([]uint64, error)

	// Lifecycle.
	Migrate(ctx context.Context) error
	Ping(ctx context.Context) error
	Close() error
}

Storer is the minimal store interface used by Chronicle to avoid import cycles. The full store.Store interface is defined in the store package.

type StreamInfo

type StreamInfo struct {
	ID       id.ID
	AppID    string
	TenantID string
	HeadHash string
	HeadSeq  uint64

	Scheme      string
	SchemeSince uint64
}

StreamInfo is a minimal stream representation used by the Chronicle pipeline to avoid importing the stream package (which would create an import cycle).

Directories

Path Synopsis
_examples
basic command
Example: basic usage of Chronicle audit trail.
Example: basic usage of Chronicle audit trail.
compliance command
Example: Chronicle compliance report generation.
Example: Chronicle compliance report generation.
forge command
Example: Chronicle as a Forge extension.
Example: Chronicle as a Forge extension.
gdpr command
Example: GDPR crypto-erasure with Chronicle.
Example: GDPR crypto-erasure with Chronicle.
hash-chain command
Example: Hash chain verification with Chronicle.
Example: Hash chain verification with Chronicle.
plugins command
Example: Chronicle plugin system.
Example: Chronicle plugin system.
Package acceptance defines recoverable audit ingestion contracts.
Package acceptance defines recoverable audit ingestion contracts.
Package audit defines the core audit event types, query types, and the event store interface for Chronicle.
Package audit defines the core audit event types, query types, and the event store interface for Chronicle.
Package batcher provides batched event writing for Chronicle.
Package batcher provides batched event writing for Chronicle.
Package checkpoint records signed statements about where a hash chain stood, so that rewriting an event after the fact becomes provable.
Package checkpoint records signed statements about where a hash chain stood, so that rewriting an event after the fact becomes provable.
Package compliance generates audit-based compliance reports for Chronicle.
Package compliance generates audit-based compliance reports for Chronicle.
Package crypto provides AES-256-GCM encryption for GDPR crypto-erasure.
Package crypto provides AES-256-GCM encryption for GDPR crypto-erasure.
Package erasure defines GDPR crypto-erasure entities and store interface.
Package erasure defines GDPR crypto-erasure entities and store interface.
erasuretest
Package erasuretest holds checks every erasure.Store implementation must pass, so each backend runs the same scenario against its own engine.
Package erasuretest holds checks every erasure.Store implementation must pass, so each backend runs the same scenario against its own engine.
Package extension provides a Forge-compatible extension for Chronicle.
Package extension provides a Forge-compatible extension for Chronicle.
contract
Package contract wires chronicle into the Forge dashboard's contract path.
Package contract wires chronicle into the Forge dashboard's contract path.
Package handler provides the Forge-style HTTP admin API for the Chronicle audit trail library.
Package handler provides the Forge-style HTTP admin API for the Chronicle audit trail library.
Package hash provides SHA-256 hash chain computation for audit events.
Package hash provides SHA-256 hash chain computation for audit events.
Package id defines TypeID-based identity types for all Chronicle entities.
Package id defines TypeID-based identity types for all Chronicle entities.
internal
acceptancetest
Package acceptancetest exercises the common reliable backend contract.
Package acceptancetest exercises the common reliable backend contract.
metajson
Package metajson decodes stored event metadata without losing numbers.
Package metajson decodes stored event metadata without losing numbers.
pgtest
Package pgtest confines each test that writes to a live PostgreSQL to a schema of its own.
Package pgtest confines each test that writes to a live PostgreSQL to a schema of its own.
redistest
Package redistest keeps each test that runs against a live redis inside a key prefix of its own.
Package redistest keeps each test that runs against a live redis inside a key prefix of its own.
retentiontest
Package retentiontest holds checks that every retention.Store backend must pass, so the five stores are held to one definition of a purge scope.
Package retentiontest holds checks that every retention.Store backend must pass, so the five stores are held to one definition of a purge scope.
telemetry
Package telemetry is the tracing and metrics plumbing Chronicle's packages share: the tracer every span comes from, the attribute keys spans carry, how a failed span is marked, and the metric series each component records.
Package telemetry is the tracing and metrics plumbing Chronicle's packages share: the tracer every span comes from, the attribute keys spans carry, how a failed span is marked, and the metric series each component records.
telemetrytest
Package telemetrytest records what Chronicle's telemetry emits, for tests: a span recorder installed as the global tracer provider, and a go-utils metrics sink that remembers every series registered on it.
Package telemetrytest records what Chronicle's telemetry emits, for tests: a span recorder installed as the global tracer provider, and a go-utils metrics sink that remembers every series registered on it.
Package keys supplies the key material that keyed digests and signed checkpoints depend on.
Package keys supplies the key material that keyed digests and signed checkpoints depend on.
Package plugin provides the extensibility system for Chronicle.
Package plugin provides the extensibility system for Chronicle.
Package retention defines retention policy and archive entities.
Package retention defines retention policy and archive entities.
Package scope provides context-based scope extraction for audit events and queries.
Package scope provides context-based scope extraction for audit events and queries.
Package sink defines the Sink interface and built-in sink implementations.
Package sink defines the Sink interface and built-in sink implementations.
Package store defines the composite store interface for Chronicle.
Package store defines the composite store interface for Chronicle.
memory
Package memory provides an in-memory implementation of the Chronicle store.
Package memory provides an in-memory implementation of the Chronicle store.
mongo
Package mongo implements the Chronicle store interface using grove ORM with the MongoDB driver.
Package mongo implements the Chronicle store interface using grove ORM with the MongoDB driver.
postgres
Package postgres implements the Chronicle store interface using grove ORM.
Package postgres implements the Chronicle store interface using grove ORM.
redis
Package redis implements the Chronicle store interface using Grove KV with the Redis driver.
Package redis implements the Chronicle store interface using Grove KV with the Redis driver.
sealedstore
Package sealedstore decrypts sealed audit events on the way out of a store.
Package sealedstore decrypts sealed audit events on the way out of a store.
sqlite
Package sqlite implements the Chronicle store interface using grove ORM with the SQLite driver.
Package sqlite implements the Chronicle store interface using grove ORM with the SQLite driver.
Package stream defines the hash chain stream entity and store interface.
Package stream defines the hash chain stream entity and store interface.
Package verify provides hash chain verification for Chronicle audit streams.
Package verify provides hash chain verification for Chronicle audit streams.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL