type
ADR
title
ADR-001 — extract the audit log into a plugin (nx-audit)
description
Decision record for making the audit log the second plugin extracted from simple-lms: the interface, the au_ schema with tamper evidence, the transaction rule, the failure policy, the one-way migration of existing history, and retention/export as the product.
tags
adr, audit, plugin, nx-audit, schema, retention, migration
status
proposed
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
day-to-day-task/deepseek-v4.1-flash · Sat Oct 31 2026 13:40:00 GMT+0100 (Central European Standard Time)
verified
code inventory of simple-lms @ 93af927..19f0106 · Sat Oct 31 2026 13:40:00 GMT+0100 (Central European Standard Time)
sources
schema → internal/infrastructure/sqlite/migrations/001_schema.sql; domain → internal/domain/audit/audit.go; service → internal/application/audit/service.go; repo → internal/infrastructure/sqlite/audit_repo.go; helper → internal/interfaces/http/enrollment_handler.go; handler → internal/interfaces/http/audit_handler.go; route → internal/interfaces/http/routes_admin.go; settings → internal/interfaces/http/plugin_settings_handler.go; live → test-lms production database

ADR-001 — extract the audit log into a plugin

Status: proposed · Decides: the second plugin extracted from simple-lms · Supersedes: nothing · Depends on: nx-common v0.3.0 (plugin contract with Core(), APIVersion 3) and the settings contract already in use by nx-register

Decision

The audit log becomes a plugin — module github.com/nextrum-sy/nx-audit, plugin id audit, table prefix au_ — with four properties that make it a product rather than relocated code:

  1. One method on the write path, Record(ctx, Entry) error, which joins the caller's transaction or fails — it never opens its own.
  2. A schema that survives extraction: denormalised actor (no FK to users), occurred_at separate from recorded_at, correlation_id, and a prev_hash/hash chain so deletion or edit of a row is detectable.
  3. Retention and export as first-class operations (Purge, Export), because a log nobody can extract or expire is dead weight with a compliance smell.
  4. Non-core by default (Core() == false) with a visible degraded state, and Core() == true available as the one-line switch when the owner decides audit failures must stop the app.

Nothing is deleted from the existing audit_log until a verified copy exists in au_entries.

Context — what exists today (measured, not remembered)

property value evidence
code size ~304 lines non-test (repo 148, domain 34, UI 122) internal/**/audit*
table audit_log, 11 columns, no foreign keys 001_schema.sql:312
actor stored denormalised user_id, username, role are plain text same
indexes idx_audit_log_created_at only same
write path recordAudit(c, action, resource, recordID) → h.audit.Record enrollment_handler.go:337
call sites 37 grep -rn 'recordAudit('
actions written create ×18, delete ×7, update ×5, transfer, reopen, promote, count, close, archive call-site sweep
resources written cash_box ×9, payment ×5, user ×3, enrollment ×3, bus ×3, year_fee ×2, student_fee ×2, transfer, role, guardian_link, academic_year, student_transfer, bulk_enrollment call-site sweep
snapshots Before/After captured in exactly one writer: the plugin settings save plugin_settings_handler.go:237
errors swallowed: _ = h.audit.Record(...) helper body
read UI GET /audit, gated on ResourceAudit/read; filters q, user_id, action, resource, date_from, date_to routes_admin.go:48, audit_handler.go
retention none — nothing ever deletes or exports no DELETE FROM audit_log in the tree
live volume 84 rows, 2026-09-15 → 2026-10-30; actions also include login_failed and link written outside the helper tenant query
transaction Insert resolves its handle with runtime.EngineFor(ctx, r.db), so it can join a caller's tx today audit_repo.go

Three consequences follow directly from that table, and they are the reason this ADR exists rather than a refactor ticket:

Why this plugin next

Against the other candidates this is the cheapest and the most reusable:

candidate size risk reuse
audit ~300 lines, no FKs, append-only low — no cache, no cross-replica state high: every product wants it
buses small low almost none
fees / students large, entangled high — core business logic medium
users + access large (schema moves) high high, but the interface already hides it
reports read-only over everything can't precede its sources low

