audit

package
v0.0.21 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package audit provides the pluggable audit persistence interface and concrete implementations for the Pasture epoch workflow audit trail.

The Trail interface is the core abstraction; all persistence is routed through it. Two implementations are provided:

  • InMemoryAuditTrail — for testing and local development (non-durable).
  • SqliteAuditTrail — for production use (durable, CGO-free via modernc.org/sqlite).

A module-level Trail singleton is also kept (see activities.go). It is injected via InitTrail before the durable runtime starts, for callers that cannot be handed a Trail directly.

Package audit — migrate.go

Migrate is the public entry point for the audit-database schema migration framework introduced by PROPOSAL-2 (§7.10). It is invoked by NewSqliteAuditTrail on every open and (in later slices) by the `pasture migrate` CLI command — both paths share this single implementation, so behaviour is identical.

Versioning model

  • Version is stored in the audit_schema_meta table (see schema_meta.go).
  • A database with no audit_schema_meta table is treated as version 1 (legacy, pre-PROPOSAL-2).
  • MaxKnownSchemaVersion is the highest version this binary knows how to produce. A database whose recorded version exceeds MaxKnownSchemaVersion is rejected with an actionable *StructuredError (Scenario 5).

Transactional guarantees

  • Each forward step runs inside a single transaction acquired via BEGIN IMMEDIATE so that a concurrent writer cannot interleave with the migration.
  • The audit_schema_meta version bump is the LAST statement in the transaction, so a crash mid-migration leaves the database observably at the previous version (rollback).

Migration table

  • v1 → v2: bootstrap audit_schema_meta (S1, landed).
  • v2 → v3: new tables context_edges, pasture_agent_categories, pasture_well_known_agents (S2, landed); audit_events.agent_id add + role-backfill + role-drop triple (S3, landed).
  • v3 → v4: EpochContext backfill from audit_events.epoch_id into context_edges; audit_events.epoch_id column dropped (S4, landed — migrate_v3_v4.go).
  • v4 → v5: additive dedup_key TEXT column on audit_events + a partial unique index over its non-NULL values, for engine exactly-once (migrate_v4_v5.go).
  • v5 → v6: lifecycle payload blobs and replay-derived occurrences.
  • v6 → v7: normalized byte-exact lifecycle occurrence bindings.
  • v7 → v8: written_at stamp on lifecycle payload blobs; every row that existed before the column is stamped with the migration instant (migrate_v7_v8.go).
  • v8 → v9: the retired assignment-index triggers and tables are dropped; session claims are kept (migrate_v8_v9.go).
  • v9 → v10: the identity-format schema ceiling. A populated store without the slash identity marker is refused before any Provenance activation (migrate_v9_v10.go).

The ceiling is MaxKnownSchemaVersion below, not a number in this comment, and the steps this binary applies are the dispatch table migrationSteps(). The list above describes that table and is maintained by hand; the table and the constant are the authority, and the two cannot disagree silently: TestMigrate_FreshDB_LandsAtMaxKnownVersion runs Migrate on a fresh file and requires the recorded version to equal the constant, which fails when the table ends below it, and Migrate refuses a table with a gap. A future migration appends a step to migrationSteps(), bumps MaxKnownSchemaVersion, and adds its line to the list above.

Package audit — migrate_v1_v2.go

The v1→v2 migration is the bootstrap step that introduces the audit_schema_meta table. It is intentionally a near-no-op: it creates the meta table (if not already created by an earlier partial run) and seeds (version=2, applied_at=<now>) so that subsequent migrations can branch on the recorded version.

All schema work is wrapped in the migrator's enclosing transaction (see migrate.go). v1→v2 itself does NOT touch audit_events or any other existing table — pre-PROPOSAL-2 data is preserved verbatim.

Package audit — migrate_v2_v3.go

The v2→v3 migration adds three new tables that underpin the unified pasture workflow record (PROPOSAL-2 §7.2):

  • context_edges — many-to-many event⇄context attachment, BCNF (composite-key, no non-key columns; §7.8).
  • pasture_agent_categories — typed categorisation rows for Provenance agents (R8); written by SetAgentCategories.
  • pasture_well_known_agents — stable logical-name → AgentId mapping for idempotent automaton registration at daemon startup (BLOCKER A2). UAT-1 schema invariant: (agent_id PK, name UNIQUE).

IMPORTANT scope boundary: this slice (S2) does NOT touch existing audit_events rows. The audit_events.agent_id column add + role-backfill + role-drop triple lives in S3 (migrate_v3 backfill) per BLOCKER A1, which requires the entire (create-column → backfill → drop-role) sequence to run in one BEGIN IMMEDIATE transaction. Workers integrating with this file downstream must not insert any audit_events DDL into migrateV2toV3.

