From 8b55268bc7de14e2279590ba0c0bb368b1ca4975 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 2 Nov 2026 13:49:43 +0000 Subject: [PATCH] feat: add se-trace audit replay (RFC-0005) Co-authored-by: emmanuel-a-otchere --- src/audit.ts | 82 +++++++++++++++++++++++++++++++++++++++++++++ src/cli/se-trace.ts | 14 ++++++++ tests/tools.test.ts | 52 ++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 src/audit.ts diff --git a/src/audit.ts b/src/audit.ts new file mode 100644 index 0000000..3456d41 --- /dev/null +++ b/src/audit.ts @@ -0,0 +1,82 @@ +/** + * Audit trail replay: print a decision's full lifecycle from a COTS's + * audit_history, marking append-only and linkage violations (RFC-0005, + * TC-L4-01..04). + */ + +import type { Cots } from "./cots.js"; + +export interface AuditIssue { + test_id: string; + message: string; +} + +export interface AuditReport { + lines: string[]; + issues: AuditIssue[]; +} + +export const replayAudit = (cots: Cots, decisionId?: string): AuditReport => { + const lines: string[] = []; + const issues: AuditIssue[] = []; + const decisions = decisionId ? cots.decisions.filter((d) => d.id === decisionId) : cots.decisions; + + if (decisionId && decisions.length === 0) { + lines.push(`Decision not found: ${decisionId}`); + return { lines, issues }; + } + + const decisionIds = new Set(cots.decisions.map((d) => d.id)); + + for (const d of decisions) { + lines.push(`Decision: ${d.id}`); + lines.push(` status: ${d.validation_status.status} text: ${truncate(d.decision_text, 60)}`); + const history = [...d.audit_history].sort((a, b) => Date.parse(a.performed_at) - Date.parse(b.performed_at)); + if (history.length === 0) { + issues.push({ test_id: "TC-L4-01", message: `${d.id}: empty audit_history` }); + lines.push(" (no audit entries)"); + continue; + } + if (history[0].action !== "created") { + issues.push({ test_id: "TC-L4-01", message: `${d.id}: audit trail does not start with "created"` }); + } + const seen = new Set(); + for (let j = 0; j < history.length; j++) { + const e = history[j]; + lines.push(` [${e.performed_at}] ${e.action.toUpperCase()} by ${e.performed_by}`); + lines.push(` ${truncate(e.notes, 100)}`); + if (seen.has(e.id)) { + issues.push({ test_id: "TC-L4-02", message: `${d.id}: duplicate audit entry id '${e.id}' (append-only violated)` }); + } + seen.add(e.id); + if (j > 0 && Date.parse(e.performed_at) < Date.parse(history[j - 1].performed_at)) { + issues.push({ test_id: "TC-L4-02", message: `${d.id}: audit entry out of time order (append-only violated)` }); + } + if (e.action !== "created") { + const m = e.notes.match(/decision_id=(\S+)/); + if (!m || !decisionIds.has(m[1])) { + issues.push({ test_id: "TC-L4-03", message: `${d.id}: ${e.action} event does not resolve to a known decision` }); + } + } + if (e.action === "rerun" && !/rerun_of=\S+/.test(e.notes)) { + issues.push({ test_id: "TC-L4-04", message: `${d.id}: rerun event lacks rerun_of linkage` }); + } + if (e.action === "superseded" && !/superseded_by=\S+/.test(e.notes)) { + issues.push({ test_id: "TC-L4-04", message: `${d.id}: superseded event lacks superseded_by linkage` }); + } + } + lines.push(""); + } + + if (issues.length === 0) { + lines.push("Audit trail: OK (append-only, lifecycle events resolve, linkages present)"); + } else { + lines.push(`Audit trail issues: ${issues.length}`); + for (const i of issues) lines.push(` [${i.test_id}] ${i.message}`); + } + + return { lines, issues }; +}; + +const truncate = (s: string, n: number): string => + s.length > n ? s.slice(0, n) + "..." : s; diff --git a/src/cli/se-trace.ts b/src/cli/se-trace.ts index a02db5f..632ecef 100644 --- a/src/cli/se-trace.ts +++ b/src/cli/se-trace.ts @@ -6,6 +6,7 @@ * - cite Print the evidence chain for a specific decision. * - visualize Render a COTS as a Reasoning Roadmap (Mermaid + HTML). * - context-graph Render a COTS's Context + Evidence as a Context Graph (RFC-0004). + * - audit [decision-id] Replay a decision's audit trail (RFC-0005). */ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; @@ -15,6 +16,7 @@ import { inspect } from "../inspect.js"; import { cite } from "../cite.js"; import { toMermaid, toHtml } from "../visualize.js"; import { toContextGraphMermaid, toContextGraphHtml } from "../context-graph.js"; +import { replayAudit } from "../audit.js"; import type { Cots } from "../cots.js"; // Lightweight conformance check (subset of the canonical test suite). For @@ -198,4 +200,16 @@ program console.log(`HTML: ${htmlPath}`); }); +program + .command("audit") + .description("Replay the audit trail of every decision (or one decision) and flag append-only/linkage violations (RFC-0005)") + .argument("", "Path to a COTS JSON file") + .argument("[decision-id]", "Optional decision id to replay") + .action((path: string, decisionId?: string) => { + const cots = loadCots(path); + const report = replayAudit(cots, decisionId); + for (const line of report.lines) console.log(line); + if (report.issues.length > 0) process.exit(1); + }); + program.parse(); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 53a2067..be549f2 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -12,6 +12,7 @@ import { inspect } from "../src/inspect.js"; import { cite } from "../src/cite.js"; import { toMermaid, toHtml } from "../src/visualize.js"; import { toContextGraphMermaid, toContextGraphHtml } from "../src/context-graph.js"; +import { replayAudit } from "../src/audit.js"; import type { Cots } from "../src/cots.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -140,6 +141,57 @@ describe("context-graph (RFC-0004)", () => { }); }); +describe("audit (RFC-0005)", () => { + it("replays a clean audit trail with no issues", () => { + const cots = loadFixture("legal-summarization.cots.json"); + const did = cots.decisions[0].id; + cots.decisions[0].audit_history.push({ + id: "urn:source-engineering:audit:test-002", + action: "appealed", + performed_by: "reviewer-1", + performed_at: "2026-11-02T09:00:00Z", + notes: `User appealed. decision_id=${did}`, + }); + const report = replayAudit(cots); + expect(report.issues).toHaveLength(0); + expect(report.lines.join("\n")).toContain("APPEALED"); + expect(report.lines.join("\n")).toContain("Audit trail: OK"); + }); + + it("flags a dangling lifecycle event", () => { + const cots = loadFixture("legal-summarization.cots.json"); + cots.decisions[0].audit_history.push({ + id: "urn:source-engineering:audit:test-003", + action: "appealed", + performed_by: "reviewer-1", + performed_at: "2026-11-02T09:00:00Z", + notes: "User appealed. decision_id=urn:source-engineering:decision:nope", + }); + const report = replayAudit(cots); + expect(report.issues.some((i) => i.test_id === "TC-L4-03")).toBe(true); + }); + + it("flags an unlinked rerun", () => { + const cots = loadFixture("legal-summarization.cots.json"); + const did = cots.decisions[0].id; + cots.decisions[0].audit_history.push({ + id: "urn:source-engineering:audit:test-004", + action: "rerun", + performed_by: "reviewer-1", + performed_at: "2026-11-02T09:00:00Z", + notes: `Re-run. decision_id=${did}`, + }); + const report = replayAudit(cots, did); + expect(report.issues.some((i) => i.test_id === "TC-L4-04")).toBe(true); + }); + + it("reports a not-found decision", () => { + const cots = loadFixture("legal-summarization.cots.json"); + const report = replayAudit(cots, "urn:source-engineering:decision:does-not-exist"); + expect(report.lines.join("\n")).toContain("not found"); + }); +}); + describe("validate-runtime", () => { it("reports a Layer 2 Validation record when present", async () => { const cots = loadFixture("legal-summarization.cots.json");