@int-loop/core 0.2.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.
@@ -0,0 +1,9 @@
1
+ export type Control = {
2
+ id: string;
3
+ name: string;
4
+ implemented_by: string;
5
+ phase: 1 | 2 | 3;
6
+ retired?: true;
7
+ };
8
+ export declare const CONTROLS: readonly Control[];
9
+ export declare const CONTROL_IDS: ReadonlySet<string>;
@@ -0,0 +1,19 @@
1
+ // The control catalogue. IDs are permanent: a retired control keeps its ID and
2
+ // gains `retired`. Packs may only reference controls listed here. Lives in core so
3
+ // evidence reports can be rebuilt offline (DECISIONS.md, 2026-11-10).
4
+ export const CONTROLS = [
5
+ { id: "CTL-OVERRIDE", name: "Every request can be denied, and approvers can attach conditions", implemented_by: "Request validation (exactly one deny), approve_with_conditions", phase: 1 },
6
+ { id: "CTL-MORE-INFO", name: "Approvers can ask for more information before deciding", implemented_by: "more_info flow", phase: 1 },
7
+ { id: "CTL-FACTS-FIRST", name: "Structured facts shown before the AI-written narrative, which is labelled", implemented_by: "Approval page", phase: 1 },
8
+ { id: "CTL-ATTENTION", name: "Time spent and context viewed are recorded; rubber-stamp patterns flagged", implemented_by: "Evidence capture; oversight reports (Phase 2)", phase: 1 },
9
+ { id: "CTL-RATIONALE", name: "Written rationale captured and bound to the decision", implemented_by: "L3", phase: 1 },
10
+ { id: "CTL-COMPREHENSION", name: "Deterministic comprehension question answered", implemented_by: "L4", phase: 2 },
11
+ { id: "CTL-IDENTITY", name: "Decision bound to a passkey held by a named, enrolled person", implemented_by: "L2+ binding", phase: 1 },
12
+ { id: "CTL-QUORUM-2", name: "At least two distinct qualified people decide", implemented_by: "Policy quorum, L4", phase: 1 },
13
+ { id: "CTL-SOD", name: "The agent's owner cannot approve its requests", implemented_by: "Policy engine", phase: 1 },
14
+ { id: "CTL-ATTR-REQUIRED", name: "Only approvers with the required authority and current training can decide", implemented_by: "Directory attributes, stage requires", phase: 1 },
15
+ { id: "CTL-LEDGER", name: "Tamper-evident record anyone can verify offline", implemented_by: "Ledger, int-loop-verify", phase: 1 },
16
+ { id: "CTL-RETENTION", name: "Records kept for at least the strictest active floor", implemented_by: "Retention rule", phase: 1 },
17
+ { id: "CTL-CONTEST", name: "People affected by a decision can ask for a human to review it", implemented_by: "Contest flow", phase: 3 },
18
+ ];
19
+ export const CONTROL_IDS = new Set(CONTROLS.map((c) => c.id));
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export * from "./x509.ts";
13
13
  export * from "./tsp.ts";
14
14
  export * from "./tsa-roots.ts";
15
15
  export * from "./anchor.ts";
16
+ export * from "./controls.ts";
17
+ export * from "./report.ts";
16
18
  export * from "./schema/common.ts";
17
19
  export * from "./schema/request.ts";
18
20
  export * from "./schema/directory.ts";
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ export * from "./x509.js";
13
13
  export * from "./tsp.js";
14
14
  export * from "./tsa-roots.js";
15
15
  export * from "./anchor.js";
16
+ export * from "./controls.js";
17
+ export * from "./report.js";
16
18
  export * from "./schema/common.js";
17
19
  export * from "./schema/request.js";
18
20
  export * from "./schema/directory.js";
@@ -0,0 +1,92 @@
1
+ import { type AssuranceLevel } from "./schema/common.ts";
2
+ import type { LedgerEntry } from "./schema/ledger.ts";
3
+ import { Pack, type Obligation } from "./schema/pack.ts";
4
+ export declare const REPORT_FORMAT = "int-loop.oversight-report";
5
+ export type ReportInput = {
6
+ /** The tenant's ledger from entry 1, in order. Entries after `throughSeq` are ignored. */
7
+ entries: readonly LedgerEntry[];
8
+ /** Texts by hash, as the evidence pack holds them; null for an erased text. */
9
+ texts: ReadonlyMap<string, string | null>;
10
+ /** The pack to report against, as `id@version`. It must have been activated in the ledger. */
11
+ packRef: string;
12
+ /** Inclusive UTC dates, YYYY-MM-DD. */
13
+ period: {
14
+ from: string;
15
+ to: string;
16
+ };
17
+ generatedAt: string;
18
+ /** The last entry the report considers. Default: the last entry given. */
19
+ throughSeq?: number;
20
+ };
21
+ export type ControlEvidence = {
22
+ id: string;
23
+ name: string;
24
+ status: "available" | "not_yet_available";
25
+ evidence: string[];
26
+ };
27
+ export type ObligationSection = Pick<Obligation, "id" | "citation" | "summary" | "applies_from" | "coverage" | "coverage_note"> & {
28
+ controls: ControlEvidence[];
29
+ };
30
+ export type ReportGap = {
31
+ kind: string;
32
+ message: string;
33
+ seqs: number[];
34
+ };
35
+ export type OversightReport = {
36
+ format: typeof REPORT_FORMAT;
37
+ format_version: 1;
38
+ scope_statement: string[];
39
+ tenant_id: string;
40
+ generated_at: string;
41
+ period: {
42
+ from: string;
43
+ to: string;
44
+ };
45
+ pack: Pick<Pack, "id" | "version" | "title" | "jurisdiction" | "status" | "review" | "sources"> & {
46
+ ref: string;
47
+ hash: string;
48
+ };
49
+ ledger: {
50
+ through_seq: number;
51
+ through_hash: string;
52
+ period_first_seq: number | null;
53
+ period_last_seq: number | null;
54
+ /** The first anchor recorded after the period that covers its last entry, as recorded. int-loop-verify checks the tokens. */
55
+ anchor: {
56
+ anchor_seq: number;
57
+ anchored_seq: number;
58
+ tsas: string[];
59
+ gen_times: string[];
60
+ } | null;
61
+ };
62
+ policy_hashes: string[];
63
+ summary: {
64
+ requests_in_scope: number;
65
+ decisions_in_scope: number;
66
+ distinct_approvers: number;
67
+ outcomes: Record<string, number>;
68
+ assurance: Record<AssuranceLevel, number>;
69
+ assurance_floor: AssuranceLevel | null;
70
+ decisions_at_or_above_floor: number;
71
+ training_floor: string[];
72
+ training_on_decision_day: {
73
+ met: number;
74
+ not_met: number;
75
+ unknown: number;
76
+ };
77
+ median_time_spent_ms: number | null;
78
+ };
79
+ obligations: {
80
+ in_force: ObligationSection[];
81
+ not_yet_in_force: ObligationSection[];
82
+ date_to_confirm: ObligationSection[];
83
+ };
84
+ gaps: ReportGap[];
85
+ out_of_scope: string[];
86
+ notes: string[];
87
+ };
88
+ export declare class ReportError extends Error {
89
+ }
90
+ export declare function buildOversightReport(input: ReportInput): Promise<OversightReport>;
91
+ /** Plain text, for the terminal and for reading. The JSON is the record. */
92
+ export declare function renderReportText(r: OversightReport): string;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@int-loop/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Schemas, canonicalisation, hashing, passkey binding and ledger and receipt verification shared by the int-loop service and int-loop-verify.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://int-loop.com",