Layer Integration Points exposed by this slice:

  • pasture_well_known_agents — DDL here; rows written by S7 (startup registration); cached AgentIDs read by S8 (activity attribution).
  • context_edges — DDL here; consumed by S5 (AttachContext writes), S6 (`pasture task contexts` reads), S8 (epoch attachment), S9 (free-floating contexts).
  • pasture_agent_categories — DDL here; written by S5 SetAgentCategories; read by S6 (`pasture task agents` listing).

Package audit — migrate_v3_backfill.go

S3 backfill: extends the v2→v3 transition (begun by S2 in migrate_v2_v3.go) with the audit_events.agent_id add + role-backfill + role-drop triple.

All five steps below run inside the SAME BEGIN IMMEDIATE transaction owned by migrate.runStep — including the audit_schema_meta version bump from 2 to 3, which is the LAST statement before tx.Commit per BLOCKER A1 (PROPOSAL-2 §7.10.2). A crash mid-way leaves the file at v2 (the transaction is rolled back); on the next open, Migrate observes MAX(version)=2 and re-runs the v3 step from scratch with no orphan agents_software rows (idempotency guaranteed by the find-or-create branch in step 3).

Pseudocode parity with PROPOSAL-2 §7.10.2:

  1. ALTER TABLE audit_events ADD COLUMN agent_id TEXT
  2. SELECT DISTINCT role FROM audit_events
  3. For each role: find-or-create a SoftwareAgent in agents_software named "pasture/legacy-role/<role>" via raw SQL (no Provenance Go API call). Find query: SELECT a.id FROM agents a JOIN agents_software s ... WHERE a.kind_id = 2 AND s.name = ? LIMIT 1. If absent, INSERT into agents (id, kind_id=2) and agents_software (agent_id, name, version, source). The INSERT shape MUST match Provenance's RegisterSoftwareAgent (provenance/internal/sqlite/agents.go:96-121) byte-for-byte so a future Provenance lookup of the agent succeeds.
  4. UPDATE audit_events SET agent_id = ? WHERE role = ? AND agent_id IS NULL
  5. SQLite table-rebuild to drop the role column: CREATE NEW → INSERT SELECT → DROP OLD → RENAME → recreate indexes.

C4 compliance: Provenance is NOT modified. The audit migrator opens its own *sql.DB against the same shared SQLite file and writes raw SQL that matches Provenance's INSERT shape exactly. Find-or-create on (kind_id=2, name) is keyed precisely so a partial run that crashed after agent creation but before the version bump reuses the existing row on retry rather than creating a duplicate.

Package audit — migrate_v3_backfill_export.go

Test-only public entry point so the cmd/pasture-migrate-crash binary can run the v3 backfill body (steps 0–5: ensure Provenance tables, add agent_id column, distinct roles, find-or-create agents, backfill agent_id, table-rebuild) WITHOUT the audit_schema_meta version bump or tx.Commit. The crash binary uses this to stage the partial v3 transaction state Scenario 11 needs to assert on.

Why this isn't gated by build tags

The cmd/pasture-migrate-crash binary is built by the standard `make build` target (HANDOFF §7) so it ships in dist/ alongside the production binaries. Build-tag gating would require a parallel build path; given the binary is small, useful only in test contexts, and imports from internal/audit (so accidental production use in any surface besides cmd/pasture-migrate-crash is not a concern), we expose this function unconditionally.

Workers extending the v3 backfill MUST keep the body of migrateV3Backfill in sync with what this function exposes — it is a thin re-export of the same private helper.

Package audit — migrate_v3_v4.go

S4 EpochContext migration: extends the schema chain with the v3→v4 step.

The v4 step does two pieces of work in the SAME BEGIN IMMEDIATE transaction owned by migrate.runStep:

  1. Backfill context_edges from audit_events.epoch_id. For every row in audit_events with non-NULL epoch_id, INSERT a context_edges (event_id, 'EpochContext', epoch_id) row. Uses INSERT OR IGNORE so a defensive replay (impossible in practice — the whole step is one transaction — but safe defensively) is a no-op against the composite PK (event_id, context_kind, context_id).
  2. SQLite table-rebuild to drop the epoch_id column from audit_events: CREATE NEW (without epoch_id) → INSERT SELECT → DROP OLD → RENAME → recreate post-v3 indexes.