It is also the first write-path plugin that must join a host transaction for a reason other than money. The register proved the seam for balances; audit proves it for every mutating action in the app.

The contract

// Package audit is the plugin's public surface. The host depends on this, never
// on au_ tables or on this module's internals.
type Recorder interface {
    // Record writes one entry. It MUST join a transaction already present in ctx
    // (nx-common runtime.EngineFor) and MUST NOT open its own: an audit row that
    // outlives a rolled-back change is worse than no audit row.
    Record(ctx context.Context, e Entry) error

    // List answers the audit screen. Filters compose; zero values mean "any".
    List(ctx context.Context, q Query) (Page, error)

    // Export streams entries as JSONL, CSV or NDJSON to w, newest last, so the
    // consumer can resume from the last written id.
    Export(ctx context.Context, w io.Writer, q Query, format Format) (ExportResult, error)

    // Purge deletes entries whose occurred_at is older than before, in bounded
    // batches, and reports how many went. It never touches the newest row.
    Purge(ctx context.Context, before time.Time) (PurgeResult, error)

    // Verify walks the hash chain over [from, to] and returns the first break.
    Verify(ctx context.Context, from, to time.Time) (VerifyResult, error)

    // Healthy reports whether recording is currently working; the host surfaces
    // this instead of failing silently.
    Healthy(ctx context.Context) Health
}

type Entry struct {
    Action     string            // create | update | delete | login_failed | …
    Resource   string            // payment, cash_box, user, plugin:<id>, …
    RecordID   string            // "" when the action has no target
    Reason     string            // operator-supplied justification, if any
    Actor      Actor             // UserID, Username, Role, IP — denormalised on purpose
    OccurredAt time.Time         // when it happened; defaults to now() if zero
    Before     []byte            // optional JSON snapshot, capped at 4 KiB
    After      []byte            // optional JSON snapshot, capped at 4 KiB
    CorrelationID string         // groups every entry of one request or one import
    Meta       map[string]string // small structured extras (kind, source, method)
}

type Actor struct{ UserID, Username, Role, IP string }

Contract rules, each with a test in the conformance kit:

  1. Record inside a rolled-back host transaction leaves no row.
  2. Record never truncates silently: a snapshot over 4 KiB is refused with a typed error, and Meta over 16 keys likewise (the register already caps meta at 4 KiB, so the two plugins behave the same way).
  3. Export is deterministic: same query twice ⇒ byte-identical output, because the sequence number is the sort key.
  4. Purge never removes the row that anchors the chain unless it rewrites the anchor and says so in PurgeResult.
  5. Every method honours ctx cancellation; none of them panic on nil optional fields.

Schema (au_)

Follows nx-register's conventions — plugin prefix, explicit versioned migrations, meta on plugin tables — with the fields the current table lacks.

CREATE TABLE au_entries (
    id              TEXT PRIMARY KEY,
    seq             INTEGER NOT NULL UNIQUE,          -- chain order, assigned in-tx
    occurred_at     TIMESTAMP NOT NULL,               -- when the action happened
    recorded_at     TIMESTAMP NOT NULL,               -- when the row was written
    action          TEXT NOT NULL,
    resource        TEXT NOT NULL,
    record_id       TEXT,
    reason          TEXT,
    actor_user_id   TEXT,
    actor_username  TEXT,
    actor_role      TEXT,
    actor_ip        TEXT,
    correlation_id  TEXT,
    meta_json       TEXT,
    before_json     TEXT,
    after_json      TEXT,
    prev_hash       TEXT,
    hash            TEXT NOT NULL                     -- sha256(prev_hash || canonical row)
);
CREATE INDEX au_entries_occurred  ON au_entries (occurred_at);
CREATE INDEX au_entries_record    ON au_entries (resource, record_id);
CREATE INDEX au_entries_actor     ON au_entries (actor_user_id);
CREATE INDEX au_entries_corr      ON au_entries (correlation_id);
CREATE INDEX au_entries_action    ON au_entries (action, occurred_at);

