@zanii/custody-verdicts 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/index.js ADDED
@@ -0,0 +1,1189 @@
1
+ /**
2
+ * @zanii/custody-verdicts: the one verifier. One verifier, two surfaces: the subject
3
+ * page (zanii-id) and `@zanii/cli` both import this package and call `computeVerdicts`.
4
+ * Never two implementations of the verdict logic: two drift, and the drift always
5
+ * favours the vendor.
6
+ *
7
+ * Verdicts are **computed, never stored**. Input is a `SubjectHistory` from
8
+ * `@zanii/subject` (`fetchSubjectHistory`): the subject's receipt slice, each receipt
9
+ * already verified offline (signature + delegation + scope), plus the signed tree
10
+ * head. SpiritWave's custody receipts embed the `@zanii/data-custody` touch payload
11
+ * inside the receipt as a signature-covered `payload` field (the ledger has no
12
+ * payload store); row 1 re-checks every embedded payload with `verifyTouch`, and
13
+ * tolerates the other custody kind this package itself consumes: a `@zanii/retention`
14
+ * crypto-shredding payload (`verifyKeyDestruction`), which row 5 reads as the
15
+ * key-destruction evidence.
16
+ *
17
+ * Four states per line: `proven`, `not covered`, `failed`, `cannot determine`. The
18
+ * last three are as first-class as the first: a missing proof is NOT a clean result
19
+ * ("no declared training runs to check against", never "never"). A bad receipt is a
20
+ * red, not a grey, and a presented-but-broken proof is a red too.
21
+ *
22
+ * The function is pure: no network, no clock (`computeAnchorLag` takes `now` from the
23
+ * caller). It is async for exactly one reason: row 3's coverage attestation is checked
24
+ * through `@zanii/attest`'s `verifyAttestation`, whose injected quote verifier may be
25
+ * async. Verification paths never throw: malformed input yields `cannot determine` /
26
+ * `not covered` / `failed` rows with reasons, per the codebase convention (creation
27
+ * paths throw, verification paths return results).
28
+ *
29
+ * Rows 3–5 consume real producer evidence, checked here, narrowly and offline:
30
+ * row 3 a SIGNED coverage attestation (`@zanii/attest` verifies the embedded
31
+ * attestation with an injected quote verifier; the producer side ships with the
32
+ * enclave phase, so until then `proven` is unreachable, by design), row 4 a declared,
33
+ * anchor-ordered training manifest plus `@zanii/train`'s sorted-Merkle `AbsenceProof`
34
+ * (`verifyAbsence`), row 5 the `@zanii/retention` shred receipts carried by the slice
35
+ * itself (`verifyKeyDestruction`), optionally bound to a published image hash via
36
+ * `@zanii/attest`'s `attestationHash` convention.
37
+ *
38
+ * Rows 6–8 consume the slice the same narrow way: row 6 residency claims
39
+ * (`@zanii/residency`'s `buildResidencyClaim` shape) verified with an injected region
40
+ * verifier against the reader-pinned jurisdiction allow-set, plus `detectCrossings`
41
+ * over the verified touch sequence; row 7 onward transfers (`@zanii/subprocessor`'s
42
+ * `buildTransfer` shape) and custody-touch junctions checked against the
43
+ * reader-pinned declared subprocessor list via `verifyTransfers`; row 8 field-set
44
+ * commitments (`@zanii/minimization`'s `commitFieldSet` records) and access proofs
45
+ * (`buildAccessProof`) checked with `verifyMinimization`. The jurisdiction allow-set
46
+ * and the declared subprocessor list are READER configuration (ExtraEvidence), never
47
+ * producer-supplied: the same trust rule as `coverage.trustedSigners`.
48
+ */
49
+ import { ed25519 } from '@noble/curves/ed25519';
50
+ import { hexToBytes } from '@noble/hashes/utils';
51
+ import { canonicalBytes, publicKeyFromDid, verifySTH, } from '@zanii/core';
52
+ import { verifyTouch } from '@zanii/data-custody';
53
+ import { verifyKeyDestruction } from '@zanii/retention';
54
+ import { verifyAbsence } from '@zanii/train';
55
+ import { attestationHash, verifyAttestation, } from '@zanii/attest';
56
+ import { detectCrossings, verifyResidency, } from '@zanii/residency';
57
+ import { verifyTransfers } from '@zanii/subprocessor';
58
+ import { verifyFieldSet, verifyMinimization } from '@zanii/minimization';
59
+ /**
60
+ * The well-known ledger tag under which declared training manifests are published.
61
+ * The subject page and the CLI fetch that slice to build the row-4 training evidence
62
+ * bag (`ExtraEvidence.training`).
63
+ */
64
+ export const TRAINING_MANIFEST_TAG = 'zanii:training-manifests';
65
+ const LABELS = {
66
+ purpose: 'Used only for its declared purpose',
67
+ self_sovereign: 'You can check this yourself',
68
+ human_read: 'Nobody read it',
69
+ trained_on: 'Never used to train a model',
70
+ copy_kept: 'No copy was kept',
71
+ residency: 'Data stayed in its declared region',
72
+ subprocessor: 'Only declared third parties touched it',
73
+ minimization: 'Only what was needed was taken',
74
+ };
75
+ function touchPayloadOf(sr) {
76
+ return sr?.receipt?.payload;
77
+ }
78
+ function asPayload(p) {
79
+ return p !== null && typeof p === 'object' && !Array.isArray(p)
80
+ ? p
81
+ : undefined;
82
+ }
83
+ const plural = (n, word, pluralForm) => `${n} ${n === 1 ? word : (pluralForm ?? `${word}s`)}`;
84
+ function parseTime(ts) {
85
+ if (typeof ts !== 'string')
86
+ return null;
87
+ const ms = Date.parse(ts);
88
+ return Number.isNaN(ms) ? null : ms;
89
+ }
90
+ /** `sha256:<64 hex>` → bytes, null on any other shape. */
91
+ function parseHash(s) {
92
+ if (typeof s !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(s))
93
+ return null;
94
+ return hexToBytes(s.slice('sha256:'.length));
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Row 1 (purpose): "Used only for its declared purpose"
98
+ // ---------------------------------------------------------------------------
99
+ // Structural validators for the evidence payloads rows 6-8 consume, shared with row
100
+ // 1's closed world. Creation paths throw, verification paths return reasons.
101
+ /** Structural check of a `@zanii/residency` claim (`buildResidencyClaim`'s shape). */
102
+ function residencyClaimReasons(p) {
103
+ const reasons = [];
104
+ if (p.v !== 1 || p.type !== 'residency.claim')
105
+ reasons.push('not a residency claim (v1)');
106
+ const ra = asPayload(p.region_attestation);
107
+ if (!ra)
108
+ reasons.push('missing region attestation');
109
+ else {
110
+ if (typeof ra.region !== 'string' || !ra.region)
111
+ reasons.push('missing/invalid region');
112
+ if (typeof ra.source !== 'string' || !ra.source)
113
+ reasons.push('missing region source');
114
+ }
115
+ if (parseTime(p.ts) === null)
116
+ reasons.push('missing/invalid ts');
117
+ return reasons;
118
+ }
119
+ /** Structural check of a `@zanii/subprocessor` transfer (`buildTransfer`'s shape). */
120
+ function transferReasons(p) {
121
+ const reasons = [];
122
+ if (p.v !== 1 || p.type !== 'subprocessor.transfer')
123
+ reasons.push('not a subprocessor transfer (v1)');
124
+ if (typeof p.from !== 'string' || !p.from)
125
+ reasons.push('missing from');
126
+ if (typeof p.to !== 'string' || !p.to)
127
+ reasons.push('missing to');
128
+ const agreement = asPayload(p.agreement);
129
+ if (!agreement || typeof agreement.type !== 'string' || !agreement.type)
130
+ reasons.push('missing agreement');
131
+ if (parseTime(p.ts) === null)
132
+ reasons.push('missing/invalid ts');
133
+ return reasons;
134
+ }
135
+ /** Structural check of a `@zanii/minimization` field-set commitment record: the
136
+ * `commitFieldSet` output (`{ commitment, fields }`) carried as a payload of type
137
+ * `minimization.commit`. */
138
+ function commitReasons(p) {
139
+ const reasons = [];
140
+ if (p.type !== 'minimization.commit')
141
+ reasons.push('not a field-set commitment record');
142
+ if (typeof p.commitment !== 'string' || !p.commitment.startsWith('minfields:'))
143
+ reasons.push('missing/invalid commitment');
144
+ if (!Array.isArray(p.fields) || p.fields.some((f) => typeof f !== 'string'))
145
+ reasons.push('missing/invalid fields');
146
+ return reasons;
147
+ }
148
+ /** Structural check of a `@zanii/minimization` access proof (`buildAccessProof`'s shape). */
149
+ function accessReasons(p) {
150
+ const reasons = [];
151
+ if (p.v !== 1 || p.type !== 'minimization.access')
152
+ reasons.push('not a minimization access proof (v1)');
153
+ if (typeof p.field_set !== 'string' || !p.field_set.startsWith('minfields:'))
154
+ reasons.push('missing/invalid field_set');
155
+ if (!Array.isArray(p.accessed) || p.accessed.some((f) => typeof f !== 'string'))
156
+ reasons.push('missing/invalid accessed');
157
+ return reasons;
158
+ }
159
+ /** The evidence payload kinds rows 6-8 consume, identified by their `type` tag. */
160
+ function evidenceKindOf(p) {
161
+ switch (p.type) {
162
+ case 'residency.claim': return 'residency';
163
+ case 'subprocessor.transfer': return 'transfer';
164
+ case 'minimization.commit': return 'commit';
165
+ case 'minimization.access': return 'access';
166
+ default: return null;
167
+ }
168
+ }
169
+ /**
170
+ * A slice receipt is well-formed custody evidence when its embedded payload is a
171
+ * well-formed custody touch (`@zanii/data-custody`) OR a well-formed retention shred
172
+ * (`@zanii/retention`) OR one of the evidence payloads rows 6-8 consume (a residency
173
+ * claim, a subprocessor transfer, a field-set commitment, a minimization access
174
+ * proof). Anything else in a custody slice is a red. Closed world on purpose: new
175
+ * custody kinds stay red until wired here. The returned kind lets the row say
176
+ * honestly what passed or what failed: a retention payload never gets touch-schema
177
+ * reasons.
178
+ */
179
+ function wellFormedCustodyPayload(p) {
180
+ const payload = asPayload(p);
181
+ // A retention-claimed payload is checked as one, pass or fail.
182
+ if (payload && payload['_zr_kind'] === 'retention') {
183
+ let shred;
184
+ try {
185
+ shred = verifyKeyDestruction(payload);
186
+ }
187
+ catch {
188
+ shred = { ok: false, reasons: ['unreadable payload'] };
189
+ }
190
+ return shred.ok ? { ok: true, kind: 'shred' } : { ok: false, kind: 'retention', reasons: shred.reasons };
191
+ }
192
+ const kind = payload ? evidenceKindOf(payload) : null;
193
+ if (kind !== null && payload) {
194
+ const reasons = kind === 'residency' ? residencyClaimReasons(payload)
195
+ : kind === 'transfer' ? transferReasons(payload)
196
+ : kind === 'commit' ? commitReasons(payload)
197
+ : accessReasons(payload);
198
+ return reasons.length === 0 ? { ok: true, kind } : { ok: false, kind, reasons };
199
+ }
200
+ let touch;
201
+ try {
202
+ touch = verifyTouch(payload);
203
+ }
204
+ catch {
205
+ touch = { ok: false, reasons: ['unreadable payload'] };
206
+ }
207
+ return touch.ok ? { ok: true, kind: 'touch' } : { ok: false, kind: 'touch', reasons: touch.reasons };
208
+ }
209
+ const EVIDENCE_NOUNS = {
210
+ residency: ['residency claim'],
211
+ transfer: ['transfer record'],
212
+ commit: ['field-set commitment'],
213
+ access: ['access proof'],
214
+ };
215
+ function purposeRow(history, receipts) {
216
+ const id = 'purpose';
217
+ if (receipts.length === 0) {
218
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
219
+ }
220
+ if (history.verified !== true) {
221
+ return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
222
+ }
223
+ let touches = 0;
224
+ let shreds = 0;
225
+ const evidence = [];
226
+ for (const sr of receipts) {
227
+ const check = wellFormedCustodyPayload(touchPayloadOf(sr));
228
+ if (!check.ok) {
229
+ const what = check.kind === 'touch' ? 'custody touch'
230
+ : check.kind === 'retention' ? 'retention'
231
+ : `${EVIDENCE_NOUNS[check.kind][0]}`;
232
+ return {
233
+ id,
234
+ label: LABELS[id],
235
+ state: 'failed',
236
+ detail: `Receipt ${sr?.hash} (index ${sr?.index}) has a malformed ${what} payload: ${check.reasons.join(', ')}.`,
237
+ };
238
+ }
239
+ if (check.kind === 'shred')
240
+ shreds += 1;
241
+ else if (check.kind === 'touch')
242
+ touches += 1;
243
+ else
244
+ evidence.push(check.kind);
245
+ }
246
+ if (evidence.length === 0) {
247
+ if (shreds === 0) {
248
+ return {
249
+ id,
250
+ label: LABELS[id],
251
+ state: 'proven',
252
+ detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope) and each carries a well-formed custody touch payload naming its data class and operation.`,
253
+ };
254
+ }
255
+ return {
256
+ id,
257
+ label: LABELS[id],
258
+ state: 'proven',
259
+ detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope): ${plural(touches, 'touch', 'touches')} carried a well-formed custody touch payload naming its data class and operation, and ${plural(shreds, 'retention shred')} carried a well-formed key-destruction payload.`,
260
+ };
261
+ }
262
+ // Slices that also carry the rows 6-8 evidence payloads: enumerate them honestly.
263
+ const counts = new Map();
264
+ for (const k of evidence)
265
+ counts.set(k, (counts.get(k) ?? 0) + 1);
266
+ const named = [...counts.entries()].map(([k, n]) => {
267
+ const [noun, pluralForm] = EVIDENCE_NOUNS[k];
268
+ return plural(n, noun, pluralForm);
269
+ });
270
+ const parts = [`${plural(touches, 'touch', 'touches')} carried a well-formed custody touch payload naming its data class and operation`];
271
+ if (shreds > 0)
272
+ parts.push(`${plural(shreds, 'retention shred')} carried a well-formed key-destruction payload`);
273
+ parts.push(`${named.join(', ')} passed structural checks`);
274
+ return {
275
+ id,
276
+ label: LABELS[id],
277
+ state: 'proven',
278
+ detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope): ${parts.join(', and ')}.`,
279
+ };
280
+ }
281
+ function offlineFailureDetail(history) {
282
+ const failures = Array.isArray(history.failures) ? history.failures : [];
283
+ if (failures.length === 0)
284
+ return 'The slice did not verify offline.';
285
+ return `${plural(failures.length, 'receipt')} failed offline verification: ${failures.join('; ')}. A receipt that does not verify is a failure, not an unknown.`;
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Row 2 (self-sovereign): "You can check this yourself"
289
+ // ---------------------------------------------------------------------------
290
+ function selfSovereignRow(history, receipts) {
291
+ const id = 'self_sovereign';
292
+ if (receipts.length === 0) {
293
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
294
+ }
295
+ if (history.verified !== true) {
296
+ return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
297
+ }
298
+ if (history.sth === undefined || history.sth === null) {
299
+ return {
300
+ id,
301
+ label: LABELS[id],
302
+ state: 'cannot determine',
303
+ detail: 'The slice came with no signed tree head, so the anchor commitment could not be verified.',
304
+ };
305
+ }
306
+ let sthOk = false;
307
+ try {
308
+ sthOk = verifySTH(history.sth);
309
+ }
310
+ catch {
311
+ sthOk = false;
312
+ }
313
+ if (!sthOk) {
314
+ return {
315
+ id,
316
+ label: LABELS[id],
317
+ state: 'failed',
318
+ detail: "The signed tree head failed verification: the log's commitment to this slice does not check out.",
319
+ };
320
+ }
321
+ const size = history.sth.size;
322
+ return {
323
+ id,
324
+ label: LABELS[id],
325
+ state: 'proven',
326
+ detail: `All ${plural(receipts.length, 'receipt')} verified offline in this process (signature, delegation, scope) and the signed tree head (size ${typeof size === 'number' ? size : '?'}) verifies: zero network calls, nothing taken on trust.`,
327
+ };
328
+ }
329
+ // ---------------------------------------------------------------------------
330
+ // Row 3 (human_read): "Nobody read it"
331
+ // ---------------------------------------------------------------------------
332
+ async function humanReadRow(receipts, evidence, verifyQuote) {
333
+ const id = 'human_read';
334
+ // A receipt shows a human actor when its embedded touch payload declares one
335
+ // (`actor: "human"`). The check is cheap and always runs, evidence or not, and a
336
+ // human actor beats any coverage attestation: failed stays failed.
337
+ for (const sr of receipts) {
338
+ const p = asPayload(touchPayloadOf(sr));
339
+ if (p && p['actor'] === 'human') {
340
+ return {
341
+ id,
342
+ label: LABELS[id],
343
+ state: 'failed',
344
+ detail: `Receipt ${sr?.hash} (index ${sr?.index}) records a human actor: a person, not only an agent, touched this data.`,
345
+ };
346
+ }
347
+ }
348
+ const coverage = evidence.coverage;
349
+ if (!coverage) {
350
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No coverage attestation for this window' };
351
+ }
352
+ if (typeof coverage !== 'object' || Array.isArray(coverage) || coverage.v !== 1 || coverage.type !== 'coverage.attestation') {
353
+ return { id, label: LABELS[id], state: 'not covered', detail: 'The coverage evidence is not a signed coverage attestation.' };
354
+ }
355
+ if (receipts.length === 0) {
356
+ // Coverage over nothing proves nothing about the subject's data.
357
+ return {
358
+ id,
359
+ label: LABELS[id],
360
+ state: 'cannot determine',
361
+ detail: "This slice has no receipts, so there is nothing for the coverage attestation to cover: coverage over nothing proves nothing about this subject's data.",
362
+ };
363
+ }
364
+ // Authenticate before evaluating: an unsigned or badly-signed claim is not evidence.
365
+ const signerPub = publicKeyFromDid(typeof coverage.signer === 'string' ? coverage.signer : '');
366
+ if (!signerPub || typeof coverage.sig !== 'string' || !/^ed25519:[0-9a-f]{128}$/.test(coverage.sig)) {
367
+ return {
368
+ id,
369
+ label: LABELS[id],
370
+ state: 'failed',
371
+ detail: 'The coverage attestation is unsigned or carries a malformed signature: an unsigned coverage claim is not evidence.',
372
+ };
373
+ }
374
+ // The trust root: when the deployment pins coverage-signing DIDs, a signer outside
375
+ // the list is never proven, however good its signature.
376
+ const trustedSigners = coverage.trustedSigners;
377
+ if (Array.isArray(trustedSigners) && !trustedSigners.includes(coverage.signer)) {
378
+ return {
379
+ id,
380
+ label: LABELS[id],
381
+ state: 'failed',
382
+ detail: `The coverage attestation is from an unrecognized signer (${coverage.signer}): it is not in this deployment's trusted signers list, so the claim is not evidence.`,
383
+ };
384
+ }
385
+ // trustedSigners is reader-side config merged in after signing: it is NOT part of
386
+ // the signed record, so it is excluded from the canonical preimage here.
387
+ const { sig: _omitted, trustedSigners: _ts, ...unsigned } = coverage;
388
+ let sigOk = false;
389
+ try {
390
+ sigOk = ed25519.verify(hexToBytes(coverage.sig.slice('ed25519:'.length)), canonicalBytes(unsigned), signerPub);
391
+ }
392
+ catch {
393
+ sigOk = false;
394
+ }
395
+ if (!sigOk) {
396
+ return {
397
+ id,
398
+ label: LABELS[id],
399
+ state: 'failed',
400
+ detail: "The coverage attestation's signature does not verify: a coverage claim that does not verify is a failure, not an unknown.",
401
+ };
402
+ }
403
+ // The claim must be bound to an attested image: attestationHash(embedded) === imageHash.
404
+ const attestation = asPayload(coverage.attestation);
405
+ let boundHash = null;
406
+ if (attestation) {
407
+ try {
408
+ boundHash = attestationHash(attestation);
409
+ }
410
+ catch {
411
+ boundHash = null;
412
+ }
413
+ }
414
+ if (!attestation || boundHash === null || coverage.imageHash !== boundHash) {
415
+ return {
416
+ id,
417
+ label: LABELS[id],
418
+ state: 'failed',
419
+ detail: "The coverage attestation's image hash does not match its embedded attestation (attestationHash convention): the binding to the attested image is broken.",
420
+ };
421
+ }
422
+ // The embedded attestation verifies through @zanii/attest with the injected quote
423
+ // verifier. A disproven quote is a red; no verifier injected is an honest grey.
424
+ let check;
425
+ try {
426
+ check = await verifyAttestation(attestation, verifyQuote ? { verifyQuote } : {});
427
+ }
428
+ catch {
429
+ check = { ok: false, structural: false, quoteVerified: false, reasons: ['attestation verification failed'] };
430
+ }
431
+ if (!check.structural) {
432
+ return {
433
+ id,
434
+ label: LABELS[id],
435
+ state: 'failed',
436
+ detail: `The coverage attestation's embedded attestation is malformed: ${check.reasons.join(', ')}.`,
437
+ };
438
+ }
439
+ if (check.quoteVerified === false) {
440
+ return {
441
+ id,
442
+ label: LABELS[id],
443
+ state: 'failed',
444
+ detail: "The coverage attestation's enclave quote failed verification: the code that claimed coverage is not confirmed to be the attested image.",
445
+ };
446
+ }
447
+ if (check.quoteVerified === null) {
448
+ return {
449
+ id,
450
+ label: LABELS[id],
451
+ state: 'cannot determine',
452
+ detail: 'The coverage attestation is signed and well-formed, but no enclave quote verifier was injected, so the attested image could not be confirmed.',
453
+ };
454
+ }
455
+ if (coverage.allPathsGated !== true) {
456
+ return {
457
+ id,
458
+ label: LABELS[id],
459
+ state: 'not covered',
460
+ detail: 'The coverage attestation does not assert that all egress paths were receipt-gated.',
461
+ };
462
+ }
463
+ const start = parseTime(coverage.windowStart);
464
+ const end = parseTime(coverage.windowEnd);
465
+ if (start === null || end === null || start > end) {
466
+ return { id, label: LABELS[id], state: 'not covered', detail: 'The coverage attestation window is missing or invalid.' };
467
+ }
468
+ const times = receipts.map((sr) => parseTime(sr?.receipt?.ts));
469
+ if (times.some((t) => t === null || t < start || t > end)) {
470
+ const valid = times.filter((t) => t !== null);
471
+ const span = valid.length > 0
472
+ ? ` (this slice spans ${new Date(Math.min(...valid)).toISOString()} to ${new Date(Math.max(...valid)).toISOString()})`
473
+ : '';
474
+ return {
475
+ id,
476
+ label: LABELS[id],
477
+ state: 'not covered',
478
+ detail: `The coverage attestation (${coverage.windowStart} to ${coverage.windowEnd}) does not cover every receipt in this slice${span}.`,
479
+ };
480
+ }
481
+ return {
482
+ id,
483
+ label: LABELS[id],
484
+ state: 'proven',
485
+ detail: `Signed coverage attestation verified (signature and enclave quote): all egress paths were receipt-gated for the whole window (${coverage.windowStart} to ${coverage.windowEnd}) under attested image ${coverage.imageHash}, and no receipt in this slice shows a human actor.`,
486
+ };
487
+ }
488
+ // ---------------------------------------------------------------------------
489
+ // Row 4 (trained_on): "Never used to train a model"
490
+ // ---------------------------------------------------------------------------
491
+ function trainedOnRow(history, receipts, evidence) {
492
+ const id = 'trained_on';
493
+ const training = evidence.training;
494
+ if (!training) {
495
+ // Exact wording matters: a missing proof is NOT a clean result. Never "never".
496
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No declared training runs to check against' };
497
+ }
498
+ const anchoredAt = parseTime(training.anchoredAt);
499
+ const runAttestedAt = parseTime(training.runAttestedAt);
500
+ if (anchoredAt === null || runAttestedAt === null) {
501
+ return {
502
+ id,
503
+ label: LABELS[id],
504
+ state: 'cannot determine',
505
+ detail: 'The training evidence is missing a valid manifest anchor time or run attestation time, so the ordering cannot be checked.',
506
+ };
507
+ }
508
+ if (anchoredAt > runAttestedAt) {
509
+ return {
510
+ id,
511
+ label: LABELS[id],
512
+ state: 'cannot determine',
513
+ detail: `The training manifest's anchor (${training.anchoredAt}) does not precede the run attestation (${training.runAttestedAt}). A manifest anchored after the run could have been written to exclude this record.`,
514
+ };
515
+ }
516
+ if (receipts.length === 0) {
517
+ return {
518
+ id,
519
+ label: LABELS[id],
520
+ state: 'not covered',
521
+ detail: "This slice has no receipts: a training-absence proof over an empty slice proves nothing about this subject's data.",
522
+ };
523
+ }
524
+ const manifest = training.manifest;
525
+ if (!manifest || typeof manifest !== 'object' || manifest.v !== 1 || manifest.type !== 'training.manifest'
526
+ || typeof manifest.run_id !== 'string' || typeof manifest.root !== 'string' || parseHash(manifest.root) === null
527
+ || !Number.isInteger(manifest.size) || manifest.size < 0) {
528
+ return {
529
+ id,
530
+ label: LABELS[id],
531
+ state: 'failed',
532
+ detail: 'The training manifest is missing or malformed, so the absence proof cannot be checked against it.',
533
+ };
534
+ }
535
+ // The proof must speak to THIS subject, not someone else.
536
+ const proof = training.absenceProof;
537
+ if (!proof || typeof proof !== 'object' || proof.ref !== history.tag) {
538
+ return {
539
+ id,
540
+ label: LABELS[id],
541
+ state: 'failed',
542
+ detail: "The non-membership proof is missing or names a different ref than this subject's tag, so it says nothing about this slice.",
543
+ };
544
+ }
545
+ let ok = false;
546
+ try {
547
+ ok = verifyAbsence(proof, manifest);
548
+ }
549
+ catch {
550
+ ok = false;
551
+ }
552
+ if (!ok) {
553
+ return {
554
+ id,
555
+ label: LABELS[id],
556
+ state: 'failed',
557
+ detail: `The non-membership proof does not verify against the declared training manifest (root ${manifest.root}).`,
558
+ };
559
+ }
560
+ return {
561
+ id,
562
+ label: LABELS[id],
563
+ state: 'proven',
564
+ detail: `Non-membership proof verified against the declared training manifest (${manifest.size} entries, root ${manifest.root}), anchored at ${training.anchoredAt} before the run was attested at ${training.runAttestedAt}. This subject's tag is absent from the training set.`,
565
+ };
566
+ }
567
+ // ---------------------------------------------------------------------------
568
+ // Row 5 (copy_kept): "No copy was kept"
569
+ // ---------------------------------------------------------------------------
570
+ /**
571
+ * The destruction evidence lives IN the slice: a receipt stamped with this subject's
572
+ * tag whose embedded, signature-covered payload is a `@zanii/retention` shred
573
+ * (`buildKeyDestruction`'s shape). Slice membership binds the receipt to this subject
574
+ * (the ledger serves the receipt only under this tag, and the receipt's own offline
575
+ * check re-verifies here); the payload's salted subject commitment can later be
576
+ * proven to name the subject by revealing the salt. Binding to the OBJECT is by ref:
577
+ * a shred counts only for an object whose custody touches carry the same payload
578
+ * `ref`, so a shred for an unrelated object never turns this row green.
579
+ */
580
+ function shredReceiptsOf(receipts) {
581
+ const out = [];
582
+ for (const sr of receipts) {
583
+ const p = asPayload(touchPayloadOf(sr));
584
+ if (p && p['_zr_kind'] === 'retention' && p['action'] === 'shred')
585
+ out.push({ sr, payload: p });
586
+ }
587
+ return out;
588
+ }
589
+ /** The refs of the custody touches in the slice: the objects destruction must bind to. */
590
+ function touchRefsOf(receipts) {
591
+ const refs = new Set();
592
+ for (const sr of receipts) {
593
+ const p = asPayload(touchPayloadOf(sr));
594
+ if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string')
595
+ refs.add(p['ref']);
596
+ }
597
+ return [...refs].sort();
598
+ }
599
+ function copyKeptRow(receipts, evidence) {
600
+ const id = 'copy_kept';
601
+ const shreds = shredReceiptsOf(receipts);
602
+ if (shreds.length === 0) {
603
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
604
+ }
605
+ // A presented-but-broken shred is a red, not a grey: any failure anywhere in the
606
+ // slice's destruction records fails the row, however many others verify.
607
+ for (const { sr, payload } of shreds) {
608
+ if (sr?.check?.ok !== true) {
609
+ return {
610
+ id,
611
+ label: LABELS[id],
612
+ state: 'failed',
613
+ detail: `Receipt ${sr?.hash} (index ${sr?.index}) carries a key-destruction payload, but the receipt itself failed offline verification.`,
614
+ };
615
+ }
616
+ let check;
617
+ try {
618
+ check = verifyKeyDestruction(payload);
619
+ }
620
+ catch {
621
+ check = { ok: false, reasons: ['unreadable payload'] };
622
+ }
623
+ if (!check.ok) {
624
+ return {
625
+ id,
626
+ label: LABELS[id],
627
+ state: 'failed',
628
+ detail: `The key-destruction payload on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
629
+ };
630
+ }
631
+ }
632
+ // Ref binding: a shred is evidence only for the objects this slice touches. A
633
+ // shred carrying no ref, or a ref no touch in this slice carries, does not count.
634
+ const touchRefs = touchRefsOf(receipts);
635
+ const bound = shreds.filter(({ payload }) => typeof payload['ref'] === 'string' && touchRefs.includes(payload['ref']));
636
+ if (bound.length === 0) {
637
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
638
+ }
639
+ const coveredRefs = new Set(bound.map(({ payload }) => payload['ref']));
640
+ const uncoveredRefs = touchRefs.filter((r) => !coveredRefs.has(r));
641
+ if (uncoveredRefs.length > 0) {
642
+ return {
643
+ id,
644
+ label: LABELS[id],
645
+ state: 'cannot determine',
646
+ detail: `Verified key-destruction records cover ${coveredRefs.size} of ${plural(touchRefs.length, 'object')} touched in this slice: no key-destruction record binds to ${plural(uncoveredRefs.length, 'object')} (${uncoveredRefs.join(', ')}), so ${uncoveredRefs.length === 1 ? 'its destruction' : 'their destruction'} is unknown.`,
647
+ };
648
+ }
649
+ // Optional image evidence: check the claimed attestation hashes to the published
650
+ // image hash (bag-internal consistency; it does not bind the shred to the image).
651
+ const image = evidence.destructionImage;
652
+ let imagePart = '';
653
+ if (image !== undefined && image !== null) {
654
+ const attestation = asPayload(image.attestation);
655
+ const published = image.publishedImageHash;
656
+ let attested = null;
657
+ if (attestation) {
658
+ try {
659
+ attested = attestationHash(attestation);
660
+ }
661
+ catch {
662
+ attested = null;
663
+ }
664
+ }
665
+ if (!attestation || attested === null || typeof published !== 'string' || published.length === 0) {
666
+ return {
667
+ id,
668
+ label: LABELS[id],
669
+ state: 'failed',
670
+ detail: 'The destruction image evidence is incomplete: it must carry the attestation record and the published image hash.',
671
+ };
672
+ }
673
+ if (attested !== published) {
674
+ return {
675
+ id,
676
+ label: LABELS[id],
677
+ state: 'failed',
678
+ detail: `The attested image hash (${attested}) does not match the published image hash (${published}).`,
679
+ };
680
+ }
681
+ imagePart = ` The attested image hash matches the published one (${published}).`;
682
+ }
683
+ const first = bound[0];
684
+ return {
685
+ id,
686
+ label: LABELS[id],
687
+ state: 'proven',
688
+ detail: `Key-destruction record verified: receipt ${first.sr?.hash} (index ${first.sr?.index}) carries a crypto-shredding attestation signed by ${first.sr?.receipt?.agent_id}, key commitment ${first.payload['key_commitment']}, destroyed at ${first.payload['executed_at']}, bound to object ${first.payload['ref']}. ${plural(bound.length, 'verified key-destruction record')} ${bound.length === 1 ? 'covers' : 'cover'} every object touched in this slice (${plural(touchRefs.length, 'object')}).${imagePart}`,
689
+ };
690
+ }
691
+ // ---------------------------------------------------------------------------
692
+ // Row 6 (residency): "Data stayed in its declared region"
693
+ // ---------------------------------------------------------------------------
694
+ /** Residency claims carried by the slice (`buildResidencyClaim`'s shape, embedded
695
+ * signature-covered exactly like the row-5 shred payloads). */
696
+ function residencyClaimsOf(receipts) {
697
+ const out = [];
698
+ for (const sr of receipts) {
699
+ const p = asPayload(touchPayloadOf(sr));
700
+ if (p && p['type'] === 'residency.claim')
701
+ out.push({ sr, payload: p });
702
+ }
703
+ return out;
704
+ }
705
+ /** A claim whose region signal is the operator asserting its own region: not an
706
+ * attestation, whatever it claims. */
707
+ function isSelfDeclared(p) {
708
+ const source = asPayload(p.region_attestation)?.source;
709
+ return typeof source === 'string' && source.toLowerCase().startsWith('self');
710
+ }
711
+ function receiptDetailPrefix(sr) {
712
+ return `Receipt ${sr?.hash} (index ${sr?.index})`;
713
+ }
714
+ async function residencyRow(receipts, evidence, verifyRegion) {
715
+ const id = 'residency';
716
+ if (receipts.length === 0) {
717
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
718
+ }
719
+ const claims = residencyClaimsOf(receipts);
720
+ if (claims.length === 0) {
721
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No residency attestations on record' };
722
+ }
723
+ // A self-declared claim is no attestation: it does not count as evidence, here or
724
+ // in the coverage and crossing checks below.
725
+ const attested = claims.filter(({ payload }) => !isSelfDeclared(payload));
726
+ const selfDeclared = claims.length - attested.length;
727
+ if (attested.length === 0) {
728
+ return {
729
+ id,
730
+ label: LABELS[id],
731
+ state: 'not covered',
732
+ detail: `Every residency claim on record is self-declared: the region source is self-declared (the operator asserted its own region), not an independently verified signal.`,
733
+ };
734
+ }
735
+ // A presented-but-broken claim is a red, not a grey: structural check first, then
736
+ // the receipt's own offline verification (an unverified receipt's payload is not
737
+ // signature-covered evidence).
738
+ for (const { sr, payload } of attested) {
739
+ const reasons = residencyClaimReasons(payload);
740
+ if (reasons.length > 0) {
741
+ return {
742
+ id,
743
+ label: LABELS[id],
744
+ state: 'failed',
745
+ detail: `${receiptDetailPrefix(sr)} carries a malformed residency claim: ${reasons.join(', ')}.`,
746
+ };
747
+ }
748
+ if (sr?.check?.ok !== true) {
749
+ return {
750
+ id,
751
+ label: LABELS[id],
752
+ state: 'failed',
753
+ detail: `${receiptDetailPrefix(sr)} carries a residency claim payload, but the receipt itself failed offline verification.`,
754
+ };
755
+ }
756
+ }
757
+ // The jurisdiction allow-set is READER config, never producer-supplied (same trust
758
+ // rule as trustedSigners). Absent: unknown, never proven. Empty: allows no region,
759
+ // so every verified claim below is a crossing.
760
+ const jurisdiction = evidence.jurisdiction;
761
+ if (!Array.isArray(jurisdiction)) {
762
+ return {
763
+ id,
764
+ label: LABELS[id],
765
+ state: 'cannot determine',
766
+ detail: 'Residency claims are on record, but the reader provided no declared jurisdiction allow-set, so there is nothing to check them against.',
767
+ };
768
+ }
769
+ if (verifyRegion === undefined) {
770
+ return {
771
+ id,
772
+ label: LABELS[id],
773
+ state: 'cannot determine',
774
+ detail: 'Residency claims are on record, but no region verifier was injected, so the region signals could not be authenticated.',
775
+ };
776
+ }
777
+ const verified = [];
778
+ for (const { sr, payload } of attested) {
779
+ let check;
780
+ try {
781
+ check = await verifyResidency(payload, { allowedRegions: jurisdiction, verifyRegion });
782
+ }
783
+ catch {
784
+ check = { ok: false, reasons: ['the residency check failed'], region: null, crossed: false };
785
+ }
786
+ if (check.crossed) {
787
+ // Do not echo the producer reason: its wording is not reader-facing.
788
+ return {
789
+ id,
790
+ label: LABELS[id],
791
+ state: 'failed',
792
+ detail: `The residency claim on receipt ${sr?.hash} (index ${sr?.index}) verifies, but its region ${check.region} is outside the declared jurisdiction: a border crossing is on record.`,
793
+ };
794
+ }
795
+ if (!check.ok || check.region === null) {
796
+ return {
797
+ id,
798
+ label: LABELS[id],
799
+ state: 'failed',
800
+ detail: `The residency claim on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
801
+ };
802
+ }
803
+ verified.push({
804
+ ts: payload.ts,
805
+ region: check.region,
806
+ ref: typeof payload.ref === 'string' && payload.ref ? payload.ref : null,
807
+ });
808
+ }
809
+ // The sequence check over the verified regions: any exit from the allow-set is red.
810
+ // (Per-claim verification already rejects an outside region, so this is belt and
811
+ // suspenders; it also catches a sequence that STARTS outside, which per-claim
812
+ // checks phrase differently.)
813
+ const crossings = detectCrossings(verified, jurisdiction);
814
+ if (crossings.length > 0) {
815
+ const c = crossings[0];
816
+ return {
817
+ id,
818
+ label: LABELS[id],
819
+ state: 'failed',
820
+ detail: `Verified residency claims show the data left the declared jurisdiction at ${c.ts} (moved to region ${c.region}).`,
821
+ };
822
+ }
823
+ // Coverage is per touch, never window-span: EVERY custody touch in the slice must
824
+ // be covered by a verified claim. A claim carrying a ref binds to every touch of
825
+ // that object; a ref-less claim is time-scoped and covers the touches made at its
826
+ // own timestamp. Anything looser would let two claims at the window's ends prove
827
+ // green over an uncovered middle.
828
+ const touches = [];
829
+ for (const sr of receipts) {
830
+ const p = asPayload(touchPayloadOf(sr));
831
+ if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string' && parseTime(p['ts']) !== null) {
832
+ touches.push({ ref: p['ref'], ts: p['ts'] });
833
+ }
834
+ }
835
+ if (touches.length === 0) {
836
+ return {
837
+ id,
838
+ label: LABELS[id],
839
+ state: 'cannot determine',
840
+ detail: 'This slice carries no custody touches, so per-touch residency coverage cannot be established.',
841
+ };
842
+ }
843
+ const isCovered = (t) => verified.some((c) => (c.ref !== null ? c.ref === t.ref : c.ts === t.ts));
844
+ const uncovered = touches.filter((t) => !isCovered(t));
845
+ if (uncovered.length > 0) {
846
+ const covered = touches.length - uncovered.length;
847
+ return {
848
+ id,
849
+ label: LABELS[id],
850
+ state: 'cannot determine',
851
+ detail: `Verified residency claims cover ${covered} of ${plural(touches.length, 'touch', 'touches')} in this slice: ${plural(uncovered.length, 'touch', 'touches')} ${uncovered.length === 1 ? 'has' : 'have'} no valid in-jurisdiction claim, so ${uncovered.length === 1 ? 'its' : 'their'} residency is unknown.`,
852
+ };
853
+ }
854
+ const selfNote = selfDeclared > 0
855
+ ? ` ${plural(selfDeclared, 'self-declared claim')} ${selfDeclared === 1 ? 'was' : 'were'} not counted as evidence: the region source is self-declared.`
856
+ : '';
857
+ return {
858
+ id,
859
+ label: LABELS[id],
860
+ state: 'proven',
861
+ detail: `Verified residency claims cover every touch in this slice (${plural(touches.length, 'touch', 'touches')}): every region signal authenticated against the declared jurisdiction allow-set, and no border crossings were detected.${selfNote}`,
862
+ };
863
+ }
864
+ // ---------------------------------------------------------------------------
865
+ // Row 7 (subprocessor): "Only declared third parties touched it"
866
+ // ---------------------------------------------------------------------------
867
+ /** Onward transfers carried by the slice (`buildTransfer`'s shape). */
868
+ function transfersOf(receipts) {
869
+ const out = [];
870
+ for (const sr of receipts) {
871
+ const p = asPayload(touchPayloadOf(sr));
872
+ if (p && p['type'] === 'subprocessor.transfer')
873
+ out.push({ sr, payload: p });
874
+ }
875
+ return out;
876
+ }
877
+ /** Custody-touch junctions: the destinations the touch payloads say data crossed to. */
878
+ function junctionsOf(receipts) {
879
+ const out = [];
880
+ for (const sr of receipts) {
881
+ const p = asPayload(touchPayloadOf(sr));
882
+ const j = p ? asPayload(p['junction']) : undefined;
883
+ // Only share/export are onward disclosures to a third party. Store/read/transform
884
+ // may carry a junction for topology (source/destination of the data), but they
885
+ // are not subprocessor transfers.
886
+ const op = typeof p?.['op'] === 'string' ? p['op'] : undefined;
887
+ if (j && typeof j.to === 'string' && j.to && (op === 'share' || op === 'export'))
888
+ out.push({ sr, to: j.to });
889
+ }
890
+ return out;
891
+ }
892
+ function subprocessorRow(receipts, evidence) {
893
+ const id = 'subprocessor';
894
+ const transfers = transfersOf(receipts);
895
+ const junctions = junctionsOf(receipts);
896
+ if (transfers.length === 0 && junctions.length === 0) {
897
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No onward transfers declared or recorded' };
898
+ }
899
+ // Presented-but-broken is a red before any trust-root question.
900
+ for (const { sr, payload } of transfers) {
901
+ const reasons = transferReasons(payload);
902
+ if (reasons.length > 0) {
903
+ return {
904
+ id,
905
+ label: LABELS[id],
906
+ state: 'failed',
907
+ detail: `${receiptDetailPrefix(sr)} carries a malformed transfer record: ${reasons.join(', ')}.`,
908
+ };
909
+ }
910
+ if (sr?.check?.ok !== true) {
911
+ return {
912
+ id,
913
+ label: LABELS[id],
914
+ state: 'failed',
915
+ detail: `${receiptDetailPrefix(sr)} carries a transfer record payload, but the receipt itself failed offline verification.`,
916
+ };
917
+ }
918
+ // Stricter than verifyTransfers (which requires an id only for dpa/scc): any
919
+ // onward transfer without an agreement id is a red here.
920
+ const agreement = asPayload(payload.agreement);
921
+ const agreementId = agreement?.id;
922
+ if (typeof agreementId !== 'string' || !agreementId) {
923
+ return {
924
+ id,
925
+ label: LABELS[id],
926
+ state: 'failed',
927
+ detail: `The transfer to '${payload.to}' at ${payload.ts} carries no agreement id: an onward transfer without an agreement reference is a failure, not an unknown.`,
928
+ };
929
+ }
930
+ }
931
+ // The declared subprocessor list is READER config, never producer-supplied (same
932
+ // trust rule as trustedSigners). Without it there is nothing to check against.
933
+ const declared = evidence.declaredSubprocessors;
934
+ if (!Array.isArray(declared)) {
935
+ return {
936
+ id,
937
+ label: LABELS[id],
938
+ state: 'cannot determine',
939
+ detail: 'Custody touches or transfer records name onward destinations, but the reader provided no declared subprocessor list, so there is nothing to check them against.',
940
+ };
941
+ }
942
+ let check;
943
+ try {
944
+ check = verifyTransfers(transfers.map(({ payload }) => payload), declared);
945
+ }
946
+ catch {
947
+ check = { ok: false, reasons: ['the transfer check failed'] };
948
+ }
949
+ if (!check.ok) {
950
+ return {
951
+ id,
952
+ label: LABELS[id],
953
+ state: 'failed',
954
+ detail: `The declared-subprocessor check failed: ${check.reasons.join('; ')}.`,
955
+ };
956
+ }
957
+ // Divergence is red, never silent: a junction naming a destination with no matching
958
+ // declared transfer means the custody trail and the transfer record disagree.
959
+ for (const { sr, to } of junctions) {
960
+ const matched = transfers.some(({ payload }) => payload.to === to && declared.includes(payload.to));
961
+ if (!matched) {
962
+ return {
963
+ id,
964
+ label: LABELS[id],
965
+ state: 'failed',
966
+ detail: `${receiptDetailPrefix(sr)} records a custody junction to '${to}', but no declared transfer to that party is on record: a divergence between the custody trail and the transfer record is a failure, never silent.`,
967
+ };
968
+ }
969
+ }
970
+ return {
971
+ id,
972
+ label: LABELS[id],
973
+ state: 'proven',
974
+ detail: `Every onward destination in this slice resolves to a declared subprocessor under a named agreement: ${plural(junctions.length, 'custody junction')} and ${plural(transfers.length, 'recorded transfer')} checked against the declared list (${declared.length} ${declared.length === 1 ? 'party' : 'parties'}), with no undeclared party, no missing agreement id, and no divergence.`,
975
+ };
976
+ }
977
+ // ---------------------------------------------------------------------------
978
+ // Row 8 (minimization): "Only what was needed was taken"
979
+ // ---------------------------------------------------------------------------
980
+ /** Field-set commitments carried by the slice (`commitFieldSet` records under type
981
+ * `minimization.commit`). */
982
+ function commitsOf(receipts) {
983
+ const out = [];
984
+ for (const sr of receipts) {
985
+ const p = asPayload(touchPayloadOf(sr));
986
+ if (p && p['type'] === 'minimization.commit')
987
+ out.push({ sr, payload: p });
988
+ }
989
+ return out;
990
+ }
991
+ /** Access proofs carried by the slice (`buildAccessProof`'s shape). */
992
+ function accessProofsOf(receipts) {
993
+ const out = [];
994
+ for (const sr of receipts) {
995
+ const p = asPayload(touchPayloadOf(sr));
996
+ if (p && p['type'] === 'minimization.access')
997
+ out.push({ sr, payload: p });
998
+ }
999
+ return out;
1000
+ }
1001
+ function minimizationRow(receipts) {
1002
+ const id = 'minimization';
1003
+ if (receipts.length === 0) {
1004
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
1005
+ }
1006
+ const commits = commitsOf(receipts);
1007
+ const accesses = accessProofsOf(receipts);
1008
+ for (const { sr, payload } of commits) {
1009
+ const reasons = commitReasons(payload);
1010
+ if (reasons.length > 0) {
1011
+ return {
1012
+ id,
1013
+ label: LABELS[id],
1014
+ state: 'failed',
1015
+ detail: `${receiptDetailPrefix(sr)} carries a malformed field-set commitment record: ${reasons.join(', ')}.`,
1016
+ };
1017
+ }
1018
+ if (sr?.check?.ok !== true) {
1019
+ return {
1020
+ id,
1021
+ label: LABELS[id],
1022
+ state: 'failed',
1023
+ detail: `${receiptDetailPrefix(sr)} carries a field-set commitment payload, but the receipt itself failed offline verification.`,
1024
+ };
1025
+ }
1026
+ // Recompute the commitment from the claimed fields: a mismatch is a set widened
1027
+ // (or swapped) after the commitment was recorded.
1028
+ let matches = false;
1029
+ try {
1030
+ matches = verifyFieldSet(payload.fields, payload.commitment);
1031
+ }
1032
+ catch {
1033
+ matches = false;
1034
+ }
1035
+ if (!matches) {
1036
+ return {
1037
+ id,
1038
+ label: LABELS[id],
1039
+ state: 'failed',
1040
+ detail: `${receiptDetailPrefix(sr)} carries a field-set commitment whose fields do not recompute to the commitment: the declared minimum may have been widened after the fact.`,
1041
+ };
1042
+ }
1043
+ }
1044
+ // Access proofs are validated before the commits-empty branch: a presented but
1045
+ // broken proof is a red even when no commitments are on record.
1046
+ for (const { sr, payload } of accesses) {
1047
+ const reasons = accessReasons(payload);
1048
+ if (reasons.length > 0) {
1049
+ return {
1050
+ id,
1051
+ label: LABELS[id],
1052
+ state: 'failed',
1053
+ detail: `${receiptDetailPrefix(sr)} carries a malformed minimization access proof: ${reasons.join(', ')}.`,
1054
+ };
1055
+ }
1056
+ if (sr?.check?.ok !== true) {
1057
+ return {
1058
+ id,
1059
+ label: LABELS[id],
1060
+ state: 'failed',
1061
+ detail: `${receiptDetailPrefix(sr)} carries an access proof payload, but the receipt itself failed offline verification.`,
1062
+ };
1063
+ }
1064
+ }
1065
+ if (commits.length === 0) {
1066
+ if (accesses.length > 0) {
1067
+ // Symmetric with commits-without-accesses: an access proof whose declared
1068
+ // minimum is not on record cannot be checked, and unchecked is unknown.
1069
+ return {
1070
+ id,
1071
+ label: LABELS[id],
1072
+ state: 'cannot determine',
1073
+ detail: 'Access proofs are on record, but no minimum-field commitments: the declared minimum they claim cannot be checked, and an unchecked access is unknown, not clean.',
1074
+ };
1075
+ }
1076
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No minimum-field commitments on record' };
1077
+ }
1078
+ if (accesses.length === 0) {
1079
+ return {
1080
+ id,
1081
+ label: LABELS[id],
1082
+ state: 'cannot determine',
1083
+ detail: 'Field-set commitments are on record, but no access proofs: no recorded access was checked against the declared minimum.',
1084
+ };
1085
+ }
1086
+ for (const { sr, payload } of accesses) {
1087
+ const commit = commits.find(({ payload: c }) => c.commitment === payload.field_set);
1088
+ if (!commit) {
1089
+ return {
1090
+ id,
1091
+ label: LABELS[id],
1092
+ state: 'cannot determine',
1093
+ detail: `${receiptDetailPrefix(sr)} carries an access proof against field-set commitment ${payload.field_set}, but that commitment is not on record in this slice: the declared minimum it claims cannot be checked.`,
1094
+ };
1095
+ }
1096
+ let check;
1097
+ try {
1098
+ check = verifyMinimization(payload, commit.payload.fields);
1099
+ }
1100
+ catch {
1101
+ check = { ok: false, reasons: ['the minimization check failed'], excess: [] };
1102
+ }
1103
+ if (!check.ok) {
1104
+ return {
1105
+ id,
1106
+ label: LABELS[id],
1107
+ state: 'failed',
1108
+ detail: `The minimization check failed for receipt ${sr?.hash} (index ${sr?.index}): ${check.reasons.join(', ')}.`,
1109
+ };
1110
+ }
1111
+ }
1112
+ return {
1113
+ id,
1114
+ label: LABELS[id],
1115
+ state: 'proven',
1116
+ detail: `Every recorded access (${plural(accesses.length, 'proof')}) in this slice stayed within its committed minimum field set: all ${plural(commits.length, 'field-set commitment')} recomputed to their recorded commitments, and no accessed field fell outside the declared minimum.`,
1117
+ };
1118
+ }
1119
+ // ---------------------------------------------------------------------------
1120
+ // The one function both surfaces call.
1121
+ // ---------------------------------------------------------------------------
1122
+ /**
1123
+ * Compute the eight verdict rows from an offline-verified subject history. Pure: no
1124
+ * network, no clock. Async only because row 3's coverage attestation is checked
1125
+ * through `@zanii/attest`'s `verifyAttestation` and row 6's residency claims through
1126
+ * `@zanii/residency`'s `verifyResidency`, whose injected verifiers may be async;
1127
+ * pass them via `options.verifyQuote` / `options.verifyRegion`. Never throws:
1128
+ * malformed input yields honest `cannot determine` / `not covered` / `failed` rows
1129
+ * with reasons. Rows are always returned in this fixed order: purpose,
1130
+ * self_sovereign, human_read, trained_on, copy_kept, residency, subprocessor,
1131
+ * minimization.
1132
+ */
1133
+ export async function computeVerdicts(history, evidence = {}, options = {}) {
1134
+ const h = (history && typeof history === 'object' ? history : {});
1135
+ const receipts = Array.isArray(h.receipts) ? h.receipts : [];
1136
+ const ev = (evidence && typeof evidence === 'object' ? evidence : {});
1137
+ const verifyQuote = options && typeof options === 'object' ? options.verifyQuote : undefined;
1138
+ const verifyRegion = options && typeof options === 'object' ? options.verifyRegion : undefined;
1139
+ const rows = [
1140
+ () => purposeRow(h, receipts),
1141
+ () => selfSovereignRow(h, receipts),
1142
+ () => humanReadRow(receipts, ev, verifyQuote),
1143
+ () => trainedOnRow(h, receipts, ev),
1144
+ () => copyKeptRow(receipts, ev),
1145
+ () => residencyRow(receipts, ev, verifyRegion),
1146
+ () => subprocessorRow(receipts, ev),
1147
+ () => minimizationRow(receipts),
1148
+ ];
1149
+ const ids = ['purpose', 'self_sovereign', 'human_read', 'trained_on', 'copy_kept', 'residency', 'subprocessor', 'minimization'];
1150
+ const out = [];
1151
+ for (let i = 0; i < rows.length; i++) {
1152
+ try {
1153
+ out.push(await rows[i]());
1154
+ }
1155
+ catch (err) {
1156
+ // Verification paths never crash: an unexpected error is an honest grey.
1157
+ const id = ids[i];
1158
+ out.push({
1159
+ id,
1160
+ label: LABELS[id],
1161
+ state: 'cannot determine',
1162
+ detail: `This line could not be computed: ${err instanceof Error ? err.message : String(err)}.`,
1163
+ });
1164
+ }
1165
+ }
1166
+ return out;
1167
+ }
1168
+ /**
1169
+ * Seconds between the signed tree head's timestamp and `now` (the caller supplies
1170
+ * `now`; this package has no clock). The page shows anchor lag: four minutes vs
1171
+ * four hours is a property the reader is entitled to see. Null when unknown (no sth,
1172
+ * no timestamp, unparseable). Negative means the sth is newer than the caller's
1173
+ * clock: possible skew, shown as-is.
1174
+ */
1175
+ export function computeAnchorLag(sth, now) {
1176
+ try {
1177
+ const ts = parseTime(sth?.ts);
1178
+ if (ts === null)
1179
+ return null;
1180
+ const nowMs = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : parseTime(now);
1181
+ if (nowMs === null || Number.isNaN(nowMs))
1182
+ return null;
1183
+ return Math.round((nowMs - ts) / 1000);
1184
+ }
1185
+ catch {
1186
+ return null;
1187
+ }
1188
+ }
1189
+ //# sourceMappingURL=index.js.map