The audit_schema_meta version bump from 3 to 4 is the LAST statement in the transaction (per BLOCKER A1 / PROPOSAL-2 §7.10.1 v4 row). A crash before tx.Commit rolls everything back, leaving the file observably at v3; the next open re-runs the v4 step from scratch (idempotent because step 1 uses INSERT OR IGNORE and step 2 lives entirely inside the rolled-back transaction).

Migration-note guarantee (PROPOSAL-2 §7.12, last paragraph)

Legacy audit_events.epoch_id values are migrated AS-IS into context_edges, regardless of whether the string parses as a Provenance TaskId. Free-string epoch IDs (e.g. "epoch-2026-04-22-mvp") and TaskIDs in the retired two-dash form (e.g. "aura-plugins--01968a3c-1234-7000-8000-...") are both preserved because they are historical records and rejecting them would lose audit data. The §7.12 ParseTaskId validation applies only to NEW workflow starts post-migration; that validation is owned by S8.

Pseudocode parity with PROPOSAL-2 §7.10.1 v4 row:

  1. INSERT INTO context_edges (event_id, context_kind, context_id) SELECT id, 'EpochContext', epoch_id FROM audit_events WHERE epoch_id IS NOT NULL
  2. CREATE TABLE audit_events_new (no epoch_id column)
  3. INSERT INTO audit_events_new SELECT (no epoch_id) FROM audit_events
  4. DROP TABLE audit_events
  5. ALTER TABLE audit_events_new RENAME TO audit_events
  6. CREATE INDEX idx_audit_events_agent ON audit_events (agent_id)
  7. CREATE INDEX idx_audit_events_timestamp ON audit_events (timestamp)
  8. (Caller — migrate.runStep wrapper — calls writeVersion(4, ...) and Commit.)

Package audit — migrate_v4_v5.go

The v4→v5 step adds a deterministic deduplication key to audit_events so the durable engine can record a forensic row exactly once even when a crashed step replays.

Two additive statements run in the SAME BEGIN IMMEDIATE transaction owned by runStep, then the version bump:

  1. ALTER TABLE audit_events ADD COLUMN dedup_key TEXT
  2. CREATE UNIQUE INDEX idx_audit_events_dedup ON audit_events(dedup_key) WHERE dedup_key IS NOT NULL

The uniqueness is a PARTIAL unique index, not a column UNIQUE constraint: SQLite's ALTER TABLE ADD COLUMN forbids adding a UNIQUE/PK constraint, and a column constraint would force a full table rebuild. A partial index on the non-NULL keys lets legacy/non-engine rows (which leave dedup_key NULL) coexist freely — multiple NULLs are allowed — while still rejecting a duplicate engine emission. The v4 CREATE TABLE shape is left untouched.

dedup_key is NULL for every existing row and for any writer that does not supply one, so the column is additive and back-compatible: existing databases migrate without data loss.

Package audit — schema_meta.go

The audit_schema_meta table records the on-disk schema version of the pasture audit database. It is the single source of truth read by the migrator on every open to decide which forward migrations (if any) need to run, and to detect a database that is newer than the running binary supports.

Schema (per PROPOSAL-2 §7.10.1):

version    INTEGER PRIMARY KEY  — monotonically increasing schema version
applied_at INTEGER NOT NULL     — Unix nanoseconds UTC when the row landed

The table is created lazily by audit.Migrate (NOT by ensureSchema) so that pre-existing v1 databases — which predate this table — are still openable and detectable as "no audit_schema_meta row yet ⇒ version 1".

Index

Constants

View Source
const MaxKnownSchemaVersion = 10

MaxKnownSchemaVersion is the highest schema version this binary can produce. Bumped by S2 (→3, landed), S4 (→4, landed), the v8 → v9 assignment-index removal (migrate_v8_v9.go), and the v9 → v10 identity-format ceiling (migrate_v9_v10.go).

Layer Integration Point owned by S1: any caller that needs to know "what version does my binary support?" reads this constant. The §11 Scenario 5 newer-schema rejection error reports this value as the "supported version" — bumping it here automatically updates the assertion.

Variables

This section is empty.

Functions

func ApplyCeilingInTx added in v0.0.21

func ApplyCeilingInTx(ctx context.Context, tx *sql.Tx, fromVersion int, nowUnixNano int64) error

ApplyCeilingInTx applies every remaining forward migration step to the caller's transaction, using each step's real apply function, through MaxKnownSchemaVersion. The audit version bump is the last statement of each step, so a failure rolls the whole transaction back.