CREATE TABLE au_settings   (key TEXT PRIMARY KEY, value TEXT NOT NULL);  -- via nx-common settings
CREATE TABLE au_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL);

Append-only is enforced, not promised: BEFORE UPDATE and BEFORE DELETE triggers raise au_entries_append_only, the same shape as the register's line_append_only. Purge therefore runs as a controlled exception (drop and recreate the delete trigger inside its own transaction, exactly as the register migration does).

Two deliberate choices:

Hash chain: what it buys and what it costs

hash = sha256(prev_hash || id || seq || occurred_at || action || resource || record_id || actor_user_id || before_json || after_json). Editing or deleting a row breaks every later hash, so Verify can point at the first inconsistency.

Cost: a global chain serialises inserts — one writer at a time per tenant. That is already true of this deployment (SQLite, one connection, one replica per tenant), so it costs nothing today. If the plugin ever meets a multi-writer database, the fallback is a per-resource chain, and this ADR records the switch as a v2 decision rather than an accident.

Failure policy

failure Core() == false (default) Core() == true (strict)
plugin fails to migrate host logs, audit feature disabled, everything else serves host refuses to start
plugin fails to init same as above host refuses to start
Record returns an error mid-request host logs at error, increments audit_failures_total, request proceeds request fails with 500

Non-core is the default for the same reason the register will be flipped later: a school that cannot record attendance because the log is unavailable is a worse outcome than a school with a gap in the log. But the gap must be visible:

Strict mode is a code decision (Core()), not a setting: settings must not be able to make a process refuse to start, or a bad document can brick the app.

Migration of existing history

The register migration dropped tables because nothing in them mattered. Audit is the opposite: these rows cannot be regenerated, so the copy is a first-class deliverable with a proof.

  1. Create au_* from the plugin's own migrations (plugin owns its schema).
  2. Copy in one transaction: INSERT INTO au_entries (id, seq, occurred_at, recorded_at, …) SELECT id, ROW_NUMBER() OVER (ORDER BY created_at, id), created_at, created_at, … FROM audit_log ORDER BY created_at, id; correlation_id is NULL for historical rows — it is new information, and inventing one would be a lie.
  3. Compute prev_hash/hash in the same transaction, in seq order.
  4. Verify before dropping anything: equal row counts; equal set hashes of (id, action, resource, record_id, created_at) between audit_log and au_entries; Verify walks the new chain clean.
  5. audit_log stays read-only for one release (a DROP is irreversible and the live tenant is a real school). Drop it in the release after the copy is verified in production, in a separate, named migration.

The 84 rows on test-lms are the regression fixture: the copy must reproduce them exactly, including the two actions (login_failed, link) written outside the recordAudit helper.

Host integration

The seam is small because the current code already has one entry point:

Boot becomes explicit, driven by the contract the host already implements for the register:

bootPlugins([]plugin.Plugin{registerPlugin, auditPlugin})
// Register → always hard fail
// Migrate / Init → hard for Core(); logged + feature hidden otherwise

Settings (via the nx-common contract, not bespoke)

key type default meaning
retention_days int 0 0 = keep forever; anything else = Purge anything older at boot and daily
spool_path string "" empty = no spool; a path = failed writes append there and replay on next boot
export_batch int 1000 rows per Export page; keeps memory flat on a decade of history
hash_chain bool true lets an operator turn the chain off if insert latency ever matters

strict is deliberately absent — see the failure policy.

Scheduling belongs to the host, not the plugin. The plugin owns the rule (Purge(before)); the host owns when (boot, plus a ticker). A plugin that starts goroutines behind the host's back is a plugin that survives shutdown badly.

Non-goals

Alternatives considered

