@verax-ai/proxy 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.
@@ -0,0 +1,91 @@
1
+ import { renameSync } from "node:fs";
2
+ /** I/O seam so drain-busy tests can inject rename failures. */
3
+ export declare const approvalFs: {
4
+ renameSync: typeof renameSync;
5
+ };
6
+ export declare const drainMetrics: {
7
+ renameBusy: number;
8
+ };
9
+ import type { ApprovalChannel, InputsLog, Ledger, RecordSigner } from "./types.ts";
10
+ export type ApprovalRow = {
11
+ ref: string;
12
+ requestHash: string;
13
+ subject: string;
14
+ args: Record<string, unknown>;
15
+ ruleId: string | null;
16
+ ruleText: string | null;
17
+ inputsSummary: {
18
+ count: number;
19
+ ids: string[];
20
+ };
21
+ amount?: unknown;
22
+ payee?: unknown;
23
+ currency?: unknown;
24
+ createdAtMs?: number;
25
+ expiresAtMs: number;
26
+ status: "pending" | "approved" | "expired";
27
+ brain: string;
28
+ allowRef?: string;
29
+ };
30
+ export type ApprovalsLog = {
31
+ append(row: ApprovalRow): Promise<void>;
32
+ get(ref: string): Promise<ApprovalRow | null>;
33
+ listPending(): Promise<ApprovalRow[]>;
34
+ listAll(): Promise<ApprovalRow[]>;
35
+ updateStatus(ref: string, status: "approved" | "expired", extra?: {
36
+ allowRef?: string;
37
+ }): Promise<void>;
38
+ };
39
+ export declare class MemoryApprovalsLog implements ApprovalsLog {
40
+ private readonly rows;
41
+ private readonly byRef;
42
+ append(row: ApprovalRow): Promise<void>;
43
+ get(ref: string): Promise<ApprovalRow | null>;
44
+ listPending(): Promise<ApprovalRow[]>;
45
+ listAll(): Promise<ApprovalRow[]>;
46
+ updateStatus(ref: string, status: "approved" | "expired", extra?: {
47
+ allowRef?: string;
48
+ }): Promise<void>;
49
+ }
50
+ export declare class FileApprovalsLog implements ApprovalsLog {
51
+ private readonly path;
52
+ private readonly byRef;
53
+ constructor(dir: string);
54
+ append(row: ApprovalRow): Promise<void>;
55
+ get(ref: string): Promise<ApprovalRow | null>;
56
+ listPending(): Promise<ApprovalRow[]>;
57
+ listAll(): Promise<ApprovalRow[]>;
58
+ updateStatus(ref: string, status: "approved" | "expired", extra?: {
59
+ allowRef?: string;
60
+ }): Promise<void>;
61
+ private read;
62
+ }
63
+ export declare function approvalsLogFor(ledger: object): ApprovalsLog;
64
+ export declare function loadApprovalsFromDir(dir: string): ApprovalRow[];
65
+ export type ApproveResult = {
66
+ ok: true;
67
+ allowRef: string;
68
+ } | {
69
+ ok: false;
70
+ reason: string;
71
+ };
72
+ export declare function approvePending(opts: {
73
+ ledger: Ledger;
74
+ recordSigner: RecordSigner;
75
+ now: () => number;
76
+ nonce: () => string;
77
+ ref: string;
78
+ approverId: string;
79
+ /** Signed into the inputs document, so it has to be the door the approval came through. */
80
+ via: ApprovalChannel;
81
+ policyHash: string;
82
+ approvals: ApprovalsLog;
83
+ inputsLog?: InputsLog;
84
+ }): Promise<ApproveResult>;
85
+ export type ApprovalCommand = {
86
+ ref: string;
87
+ approverId: string;
88
+ atMs: number;
89
+ };
90
+ export declare function enqueueApprovalCommand(dir: string, cmd: ApprovalCommand): void;
91
+ export declare function drainApprovalCommands(dir: string, apply: (cmd: ApprovalCommand) => Promise<void>): Promise<void>;
@@ -0,0 +1,282 @@
1
+ import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, } from "node:fs";
2
+ /** I/O seam so drain-busy tests can inject rename failures. */
3
+ export const approvalFs = {
4
+ renameSync,
5
+ };
6
+ export const drainMetrics = { renameBusy: 0 };
7
+ import { join } from "node:path";
8
+ import { signDecisionRecord } from "@cedulon/core";
9
+ import { appendDurable, lookupDecisionByRef, lookupResolvedBy, noteResolution } from "./ledger.js";
10
+ import { effectDescriptor, sha256Canonical } from "./hash.js";
11
+ import { inputsLogFor } from "./inputs.js";
12
+ function lastByRef(rows) {
13
+ const map = new Map();
14
+ for (const row of rows)
15
+ map.set(row.ref, row);
16
+ return map;
17
+ }
18
+ function parseLines(text) {
19
+ const rows = [];
20
+ for (const line of text.split("\n")) {
21
+ if (line === "")
22
+ continue;
23
+ rows.push(JSON.parse(line));
24
+ }
25
+ return rows;
26
+ }
27
+ export class MemoryApprovalsLog {
28
+ rows = [];
29
+ byRef = new Map();
30
+ async append(row) {
31
+ this.rows.push(row);
32
+ this.byRef.set(row.ref, row);
33
+ }
34
+ async get(ref) {
35
+ return this.byRef.get(ref) ?? null;
36
+ }
37
+ async listPending() {
38
+ return [...this.byRef.values()].filter((r) => r.status === "pending");
39
+ }
40
+ async listAll() {
41
+ return [...this.byRef.values()];
42
+ }
43
+ async updateStatus(ref, status, extra) {
44
+ const cur = this.byRef.get(ref);
45
+ if (!cur)
46
+ return;
47
+ await this.append({ ...cur, status, ...(extra?.allowRef !== undefined ? { allowRef: extra.allowRef } : {}) });
48
+ }
49
+ }
50
+ export class FileApprovalsLog {
51
+ path;
52
+ byRef = new Map();
53
+ constructor(dir) {
54
+ this.path = join(dir, "approvals.jsonl");
55
+ for (const row of this.read())
56
+ this.byRef.set(row.ref, row);
57
+ }
58
+ async append(row) {
59
+ await appendDurable(this.path, `${JSON.stringify(row)}\n`);
60
+ this.byRef.set(row.ref, row);
61
+ }
62
+ async get(ref) {
63
+ return this.byRef.get(ref) ?? null;
64
+ }
65
+ async listPending() {
66
+ return [...this.byRef.values()].filter((r) => r.status === "pending");
67
+ }
68
+ async listAll() {
69
+ return [...this.byRef.values()];
70
+ }
71
+ async updateStatus(ref, status, extra) {
72
+ const cur = this.byRef.get(ref);
73
+ if (!cur)
74
+ return;
75
+ await this.append({ ...cur, status, ...(extra?.allowRef !== undefined ? { allowRef: extra.allowRef } : {}) });
76
+ }
77
+ read() {
78
+ try {
79
+ return parseLines(readFileSync(this.path, "utf8"));
80
+ }
81
+ catch (err) {
82
+ if (err.code === "ENOENT")
83
+ return [];
84
+ throw err;
85
+ }
86
+ }
87
+ }
88
+ const STASH = Symbol.for("verax.approvalsLog");
89
+ export function approvalsLogFor(ledger) {
90
+ const bag = ledger;
91
+ if (bag[STASH])
92
+ return bag[STASH];
93
+ const log = typeof bag.dir === "string" ? new FileApprovalsLog(bag.dir) : new MemoryApprovalsLog();
94
+ bag[STASH] = log;
95
+ return log;
96
+ }
97
+ export function loadApprovalsFromDir(dir) {
98
+ const path = join(dir, "approvals.jsonl");
99
+ try {
100
+ return [...lastByRef(parseLines(readFileSync(path, "utf8"))).values()];
101
+ }
102
+ catch (err) {
103
+ if (err.code === "ENOENT")
104
+ return [];
105
+ throw err;
106
+ }
107
+ }
108
+ async function hasResolves(inputsLog, ledger, deferRef) {
109
+ const indexed = ledger;
110
+ if (typeof indexed.lookupResolvedBy === "function") {
111
+ return indexed.lookupResolvedBy(deferRef) != null;
112
+ }
113
+ for (const d of await ledger.decisions()) {
114
+ if (!d.claims.ref || d.claims.ref === deferRef)
115
+ continue;
116
+ const inp = await inputsLog.get(d.claims.ref);
117
+ if (inp?.approver?.resolves === deferRef)
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ export async function approvePending(opts) {
123
+ const inputsLog = opts.inputsLog ?? inputsLogFor(opts.ledger);
124
+ const defer = await lookupDecisionByRef(opts.ledger, opts.ref);
125
+ if (!defer || defer.decision !== "defer")
126
+ return { ok: false, reason: "unknown-ref" };
127
+ const snap = await opts.approvals.get(opts.ref);
128
+ if (snap && snap.status !== "pending")
129
+ return { ok: false, reason: "already-resolved" };
130
+ const resolved = lookupResolvedBy(opts.ledger, opts.ref);
131
+ if (resolved || (await hasResolves(inputsLog, opts.ledger, opts.ref))) {
132
+ const hit = resolved ?? lookupResolvedBy(opts.ledger, opts.ref);
133
+ if (snap?.status === "pending" && hit) {
134
+ await opts.approvals.updateStatus(opts.ref, hit.kind === "allow" ? "approved" : "expired", hit.kind === "allow" ? { allowRef: hit.ref } : undefined);
135
+ }
136
+ return { ok: false, reason: "already-resolved" };
137
+ }
138
+ if (!snap)
139
+ return { ok: false, reason: "snapshot-missing" };
140
+ const requestHash = sha256Canonical({ name: snap.subject, arguments: snap.args });
141
+ if (requestHash !== defer.requestHash || requestHash !== snap.requestHash) {
142
+ return { ok: false, reason: "hash-mismatch" };
143
+ }
144
+ if (opts.now() > snap.expiresAtMs) {
145
+ const expireRef = opts.nonce();
146
+ const prior = await inputsLog.get(opts.ref);
147
+ const inputs = {
148
+ principal: prior?.principal ?? { brain: snap.brain, scopes: [] },
149
+ inputs: prior?.inputs ?? [],
150
+ approver: { id: opts.approverId, via: opts.via, resolves: opts.ref },
151
+ };
152
+ const inputsHash = sha256Canonical(inputs);
153
+ await inputsLog.append(expireRef, inputs);
154
+ await opts.ledger.appendDecisionChained((prevRecordHash) => signDecisionRecord({
155
+ decider: "verax-operator",
156
+ subject: defer.subject,
157
+ requestHash: defer.requestHash,
158
+ policyHash: opts.policyHash,
159
+ inputsHash,
160
+ decision: "deny",
161
+ reasonCode: "expired",
162
+ ref: expireRef,
163
+ effectHash: null,
164
+ // A refusal names what it refused, in the same word the effect row
165
+ // would have carried had the operator approved in time.
166
+ effectClass: defer.subject,
167
+ timestampMs: opts.now(),
168
+ nonce: expireRef,
169
+ prevRecordHash,
170
+ }, opts.recordSigner.privateKeyPem, opts.recordSigner.publicKeyPem));
171
+ noteResolution(opts.ledger, opts.ref, { ref: expireRef, kind: "expired" });
172
+ await opts.approvals.updateStatus(opts.ref, "expired");
173
+ return { ok: false, reason: "expired" };
174
+ }
175
+ const allowRef = opts.nonce();
176
+ const prior = await inputsLog.get(opts.ref);
177
+ const inputs = {
178
+ principal: prior?.principal ?? { brain: snap.brain, scopes: [] },
179
+ inputs: prior?.inputs ?? [],
180
+ approver: { id: opts.approverId, via: opts.via, resolves: opts.ref },
181
+ };
182
+ const inputsHash = sha256Canonical(inputs);
183
+ const effectHash = sha256Canonical(effectDescriptor(snap.subject, snap.args));
184
+ await inputsLog.append(allowRef, inputs);
185
+ await opts.ledger.appendDecisionChained((prevRecordHash) => signDecisionRecord({
186
+ decider: "verax-operator",
187
+ subject: defer.subject,
188
+ requestHash: defer.requestHash,
189
+ policyHash: opts.policyHash,
190
+ inputsHash,
191
+ decision: "allow",
192
+ reasonCode: "approved-by-operator",
193
+ ref: allowRef,
194
+ effectHash,
195
+ // Same word the effect row below carries, so the audit can hold the
196
+ // two to each other.
197
+ effectClass: defer.subject,
198
+ timestampMs: opts.now(),
199
+ nonce: allowRef,
200
+ prevRecordHash,
201
+ }, opts.recordSigner.privateKeyPem, opts.recordSigner.publicKeyPem));
202
+ noteResolution(opts.ledger, opts.ref, { ref: allowRef, kind: "allow" });
203
+ if (snap.subject === "spend") {
204
+ const resultHash = sha256Canonical({
205
+ authorized: true,
206
+ ref: allowRef,
207
+ amountMinor: snap.args.amountMinor,
208
+ currency: snap.args.currency,
209
+ payee: snap.args.payee,
210
+ reference: snap.args.reference,
211
+ });
212
+ await opts.ledger.appendEffect({
213
+ ref: allowRef,
214
+ effectHash,
215
+ effectClass: "spend",
216
+ timestampMs: opts.now(),
217
+ actor: opts.approverId,
218
+ }, "self", resultHash);
219
+ }
220
+ await opts.approvals.updateStatus(opts.ref, "approved", { allowRef });
221
+ return { ok: true, allowRef };
222
+ }
223
+ export function enqueueApprovalCommand(dir, cmd) {
224
+ appendFileSync(join(dir, "approval-commands.jsonl"), `${JSON.stringify(cmd)}\n`, {
225
+ encoding: "utf8",
226
+ mode: 0o600,
227
+ });
228
+ }
229
+ export async function drainApprovalCommands(dir, apply) {
230
+ const live = join(dir, "approval-commands.jsonl");
231
+ if (existsSync(live)) {
232
+ const processing = join(dir, `approval-commands.processing-${Date.now()}-${process.pid}`);
233
+ try {
234
+ approvalFs.renameSync(live, processing);
235
+ }
236
+ catch (err) {
237
+ const code = err.code;
238
+ if (code === "EPERM" || code === "EBUSY") {
239
+ drainMetrics.renameBusy += 1;
240
+ process.stderr.write(`verax-drain: rename-busy ${code} count=${drainMetrics.renameBusy}\n`);
241
+ return;
242
+ }
243
+ if (code !== "ENOENT")
244
+ throw err;
245
+ }
246
+ }
247
+ let names;
248
+ try {
249
+ names = readdirSync(dir)
250
+ .filter((n) => n.startsWith("approval-commands.processing-"))
251
+ .sort();
252
+ }
253
+ catch (err) {
254
+ if (err.code === "ENOENT")
255
+ return;
256
+ throw err;
257
+ }
258
+ const donePath = join(dir, "approval-commands.applied.jsonl");
259
+ const poisonPath = join(dir, "approval-commands.poison.jsonl");
260
+ for (const name of names) {
261
+ const file = join(dir, name);
262
+ try {
263
+ const text = readFileSync(file, "utf8");
264
+ for (const line of text.split("\n")) {
265
+ if (line === "")
266
+ continue;
267
+ try {
268
+ const cmd = JSON.parse(line);
269
+ await apply(cmd);
270
+ appendFileSync(donePath, `${line}\n`, { encoding: "utf8", mode: 0o600 });
271
+ }
272
+ catch (err) {
273
+ const error = err instanceof Error ? err.message : String(err);
274
+ appendFileSync(poisonPath, `${JSON.stringify({ line, error, atMs: Date.now() })}\n`, { encoding: "utf8", mode: 0o600 });
275
+ }
276
+ }
277
+ }
278
+ finally {
279
+ unlinkSync(file);
280
+ }
281
+ }
282
+ }
@@ -0,0 +1,3 @@
1
+ import type { SignedCheckpoint } from "@cedulon/checkpoint";
2
+ export declare function checkpointsPath(dir: string): string;
3
+ export declare function loadCheckpoints(dir: string): SignedCheckpoint[];
@@ -0,0 +1,31 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function checkpointsPath(dir) {
4
+ return join(dir, "checkpoints.jsonl");
5
+ }
6
+ export function loadCheckpoints(dir) {
7
+ let text;
8
+ try {
9
+ text = readFileSync(checkpointsPath(dir), "utf8");
10
+ }
11
+ catch (err) {
12
+ if (err.code === "ENOENT")
13
+ return [];
14
+ throw err;
15
+ }
16
+ const out = [];
17
+ for (const line of text.split("\n")) {
18
+ if (line === "")
19
+ continue;
20
+ try {
21
+ const row = JSON.parse(line);
22
+ if (row && typeof row === "object" && row.claims && typeof row.coseHex === "string") {
23
+ out.push(row);
24
+ }
25
+ }
26
+ catch {
27
+ // skip a truncated line; the next honest row still loads
28
+ }
29
+ }
30
+ return out;
31
+ }
package/dist/disk.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export declare function defaultDiskFreeBytes(): number;
2
+ /** Injected by tests. Production uses statfs on stateDir. */
3
+ export declare const diskProbe: {
4
+ failAppend: boolean;
5
+ freeBytes(dir: string): number;
6
+ };
package/dist/disk.js ADDED
@@ -0,0 +1,13 @@
1
+ import { statfsSync } from "node:fs";
2
+ const DEFAULT_DISK_FREE_BYTES = 64 * 1024 * 1024;
3
+ export function defaultDiskFreeBytes() {
4
+ return DEFAULT_DISK_FREE_BYTES;
5
+ }
6
+ /** Injected by tests. Production uses statfs on stateDir. */
7
+ export const diskProbe = {
8
+ failAppend: false,
9
+ freeBytes(dir) {
10
+ const s = statfsSync(dir);
11
+ return Number(s.bavail) * Number(s.bsize);
12
+ },
13
+ };
@@ -0,0 +1,13 @@
1
+ import type { ExplainOpts, ExplainResult, Ledger } from "./types.ts";
2
+ /**
3
+ * Re-runs the Cedulon decision profile on the whole ledger. Finding codes
4
+ * are Cedulon's; this wrapper does not invent names. With no durable
5
+ * checkpoint on disk, window-coverage is listed as notApplicable and
6
+ * dropped from the finding set before balanced is computed. When
7
+ * `checkpoints.jsonl` has at least one signed row, that finding is kept.
8
+ * General issuer/extract warnings and the audit guarantee are kept. A
9
+ * self witness and each general Cedulon warning are named in the
10
+ * condition list; "balanced" is never written alone, and "conditional"
11
+ * is never written twice.
12
+ */
13
+ export declare function explain(ledger: Ledger, ref: string, opts?: ExplainOpts): Promise<ExplainResult>;
@@ -0,0 +1,211 @@
1
+ import { audit, DECISION_PROFILE } from "@cedulon/audit";
2
+ import { findDecisionRecordChainBreak } from "@cedulon/core";
3
+ import { approvalsLogFor } from "./approvals.js";
4
+ import { sha256Canonical } from "./hash.js";
5
+ import { inputsLogFor } from "./inputs.js";
6
+ import { lookupResolvedBy } from "./ledger.js";
7
+ import { loadCheckpoints } from "./checkpoints.js";
8
+ async function pairFor(ledger, decisions, record, inputsLog, approvals) {
9
+ const ref = record.claims.ref;
10
+ if (!ref)
11
+ return { defer: null, resolution: null };
12
+ if (record.claims.decision === "defer") {
13
+ const snap = await approvals.get(ref);
14
+ if (snap?.allowRef) {
15
+ const resolution = decisions.find((d) => d.claims.ref === snap.allowRef) ?? null;
16
+ return { defer: record, resolution };
17
+ }
18
+ const hit = lookupResolvedBy(ledger, ref);
19
+ if (hit) {
20
+ const resolution = decisions.find((d) => d.claims.ref === hit.ref) ?? null;
21
+ return { defer: record, resolution };
22
+ }
23
+ return { defer: null, resolution: null };
24
+ }
25
+ const own = await inputsLog.get(ref);
26
+ if (own?.approver?.resolves) {
27
+ const defer = decisions.find((d) => d.claims.ref === own.approver?.resolves) ?? null;
28
+ return { defer, resolution: record };
29
+ }
30
+ return { defer: null, resolution: null };
31
+ }
32
+ const WINDOW_COVERAGE = "window-coverage";
33
+ function resolveIssuerTrust(opts) {
34
+ if (opts?.issuerTrust) {
35
+ return {
36
+ pin: { publicKeyPem: opts.issuerTrust.publicKeyPem },
37
+ source: opts.issuerTrust.source ?? null,
38
+ };
39
+ }
40
+ const env = process.env.VERAX_RECORD_PUBKEY_PIN;
41
+ if (typeof env === "string" && env.trim() !== "") {
42
+ return { pin: { publicKeyPem: env }, source: "env" };
43
+ }
44
+ return { pin: undefined, source: null };
45
+ }
46
+ function asWarning(f) {
47
+ return { id: f.id, code: f.code, detail: f.detail };
48
+ }
49
+ function conditionName(f, extractPresented) {
50
+ if (f.code === "unauthenticated-issuer")
51
+ return "issuer unpinned";
52
+ if (f.code === "unauthenticated-extract") {
53
+ // Name from whether an extract was handed to audit, not Cedulon detail text.
54
+ return extractPresented ? "extract unpinned" : "extract unsigned";
55
+ }
56
+ return null;
57
+ }
58
+ function balancedSummary(guarantee, conditions, cedulonSummary) {
59
+ if (conditions.length > 0) {
60
+ return `audit: balanced (${guarantee}: ${conditions.join("; ")})`;
61
+ }
62
+ if (guarantee === "unconditional") {
63
+ return "audit: balanced (unconditional)";
64
+ }
65
+ const why = cedulonSummary.replace(/^audit:\s*/i, "").trim();
66
+ if (why !== "" && why !== "balanced" && why !== "conditional") {
67
+ return `audit: balanced (${guarantee}: ${why})`;
68
+ }
69
+ return `audit: balanced (${guarantee})`;
70
+ }
71
+ /**
72
+ * Re-runs the Cedulon decision profile on the whole ledger. Finding codes
73
+ * are Cedulon's; this wrapper does not invent names. With no durable
74
+ * checkpoint on disk, window-coverage is listed as notApplicable and
75
+ * dropped from the finding set before balanced is computed. When
76
+ * `checkpoints.jsonl` has at least one signed row, that finding is kept.
77
+ * General issuer/extract warnings and the audit guarantee are kept. A
78
+ * self witness and each general Cedulon warning are named in the
79
+ * condition list; "balanced" is never written alone, and "conditional"
80
+ * is never written twice.
81
+ */
82
+ export async function explain(ledger, ref, opts) {
83
+ const decisions = await ledger.decisions();
84
+ const effects = await ledger.effects();
85
+ const record = decisions.find((d) => d.claims.ref === ref);
86
+ if (!record) {
87
+ throw new Error(`explain-unknown-ref:${ref}`);
88
+ }
89
+ const effect = effects.find((e) => e.row.ref === ref && e.row.effectClass !== "duplicate-effect") ?? null;
90
+ const presentedExtract = opts?.extract ?? effect?.receipt;
91
+ const extractPresented = Boolean(presentedExtract);
92
+ const resolvedTrust = resolveIssuerTrust(opts);
93
+ const issuerTrust = resolvedTrust.pin;
94
+ const pinSource = resolvedTrust.source;
95
+ const pinned = issuerTrust !== undefined;
96
+ const dir = ledger.dir;
97
+ const checkpoints = typeof dir === "string" ? loadCheckpoints(dir) : [];
98
+ const report = audit({
99
+ receipts: decisions,
100
+ checkpoints,
101
+ settlements: effects.map((e) => e.row),
102
+ profile: DECISION_PROFILE,
103
+ ...(issuerTrust ? { issuerTrust } : {}),
104
+ ...(presentedExtract ? { extract: presentedExtract } : {}),
105
+ });
106
+ const dropWindow = checkpoints.length === 0;
107
+ const dropped = dropWindow
108
+ ? [
109
+ ...new Set([...report.findings, ...report.warnings]
110
+ .filter((f) => f.code === WINDOW_COVERAGE)
111
+ .map((f) => f.code)),
112
+ ]
113
+ : [];
114
+ const applicable = dropWindow
115
+ ? report.findings.filter((f) => f.code !== WINDOW_COVERAGE)
116
+ : report.findings;
117
+ const applicableWarnings = dropWindow
118
+ ? report.warnings.filter((f) => f.code !== WINDOW_COVERAGE)
119
+ : report.warnings;
120
+ const refFindings = applicable.filter((f) => f.id === ref);
121
+ const issuerFinding = applicable.find((f) => f.code === "issuer-key-mismatch" || f.id === "issuer") ??
122
+ applicableWarnings.find((f) => f.code === "issuer-key-mismatch") ??
123
+ null;
124
+ const hit = refFindings[0] ??
125
+ (pinned ? issuerFinding : null) ??
126
+ applicableWarnings.find((f) => f.id === ref) ??
127
+ null;
128
+ const prefix = decisions.slice(0, decisions.indexOf(record) + 1);
129
+ const brk = findDecisionRecordChainBreak(prefix);
130
+ const breakAt = brk ? (prefix[brk.index]?.claims.ref ?? null) : null;
131
+ const chain = { intact: brk === null, breakAt };
132
+ const witnessClass = effect?.witnessClass ?? null;
133
+ const self = witnessClass === "self";
134
+ const chainBreak = !chain.intact;
135
+ const conditions = [];
136
+ if (self)
137
+ conditions.push("self witness");
138
+ if (pinSource === "own-key")
139
+ conditions.push("issuer pinned to own key");
140
+ for (const w of applicableWarnings) {
141
+ const name = conditionName(w, extractPresented);
142
+ if (name && !conditions.includes(name))
143
+ conditions.push(name);
144
+ }
145
+ const issuerMismatch = applicable.concat(applicableWarnings).some((f) => f.code === "issuer-key-mismatch" || (pinned && f.code === "unauthenticated-issuer"));
146
+ const inputsLog = opts?.inputsLog ?? inputsLogFor(ledger);
147
+ const approvals = approvalsLogFor(ledger);
148
+ const inputsDoc = record.claims.ref ? await inputsLog.get(record.claims.ref) : null;
149
+ const inputsMismatch = Boolean(inputsDoc &&
150
+ typeof record.claims.inputsHash === "string" &&
151
+ sha256Canonical(inputsDoc) !== record.claims.inputsHash);
152
+ const inputsMissing = typeof record.claims.inputsHash === "string" && inputsDoc === null;
153
+ if (inputsMissing)
154
+ conditions.push("inputs document missing");
155
+ const balanced = refFindings.length === 0 && !chainBreak && !issuerMismatch && !inputsMismatch && !inputsMissing;
156
+ const code = chainBreak
157
+ ? "receipt-chain-break"
158
+ : inputsMismatch
159
+ ? "inputs-hash-mismatch"
160
+ : inputsMissing
161
+ ? "inputs-document-missing"
162
+ : (hit?.code ?? null);
163
+ const detail = chainBreak
164
+ ? `receipt-chain-break at ${breakAt ?? "unknown"} (${brk?.reason ?? "broken"})`
165
+ : inputsMismatch
166
+ ? "inputs document hash does not match claims.inputsHash"
167
+ : inputsMissing
168
+ ? "claims.inputsHash is on the record; the inputs document is not on disk"
169
+ : (hit?.detail ?? null);
170
+ const findingCount = chainBreak
171
+ ? Math.max(refFindings.length, 1)
172
+ : refFindings.length +
173
+ (issuerMismatch && pinned ? 1 : 0) +
174
+ (inputsMismatch ? 1 : 0) +
175
+ (inputsMissing ? 1 : 0);
176
+ const guarantee = report.guarantee;
177
+ const summary = balanced
178
+ ? balancedSummary(guarantee, conditions, report.summary)
179
+ : `audit: ${Math.max(findingCount, 1)} finding(s) → FAIL`;
180
+ const warnings = applicableWarnings.filter((f) => f.id === "issuer" || f.id === "extract").map(asWarning);
181
+ if (inputsMissing) {
182
+ warnings.push({
183
+ id: "inputs",
184
+ code: "inputs-document-missing",
185
+ detail: "claims.inputsHash is on the record; the inputs document is not on disk",
186
+ });
187
+ }
188
+ return {
189
+ record,
190
+ effect,
191
+ pair: await pairFor(ledger, decisions, record, inputsLog, approvals),
192
+ witnessClass,
193
+ balanced,
194
+ chain,
195
+ guarantee,
196
+ warnings,
197
+ trustRoot: {
198
+ pinned,
199
+ issuerMatches: pinned ? !issuerMismatch && !warnings.some((w) => w.id === "issuer") : null,
200
+ source: pinSource,
201
+ },
202
+ ...(report.scope ? { scope: report.scope } : {}),
203
+ finding: {
204
+ code,
205
+ label: guarantee === "conditional" || conditions.length > 0 ? "conditional" : null,
206
+ detail,
207
+ summary,
208
+ ...(dropped.length > 0 ? { notApplicable: dropped } : {}),
209
+ },
210
+ };
211
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** SHA-256 of Cedulon's RFC 8785 canonical JSON, 64-char lowercase hex. */
2
+ export declare function sha256Canonical(value: unknown): string;
3
+ /** Decision and effect rows hash this same descriptor on a successful allow. */
4
+ export declare function effectDescriptor(tool: string, args: Record<string, unknown>, threw?: boolean): Record<string, unknown>;
package/dist/hash.js ADDED
@@ -0,0 +1,10 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonical } from "@cedulon/core";
3
+ /** SHA-256 of Cedulon's RFC 8785 canonical JSON, 64-char lowercase hex. */
4
+ export function sha256Canonical(value) {
5
+ return createHash("sha256").update(canonical(value), "utf8").digest("hex");
6
+ }
7
+ /** Decision and effect rows hash this same descriptor on a successful allow. */
8
+ export function effectDescriptor(tool, args, threw = false) {
9
+ return threw ? { tool, arguments: args, threw: true } : { tool, arguments: args };
10
+ }