The offline cutover calls it as its LAST phase (after the marker and the receipt are written), so a store can never carry the identity-format ceiling without the marker and the receipt. A future real migration step is therefore applied by name, never silently stamped.

func InitTrail

func InitTrail(t Trail)

InitTrail injects the Trail implementation used by the activity wrappers.

Must be called once before the durable runtime starts. Passing nil resets the singleton (useful in tests to isolate state between test cases).

This function is not concurrency-safe with workflow execution; call it during daemon startup, before any workflow can run.

func Migrate

func Migrate(db *sql.DB) error

Migrate brings the audit database at db up to MaxKnownSchemaVersion. It is safe to call repeatedly: an already-current database is a no-op.

Behaviour summary:

  • If the database has no audit_schema_meta row yet (legacy v1), Migrate runs forward steps starting from v1.
  • If the database is already at MaxKnownSchemaVersion, Migrate returns nil without opening a transaction.
  • If the database is at a version higher than MaxKnownSchemaVersion (a future binary wrote it), Migrate returns a *StructuredError with Category=CategoryStorage. This is the "newer-schema rejection" path asserted by §11 Scenario 5.

Each step runs in its own BEGIN IMMEDIATE transaction so a crash between steps leaves the database at a consistent intermediate version.

Layer Integration Point: this signature is owned by S1 and consumed by S5 (OpenTaskTracker calls it) and S6 (`pasture migrate` calls it). Callers should treat the returned error as a *pasterrors.StructuredError (use errors.As).

func MigrateContext added in v0.0.17

func MigrateContext(ctx context.Context, db *sql.DB) error

MigrateContext applies whole migration steps under the caller's budget.

func MigrateContextToCeiling added in v0.0.21

func MigrateContextToCeiling(ctx context.Context, db *sql.DB, ceiling int) error

MigrateContextToCeiling applies whole migration steps up to the caller's ceiling. It is the deferred-ceiling entry the offline cutover's catch-up phase uses: the catch-up stops short of the identity-format ceiling, and the conversion transaction applies the remaining step(s) atomically with the marker and the receipt.

Semantics (a store already at or above the ceiling is a NO-OP, never a newer-schema refusal; only a store above MaxKnownSchemaVersion is newer):

  • recorded > MaxKnownSchemaVersion -> newer-schema refusal (unchanged).
  • recorded >= ceiling -> no-op success.
  • recorded < ceiling -> apply steps through the ceiling.

func MigrateV3BackfillForCrashTest

func MigrateV3BackfillForCrashTest(tx *sql.Tx) error

MigrateV3BackfillForCrashTest runs the full v3 backfill body (migrateV3Backfill) against the supplied transaction WITHOUT calling writeVersion(3) and WITHOUT committing the transaction.

The caller is responsible for either:

  • Calling writeVersion(3, ...) and tx.Commit() — production parity, or
  • Calling tx.Commit() / tx.Rollback() / os.Exit() to inject a crash between the body's last statement and the commit (Scenario 11 test path).

nowUnixNano is reserved for future signature parity with migrateV3Backfill; this entry point ignores it.

This is the ONLY exported function in the audit package that bypasses the migration framework's transaction lifecycle. Production callers MUST use audit.Migrate or NewSqliteAuditTrail; this entry point is reserved for the crash-test binary.

func NewerSchemaError added in v0.0.21

func NewerSchemaError(dbVersion, maxKnownVersion int) error

NewerSchemaError is the exported constructor for the newer-schema refusal. The migrator and the read-only preflight both call it, so the sentence is literally one source and the archive/conversion frontends cannot drift.

func QueryEventsOn added in v0.0.5

func QueryEventsOn(ctx context.Context, db *sql.DB, epochId string, phase *protocol.PhaseId, role *string) ([]protocol.AuditEvent, error)

QueryEventsOn is the single canonical implementation of the post-v4 context_edges JOIN query for audit events. Both SqliteAuditTrail.QueryEvents and StatusReader.QueryEvents delegate to this function so any schema change (a v5+ touching audit_events, context_edges, or the legacy-role naming scheme) is applied in one place.

This function is SELECT-only: it issues no writes, DDL, migration, or any operation that could alter the database. It is safe to call on a read-only handle opened next to a running daemon.

epochId is required and always part of the WHERE clause. phase and role are optional; nil means "no filter". Results are returned oldest-first.

Legacy-role compatibility: the v3 schema dropped audit_events.role and replaced it with agent_id. To preserve the existing API where callers filter by role and read event.Role on the result, this function LEFT JOINs audit_events with agents_software (via agent_id) and:

  • When role != nil, restricts the JOIN target to asw.name = "pasture/legacy-role/<role>".
  • When reading rows, strips the "pasture/legacy-role/" prefix from the joined name to repopulate event.Role. Agents whose name does not match the legacy prefix (e.g. well-known automaton agents) report the full name as-is so the caller still gets a non-empty Role for those events.

LEFT JOIN (rather than INNER JOIN) defends against orphan agent_id values that have no agents_software row — those rows are returned with an empty Role rather than dropped silently.

func RaiseIDFormatCeilingInTx added in v0.0.21

func RaiseIDFormatCeilingInTx(tx *sql.Tx, storePath string, nowUnixNano int64) error

RaiseIDFormatCeilingInTx applies the remaining identity-format ceiling step(s) inside the CALLER's conversion transaction, after the dependency has published the slash marker and this transaction has written the cutover receipt. It is the same floor the normal open path enforces, applied at the moment of conversion, so the marker, the receipt and the ceiling can never disagree: one without the others is exactly the half-converted state the ceiling exists to prevent. The caller owns Commit/Rollback.

func ReadSchemaVersion

func ReadSchemaVersion(db *sql.DB) (int, error)

ReadSchemaVersion returns the highest schema version recorded in audit_schema_meta. Returns 1 if the table does not exist (legacy pre-PROPOSAL-2 database). Returns 0 only on infrastructure failure (with a *pasterrors.StructuredError).

This is the public face of readVersion (which is package-private) so the `pasture migrate` CLI handler can probe the on-disk version WITHOUT running the migrator. Used by the dry-run path AND by the post-apply success line (which prints "migrated <db> from v<from> to v<to>").

Layer Integration Point owned by S1 (read helpers) + S6 (CLI consumer).

Types

type InMemoryAuditTrail

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

InMemoryAuditTrail is a Trail implementation backed by an in-memory slice.

Intended for testing and local development. Events are not persisted across process restarts. All methods are safe for concurrent use.

Synthetic event ids

RecordEventReturningId returns a synthetic monotonic counter value (lastEventId, starting at 1 on the first call). The counter is per-trail and increments under m.mu, so two concurrent callers always observe two distinct ids — matching the SQLite-backed trail's per-statement-LastInsertId guarantee. The id is NOT a real audit_events row id and MUST NOT be persisted across processes; it exists so test code that exercises AttachContext-style flows against the in-memory trail can rely on per-call uniqueness.

func NewInMemoryAuditTrail

func NewInMemoryAuditTrail() *InMemoryAuditTrail

NewInMemoryAuditTrail returns an empty, ready-to-use InMemoryAuditTrail.

func (*InMemoryAuditTrail) Events

func (m *InMemoryAuditTrail) Events() []protocol.AuditEvent

Events returns a defensive copy of all recorded events.

Intended for use in tests and assertions — callers receive a snapshot that is safe to inspect without holding the internal lock.

func (*InMemoryAuditTrail) QueryEvents

func (m *InMemoryAuditTrail) QueryEvents(_ context.Context, epochId string, phase *protocol.PhaseId, role *string) ([]protocol.AuditEvent, error)

QueryEvents returns all events matching the filters in insertion order.

epochId is required. phase and role are optional; nil means "no filter".

func (*InMemoryAuditTrail) QuerySessionEntries

func (m *InMemoryAuditTrail) QuerySessionEntries(_ context.Context, sessionId string) ([]protocol.SessionEntry, error)

QuerySessionEntries returns all session entries for the given sessionId in ascending entry_index order (ties broken by insertion order). This matches the SqliteAuditTrail contract (ORDER BY entry_index ASC, id ASC) so callers get the same ordering regardless of backend. Returns an empty (non-nil) slice when no entries exist. Safe for concurrent use.

func (*InMemoryAuditTrail) RecordEvent

func (m *InMemoryAuditTrail) RecordEvent(ctx context.Context, event protocol.AuditEvent) error

RecordEvent appends event to the in-memory list, discarding the synthetic row id. It is safe for concurrent use. See RecordEventReturningId for the id-returning variant; callers that need the id MUST use that.

func (*InMemoryAuditTrail) RecordEventReturningId

func (m *InMemoryAuditTrail) RecordEventReturningId(_ context.Context, event protocol.AuditEvent) (int64, error)

RecordEventReturningId appends event and returns a synthetic monotonic id (per-trail, starting at 1, incremented on every successful call). The id-and-append are performed atomically under m.mu so concurrent callers always observe distinct ids — matching the SQLite-backed trail's per-statement-LastInsertId race-safety guarantee.

