@irisrun/audit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit.d.ts +37 -0
- package/dist/audit.js +132 -0
- package/dist/fnv.d.ts +1 -0
- package/dist/fnv.js +11 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/verify.d.ts +30 -0
- package/dist/verify.js +104 -0
- package/package.json +33 -0
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { StateStore, RecordKind, EffectKind, Json } from "@irisrun/core";
|
|
2
|
+
import type { ApprovalAuditEntry } from "@irisrun/auth";
|
|
3
|
+
/** One journal record, audit-projected. A superset of inspect's InspectedRecord:
|
|
4
|
+
* effect entries carry typed `effectKind`/`effectId`/`outcome` so a compliance
|
|
5
|
+
* reader sees what each effect was, while `detail` retains the raw payload. */
|
|
6
|
+
export type AuditEntry = {
|
|
7
|
+
seq: number;
|
|
8
|
+
ts: number;
|
|
9
|
+
defDigest: string;
|
|
10
|
+
kind: RecordKind;
|
|
11
|
+
effectKind?: EffectKind;
|
|
12
|
+
effectId?: string;
|
|
13
|
+
outcome?: "ok" | "error";
|
|
14
|
+
summary: string;
|
|
15
|
+
detail: Json;
|
|
16
|
+
};
|
|
17
|
+
export type SessionAudit = {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
governingDigest: string | null;
|
|
20
|
+
terminal: "finished" | "parked" | "open";
|
|
21
|
+
complete: boolean;
|
|
22
|
+
firstRetainedSeq: number;
|
|
23
|
+
truncatedBefore: number | null;
|
|
24
|
+
snapshotUpTo: number | null;
|
|
25
|
+
records: AuditEntry[];
|
|
26
|
+
approvals: ApprovalAuditEntry[];
|
|
27
|
+
counts: {
|
|
28
|
+
effects: number;
|
|
29
|
+
results: number;
|
|
30
|
+
markers: number;
|
|
31
|
+
decisions: number;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** Audit a recorded session over its FULL retained journal. Pure read; never writes. */
|
|
35
|
+
export declare function auditSession(store: StateStore, sessionId: string): Promise<SessionAudit>;
|
|
36
|
+
/** Deterministic, human-first compliance report over a SessionAudit. */
|
|
37
|
+
export declare function renderAudit(audit: SessionAudit): string;
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// auditSession (roadmap P2-8): a whole-session, compliance-grade audit. UNLIKE
|
|
2
|
+
// `inspectSession` (which reads only the POST-snapshot tail), this reads the FULL
|
|
3
|
+
// retained journal from seq 0 — every effect intent/result, every marker — plus the
|
|
4
|
+
// governed approval trail (via @irisrun/auth's `auditApprovals`, also full-journal).
|
|
5
|
+
//
|
|
6
|
+
// COMPLETENESS (the load-bearing property, LRN:gotcha d8ddf8a1): the engine snapshots
|
|
7
|
+
// and TRUNCATES the journal past each boundary unless a turn ran with `keepHistory:true`.
|
|
8
|
+
// A truncated session keeps only the surviving tail — so a compliance trail must report
|
|
9
|
+
// `complete:false` LOUDLY (with `truncatedBefore`) rather than silently dropping
|
|
10
|
+
// pre-snapshot events. `complete` ⇔ the retained journal still starts at seq 0.
|
|
11
|
+
//
|
|
12
|
+
// Pure over the journal bytes ⇒ re-auditing the same store is byte-identical.
|
|
13
|
+
import { decode } from "@irisrun/core";
|
|
14
|
+
import { auditApprovals, renderApprovalAudit } from "@irisrun/auth";
|
|
15
|
+
// Deterministic one-line summary per record (mirrors @irisrun/inspect's private summarize
|
|
16
|
+
// so audit and inspect renderings read alike).
|
|
17
|
+
function summarize(rec) {
|
|
18
|
+
switch (rec.kind) {
|
|
19
|
+
case "effect_intent": {
|
|
20
|
+
const p = rec.payload;
|
|
21
|
+
return `effect ${p.effectKind} (intent ${p.effectId}${p.retrySafe ? "" : ", retry-unsafe"})`;
|
|
22
|
+
}
|
|
23
|
+
case "effect_result": {
|
|
24
|
+
const p = rec.payload;
|
|
25
|
+
return `result ${p.effectId} → ${p.outcome.ok ? "ok" : `error: ${p.outcome.error.message}`}`;
|
|
26
|
+
}
|
|
27
|
+
case "decision": {
|
|
28
|
+
const p = rec.payload;
|
|
29
|
+
return `decision ${p.seam} → ${p.tacticId}`;
|
|
30
|
+
}
|
|
31
|
+
case "marker": {
|
|
32
|
+
const p = rec.payload;
|
|
33
|
+
if (p.marker === "wait")
|
|
34
|
+
return `marker wait (${p.wait.kind}${p.wait.kind === "signal" ? `:${p.wait.name}` : ""})`;
|
|
35
|
+
if (p.marker === "finish")
|
|
36
|
+
return "marker finish";
|
|
37
|
+
if (p.marker === "upgraded")
|
|
38
|
+
return `marker upgraded ${p.from}→${p.to} @${p.atTurn}`;
|
|
39
|
+
if (p.marker === "snapshot")
|
|
40
|
+
return `marker snapshot upTo ${p.upToSeq}`;
|
|
41
|
+
return `marker ${p.marker}`;
|
|
42
|
+
}
|
|
43
|
+
default:
|
|
44
|
+
return rec.kind;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function toEntry(rec) {
|
|
48
|
+
const base = {
|
|
49
|
+
seq: rec.seq,
|
|
50
|
+
ts: rec.ts,
|
|
51
|
+
defDigest: rec.defDigest,
|
|
52
|
+
kind: rec.kind,
|
|
53
|
+
summary: summarize(rec),
|
|
54
|
+
detail: rec.payload,
|
|
55
|
+
};
|
|
56
|
+
if (rec.kind === "effect_intent") {
|
|
57
|
+
const p = rec.payload;
|
|
58
|
+
base.effectKind = p.effectKind;
|
|
59
|
+
base.effectId = p.effectId;
|
|
60
|
+
}
|
|
61
|
+
else if (rec.kind === "effect_result") {
|
|
62
|
+
const p = rec.payload;
|
|
63
|
+
base.effectId = p.effectId;
|
|
64
|
+
base.outcome = p.outcome.ok ? "ok" : "error";
|
|
65
|
+
}
|
|
66
|
+
return base;
|
|
67
|
+
}
|
|
68
|
+
function terminalOf(records) {
|
|
69
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
70
|
+
const r = records[i];
|
|
71
|
+
if (r.kind !== "marker")
|
|
72
|
+
continue;
|
|
73
|
+
const m = r.detail.marker;
|
|
74
|
+
if (m === "finish")
|
|
75
|
+
return "finished";
|
|
76
|
+
if (m === "wait")
|
|
77
|
+
return "parked";
|
|
78
|
+
}
|
|
79
|
+
return "open";
|
|
80
|
+
}
|
|
81
|
+
/** Audit a recorded session over its FULL retained journal. Pure read; never writes. */
|
|
82
|
+
export async function auditSession(store, sessionId) {
|
|
83
|
+
const rows = await store.readJournal(sessionId, 0); // FULL retained journal — NOT inspectSession's tail
|
|
84
|
+
const snap = await store.readLatestSnapshot(sessionId);
|
|
85
|
+
const snapshotUpTo = snap ? snap.upToSeq : null;
|
|
86
|
+
const records = rows.map((row) => toEntry(decode(row.bytes)));
|
|
87
|
+
// completeness: the retained journal still starts at seq 0 (or nothing was ever
|
|
88
|
+
// recorded). A snapshot+truncate leaves the surviving tail starting at boundary+1.
|
|
89
|
+
const firstRetainedSeq = rows.length ? rows[0].seq : snap ? snap.upToSeq + 1 : 0;
|
|
90
|
+
const complete = rows.length === 0 ? snap === null : rows[0].seq === 0;
|
|
91
|
+
const truncatedBefore = complete ? null : firstRetainedSeq;
|
|
92
|
+
const counts = { effects: 0, results: 0, markers: 0, decisions: 0 };
|
|
93
|
+
for (const r of records) {
|
|
94
|
+
if (r.kind === "effect_intent")
|
|
95
|
+
counts.effects += 1;
|
|
96
|
+
else if (r.kind === "effect_result")
|
|
97
|
+
counts.results += 1;
|
|
98
|
+
else if (r.kind === "marker")
|
|
99
|
+
counts.markers += 1;
|
|
100
|
+
else if (r.kind === "decision")
|
|
101
|
+
counts.decisions += 1;
|
|
102
|
+
}
|
|
103
|
+
const governingDigest = records.length ? records[records.length - 1].defDigest : null;
|
|
104
|
+
const approvals = await auditApprovals(store, sessionId); // full-journal approval trail
|
|
105
|
+
return {
|
|
106
|
+
sessionId,
|
|
107
|
+
governingDigest,
|
|
108
|
+
terminal: terminalOf(records),
|
|
109
|
+
complete,
|
|
110
|
+
firstRetainedSeq,
|
|
111
|
+
truncatedBefore,
|
|
112
|
+
snapshotUpTo,
|
|
113
|
+
records,
|
|
114
|
+
approvals,
|
|
115
|
+
counts,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Deterministic, human-first compliance report over a SessionAudit. */
|
|
119
|
+
export function renderAudit(audit) {
|
|
120
|
+
const completeness = audit.complete
|
|
121
|
+
? "COMPLETE"
|
|
122
|
+
: `PARTIAL (truncated before #${audit.firstRetainedSeq}; re-run with keepHistory:true for a complete trail)`;
|
|
123
|
+
const header = `session ${audit.sessionId} | digest ${audit.governingDigest ?? "—"} | ` +
|
|
124
|
+
`terminal ${audit.terminal} | snapshot ${audit.snapshotUpTo ?? "—"} | ` +
|
|
125
|
+
`${audit.records.length} record(s) | ${completeness} | ${audit.approvals.length} approval(s)`;
|
|
126
|
+
const lines = audit.records.map((r) => ` #${r.seq} ${r.kind} ${r.summary}`);
|
|
127
|
+
const approvalsBlock = renderApprovalAudit(audit.approvals)
|
|
128
|
+
.split("\n")
|
|
129
|
+
.map((l) => ` ${l}`)
|
|
130
|
+
.join("\n");
|
|
131
|
+
return [header, ...lines, "approvals:", approvalsBlock].join("\n");
|
|
132
|
+
}
|
package/dist/fnv.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fnv1a32hex(s: string): string;
|
package/dist/fnv.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A tiny, pure FNV-1a (32-bit) hex hash. Used as a SHORT, deterministic fingerprint
|
|
2
|
+
// for cross-host state/journal comparison — NOT a security hash. Pure (no node:crypto)
|
|
3
|
+
// so @irisrun/audit stays Node-free and the digest stays compact (8 hex chars).
|
|
4
|
+
export function fnv1a32hex(s) {
|
|
5
|
+
let h = 0x811c9dc5;
|
|
6
|
+
for (let i = 0; i < s.length; i++) {
|
|
7
|
+
h ^= s.charCodeAt(i);
|
|
8
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
9
|
+
}
|
|
10
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
11
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const PACKAGE = "@irisrun/audit";
|
|
2
|
+
export { auditSession, renderAudit } from "./audit.js";
|
|
3
|
+
export type { AuditEntry, SessionAudit } from "./audit.js";
|
|
4
|
+
export { verifyReplay, verifySession } from "./verify.js";
|
|
5
|
+
export type { VerifyResult } from "./verify.js";
|
|
6
|
+
export { fnv1a32hex } from "./fnv.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @irisrun/audit — the audit & reproducible-eval product surface (roadmap P2-8).
|
|
2
|
+
// Pure read-only projections over the existing journal; zero kernel change.
|
|
3
|
+
export const PACKAGE = "@irisrun/audit";
|
|
4
|
+
export { auditSession, renderAudit } from "./audit.js";
|
|
5
|
+
export { verifyReplay, verifySession } from "./verify.js";
|
|
6
|
+
export { fnv1a32hex } from "./fnv.js";
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Reducer, JournalRecord, StateStore, Json } from "@irisrun/core";
|
|
2
|
+
export type VerifyResult = {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
wellFormed: boolean;
|
|
5
|
+
replayDeterministic: boolean;
|
|
6
|
+
total: boolean;
|
|
7
|
+
finalStateDigest: string | null;
|
|
8
|
+
retainedRange: {
|
|
9
|
+
from: number;
|
|
10
|
+
to: number;
|
|
11
|
+
} | null;
|
|
12
|
+
complete: boolean;
|
|
13
|
+
issues: string[];
|
|
14
|
+
};
|
|
15
|
+
/** Pure verification of a fold over `startState`. `records` is the retained tail to
|
|
16
|
+
* fold; `reducer` MUST match how the session was recorded (caller's responsibility).
|
|
17
|
+
* `opts.rowSeqs` are the store row positions for the self-seq integrity check. */
|
|
18
|
+
export declare function verifyReplay<S extends Json>(reducer: Reducer<S>, records: JournalRecord[], startState: S, opts?: {
|
|
19
|
+
complete?: boolean;
|
|
20
|
+
firstSeq?: number;
|
|
21
|
+
rowSeqs?: number[];
|
|
22
|
+
}): VerifyResult;
|
|
23
|
+
/** Verify a recorded session from a StateStore. Mirrors the engine's live replay
|
|
24
|
+
* window (snapshot state + post-snapshot tail). The CALLER supplies the reducer
|
|
25
|
+
* matching the recording config (and, for a no-snapshot session, the program
|
|
26
|
+
* initial as `opts.startState`). */
|
|
27
|
+
export declare function verifySession<S extends Json>(store: StateStore, sessionId: string, reducer: Reducer<S>, opts?: {
|
|
28
|
+
startState?: S;
|
|
29
|
+
complete?: boolean;
|
|
30
|
+
}): Promise<VerifyResult>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// verifyReplay/verifySession (roadmap P2-8): offline, compliance-grade verification
|
|
2
|
+
// of a recorded session. THREE SOUND GUARANTEES (and no more — honesty matters):
|
|
3
|
+
// 1. structural integrity (reducer-free, the strongest claim): dense monotonic seq;
|
|
4
|
+
// each record's self-reported seq matches its store row position (corruption/
|
|
5
|
+
// desync catch); ≤1 effect_result per effectId; and — only when the journal is
|
|
6
|
+
// COMPLETE — every effect_result joins a prior effect_intent. (When the prefix
|
|
7
|
+
// was truncated, an orphan result is legitimate and NOT flagged.)
|
|
8
|
+
// 2. in-process replay-determinism: fold the retained records twice and compare via
|
|
9
|
+
// canonicalEqual. This proves the reducer is a pure function of its inputs IN
|
|
10
|
+
// THIS PROCESS (catches mutable-shared-state / iteration-order bugs). It does NOT
|
|
11
|
+
// prove the reducer never read a clock/RNG at record time — that is the ONLINE
|
|
12
|
+
// `assertReplayConsistency` (engine.ts), run on every live step.
|
|
13
|
+
// 3. totality: replay does not throw.
|
|
14
|
+
// It deliberately does NOT claim snapshot-fidelity (that needs the original input,
|
|
15
|
+
// which is not journaled for no-snapshot sessions — see the initiative decisions).
|
|
16
|
+
import { replay, canonicalize, canonicalEqual, decode } from "@irisrun/core";
|
|
17
|
+
import { fnv1a32hex } from "./fnv.js";
|
|
18
|
+
/** Pure verification of a fold over `startState`. `records` is the retained tail to
|
|
19
|
+
* fold; `reducer` MUST match how the session was recorded (caller's responsibility).
|
|
20
|
+
* `opts.rowSeqs` are the store row positions for the self-seq integrity check. */
|
|
21
|
+
export function verifyReplay(reducer, records, startState, opts = {}) {
|
|
22
|
+
const complete = opts.complete ?? true;
|
|
23
|
+
const structural = [];
|
|
24
|
+
const retainedRange = records.length ? { from: records[0].seq, to: records[records.length - 1].seq } : null;
|
|
25
|
+
// (a) dense, monotonic seq within the retained range
|
|
26
|
+
for (let i = 1; i < records.length; i++) {
|
|
27
|
+
if (records[i].seq !== records[i - 1].seq + 1) {
|
|
28
|
+
structural.push(`seq not dense at index ${i}: #${records[i - 1].seq} → #${records[i].seq}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// (b) each record's self-reported seq matches its store row position
|
|
32
|
+
if (opts.rowSeqs) {
|
|
33
|
+
for (let i = 0; i < records.length; i++) {
|
|
34
|
+
if (opts.rowSeqs[i] !== records[i].seq) {
|
|
35
|
+
structural.push(`self-seq mismatch at index ${i}: record #${records[i].seq} stored at row position ${opts.rowSeqs[i]}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// (c) ≤1 effect_result per effectId; (d) when complete, every result joins a prior intent
|
|
40
|
+
const seenResult = new Set();
|
|
41
|
+
const intentIds = new Set();
|
|
42
|
+
for (const r of records) {
|
|
43
|
+
if (r.kind === "effect_intent") {
|
|
44
|
+
intentIds.add(r.payload.effectId);
|
|
45
|
+
}
|
|
46
|
+
else if (r.kind === "effect_result") {
|
|
47
|
+
const id = r.payload.effectId;
|
|
48
|
+
if (seenResult.has(id))
|
|
49
|
+
structural.push(`duplicate effect_result for effectId ${id} (#${r.seq})`);
|
|
50
|
+
seenResult.add(id);
|
|
51
|
+
if (complete && !intentIds.has(id)) {
|
|
52
|
+
structural.push(`effect_result for effectId ${id} (#${r.seq}) has no prior effect_intent`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const wellFormed = structural.length === 0;
|
|
57
|
+
const issues = [...structural];
|
|
58
|
+
// replay: in-process determinism (fold twice) + totality
|
|
59
|
+
let total = true;
|
|
60
|
+
let replayDeterministic = false;
|
|
61
|
+
let finalStateDigest = null;
|
|
62
|
+
try {
|
|
63
|
+
const a = replay(startState, records, reducer);
|
|
64
|
+
const b = replay(startState, records, reducer);
|
|
65
|
+
replayDeterministic = canonicalEqual(a, b);
|
|
66
|
+
if (!replayDeterministic) {
|
|
67
|
+
issues.push("replay is not deterministic: two folds of the same records produced different state (in-process reducer nondeterminism)");
|
|
68
|
+
}
|
|
69
|
+
finalStateDigest = fnv1a32hex(canonicalize(a));
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
total = false;
|
|
73
|
+
replayDeterministic = false;
|
|
74
|
+
issues.push(`replay threw (not total): ${e instanceof Error ? e.message : String(e)}`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ok: wellFormed && replayDeterministic && total,
|
|
78
|
+
wellFormed,
|
|
79
|
+
replayDeterministic,
|
|
80
|
+
total,
|
|
81
|
+
finalStateDigest,
|
|
82
|
+
retainedRange,
|
|
83
|
+
complete,
|
|
84
|
+
issues,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Verify a recorded session from a StateStore. Mirrors the engine's live replay
|
|
88
|
+
* window (snapshot state + post-snapshot tail). The CALLER supplies the reducer
|
|
89
|
+
* matching the recording config (and, for a no-snapshot session, the program
|
|
90
|
+
* initial as `opts.startState`). */
|
|
91
|
+
export async function verifySession(store, sessionId, reducer, opts = {}) {
|
|
92
|
+
const snap = await store.readLatestSnapshot(sessionId);
|
|
93
|
+
const snapUpTo = snap ? snap.upToSeq : -1;
|
|
94
|
+
const tailRows = await store.readJournal(sessionId, snapUpTo + 1);
|
|
95
|
+
const tail = tailRows.map((r) => decode(r.bytes));
|
|
96
|
+
const start = opts.startState ?? (snap ? decode(snap.bytes) : null);
|
|
97
|
+
let complete = opts.complete;
|
|
98
|
+
if (complete === undefined) {
|
|
99
|
+
const full = await store.readJournal(sessionId, 0);
|
|
100
|
+
complete = full.length === 0 ? snap === null : full[0].seq === 0;
|
|
101
|
+
}
|
|
102
|
+
const firstSeq = tailRows.length ? tailRows[0].seq : snapUpTo + 1;
|
|
103
|
+
return verifyReplay(reducer, tail, start, { complete, firstSeq, rowSeqs: tailRows.map((r) => r.seq) });
|
|
104
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@irisrun/audit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Iris audit & reproducible-eval product surface — whole-session, compliance-grade audit over the FULL retained journal (every effect/marker/approval, with completeness) plus offline replay-verification (structural integrity + in-process replay-determinism + totality). Pure: a read-only projection over the existing journal, zero kernel change. Deps @irisrun/core + @irisrun/auth only.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"iris-src": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@irisrun/core": "^0.1.0",
|
|
15
|
+
"@irisrun/auth": "^0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=24"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/xoai/iris.git",
|
|
27
|
+
"directory": "packages/audit"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/xoai/iris#readme",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
]
|
|
33
|
+
}
|