Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions daemon/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.27.0-alpha.1] - 2026-06-16

### Added

- **Out-of-band checkpoint anchor for tail-truncation resistance (ADR-0008 §2, spike #600).** The daemon can emit an additive, Ed25519-signed checkpoint of each chain HEAD — `{chain_id, sequence, receipt_hash, timestamp}`, canonicalised through the existing RFC 8785 path — to one or more append-only sinks the agent UID cannot rewrite. Enable with `--checkpoint-anchor`, a comma-separated fan-out list of `file:<path>`, `git:<dir>`, or `syslog:<tag>` specs (env `AGENTRECEIPTS_CHECKPOINT_ANCHOR`, TOML `checkpoint_anchor`), and `--checkpoint-cadence` (receipts per checkpoint; default every receipt). A checkpoint is signed once and fanned out to every sink; the git sink commits each record so its commit chain is a tamper-evident log. Sink write failures are logged and metered but never block or undo receipt emission — receipts are the primary record. `obsigna receipt verify --against-anchor <log>` additionally verifies each checkpoint signature, asserts the log is strictly increasing in file order, and fails on tail truncation (a checkpoint ahead of the store HEAD). Receipts stay a linear verifiable-credential chain — this touches neither the receipt schema, hash chain, `@context`, nor the issuer DID (the anchoring freeze, ADR-0008). **Off by default:** with no `--checkpoint-anchor` configured, the daemon and `verify` are byte-identical to before. **Alpha — opt-in and experimental:** checkpoint emission is synchronous on the commit path, and durability/retry plus production-grade sinks (object-lock storage, TPM, transparency log) are not yet built.

## [0.26.0] - 2026-06-14

### Changed
Expand Down
42 changes: 42 additions & 0 deletions daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,48 @@ Exit codes are stable for scripting:
| `1` | Chain failed verification (output lists per-receipt status) |
| `2` | Usage error (bad flags, missing key file, unreadable DB) |

### Checkpoint anchoring (truncation resistance)

Chain verification does **not** detect tail truncation on its own (ADR-0008 §2):
dropping the last N receipts leaves a chain that still verifies `VALID`, because
the remaining receipts are internally consistent. The fix is an **out-of-band,
additive checkpoint anchor** — the daemon signs the chain HEAD (`{chain_id,
sequence, receipt_hash, timestamp}`, Ed25519, same RFC 8785 path as receipts)
and writes it to one or more append-only sinks the agent UID cannot rewrite.
Receipts stay a linear chain; the checkpoint is a separate artifact and never
appears in the receipt (the anchoring freeze, ADR-0008).

Enable it on the daemon:

```sh
obsigna-daemon \
--checkpoint-anchor 'git:/var/lib/agentreceipts/anchor,file:/var/log/agentreceipts/anchor.ndjson' \
--checkpoint-cadence 1
```

- `--checkpoint-anchor` is a comma-separated fan-out list. Each entry is
`git:<dir>` (commits to a repo the agent UID cannot write — the commit chain
is the tamper-evident structure), `file:<path>` (append-only NDJSON), or
`syslog:<tag>` (local/forwarded syslog, a different host/principal). A bare
path means `file:`. One checkpoint goes to **all** sinks.
- `--checkpoint-cadence` is receipts-per-checkpoint (default `1`, every
receipt). A graceful shutdown always flushes a final checkpoint.
- A sink **write** failure is logged and metered but never blocks or fails
receipt emission — receipts are the primary record, and a missing checkpoint
is caught later by `verify` gap detection. A sink that fails to **open** at
startup is fatal (you asked for an anchor that cannot be provided).

Check a chain against its anchor (opt-in; default behaviour is unchanged):

```sh
obsigna receipt verify --against-anchor /var/log/agentreceipts/anchor.ndjson
```

This additionally verifies each checkpoint's signature, that the checkpoint log
is strictly increasing, and that the latest checkpoint matches the store HEAD.
A store HEAD *behind* the anchored HEAD is reported as `FAIL (truncation)`.
Without `--against-anchor`, `verify` is byte-identical to today.

## Read interface: `obsigna receipt show <seq>`

```sh
Expand Down
65 changes: 65 additions & 0 deletions daemon/cmd/obsigna-daemon/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,71 @@ shutdown_deadline = "750ms"
}
}

// TestResolveConfig_CheckpointAnchor covers the checkpoint anchor sink list and
// cadence across all three layers, including the comma-split into a []string.
func TestResolveConfig_CheckpointAnchor(t *testing.T) {
t.Run("file", func(t *testing.T) {
path := writeConfig(t, `
checkpoint_anchor = "git:/var/anchor, file:/var/a.ndjson"
checkpoint_cadence = 5
`)
r, err := resolveConfig([]string{"--config", path}, noEnv, io.Discard)
if err != nil {
t.Fatal(err)
}
want := []string{"git:/var/anchor", "file:/var/a.ndjson"}
if len(r.cfg.CheckpointAnchors) != len(want) {
t.Fatalf("anchors = %v, want %v", r.cfg.CheckpointAnchors, want)
}
for i := range want {
if r.cfg.CheckpointAnchors[i] != want[i] {
t.Errorf("anchor[%d] = %q, want %q", i, r.cfg.CheckpointAnchors[i], want[i])
}
}
if r.cfg.CheckpointCadence != 5 {
t.Errorf("cadence = %d, want 5", r.cfg.CheckpointCadence)
}
})
t.Run("env overrides file", func(t *testing.T) {
path := writeConfig(t, "checkpoint_anchor = \"file:/from-file\"\n")
env := envMap(map[string]string{"AGENTRECEIPTS_CHECKPOINT_ANCHOR": "git:/from-env"})
r, err := resolveConfig([]string{"--config", path}, env, io.Discard)
if err != nil {
t.Fatal(err)
}
if len(r.cfg.CheckpointAnchors) != 1 || r.cfg.CheckpointAnchors[0] != "git:/from-env" {
t.Errorf("anchors = %v, want env to override file", r.cfg.CheckpointAnchors)
}
})
t.Run("flag overrides env and file", func(t *testing.T) {
path := writeConfig(t, "checkpoint_anchor = \"file:/from-file\"\n")
env := envMap(map[string]string{"AGENTRECEIPTS_CHECKPOINT_ANCHOR": "git:/from-env"})
r, err := resolveConfig([]string{"--config", path, "--checkpoint-anchor", "syslog:tag"}, env, io.Discard)
if err != nil {
t.Fatal(err)
}
if len(r.cfg.CheckpointAnchors) != 1 || r.cfg.CheckpointAnchors[0] != "syslog:tag" {
t.Errorf("anchors = %v, want flag to win", r.cfg.CheckpointAnchors)
}
})
t.Run("absent means disabled", func(t *testing.T) {
path := writeConfig(t, "chain_id = \"x\"\n")
r, err := resolveConfig([]string{"--config", path}, noEnv, io.Discard)
if err != nil {
t.Fatal(err)
}
if len(r.cfg.CheckpointAnchors) != 0 {
t.Errorf("anchors = %v, want empty (checkpointing off by default)", r.cfg.CheckpointAnchors)
}
})
t.Run("bad env cadence is rejected", func(t *testing.T) {
env := envMap(map[string]string{"AGENTRECEIPTS_CHECKPOINT_CADENCE": "soon"})
if _, err := resolveConfig(nil, env, io.Discard); err == nil {
t.Fatal("expected error for non-integer AGENTRECEIPTS_CHECKPOINT_CADENCE")
}
})
}

// TestResolveConfig_EnvOverridesFile: an env var beats a file value (file is
// the lowest-priority layer).
func TestResolveConfig_EnvOverridesFile(t *testing.T) {
Expand Down
37 changes: 37 additions & 0 deletions daemon/cmd/obsigna-daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ func resolveConfig(args []string, getenv func(string) string, errOut io.Writer)
fs.BoolVar(&cfg.UnsafeSocketPath, "unsafe-socket-path", cfg.UnsafeSocketPath, "Permit a --socket/AGENTRECEIPTS_SOCKET path outside the per-platform safe set (logs a warning; does not override TCP rejection) (env: AGENTRECEIPTS_UNSAFE_SOCKET_PATH)")
fs.StringVar(&cfg.RedactPatternsPath, "redact-patterns", cfg.RedactPatternsPath, "Path to a YAML file of additional redaction patterns (merged with built-in defaults) (env: AGENTRECEIPTS_REDACT_PATTERNS)")
fs.DurationVar(&cfg.ShutdownDeadline, "shutdown-deadline", cfg.ShutdownDeadline, "Best-effort time budget for emitting interrupted-chain terminators on SIGTERM/SIGINT (cannot preempt in-progress SQLite I/O)")
checkpointAnchor := fs.String("checkpoint-anchor", strings.Join(cfg.CheckpointAnchors, ","), "Comma-separated out-of-band checkpoint anchor sinks (ADR-0008 truncation anchor): file:<path>, git:<dir>, syslog:<tag>, or a bare path (=file:). Empty disables checkpointing. (env: AGENTRECEIPTS_CHECKPOINT_ANCHOR)")
fs.IntVar(&cfg.CheckpointCadence, "checkpoint-cadence", cfg.CheckpointCadence, "Receipts between checkpoint emissions per chain; 0/1 = every receipt. A graceful shutdown always forces a final checkpoint. (env: AGENTRECEIPTS_CHECKPOINT_CADENCE)")

// scanConfigFlag (via loadConfigLayer) is the source of truth for --config:
// it has already consumed the value, so we never read *configPath. The flag
Expand All @@ -234,6 +236,11 @@ func resolveConfig(args []string, getenv func(string) string, errOut io.Writer)
return resolved{}, err
}

// --checkpoint-anchor is a comma-separated string on the flag surface but a
// []string in Config; split after Parse so an explicit flag (or its env/
// default-derived value) lands as the resolved sink list.
cfg.CheckpointAnchors = splitAnchors(*checkpointAnchor)

return resolved{
cfg: cfg,
showVersion: *showVersion,
Expand Down Expand Up @@ -368,6 +375,12 @@ func applyFileConfig(cfg *daemon.Config, fc *daemon.FileConfig) {
if fc.ShutdownDeadline != nil {
cfg.ShutdownDeadline = fc.ShutdownDeadline.Duration
}
if fc.CheckpointAnchor != nil {
cfg.CheckpointAnchors = splitAnchors(*fc.CheckpointAnchor)
}
if fc.CheckpointCadence != nil {
cfg.CheckpointCadence = *fc.CheckpointCadence
}
}

// envOverlay applies AGENTRECEIPTS_* environment variables over cfg. An unset
Expand Down Expand Up @@ -418,9 +431,31 @@ func envOverlay(cfg *daemon.Config, getenv func(string) string) error {
if v := getenv("AGENTRECEIPTS_REDACT_PATTERNS"); v != "" {
cfg.RedactPatternsPath = v
}
if v := getenv("AGENTRECEIPTS_CHECKPOINT_ANCHOR"); v != "" {
cfg.CheckpointAnchors = splitAnchors(v)
}
if v := getenv("AGENTRECEIPTS_CHECKPOINT_CADENCE"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid AGENTRECEIPTS_CHECKPOINT_CADENCE %q: want an integer", v)
}
cfg.CheckpointCadence = n
}
return nil
}

// splitAnchors parses a comma-separated checkpoint-anchor list into trimmed,
// non-empty backend specs. "" yields nil (checkpointing disabled).
func splitAnchors(v string) []string {
var specs []string
for _, part := range strings.Split(v, ",") {
if s := strings.TrimSpace(part); s != "" {
specs = append(specs, s)
}
}
return specs
}

// printConfig writes the resolved config in TOML-ish key=value form, mirroring
// the config-file keys so the output doubles as a starting daemon.toml. The
// signing-key path is printed (it is a filesystem path, not key material — the
Expand All @@ -438,4 +473,6 @@ func printConfig(w io.Writer, cfg daemon.Config) {
fmt.Fprintf(w, "redact_patterns = %q\n", cfg.RedactPatternsPath)
fmt.Fprintf(w, "unsafe_socket_path = %t\n", cfg.UnsafeSocketPath)
fmt.Fprintf(w, "shutdown_deadline = %q\n", cfg.ShutdownDeadline.String())
fmt.Fprintf(w, "checkpoint_anchor = %q\n", strings.Join(cfg.CheckpointAnchors, ","))
fmt.Fprintf(w, "checkpoint_cadence = %d\n", cfg.CheckpointCadence)
}
21 changes: 13 additions & 8 deletions daemon/configfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,24 @@ import (
// zero value" (e.g. unsafe_socket_path = false) — the config file is the
// lowest-priority layer, so an absent key must never clobber env or flags.
type FileConfig struct {
Socket *string `toml:"socket"`
DB *string `toml:"db"`
Key *string `toml:"key"`
PublicKey *string `toml:"public_key"`
ForensicPublicKey *string `toml:"forensic_public_key"`
ChainID *string `toml:"chain_id"`
IssuerID *string `toml:"issuer_id"`
VerificationMethod *string `toml:"verification_method"`
Socket *string `toml:"socket"`
DB *string `toml:"db"`
Key *string `toml:"key"`
PublicKey *string `toml:"public_key"`
ForensicPublicKey *string `toml:"forensic_public_key"`
ChainID *string `toml:"chain_id"`
IssuerID *string `toml:"issuer_id"`
VerificationMethod *string `toml:"verification_method"`
ParameterDisclosure *DisclosureConfig `toml:"parameter_disclosure"`
RedactPatterns *string `toml:"redact_patterns"`
UnsafeSocketPath *bool `toml:"unsafe_socket_path"`
// ShutdownDeadline accepts a Go duration string, e.g. "200ms" or "1s".
ShutdownDeadline *Duration `toml:"shutdown_deadline"`
// CheckpointAnchor mirrors --checkpoint-anchor: a comma-separated list of
// out-of-band checkpoint sink specs (file:/git:/syslog:). CheckpointCadence
// mirrors --checkpoint-cadence.
CheckpointAnchor *string `toml:"checkpoint_anchor"`
CheckpointCadence *int `toml:"checkpoint_cadence"`
}

// DisclosureConfig is the parsed `parameter_disclosure` config-file value. It
Expand Down
50 changes: 50 additions & 0 deletions daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import (
"strings"
"time"

"github.com/agent-receipts/ar/daemon/internal/anchor"
"github.com/agent-receipts/ar/daemon/internal/chain"
"github.com/agent-receipts/ar/daemon/internal/checkpoint"
"github.com/agent-receipts/ar/daemon/internal/keysource"
"github.com/agent-receipts/ar/daemon/internal/pipeline"
"github.com/agent-receipts/ar/daemon/internal/socket"
Expand Down Expand Up @@ -107,6 +109,24 @@ type Config struct {
// ShutdownDeadline is the total time budget for emitting interrupted-chain
// terminators on SIGTERM/SIGINT. Defaults to 200ms when zero.
ShutdownDeadline time.Duration

// CheckpointAnchors is the fan-out list of out-of-band sinks the daemon
// writes signed chain-HEAD checkpoints to (ADR-0008 follow-through, the
// truncation anchor). Each entry is a backend spec — "file:<path>",
// "git:<dir>", or "syslog:<tag>" (a bare path means file). Empty disables
// checkpointing entirely: the daemon's behaviour is then byte-identical to
// a build without this feature, and `verify` without --against-anchor is
// unaffected. Set from --checkpoint-anchor (env: AGENTRECEIPTS_CHECKPOINT_ANCHOR,
// comma-separated).
CheckpointAnchors []string

// CheckpointCadence is how many committed receipts pass between checkpoint
// emissions on a chain. Defaults to 1 (every receipt) when zero — chosen for
// spike testability; production tuning is deferred. A graceful shutdown
// always forces a final checkpoint regardless of cadence. Only consulted when
// CheckpointAnchors is non-empty. Set from --checkpoint-cadence
// (env: AGENTRECEIPTS_CHECKPOINT_CADENCE).
CheckpointCadence int
}

// DefaultSocketPath returns the per-OS default socket path. Phase 1 resolves
Expand Down Expand Up @@ -553,6 +573,31 @@ func Run(ctx context.Context, cfg Config) error {
}
pp.Redactor = pipeline.NewRedactor(customPatterns)

// Wire the out-of-band checkpoint anchor (ADR-0008 follow-through) when one
// or more sinks are configured. Empty CheckpointAnchors leaves pp.Checkpointer
// nil, so the commit path and shutdown are byte-identical to a build without
// the feature. A sink that fails to OPEN is fatal (the operator asked for an
// anchor that cannot be provided); a sink that fails to WRITE later is
// logged + metered but never blocks receipt emission.
if len(cfg.CheckpointAnchors) > 0 {
sinks, err := anchor.OpenSinks(cfg.CheckpointAnchors)
if err != nil {
return fmt.Errorf("open checkpoint anchor: %w", err)
}
emitter := checkpoint.NewEmitter(sinks, ks, cfg.CheckpointCadence, cfg.Logger.Printf)
defer func() {
if err := emitter.Close(); err != nil {
cfg.Logger.Printf("level=warn checkpoint anchor close: %v", err)
}
}()
pp.Checkpointer = emitter
cadence := cfg.CheckpointCadence
if cadence < 1 {
cadence = 1
}
cfg.Logger.Printf("checkpoint anchor ACTIVE: %d sink(s), cadence=every %d receipt(s)", len(sinks), cadence)
}

ln, err := socket.Listen(socket.Options{
Path: cfg.SocketPath,
Handler: func(ctx context.Context, f socket.Frame) error { return pp.Process(f) },
Expand Down Expand Up @@ -583,6 +628,11 @@ func Run(ctx context.Context, cfg Config) error {
}
}

// Anchor the final HEAD of every open chain (including the terminal receipt
// just emitted) before the deferred st.Close() commits the WAL. No-op when
// checkpointing is disabled.
pp.FlushCheckpoints()

cfg.Logger.Printf("obsigna-daemon shutdown complete")
return nil
}
Expand Down
29 changes: 22 additions & 7 deletions daemon/internal/anchor/anchor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import (
// EventTypeRotation is the event type for key_rotated anchor records.
const EventTypeRotation = "rotation"

// EventTypeCheckpoint is the event type for signed chain-HEAD checkpoint
// records (the truncation anchor; ADR-0008 follow-through). A sink carries
// both rotation and checkpoint events; readers filter on EventType.
const EventTypeCheckpoint = "checkpoint"

// Sink is an append-only external witness for daemon events.
//
// Implementations MUST be safe for concurrent use and MUST return an error
Expand Down Expand Up @@ -71,22 +76,32 @@ func OpenFileLog(path string) (*FileLog, error) {
return &FileLog{f: f, now: time.Now}, nil
}

// Write appends one JSON record terminated by a newline and fsyncs so the
// record is durable before the caller proceeds to the local commit.
func (l *FileLog) Write(eventType string, payload []byte) error {
// recordLine marshals one newline-terminated anchor Record. anchoredAt is the
// sink-stamped time, so the daemon never chooses the recorded time. Shared by
// every Sink so all adapters emit byte-identical record envelopes.
func recordLine(anchoredAt time.Time, eventType string, payload []byte) ([]byte, error) {
if !json.Valid(payload) {
return errors.New("anchor: payload is not valid JSON")
return nil, errors.New("anchor: payload is not valid JSON")
}
rec := Record{
AnchoredAt: l.now().UTC().Format(time.RFC3339Nano),
AnchoredAt: anchoredAt.UTC().Format(time.RFC3339Nano),
EventType: eventType,
Payload: json.RawMessage(payload),
}
line, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("anchor: marshal record: %w", err)
return nil, fmt.Errorf("anchor: marshal record: %w", err)
}
return append(line, '\n'), nil
}

// Write appends one JSON record terminated by a newline and fsyncs so the
// record is durable before the caller proceeds to the local commit.
func (l *FileLog) Write(eventType string, payload []byte) error {
line, err := recordLine(l.now(), eventType, payload)
if err != nil {
return err
}
line = append(line, '\n')

l.mu.Lock()
defer l.mu.Unlock()
Expand Down
Loading
Loading