The returned id is NOT a real audit_events row id and is only meaningful for the lifetime of this in-memory trail; do not persist it.

func (*InMemoryAuditTrail) RecordSessionEntries

func (m *InMemoryAuditTrail) RecordSessionEntries(_ context.Context, entries []protocol.SessionEntry) error

RecordSessionEntries appends the given entries to the in-memory session entry list. Nil or empty slices are accepted as no-ops. Safe for concurrent use.

type MigrationStepSummary

type MigrationStepSummary struct {
	FromVersion int
	ToVersion   int
	Description string
}

MigrationStepSummary describes one forward step the migrator WOULD apply against a database currently at FromVersion. Used by the `pasture migrate --dry-run` CLI subcommand to render the plan.

Description is the human-readable summary keyed by FromVersion + ToVersion; it MUST stay in sync with the actual migration body in migrate_v*.go (the CI dry-run integration test in S6 asserts the wording). When workers add a new vN→vN+1 migration, they ALSO add a Description entry to the stepDescription() map below — there is no other place to add it.

func PlanMigrations

func PlanMigrations(currentVersion int) []MigrationStepSummary

PlanMigrations returns the ordered list of forward steps the migrator WOULD apply against a database currently at currentVersion. An empty slice means the database is already at MaxKnownSchemaVersion (no work to do).

The function is pure: it touches no on-disk state. It iterates the same registry as Migrate so the dry-run is guaranteed to match the apply.

If currentVersion is greater than MaxKnownSchemaVersion (a future binary wrote the database), PlanMigrations returns an empty slice; the CLI handler is responsible for surfacing the newer-schema rejection error separately via Migrate (which returns the actionable *StructuredError).

Layer Integration Point owned by S1 + S6.

type SqliteAuditTrail

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

SqliteAuditTrail is a Trail implementation backed by a SQLite database.

Uses modernc.org/sqlite (pure Go, CGO_ENABLED=0 compatible — no C toolchain required). Events survive process restarts. The database file and any intermediate parent directories are created on first open.

Schema (audit_events table, post-v3 — see PROPOSAL-2 §7.10.1):

id         INTEGER PRIMARY KEY AUTOINCREMENT
epoch_id   TEXT             (legacy column, dropped in v4)
phase      TEXT             (NULLABLE in v3; legacy v1 had NOT NULL)
agent_id   TEXT NOT NULL    (FK-shaped reference to agents.id)
event_type TEXT NOT NULL    (protocol.EventType string value)
payload    TEXT NOT NULL    (JSON-encoded map[string]any)
timestamp  INTEGER NOT NULL (Unix nanoseconds UTC)

Legacy-role compatibility shim

PROPOSAL-2 §7.11 plans for S8 to replace direct Trail.RecordEvent(role) calls with TaskTracker.RecordEvent(agent_id) at the workflow boundary. Until S8 lands, callers continue to pass a free-string Role on protocol.AuditEvent — the v3 schema drops role but keeps agent_id, so SqliteAuditTrail bridges the two by find-or-creating a SoftwareAgent named "pasture/legacy-role/<role>" via the same raw-SQL path the v3 migration uses (migrate_v3_backfill.go), and writes the resulting agent_id into the new column. QueryEvents joins back through agents_software to repopulate event.Role for the caller, preserving the existing API byte-for-byte.

The mapping is cached in roleToAgentId so a write-heavy workload pays the find-or-create cost only on the first event per role per process.

All methods are safe for concurrent use; SQLite WAL mode is enabled to allow concurrent readers alongside a single writer. The roleToAgentId cache is guarded by roleMu.

func NewSqliteAuditTrail

func NewSqliteAuditTrail(dbPath string) (*SqliteAuditTrail, error)

NewSqliteAuditTrail opens (or creates) the SQLite database at dbPath, applies the schema, and enables WAL mode for concurrent access.

dbPath: Filesystem path to the SQLite database file. Parent directories are created if they do not exist.

Returns an error if:

  • The parent directory cannot be created (permissions, disk full).
  • The database file cannot be opened.
  • Schema migration fails.

The caller must call Close when done to release the file handle.

func NewSqliteAuditTrailContext added in v0.0.17

func NewSqliteAuditTrailContext(ctx context.Context, dbPath string) (*SqliteAuditTrail, error)

NewSqliteAuditTrailContext uses ctx for connection setup and migrations only.

func NewSqliteAuditTrailContextWithOptions added in v0.0.17

func NewSqliteAuditTrailContextWithOptions(ctx context.Context, dbPath string, opts ...SqliteAuditTrailOption) (*SqliteAuditTrail, error)