option why rejected
Leave it in the host The 84-row table already can't be exported or expired, the write error is swallowed, and the second product would copy all 300 lines.
Record in HTTP middleware only Cannot see Before/After, cannot join the handler's transaction, and produces a request log rather than an audit trail.
SQL triggers on every table The database does not know the actor, the reason or the correlation id — and triggers would fire on the plugin's own writes.
Append to a file, no table Not queryable; the /audit screen and "who changed this record" both die.
Ship to an external log service Adds a network dependency to every write, and the deployment is a single-node SQLite app whose only guarantee is local atomicity.

Consequences

Positive. Audit gets retention, export, tamper evidence and a health signal; every future app reuses one plugin instead of copying 300 lines; the write-path seam is proven beyond money; resource+record_id becomes indexed, so "who changed this payment" stops being a table scan.

Negative. A global hash chain serialises inserts (accepted: single writer today); two audit tables coexist for one release; extraction adds an interface call per mutation (~37 sites' worth of traffic, negligible against an SQLite write); compliance semantics now live behind a version boundary, so a plugin bug is a compliance bug.

Neutral. The au_ prefix means existing SQL that names audit_log must change — deliberately, since we do not keep backwards compatibility.

Rollout

stage work done when
1 nx-audit module: contract, au_ schema, migrations, conformance tests, Version 0.1.0, nx-common v0.3.0 dep module CI green, contract tests cover rules 1–5
2 host wiring behind the interface; AuditService → Recorder; banner + correlation id middleware app builds and boots with the plugin doing nothing; suite green
3 cut the 37 call sites + the two direct writers; drop _ =; snapshots where the diff matters all actions/resources from the table above appear in au_entries on test-lms
4 backfill the 84 historical rows and verify (counts + set hashes + chain walk) verification output attached to the migration commit
5 retention (Purge) + export (Export + CLI) + Verify in the UI export round-trips: re-import of the export equals the DB
6 drop audit_log in a named migration; update this bundle grep -r audit_log in the app returns only the migration that dropped it

Open questions

  1. Chain scope — global (proposed) or per-resource? Global is verifiable as one statement; per-resource parallelises but needs N anchors.
  2. Retention default — 0 (forever) is honest but grows without bound; a school in a jurisdiction with a 7-year rule probably wants 2555.
  3. Spool default — off is safe; on is lossless. Which failure hurts more in this product?
  4. Do exports need signatures (Export + detached SHA-256, or a signed manifest) before anyone will accept them as evidence?
  5. Should login_failed live in this plugin at all, or in the access plugin — it is authentication, not a data change, and its volume profile is different.

Acceptance criteria

flowchart LR subgraph host["simple-lms (host)"] h["handler<br/>37 call sites"]:::host svc["money / rbac / settings<br/>services"]:::host tx["RunPluginTx<br/>ctx carries the tx"]:::host ui["/audit screen<br/>filters · export · verify"]:::host end subgraph plugin["nx-audit (plugin)"] rec["Recorder<br/>Record · List · Export · Purge · Verify · Healthy"]:::plugin chain["hash chain<br/>seq · prev_hash · hash"]:::plugin set["Settings<br/>retention_days · spool_path · hash_chain"]:::plugin end log[("au_entries<br/>append-only · indexed by record")]:::store failed{"record fails?"}:::bad spool["spool file (opt-in)<br/>replayed next boot"]:::ext h --> rec svc --> tx tx --> rec rec --> chain --> log set --> rec ui --> rec rec --> failed failed -->|"non-core: log + banner"| spool failed -->|"core: 500, nothing written"| ui classDef host fill:#15314a,stroke:#2f5d84,color:#e6edf7 classDef plugin fill:#163a2f,stroke:#2f7d5b,color:#e6edf7 classDef store fill:#3a2f16,stroke:#8a7327,color:#f2e8cf classDef bad fill:#4a1f28,stroke:#8a3f4f,color:#f7c9d2 classDef ext fill:#2a1f3a,stroke:#6b4f8a,color:#e9dcf5

Rendered from plugins/audit/audit-plugin.md · OKF bundle · regenerate with npm run render