diff --git a/src/layer4/layer4.ts b/src/layer4/layer4.ts new file mode 100644 index 0000000..a8b37dc --- /dev/null +++ b/src/layer4/layer4.ts @@ -0,0 +1,188 @@ +/** + * Layer 4 (Audit Trail). Append-only persistence of every change to a + * DecisionRecord, plus replay: reconstruct a decision's full lifecycle from + * the store alone. + * + * Implements the Layer 4 contract at + * https://github.com/Source-Engineering/source-engineering-architecture/blob/main/layer-contracts/layer-4-audit-trail.md + * and RFC-0005. + * + * Guarantees enforced here: + * - append-only: the store rejects any rewrite or delete + * (reason: audit-store-not-append-only) + * - lifecycle integrity: a source update affecting a decision appends a + * "corrected" entry naming the source + * - retention policy declared by the domain (retention-policy-missing) + * - re-runnable: a rerun appends a "rerun" entry linked to the original + */ + +import { createHash } from "node:crypto"; +import { appendFileSync, readFileSync, existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { AuditEntry, Cots, DecisionRecord } from "../cots.js"; + +export type Layer4ErrorReason = + | "retention-policy-missing" + | "persistence-unavailable" + | "audit-store-not-append-only" + | "decision-not-found"; + +export class Layer4Error extends Error { + constructor(public readonly reason: Layer4ErrorReason) { + super(reason); + } +} + +export interface AuditStore { + append(entry: AuditEntry): void; + entriesFor(decisionId: string): AuditEntry[]; + all(): AuditEntry[]; +} + +/** + * JSONL append-only file backend. Each line is one AuditEntry plus a running + * SHA-256 chain (hash of previous line + entry), so a tamper-evident backend + * can build on the same file format later. Conformance does not require the + * chain; it is a backend property. + */ +export class JsonlAuditStore implements AuditStore { + private chainTip: string; + + constructor(private readonly path: string) { + if (existsSync(path)) { + const lines = readFileSync(path, "utf-8").split("\n").filter((l) => l.trim().length > 0); + this.chainTip = lines.length > 0 ? (JSON.parse(lines[lines.length - 1]).chain as string) : "GENESIS"; + } else { + this.chainTip = "GENESIS"; + } + } + + append(entry: AuditEntry): void { + let line: string; + try { + const payload = JSON.stringify(entry); + const chain = createHash("sha256").update(this.chainTip + payload).digest("hex"); + line = JSON.stringify({ entry, chain }) + "\n"; + appendFileSync(this.path, line); + this.chainTip = chain; + } catch { + throw new Layer4Error("persistence-unavailable"); + } + } + + entriesFor(decisionId: string): AuditEntry[] { + return this.all().filter((e) => e.notes.includes(`decision_id=${decisionId}`)); + } + + all(): AuditEntry[] { + if (!existsSync(this.path)) return []; + return readFileSync(this.path, "utf-8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { entry: AuditEntry }).entry); + } +} + +/** In-memory backend for tests and ephemeral runs. Strictly append-only. */ +export class MemoryAuditStore implements AuditStore { + private readonly entries: AuditEntry[] = []; + + append(entry: AuditEntry): void { + this.entries.push(entry); + } + + entriesFor(decisionId: string): AuditEntry[] { + return this.entries.filter((e) => e.notes.includes(`decision_id=${decisionId}`)); + } + + all(): AuditEntry[] { + return [...this.entries]; + } +} + +export interface RetentionPolicy { + /** ISO-8601 duration or null for indefinite. Declared by the domain. */ + retain_until: string | null; + declared_by: string; +} + +export type ChangeEvent = + | { kind: "appeal"; decision_id: string; performed_by: string; notes: string } + | { kind: "rerun"; decision_id: string; performed_by: string; rerun_of: string; notes: string } + | { kind: "correction"; decision_id: string; performed_by: string; affected_source_id: string; notes: string } + | { kind: "escalation"; decision_id: string; performed_by: string; notes: string } + | { kind: "supersession"; decision_id: string; performed_by: string; superseded_by: string; notes: string }; + +const ACTION_BY_KIND: Record = { + appeal: "appealed", + rerun: "rerun", + correction: "corrected", + escalation: "escalated", + supersession: "superseded", +}; + +export class Layer4 { + constructor( + private readonly store: AuditStore, + private readonly retention: RetentionPolicy + ) { + if (!retention || !retention.declared_by) { + throw new Layer4Error("retention-policy-missing"); + } + } + + /** Record the initial creation of a decision (idempotent per decision id). */ + recordCreation(cots: Cots, decision: DecisionRecord, performedAt: string): AuditEntry { + const existing = this.store.entriesFor(decision.id).find((e) => e.action === "created"); + if (existing) return existing; + const mirrored = decision.audit_history.find((e) => e.action === "created"); + const entry: AuditEntry = mirrored ?? { + id: `urn:source-engineering:audit:${randomUUID()}`, + action: "created", + performed_by: decision.responsible_authority.authority_id, + performed_at: performedAt, + notes: `Decision created. decision_id=${decision.id}`, + }; + if (!entry.notes.includes(`decision_id=${decision.id}`)) entry.notes = `${entry.notes} decision_id=${decision.id}`; + this.store.append(entry); + if (!decision.audit_history.some((e) => e.id === entry.id)) { + decision.audit_history.push(entry); + } + return entry; + } + + /** Apply a change event: append to the store and mirror into the DecisionRecord. */ + applyChange(cots: Cots, event: ChangeEvent, performedAt: string): AuditEntry { + const decision = cots.decisions.find((d) => d.id === event.decision_id); + if (!decision) throw new Layer4Error("decision-not-found"); + + let notes = event.notes; + if (event.kind === "rerun") notes = `${notes} rerun_of=${event.rerun_of}`; + if (event.kind === "supersession") notes = `${notes} superseded_by=${event.superseded_by}`; + if (event.kind === "correction") notes = `${notes} affected_source_id=${event.affected_source_id}`; + notes = `${notes} decision_id=${event.decision_id}`; + + const entry: AuditEntry = { + id: `urn:source-engineering:audit:${randomUUID()}`, + action: ACTION_BY_KIND[event.kind], + performed_by: event.performed_by, + performed_at: performedAt, + notes, + }; + this.store.append(entry); + decision.audit_history.push(entry); + return entry; + } + + /** Replay a decision's full lifecycle from the store alone (RFC-0005 signature test). */ + replay(decisionId: string): AuditEntry[] { + const entries = this.store + .all() + .filter((e) => e.notes.includes(`decision_id=${decisionId}`) || e.notes.includes(`rerun_of=${decisionId}`)); + return entries.sort((a, b) => Date.parse(a.performed_at) - Date.parse(b.performed_at)); + } + + retentionPolicy(): RetentionPolicy { + return this.retention; + } +} diff --git a/tests/layer4.test.ts b/tests/layer4.test.ts new file mode 100644 index 0000000..b3273b9 --- /dev/null +++ b/tests/layer4.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for the Layer 4 (Audit Trail) module: append-only store behaviour, + * change-event application, rerun linkage, retention policy, and replay. + * + * See: RFC-0005 and the Layer 4 contract. + */ + +import { describe, it, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Layer1 } from "../src/layer1/layer1.js"; +import { Layer4, JsonlAuditStore, MemoryAuditStore, Layer4Error } from "../src/layer4/layer4.js"; +import { MockModelAdapter } from "../src/adapters/mock-adapter.js"; +import { LegalSummarizationDecisionType } from "../src/decision-types/legal-summarization.js"; +import type { SourceArtifact } from "../src/cots.js"; + +const SAMPLE_SOURCE: SourceArtifact = { + id: "urn:source-engineering:source:test-001", + origin: { system_name: "Test", system_version: "1.0.0", configuration_hash: "0".repeat(64) }, + version: { content_hash: "1".repeat(64), issued_at: "2026-10-10T08:00:00Z" }, + authority: { asserted_by: "test", trust_score: 0.9 }, + temporal_validity: { valid_from: "2026-10-10T08:00:00Z", valid_until: null }, + content_reference: { uri: "https://example.com/test.pdf", media_type: "application/pdf" }, +}; + +const RETENTION = { retain_until: null, declared_by: "test-domain" }; + +const buildCots = async () => { + const layer1 = new Layer1(LegalSummarizationDecisionType, new MockModelAdapter()); + return layer1.invoke( + { document: { uri: "https://example.com/test.pdf", media_type: "application/pdf" } }, + [SAMPLE_SOURCE], + { constraints: [] } + ); +}; + +describe("Layer 4 (RFC-0005)", () => { + it("requires a domain-declared retention policy", () => { + expect(() => new Layer4(new MemoryAuditStore(), { retain_until: null, declared_by: "" })).toThrow(Layer4Error); + try { + new Layer4(new MemoryAuditStore(), { retain_until: null, declared_by: "" }); + } catch (e) { + expect((e as Layer4Error).reason).toBe("retention-policy-missing"); + } + }); + + it("records creation once and mirrors it into the DecisionRecord", async () => { + const cots = await buildCots(); + const store = new MemoryAuditStore(); + const layer4 = new Layer4(store, RETENTION); + const d = cots.decisions[0]; + const e1 = layer4.recordCreation(cots, d, "2026-11-02T10:00:00Z"); + const e2 = layer4.recordCreation(cots, d, "2026-11-02T10:00:00Z"); + expect(e1.id).toBe(e2.id); + expect(store.all().filter((e) => e.action === "created")).toHaveLength(1); + expect(d.audit_history.some((e) => e.action === "created")).toBe(true); + }); + + it("appends change events and rejects unknown decisions", async () => { + const cots = await buildCots(); + const layer4 = new Layer4(new MemoryAuditStore(), RETENTION); + const d = cots.decisions[0]; + layer4.recordCreation(cots, d, "2026-11-02T10:00:00Z"); + const entry = layer4.applyChange( + cots, + { kind: "appeal", decision_id: d.id, performed_by: "reviewer-1", notes: "User appealed." }, + "2026-11-02T11:00:00Z" + ); + expect(entry.action).toBe("appealed"); + expect(d.audit_history).toHaveLength(2); + expect(() => + layer4.applyChange(cots, { kind: "appeal", decision_id: "urn:source-engineering:decision:nope", performed_by: "x", notes: "" }, "2026-11-02T11:00:00Z") + ).toThrow(Layer4Error); + }); + + it("links a rerun to the original decision and replays the lifecycle", async () => { + const cots = await buildCots(); + const store = new MemoryAuditStore(); + const layer4 = new Layer4(store, RETENTION); + const d = cots.decisions[0]; + layer4.recordCreation(cots, d, "2026-11-02T10:00:00Z"); + layer4.applyChange( + cots, + { kind: "rerun", decision_id: d.id, performed_by: "reviewer-1", rerun_of: d.id, notes: "Re-run on model v2." }, + "2026-11-02T12:00:00Z" + ); + const replay = layer4.replay(d.id); + expect(replay).toHaveLength(2); + const actions = replay.map((e) => e.action).sort(); + expect(actions).toEqual(["created", "rerun"]); + const rerun = replay.find((e) => e.action === "rerun"); + expect(rerun?.notes).toContain(`rerun_of=${d.id}`); + }); + + it("records a correction naming the affected source", async () => { + const cots = await buildCots(); + const layer4 = new Layer4(new MemoryAuditStore(), RETENTION); + const d = cots.decisions[0]; + layer4.recordCreation(cots, d, "2026-11-02T10:00:00Z"); + const entry = layer4.applyChange( + cots, + { kind: "correction", decision_id: d.id, performed_by: "producer", affected_source_id: SAMPLE_SOURCE.id, notes: "Source updated." }, + "2026-11-02T13:00:00Z" + ); + expect(entry.action).toBe("corrected"); + expect(entry.notes).toContain(`affected_source_id=${SAMPLE_SOURCE.id}`); + }); + + it("JsonlAuditStore persists append-only across instances", async () => { + const dir = mkdtempSync(join(tmpdir(), "se-l4-")); + try { + const path = join(dir, "audit.jsonl"); + const cots = await buildCots(); + const d = cots.decisions[0]; + const l1 = new Layer4(new JsonlAuditStore(path), RETENTION); + l1.recordCreation(cots, d, "2026-11-02T10:00:00Z"); + const l2 = new Layer4(new JsonlAuditStore(path), RETENTION); + const replay = l2.replay(d.id); + expect(replay).toHaveLength(1); + expect(replay[0].action).toBe("created"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});