NewSqliteAuditTrailContextWithOptions refuses nil ctx and does not retain it.

func NewSqliteAuditTrailWithOptions added in v0.0.5

func NewSqliteAuditTrailWithOptions(dbPath string, opts ...SqliteAuditTrailOption) (*SqliteAuditTrail, error)

NewSqliteAuditTrailWithOptions opens the SQLite audit trail with explicit opt-in behavior for tests that need to bypass migrations on a known-current golden database.

func (*SqliteAuditTrail) Close

func (s *SqliteAuditTrail) Close() error

Close releases the underlying database connection. Must be called when the trail is no longer needed to avoid resource leaks.

func (*SqliteAuditTrail) QueryEvents

func (s *SqliteAuditTrail) QueryEvents(ctx context.Context, epochId string, phase *protocol.PhaseId, role *string) ([]protocol.AuditEvent, error)

QueryEvents returns audit events matching the given filters in chronological order (ascending row id, which equals insertion order).

epochId is required and is always part of the WHERE clause. phase and role are optional; nil means "no filter".

Delegates to QueryEventsOn, the single canonical query implementation. Both SqliteAuditTrail.QueryEvents and StatusReader.QueryEvents use the same shared function so any schema change (v5+) is applied in one place. See QueryEventsOn for the legacy-role compatibility and LEFT JOIN mechanics.

func (*SqliteAuditTrail) QuerySessionEntries

func (s *SqliteAuditTrail) QuerySessionEntries(ctx context.Context, sessionId string) ([]protocol.SessionEntry, error)

QuerySessionEntries returns all session entries for the given sessionId in ascending entry_index order (matching insertion order for well-formed data).

Returns an empty (non-nil) slice when no entries exist for sessionId.

func (*SqliteAuditTrail) RecordEvent

func (s *SqliteAuditTrail) RecordEvent(ctx context.Context, event protocol.AuditEvent) error

RecordEvent persists a single AuditEvent to the SQLite database, discarding the inserted row id. It is a thin wrapper over RecordEventReturningId for callers that record-and-forget; callers that need the inserted id (e.g. to follow up with AttachContext) MUST use RecordEventReturningId directly.

See RecordEventReturningId for the full INSERT semantics, transaction boundary, validation rules, and error categories.

func (*SqliteAuditTrail) RecordEventReturningId

func (s *SqliteAuditTrail) RecordEventReturningId(ctx context.Context, event protocol.AuditEvent) (int64, error)

RecordEventReturningId persists a single AuditEvent and returns the audit_events.id of the newly-inserted row.

Race safety: the row id is recovered via sql.Result.LastInsertId on the SAME INSERT statement that wrote the row, INSIDE the same transaction. This is race-free under any level of write contention — the driver tracks the rowid per-statement, not per-connection — and replaces the older "RecordEvent then SELECT MAX(id)" workaround that could return a row id belonging to a concurrent writer.

Timestamp is stored as Unix nanoseconds (INTEGER) for exact round-trip without format parsing overhead.

Legacy-role compatibility: the v3 schema dropped audit_events.role and replaced it with agent_id. Until callers are wired to pass agent_id directly, this method bridges by resolving Role to a SoftwareAgent named "pasture/legacy-role/<role>" via raw SQL (find or create) and writes that agent_id into the new column. The role→agent_id mapping is cached AFTER a successful commit so that a rolled-back write never populates the cache.

All DB work (agent lookup/insert + event insert + context_edges insert) runs inside a single atomic BEGIN IMMEDIATE transaction via withAuditWrite, which retries on SQLite contention and honours the caller's context deadline.

An empty event.Role is rejected with CategoryValidation; the v3 schema requires a non-NULL agent_id and there is no sensible default.

Returns (id, nil) on success or (0, error) on failure.

func (*SqliteAuditTrail) RecordSessionEntries

func (s *SqliteAuditTrail) RecordSessionEntries(ctx context.Context, entries []protocol.SessionEntry) error

RecordSessionEntries persists a batch of SessionEntry records to the SQLite database in a single atomic transaction via withAuditWrite. Nil or empty slices are accepted as no-ops.

All entries are prepared once with tx.PrepareContext and executed with stmt.ExecContext so the caller's context governs each row write. If any INSERT fails the entire batch is rolled back (withAuditWrite owns the transaction boundary; the callback must not commit or rollback).

type SqliteAuditTrailOption added in v0.0.5

type SqliteAuditTrailOption func(*sqliteAuditTrailOptions)

