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
188 changes: 188 additions & 0 deletions src/layer4/layer4.ts
Original file line number Diff line number Diff line change
@@ -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<ChangeEvent["kind"], AuditEntry["action"]> = {
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;
}
}
126 changes: 126 additions & 0 deletions tests/layer4.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading