@int-loop/core 0.1.0 → 0.3.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/report.js ADDED
@@ -0,0 +1,443 @@
1
+ // Evidence reports (CLAUDE.md, Regulation packs; DECISIONS.md, 2026-11-10): for one
2
+ // regulation pack and one period, each obligation's controls and the evidence the ledger
3
+ // holds for them, the gaps found, and the ledger range the report rests on.
4
+ //
5
+ // A pure function of the ledger and the texts it references, so the Worker and
6
+ // int-loop-verify build byte-identical reports: anyone holding an evidence pack can
7
+ // rebuild a report and check it was not edited. Hard rule 11 applies to every string
8
+ // here: the report maps controls to obligations and never says anyone meets them.
9
+ import { CONTROLS } from "./controls.js";
10
+ import { sha256Hex, utf8 } from "./hash.js";
11
+ import { assuranceRank } from "./schema/common.js";
12
+ import { DecisionCastPayload } from "./schema/decision.js";
13
+ import { Directory, hasCurrentTraining } from "./schema/directory.js";
14
+ import { Pack } from "./schema/pack.js";
15
+ export const REPORT_FORMAT = "int-loop.oversight-report";
16
+ export class ReportError extends Error {
17
+ }
18
+ const LEVELS = ["L1", "L2", "L3", "L4"];
19
+ const SCOPE_STATEMENT = [
20
+ "This report maps int-loop controls to the obligations in the regulation pack named below, and summarises the evidence int-loop's ledger holds for them over the period.",
21
+ "It is not legal advice, and it is not a finding that any organisation meets these obligations. Whether it does depends on much that int-loop does not see.",
22
+ "It covers int-loop's own records only. Obligations listed as out of scope are not addressed by int-loop at all.",
23
+ "Every figure can be rebuilt from an evidence pack with int-loop-verify, and the ledger range below ties it to records anyone can check.",
24
+ ];
25
+ const NOTES = [
26
+ "Requests in scope: those routed in the period under a policy that named this pack. Decisions in scope: those cast in the period that recorded this pack.",
27
+ "Outcomes are as of the last entry the report considers, so a request routed late in the period may still be pending.",
28
+ "Training is checked against the directory in force when each decision was cast, on the UTC date of the decision. Training records are evidence the customer supplies; int-loop does not assess them.",
29
+ "Requests that failed routing are not yet recorded in the ledger, so they do not appear here.",
30
+ ];
31
+ const str = (v) => (typeof v === "string" ? v : null);
32
+ const num = (v) => (typeof v === "number" ? v : null);
33
+ const arr = (v) => (Array.isArray(v) ? v : []);
34
+ const day = (iso) => iso.slice(0, 10);
35
+ /** "1 decision", "2 decisions". */
36
+ const n = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
37
+ export async function buildOversightReport(input) {
38
+ const { period } = input;
39
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(period.from) || !/^\d{4}-\d{2}-\d{2}$/.test(period.to) || period.from > period.to) {
40
+ throw new ReportError("the period must be two dates, YYYY-MM-DD, the first no later than the second");
41
+ }
42
+ const throughSeq = input.throughSeq ?? input.entries.at(-1)?.seq ?? 0;
43
+ const entries = input.entries.filter((e) => e.seq <= throughSeq);
44
+ const through = entries.at(-1);
45
+ if (!through || through.seq !== throughSeq)
46
+ throw new ReportError(`the ledger does not reach entry ${throughSeq}`);
47
+ const readText = async (hash, what) => {
48
+ const body = input.texts.get(hash);
49
+ if (body === undefined || body === null)
50
+ return null;
51
+ if ((await sha256Hex(utf8(body))) !== hash)
52
+ throw new ReportError(`${what} does not match its hash`);
53
+ return body;
54
+ };
55
+ // The pack, as it was activated.
56
+ const activation = entries.filter((e) => e.type === "pack.activated" && e.payload["ref"] === input.packRef).at(-1);
57
+ if (!activation)
58
+ throw new ReportError(`${input.packRef} was never activated in this ledger`);
59
+ const packHash = str(activation.payload["hash"]) ?? "";
60
+ const packText = await readText(packHash, `pack ${input.packRef}`);
61
+ if (packText === null)
62
+ throw new ReportError(`the text of ${input.packRef} is not available; export a fresh evidence pack`);
63
+ const pack = Pack.parse(JSON.parse(packText));
64
+ const inPeriod = (e) => day(e.recorded_at) >= period.from && day(e.recorded_at) <= period.to;
65
+ const periodEntries = entries.filter(inPeriod);
66
+ const floor = pack.floors.assurance ?? null;
67
+ const trainingFloor = pack.floors.approver_requires?.training ?? [];
68
+ // One pass over the ledger: directories in force, decisions, request states.
69
+ const directories = [];
70
+ const decisionsBySeq = new Map();
71
+ const scopedRequests = new Map();
72
+ const stateOf = new Map();
73
+ const scopedDecisions = [];
74
+ const gaps = [];
75
+ const unreadable = [];
76
+ let infoRequested = 0;
77
+ for (const e of entries) {
78
+ const p = e.payload;
79
+ const requestId = str(p["request_id"]);
80
+ switch (e.type) {
81
+ case "directory.changed":
82
+ directories.push({ seq: e.seq, hash: str(p["hash"]) ?? "" });
83
+ break;
84
+ case "request.created":
85
+ if (requestId)
86
+ stateOf.set(requestId, { state: "pending", decisionsOnVersion: 0 });
87
+ break;
88
+ case "request.routed":
89
+ if (requestId && inPeriod(e) && arr(p["packs"]).includes(input.packRef)) {
90
+ const r = scopedRequests.get(requestId) ?? { routed: [] };
91
+ r.routed.push(p);
92
+ scopedRequests.set(requestId, r);
93
+ }
94
+ break;
95
+ case "decision.cast": {
96
+ const parsed = DecisionCastPayload.safeParse(p);
97
+ if (!parsed.success) {
98
+ if (inPeriod(e))
99
+ unreadable.push(e.seq);
100
+ break;
101
+ }
102
+ decisionsBySeq.set(e.seq, { approver_id: parsed.data.approver_id, request_id: parsed.data.request_id });
103
+ const s = stateOf.get(parsed.data.request_id);
104
+ if (s)
105
+ s.decisionsOnVersion++;
106
+ if (inPeriod(e) && parsed.data.packs.includes(input.packRef))
107
+ scopedDecisions.push({ entry: e, d: parsed.data });
108
+ break;
109
+ }
110
+ case "request.info_requested":
111
+ if (requestId)
112
+ stateOf.set(requestId, { ...(stateOf.get(requestId) ?? { decisionsOnVersion: 0 }), state: "info_requested" });
113
+ if (requestId && inPeriod(e) && scopedRequests.has(requestId))
114
+ infoRequested++;
115
+ break;
116
+ case "request.resolved":
117
+ if (requestId)
118
+ stateOf.set(requestId, { ...(stateOf.get(requestId) ?? { decisionsOnVersion: 0 }), state: str(p["outcome"]) ?? "resolved" });
119
+ break;
120
+ case "request.expired":
121
+ case "request.cancelled":
122
+ if (requestId)
123
+ stateOf.set(requestId, { ...(stateOf.get(requestId) ?? { decisionsOnVersion: 0 }), state: e.type === "request.expired" ? "expired" : "cancelled" });
124
+ break;
125
+ }
126
+ }
127
+ if (unreadable.length > 0)
128
+ gaps.push({ kind: "unreadable_decisions", message: `${n(unreadable.length, "decision entry", "decision entries")} in the period could not be read, so they are not counted.`, seqs: unreadable });
129
+ // Requests in scope, with their state as of the last entry considered.
130
+ const resolvedEntries = new Map();
131
+ for (const e of entries)
132
+ if (e.type === "request.resolved")
133
+ resolvedEntries.set(str(e.payload["request_id"]) ?? "", e.payload);
134
+ const outcomes = {};
135
+ let withConditions = 0;
136
+ let quorumTwo = 0;
137
+ let resolvedWithDecisions = 0;
138
+ let sodApplied = 0;
139
+ let sodWaived = 0;
140
+ const expiredUndecided = [];
141
+ const exclusions = new Map();
142
+ let minRetention = null;
143
+ const policyHashes = new Set();
144
+ const routedBelowFloor = [];
145
+ for (const [id, r] of scopedRequests) {
146
+ const s = stateOf.get(id) ?? { state: "pending", decisionsOnVersion: 0 };
147
+ outcomes[s.state] = (outcomes[s.state] ?? 0) + 1;
148
+ if (s.state === "expired" && s.decisionsOnVersion === 0)
149
+ expiredUndecided.push(id);
150
+ const resolved = resolvedEntries.get(id);
151
+ if (resolved && s.state !== "expired" && s.state !== "cancelled") {
152
+ if (arr(resolved["condition_hashes"]).length > 0)
153
+ withConditions++;
154
+ const deciders = new Set(arr(resolved["decision_seqs"]).map((seq) => decisionsBySeq.get(num(seq) ?? -1)?.approver_id).filter(Boolean));
155
+ if (deciders.size > 0)
156
+ resolvedWithDecisions++;
157
+ if (deciders.size >= 2)
158
+ quorumTwo++;
159
+ }
160
+ const last = r.routed.at(-1);
161
+ if (last["separation_of_duties"] === true)
162
+ sodApplied++;
163
+ if (arr(last["separation_of_duties_waived_by"]).length > 0)
164
+ sodWaived++;
165
+ for (const routed of r.routed) {
166
+ const ph = str(routed["policy_hash"]);
167
+ if (ph)
168
+ policyHashes.add(ph);
169
+ const retention = num(routed["retention_days"]);
170
+ if (retention !== null)
171
+ minRetention = minRetention === null ? retention : Math.min(minRetention, retention);
172
+ const level = str(routed["assurance"]);
173
+ if (floor && level && LEVELS.includes(level) && assuranceRank(level) < assuranceRank(floor))
174
+ routedBelowFloor.push(id);
175
+ }
176
+ for (const stage of arr(last["stages"])) {
177
+ for (const ex of arr(stage["excluded"])) {
178
+ for (const reason of arr(ex["reasons"]))
179
+ if (typeof reason === "string")
180
+ exclusions.set(reason, (exclusions.get(reason) ?? 0) + 1);
181
+ }
182
+ }
183
+ }
184
+ // Decisions in scope.
185
+ const assurance = Object.fromEntries(LEVELS.map((l) => [l, 0]));
186
+ const belowFloor = [];
187
+ const training = { met: 0, not_met: 0, unknown: 0 };
188
+ const trainingNotMet = [];
189
+ const trainingUnknown = [];
190
+ const directoryCache = new Map();
191
+ const times = [];
192
+ let rationale = 0;
193
+ let pasted = 0;
194
+ let comprehension = 0;
195
+ let comprehensionCorrect = 0;
196
+ let passkey = 0;
197
+ let documentDecisions = 0;
198
+ let documentEndSeen = 0;
199
+ const approvers = new Set();
200
+ for (const { entry, d } of scopedDecisions) {
201
+ approvers.add(d.approver_id);
202
+ assurance[d.assurance]++;
203
+ if (floor && assuranceRank(d.assurance) < assuranceRank(floor))
204
+ belowFloor.push(entry.seq);
205
+ if (d.webauthn !== null)
206
+ passkey++;
207
+ if (d.rationale !== null) {
208
+ rationale++;
209
+ if (d.rationale.paste_detected)
210
+ pasted++;
211
+ }
212
+ if (d.comprehension !== null) {
213
+ comprehension++;
214
+ if (d.comprehension.correct)
215
+ comprehensionCorrect++;
216
+ }
217
+ if (d.attention.time_spent_ms !== null)
218
+ times.push(d.attention.time_spent_ms);
219
+ if (d.attention.document_end_seen !== undefined) {
220
+ documentDecisions++;
221
+ if (d.attention.document_end_seen)
222
+ documentEndSeen++;
223
+ }
224
+ if (trainingFloor.length > 0) {
225
+ const dirRef = directories.filter((x) => x.seq < entry.seq).at(-1);
226
+ let dir = null;
227
+ if (dirRef) {
228
+ if (!directoryCache.has(dirRef.hash)) {
229
+ const body = await readText(dirRef.hash, `directory at entry ${dirRef.seq}`);
230
+ const parsed = body === null ? null : Directory.safeParse(JSON.parse(body));
231
+ directoryCache.set(dirRef.hash, parsed?.success ? parsed.data : null);
232
+ }
233
+ dir = directoryCache.get(dirRef.hash) ?? null;
234
+ }
235
+ const person = dir?.people[d.approver_id];
236
+ if (!person) {
237
+ training.unknown++;
238
+ trainingUnknown.push(entry.seq);
239
+ }
240
+ else if (trainingFloor.every((t) => hasCurrentTraining(person, t, day(d.attention.decided_at)))) {
241
+ training.met++;
242
+ }
243
+ else {
244
+ training.not_met++;
245
+ trainingNotMet.push(entry.seq);
246
+ }
247
+ }
248
+ }
249
+ times.sort((a, b) => a - b);
250
+ const median = times.length === 0 ? null : times.length % 2 === 1 ? times[(times.length - 1) / 2] : Math.round((times[times.length / 2 - 1] + times[times.length / 2]) / 2);
251
+ // Gaps.
252
+ if (belowFloor.length > 0)
253
+ gaps.push({ kind: "below_assurance_floor", message: `${n(belowFloor.length, "decision")} in scope ${belowFloor.length === 1 ? "was" : "were"} made below the pack's assurance floor of ${floor}.`, seqs: belowFloor });
254
+ if (routedBelowFloor.length > 0)
255
+ gaps.push({ kind: "routed_below_floor", message: `${n(routedBelowFloor.length, "request")} in scope ${routedBelowFloor.length === 1 ? "was" : "were"} routed below the pack's assurance floor of ${floor}: ${routedBelowFloor.join(", ")}.`, seqs: [] });
256
+ if (trainingNotMet.length > 0)
257
+ gaps.push({ kind: "training_not_current", message: `In ${n(trainingNotMet.length, "decision")} in scope, the approver did not hold current ${trainingFloor.join(", ")} training on the day of the decision.`, seqs: trainingNotMet });
258
+ if (trainingUnknown.length > 0)
259
+ gaps.push({ kind: "training_unknown", message: `For ${n(trainingUnknown.length, "decision")} in scope, training could not be checked: the directory in force is erased, missing, or does not list the approver.`, seqs: trainingUnknown });
260
+ if (expiredUndecided.length > 0)
261
+ gaps.push({ kind: "expired_undecided", message: `${n(expiredUndecided.length, "request")} in scope expired before anyone decided: ${expiredUndecided.join(", ")}.`, seqs: [] });
262
+ const retentionFloor = pack.floors.retention_days ?? null;
263
+ if (retentionFloor !== null && minRetention !== null && minRetention < retentionFloor) {
264
+ gaps.push({ kind: "retention_below_floor", message: `Some requests in scope were routed with ${minRetention} days' retention, below the pack's floor of ${retentionFloor}.`, seqs: [] });
265
+ }
266
+ if (exclusions.size > 0) {
267
+ const total = [...exclusions.values()].reduce((a, b) => a + b, 0);
268
+ gaps.push({ kind: "approvers_excluded", message: `Approvers were excluded at routing ${n(total, "time")}. Reasons: ${[...exclusions].sort(([a], [b]) => (a < b ? -1 : 1)).map(([r, n]) => `${r} (${n})`).join("; ")}.`, seqs: [] });
269
+ }
270
+ // The anchor covering the end of the period, as recorded.
271
+ const lastInPeriod = periodEntries.at(-1) ?? null;
272
+ let anchor = null;
273
+ if (lastInPeriod) {
274
+ for (const e of entries) {
275
+ if (e.type !== "anchor.created" || e.seq <= lastInPeriod.seq)
276
+ continue;
277
+ const anchoredSeq = num(e.payload["anchored"]?.["seq"]);
278
+ if (anchoredSeq === null || anchoredSeq < lastInPeriod.seq)
279
+ continue;
280
+ const ts = arr(e.payload["timestamps"]);
281
+ anchor = { anchor_seq: e.seq, anchored_seq: anchoredSeq, tsas: ts.map((t) => str(t["tsa"]) ?? "?"), gen_times: ts.map((t) => str(t["gen_time"]) ?? "?") };
282
+ break;
283
+ }
284
+ }
285
+ const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
286
+ const R = scopedRequests.size;
287
+ const D = scopedDecisions.length;
288
+ const evidenceFor = (c) => {
289
+ switch (c.id) {
290
+ case "CTL-OVERRIDE":
291
+ return [`Every request offers a deny option; requests without one are rejected.`, `Of ${n(R, "request")} in scope: ${outcomes["denied"] ?? 0} denied, ${withConditions} approved with conditions.`];
292
+ case "CTL-MORE-INFO":
293
+ return [`Approvers asked for more information ${n(infoRequested, "time")} on requests in scope.`];
294
+ case "CTL-FACTS-FIRST":
295
+ return ["The approval page shows the structured facts before the AI-written narrative, which is labelled as AI-written.", D === 1 ? "The decision in scope records a hash of exactly what the page displayed." : `All ${D} decisions in scope record a hash of exactly what the page displayed.`];
296
+ case "CTL-ATTENTION":
297
+ return [
298
+ times.length === 0 ? "No time on page was recorded for decisions in scope." : `Time on page recorded for ${times.length} of ${n(D, "decision")}: median ${seconds(median)}, shortest ${seconds(times[0])}.`,
299
+ ...(documentDecisions > 0 ? [`${documentEndSeen} of ${n(documentDecisions, "decision")} on a document recorded the end of it as seen.`] : []),
300
+ "Flags for patterns that suggest rubber-stamping are not yet available.",
301
+ ];
302
+ case "CTL-RATIONALE":
303
+ return [`${rationale} of ${n(D, "decision")} ${D === 1 ? "carries" : "carry"} a written rationale, bound to the decision; a paste was detected in ${pasted}.`];
304
+ case "CTL-COMPREHENSION":
305
+ return [`${comprehension} of ${n(D, "decision")} answered a comprehension question built from the facts; ${comprehensionCorrect} answered correctly.`];
306
+ case "CTL-IDENTITY":
307
+ return [`${passkey} of ${n(D, "decision")} ${D === 1 ? "is" : "are"} bound to a passkey held by a named, enrolled approver.`];
308
+ case "CTL-QUORUM-2":
309
+ return [`${quorumTwo} of ${n(resolvedWithDecisions, "request")} in scope resolved by approvers ${quorumTwo === 1 ? "was" : "were"} decided by two or more distinct people.`];
310
+ case "CTL-SOD":
311
+ return [`Separation of duties applied to ${sodApplied} of ${n(R, "request")} in scope${sodWaived > 0 ? `; waived by policy for ${sodWaived}` : ""}.`];
312
+ case "CTL-ATTR-REQUIRED":
313
+ return [
314
+ trainingFloor.length === 0
315
+ ? "This pack sets no training requirement."
316
+ : `${training.met} of ${n(D, "decision")}: the approver held current ${trainingFloor.join(", ")} training on the day. ${training.not_met} did not; ${training.unknown} could not be checked.`,
317
+ `Approvers lacking a stage's required authority, spend limit or training are excluded at routing (${n([...exclusions.values()].reduce((a, b) => a + b, 0), "exclusion")} in scope).`,
318
+ ];
319
+ case "CTL-LEDGER":
320
+ return [
321
+ lastInPeriod ? `Ledger entries ${periodEntries[0].seq} to ${lastInPeriod.seq} fall in the period, in a hash chain checked through entry ${through.seq} (${through.entry_hash}).` : "No ledger entries fall in the period.",
322
+ anchor ? `Entry ${anchor.anchored_seq} is covered by the timestamp anchor at entry ${anchor.anchor_seq} (${anchor.tsas.join(", ")}).` : "No timestamp anchor yet covers the end of the period.",
323
+ ];
324
+ case "CTL-RETENTION":
325
+ return [
326
+ `Records are kept for at least ${minRetention ?? pack.floors.retention_days ?? "the tenant's configured number of"} days${retentionFloor !== null ? `; this pack's floor is ${retentionFloor}` : ""}.`,
327
+ `The ledger begins at ${day(entries[0].recorded_at)}.`,
328
+ ];
329
+ default:
330
+ return [];
331
+ }
332
+ };
333
+ const control = (id) => {
334
+ const c = CONTROLS.find((x) => x.id === id);
335
+ if (!c)
336
+ throw new ReportError(`${input.packRef} references an unknown control ${id}`);
337
+ const available = c.phase <= 2 && !c.retired;
338
+ return { id: c.id, name: c.name, status: available ? "available" : "not_yet_available", evidence: available ? evidenceFor(c) : ["Not yet available in int-loop."] };
339
+ };
340
+ const section = (o) => ({
341
+ id: o.id,
342
+ citation: o.citation,
343
+ summary: o.summary,
344
+ applies_from: o.applies_from,
345
+ coverage: o.coverage,
346
+ ...(o.coverage_note !== undefined ? { coverage_note: o.coverage_note } : {}),
347
+ controls: o.controls.map(control),
348
+ });
349
+ const obligations = { in_force: [], not_yet_in_force: [], date_to_confirm: [] };
350
+ for (const o of pack.obligations) {
351
+ const s = section(o);
352
+ if (o.applies_from === "confirm")
353
+ obligations.date_to_confirm.push(s);
354
+ else if (o.applies_from === "in-force" || o.applies_from <= period.to)
355
+ obligations.in_force.push(s);
356
+ else
357
+ obligations.not_yet_in_force.push(s);
358
+ }
359
+ for (const s of obligations.in_force) {
360
+ const missing = s.controls.filter((c) => c.status === "not_yet_available").map((c) => c.id);
361
+ if (missing.length > 0)
362
+ gaps.push({ kind: "control_not_available", message: `${s.citation}: ${missing.join(", ")} not yet available.`, seqs: [] });
363
+ }
364
+ return {
365
+ format: REPORT_FORMAT,
366
+ format_version: 1,
367
+ scope_statement: [...SCOPE_STATEMENT, ...(pack.status === "draft" ? ["This pack is a draft: its citations, dates and coverage have not been reviewed."] : [])],
368
+ tenant_id: through.tenant_id,
369
+ generated_at: input.generatedAt,
370
+ period: { from: period.from, to: period.to },
371
+ pack: { ref: input.packRef, hash: packHash, id: pack.id, version: pack.version, title: pack.title, jurisdiction: pack.jurisdiction, status: pack.status, review: pack.review, sources: pack.sources },
372
+ ledger: { through_seq: through.seq, through_hash: through.entry_hash, period_first_seq: periodEntries[0]?.seq ?? null, period_last_seq: lastInPeriod?.seq ?? null, anchor },
373
+ policy_hashes: [...policyHashes].sort(),
374
+ summary: {
375
+ requests_in_scope: R,
376
+ decisions_in_scope: D,
377
+ distinct_approvers: approvers.size,
378
+ outcomes: Object.fromEntries(Object.entries(outcomes).sort(([a], [b]) => (a < b ? -1 : 1))),
379
+ assurance,
380
+ assurance_floor: floor,
381
+ decisions_at_or_above_floor: D - belowFloor.length,
382
+ training_floor: trainingFloor,
383
+ training_on_decision_day: training,
384
+ median_time_spent_ms: median,
385
+ },
386
+ obligations,
387
+ gaps,
388
+ out_of_scope: pack.out_of_scope,
389
+ notes: NOTES,
390
+ };
391
+ }
392
+ /** Plain text, for the terminal and for reading. The JSON is the record. */
393
+ export function renderReportText(r) {
394
+ const out = [];
395
+ const s = r.summary;
396
+ out.push(`int-loop evidence report: ${r.pack.title}`, `Pack ${r.pack.ref} (${r.pack.status}), period ${r.period.from} to ${r.period.to}, generated ${r.generated_at}`, "");
397
+ for (const line of r.scope_statement)
398
+ out.push(line);
399
+ out.push("", "SUMMARY");
400
+ out.push(` Requests in scope: ${s.requests_in_scope}. Decisions in scope: ${s.decisions_in_scope}, by ${n(s.distinct_approvers, "approver")}.`);
401
+ out.push(` Outcomes: ${Object.entries(s.outcomes).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}.`);
402
+ out.push(` Assurance: ${LEVELS.map((l) => `${l} ${s.assurance[l]}`).join(", ")}.${s.assurance_floor ? ` At or above the pack floor of ${s.assurance_floor}: ${s.decisions_at_or_above_floor} of ${s.decisions_in_scope}.` : ""}`);
403
+ if (s.training_floor.length > 0)
404
+ out.push(` Current ${s.training_floor.join(", ")} training on the day: ${s.training_on_decision_day.met} met, ${s.training_on_decision_day.not_met} not met, ${s.training_on_decision_day.unknown} unknown.`);
405
+ if (s.median_time_spent_ms !== null)
406
+ out.push(` Median time on page: ${(s.median_time_spent_ms / 1000).toFixed(1)}s.`);
407
+ const sections = [
408
+ ["OBLIGATIONS IN FORCE", r.obligations.in_force],
409
+ ["OBLIGATIONS NOT YET IN FORCE", r.obligations.not_yet_in_force],
410
+ ["OBLIGATIONS WITH A DATE TO CONFIRM", r.obligations.date_to_confirm],
411
+ ];
412
+ for (const [title, list] of sections) {
413
+ if (list.length === 0)
414
+ continue;
415
+ out.push("", title);
416
+ for (const o of list) {
417
+ out.push(` ${o.citation} (${o.applies_from === "in-force" ? "already in force" : o.applies_from === "confirm" ? "date to confirm" : `applies from ${o.applies_from}`}; coverage: ${o.coverage}${o.coverage_note ? `, ${o.coverage_note}` : ""})`, ` ${o.summary}`);
418
+ for (const c of o.controls) {
419
+ out.push(` ${c.id}: ${c.name}${c.status === "not_yet_available" ? " [not yet available]" : ""}`);
420
+ for (const e of c.evidence)
421
+ out.push(` - ${e}`);
422
+ }
423
+ }
424
+ }
425
+ out.push("", "GAPS FOUND");
426
+ if (r.gaps.length === 0)
427
+ out.push(" None found in the records for this period.");
428
+ for (const g of r.gaps)
429
+ out.push(` - ${g.message}${g.seqs.length > 0 ? ` Ledger entries: ${g.seqs.join(", ")}.` : ""}`);
430
+ out.push("", "OUT OF SCOPE");
431
+ for (const o of r.out_of_scope)
432
+ out.push(` - ${o}`);
433
+ out.push("", "RECORDS");
434
+ out.push(` Ledger checked through entry ${r.ledger.through_seq}, hash ${r.ledger.through_hash}.`);
435
+ out.push(r.ledger.period_first_seq === null ? " No entries in the period." : ` Entries in the period: ${r.ledger.period_first_seq} to ${r.ledger.period_last_seq}.`);
436
+ if (r.ledger.anchor)
437
+ out.push(` Anchored: entry ${r.ledger.anchor.anchored_seq}, by ${r.ledger.anchor.tsas.join(", ")} at ${r.ledger.anchor.gen_times.join(", ")} (anchor entry ${r.ledger.anchor.anchor_seq}).`);
438
+ out.push(` Policy versions: ${r.policy_hashes.join(", ") || "none"}.`, ` Pack hash: ${r.pack.hash}.`);
439
+ out.push("", "NOTES");
440
+ for (const n of r.notes)
441
+ out.push(` - ${n}`);
442
+ return out.join("\n") + "\n";
443
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const LEDGER_ENTRY_TYPES: readonly ["request.created", "request.routed", "notification.sent", "notification.failed", "request.viewed", "decision.cast", "decision.invalidated", "request.info_requested", "request.resolved", "request.expired", "request.cancelled", "policy.loaded", "directory.changed", "passkey.enrolled", "passkey.revoked", "export.created", "pack.activated", "pack.deactivated", "anchor.created"];
2
+ export declare const LEDGER_ENTRY_TYPES: readonly ["request.created", "request.routed", "notification.sent", "notification.failed", "request.viewed", "decision.cast", "decision.invalidated", "request.info_requested", "request.resolved", "request.expired", "request.cancelled", "receipt.redeemed", "policy.loaded", "directory.changed", "passkey.enrolled", "passkey.revoked", "export.created", "pack.activated", "pack.deactivated", "anchor.created"];
3
3
  export declare const LedgerEntryType: z.ZodEnum<{
4
4
  "anchor.created": "anchor.created";
5
5
  "decision.cast": "decision.cast";
@@ -13,6 +13,7 @@ export declare const LedgerEntryType: z.ZodEnum<{
13
13
  "passkey.enrolled": "passkey.enrolled";
14
14
  "passkey.revoked": "passkey.revoked";
15
15
  "policy.loaded": "policy.loaded";
16
+ "receipt.redeemed": "receipt.redeemed";
16
17
  "request.cancelled": "request.cancelled";
17
18
  "request.created": "request.created";
18
19
  "request.expired": "request.expired";
@@ -39,6 +40,7 @@ export declare const LedgerEntry: z.ZodObject<{
39
40
  "passkey.enrolled": "passkey.enrolled";
40
41
  "passkey.revoked": "passkey.revoked";
41
42
  "policy.loaded": "policy.loaded";
43
+ "receipt.redeemed": "receipt.redeemed";
42
44
  "request.cancelled": "request.cancelled";
43
45
  "request.created": "request.created";
44
46
  "request.expired": "request.expired";
@@ -13,6 +13,8 @@ export const LEDGER_ENTRY_TYPES = [
13
13
  "request.resolved",
14
14
  "request.expired",
15
15
  "request.cancelled",
16
+ // The executor used the request's receipt. A receipt is redeemed at most once.
17
+ "receipt.redeemed",
16
18
  "policy.loaded",
17
19
  "directory.changed",
18
20
  "passkey.enrolled",
@@ -136,6 +136,7 @@ export declare const Rule: z.ZodObject<{
136
136
  L4: "L4";
137
137
  }>>;
138
138
  respond_within: z.ZodOptional<z.ZodString>;
139
+ remind_after: z.ZodOptional<z.ZodString>;
139
140
  separation_of_duties: z.ZodOptional<z.ZodBoolean>;
140
141
  }, z.core.$strict>;
141
142
  export type Rule = z.infer<typeof Rule>;
@@ -150,6 +151,7 @@ export declare const Policy: z.ZodObject<{
150
151
  L4: "L4";
151
152
  }>>;
152
153
  respond_within: z.ZodDefault<z.ZodString>;
154
+ remind_after: z.ZodOptional<z.ZodString>;
153
155
  on_timeout: z.ZodDefault<z.ZodLiteral<"expired">>;
154
156
  deny_is_veto: z.ZodDefault<z.ZodBoolean>;
155
157
  }, z.core.$strict>;
@@ -206,6 +208,7 @@ export declare const Policy: z.ZodObject<{
206
208
  L4: "L4";
207
209
  }>>;
208
210
  respond_within: z.ZodOptional<z.ZodString>;
211
+ remind_after: z.ZodOptional<z.ZodString>;
209
212
  separation_of_duties: z.ZodOptional<z.ZodBoolean>;
210
213
  }, z.core.$strict>>;
211
214
  }, z.core.$strict>;
@@ -45,6 +45,8 @@ export const Rule = z.strictObject({
45
45
  stages: z.array(Stage).min(1),
46
46
  assurance: AssuranceLevel.optional(),
47
47
  respond_within: Duration.optional(),
48
+ // Remind approvers who have not decided once this long after the request is sent. The shortest applicable wins.
49
+ remind_after: Duration.optional(),
48
50
  separation_of_duties: z.boolean().optional(),
49
51
  });
50
52
  export const Policy = z
@@ -54,6 +56,8 @@ export const Policy = z
54
56
  defaults: z.strictObject({
55
57
  assurance: AssuranceLevel.default("L2"),
56
58
  respond_within: Duration.default("24h"),
59
+ // Optional: with no remind_after anywhere, nobody is reminded.
60
+ remind_after: Duration.optional(),
57
61
  // Timeouts resolve to expired, never to approval. There is no other value.
58
62
  on_timeout: z.literal("expired").default("expired"),
59
63
  deny_is_veto: z.boolean().default(true),
@@ -247,5 +247,6 @@ export declare const DecisionStatus: z.ZodObject<{
247
247
  }, z.core.$strict>;
248
248
  sig: z.ZodString;
249
249
  }, z.core.$strict>>;
250
+ receipt_redeemed_at: z.ZodNullable<z.ZodISODateTime>;
250
251
  }, z.core.$strict>;
251
252
  export type DecisionStatus = z.infer<typeof DecisionStatus>;
@@ -97,4 +97,6 @@ export const DecisionStatus = z.strictObject({
97
97
  evidence_url: z.url(),
98
98
  // Signed proof of what was approved, for the system that carries out the action. Null unless approved or chosen.
99
99
  receipt: Receipt.nullable(),
100
+ // When the receipt was redeemed, if it has been. A redeemed receipt cannot be used again.
101
+ receipt_redeemed_at: Timestamp.nullable(),
100
102
  });
@@ -0,0 +1,9 @@
1
+ export type TsaRoot = {
2
+ id: string;
3
+ name: string;
4
+ sha256: string;
5
+ pem: string;
6
+ };
7
+ export declare const KNOWN_TSA_ROOTS: readonly TsaRoot[];
8
+ /** The default roots, as DER. */
9
+ export declare function knownTsaRootsDer(): Uint8Array<ArrayBuffer>[];