SqliteAuditTrailOption configures NewSqliteAuditTrailWithOptions.

func WithDeferredCeiling added in v0.0.21

func WithDeferredCeiling(ceiling int) SqliteAuditTrailOption

WithDeferredCeiling stops the audit migrator short of the identity-format ceiling. The offline cutover's catch-up phase uses it so the ceiling step is applied by the conversion transaction, atomically with the marker and the receipt, never by a plain open.

func WithLogger added in v0.0.21

func WithLogger(logger *slog.Logger) SqliteAuditTrailOption

WithLogger routes committed-cleanup warnings to the caller's diagnostics. Without this option, the process's default structured logger owns them. Disabled or failed warning delivery retains the committed-cleanup error.

func WithSkipMigrations added in v0.0.5

func WithSkipMigrations() SqliteAuditTrailOption

WithSkipMigrations skips audit DDL/migration and asserts that the file is already at MaxKnownSchemaVersion. It is intended for tests that copy a pre-migrated golden database; migration tests should keep using NewSqliteAuditTrail so they exercise the real migrator.

func WithTimeoutProfile added in v0.0.5

func WithTimeoutProfile(profile timeouts.Profile) SqliteAuditTrailOption

type Trail

type Trail interface {
	// RecordEvent persists a single audit event.
	//
	// Returns an error if the underlying store is unavailable or the write
	// fails. The caller (a durable workflow step) is responsible for retry
	// policy.
	//
	// Callers that need the inserted row id (so they can attach context_edges
	// rows in the same logical step) MUST use RecordEventReturningId instead.
	// RecordEvent is retained for callers that record-and-forget; it is a
	// thin wrapper over RecordEventReturningId that discards the id.
	RecordEvent(ctx context.Context, event protocol.AuditEvent) error

	// RecordEventReturningId persists a single audit event and returns the
	// audit_events.id of the newly-inserted row.
	//
	// Implementations MUST recover the id from the same INSERT statement
	// that wrote the row (e.g. via sql.Result.LastInsertId for SQLite-backed
	// trails) so the returned id is race-safe under concurrent writes.
	// A separate post-write SELECT MAX(id) workaround is forbidden — that
	// pattern races with a concurrent writer's INSERT and can return a row
	// id that belongs to a DIFFERENT goroutine.
	//
	// On the SQLite backend this is the canonical write path; RecordEvent
	// delegates here and discards the id. On the in-memory backend the id
	// is a synthetic monotonic counter scoped to the trail's lifetime — it
	// is NOT a real audit_events row id and MUST NOT be persisted across
	// processes; the counter exists so test code that asserts unique-id-
	// per-call behaviour works against the in-memory trail too.
	//
	// Returns the new id and a nil error on success; on failure returns 0
	// and an error (typically *pasterrors.StructuredError on the SQLite
	// backend so callers can errors.As() it for category-based handling).
	RecordEventReturningId(ctx context.Context, event protocol.AuditEvent) (int64, error)

	// QueryEvents returns all audit events matching the given filters.
	//
	// epochId is required and filters by exact match. phase and role are
	// optional; a nil pointer means "no filter on this field". Results are
	// returned in chronological order (insertion order for in-memory,
	// ascending id order for SQLite).
	QueryEvents(ctx context.Context, epochId string, phase *protocol.PhaseId, role *string) ([]protocol.AuditEvent, error)

	// RecordSessionEntries persists a batch of SessionEntry records.
	//
	// Entries are appended; existing entries for the same session are not
	// replaced. The caller should pass all entries for a turn in a single call
	// for efficient batch INSERT in the SQLite backend.
	//
	// Returns an error if the underlying store is unavailable or the write
	// fails. The caller (a durable workflow step) is responsible for retry
	// policy.
	RecordSessionEntries(ctx context.Context, entries []protocol.SessionEntry) error

	// QuerySessionEntries returns all session entries for the given sessionId
	// in ascending entry_index order (ties broken by insertion order). Both
	// backends honor this contract identically: SQLite via
	// "ORDER BY entry_index ASC, id ASC" and in-memory via a stable sort
	// (previously the two backends diverged).
	//
	// Returns an empty (non-nil) slice when no entries exist for sessionId.
	// Returns an error if the underlying store is unavailable.
	QuerySessionEntries(ctx context.Context, sessionId string) ([]protocol.SessionEntry, error)
}

Trail is the pluggable audit persistence interface.

All implementations must be safe for concurrent use (multiple goroutines may call RecordEvent and QueryEvents simultaneously inside the daemon).

Jump to

Keyboard shortcuts

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