@zanii/custody-verdicts 0.1.0 → 0.2.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/src/index.ts CHANGED
@@ -1,1357 +1,1412 @@
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 {
52
- canonicalBytes, publicKeyFromDid, verifySTH,
53
- type Receipt, type SignedTreeHead,
54
- } from '@zanii/core';
55
- import { verifyTouch } from '@zanii/data-custody';
56
- import { verifyKeyDestruction } from '@zanii/retention';
57
- import { verifyAbsence, type AbsenceProof, type TrainingManifest } from '@zanii/train';
58
- import {
59
- attestationHash, verifyAttestation,
60
- type Attestation, type AttestCheck, type QuoteVerifier,
61
- } from '@zanii/attest';
62
- import {
63
- detectCrossings, verifyResidency,
64
- type RegionVerifier, type ResidencyClaim,
65
- } from '@zanii/residency';
66
- import { verifyTransfers, type Agreement, type Transfer } from '@zanii/subprocessor';
67
- import { verifyFieldSet, verifyMinimization, type AccessProof } from '@zanii/minimization';
68
- import type { SubjectHistory, SubjectReceipt } from '@zanii/subject';
69
-
70
- export type { AbsenceProof, TrainingManifest } from '@zanii/train';
71
- export type { Attestation, QuoteVerifier } from '@zanii/attest';
72
- export type { RegionVerifier, ResidencyClaim } from '@zanii/residency';
73
- export type { Agreement, Transfer } from '@zanii/subprocessor';
74
- export type { AccessProof } from '@zanii/minimization';
75
-
76
- /**
77
- * The well-known ledger tag under which declared training manifests are published.
78
- * The subject page and the CLI fetch that slice to build the row-4 training evidence
79
- * bag (`ExtraEvidence.training`).
80
- */
81
- export const TRAINING_MANIFEST_TAG = 'zanii:training-manifests';
82
-
83
- /** The four verdict states. The last three are as loud as the first. */
84
- export type VerdictState = 'proven' | 'not covered' | 'failed' | 'cannot determine';
85
-
86
- export type VerdictId =
87
- | 'purpose' | 'self_sovereign' | 'human_read' | 'trained_on' | 'copy_kept'
88
- | 'residency' | 'subprocessor' | 'minimization';
89
-
90
- export interface VerdictRow {
91
- id: VerdictId;
92
- label: string;
93
- state: VerdictState;
94
- /** Plain-language working: which receipts were computed over, why this state. Rendered verbatim. */
95
- detail: string;
96
- }
97
-
98
- /**
99
- * Coverage attestation for the "nobody read it" line: a SIGNED record asserting that
100
- * every egress path to the data was receipt-gated (the `@zanii/broker` invariant) for
101
- * the whole window, bound to the attested image whose code enforced it. The signature
102
- * covers every field but `sig`; `imageHash` must equal `attestationHash(attestation)`
103
- * (`@zanii/attest`'s convention), and the embedded attestation is verified through
104
- * `verifyAttestation` with the caller's injected quote verifier. Anything unsigned or
105
- * malformed is never `proven`. The producer side ships with the enclave phase
106
- * (`@zanii/attest` + `@zanii/broker`); until then this evidence does not exist and the
107
- * row answers `not covered`, by design.
108
- */
109
- export interface CoverageEvidence {
110
- v: 1;
111
- type: 'coverage.attestation';
112
- /** Every egress path was receipt-gated; partial coverage is no coverage. */
113
- allPathsGated: boolean;
114
- /** ISO timestamp: start of the gated window. */
115
- windowStart: string;
116
- /** ISO timestamp: end of the gated window. */
117
- windowEnd: string;
118
- /** `attestationHash` of the embedded attestation: the image the claim is bound to. */
119
- imageHash: string;
120
- /** The full attestation record (`@zanii/attest`'s `Attestation`). */
121
- attestation: Attestation;
122
- /** did:key of the platform key that signed this attestation. */
123
- signer: string;
124
- /** `ed25519:<hex>` over the canonical record minus `sig`. */
125
- sig: string;
126
- /**
127
- * Optional trust root: the deployment's pinned coverage-signing DIDs. The reader
128
- * (subject page / CLI) sets this from its OWN configuration, never from
129
- * producer-supplied data; otherwise an attacker could attest with their own key and
130
- * name it trusted. Not part of the signed record (excluded from the signature
131
- * preimage). When present, a signer not in the list is `failed`; an empty list
132
- * trusts no one. When absent, any well-formed did:key signer is accepted.
133
- */
134
- trustedSigners?: string[];
135
- }
136
-
137
- /**
138
- * A declared, anchored training run plus `@zanii/train`'s absence proof. Ordering is
139
- * the whole claim: the manifest anchor must PRECEDE the run attestation. A manifest
140
- * anchored afterwards could have been written to exclude the subject.
141
- */
142
- export interface TrainingEvidence {
143
- /** The run's sorted-Merkle training manifest (`@zanii/train`'s `TrainingManifest`). */
144
- manifest: TrainingManifest;
145
- /** ISO timestamp when the manifest root was anchored. */
146
- anchoredAt: string;
147
- /** ISO timestamp of the run attestation. */
148
- runAttestedAt: string;
149
- /** `@zanii/train`'s `AbsenceProof` for this subject's tag, verified against the manifest. */
150
- absenceProof: AbsenceProof;
151
- }
152
-
153
- /**
154
- * Optional image evidence for the "no copy was kept" line: a CLAIMED destruction-image
155
- * attestation plus the image hash the operator published for it. What is checked here
156
- * is bag-internal consistency only, via `@zanii/attest`'s `attestationHash` convention
157
- * (the published image hash is `attestationHash` of the published attestation record):
158
- * a mismatch is `failed`. A match does NOT by itself prove the destruction ran under
159
- * that image: nothing yet binds the shred receipt to the attestation, and the
160
- * attestation's quote is not verified on this path. Full binding ships with the
161
- * enclave phase.
162
- */
163
- export interface DestructionImageEvidence {
164
- /** The attestation (`@zanii/attest`'s `Attestation`) claimed for the destruction image. */
165
- attestation: Attestation;
166
- /** The published image hash: `attestationHash` of the published attestation record. */
167
- publishedImageHash: string;
168
- }
169
-
170
- /**
171
- * Optional evidence bag for rows 3–5. Row 5 needs no bag entry for the destruction
172
- * record itself: the shred receipts are read from the subject's slice, each already
173
- * signature-covered and offline-verified. `destructionImage` only checks a claimed
174
- * destruction-image attestation against the published image hash.
175
- */
176
- export interface ExtraEvidence {
177
- coverage?: CoverageEvidence;
178
- training?: TrainingEvidence;
179
- destructionImage?: DestructionImageEvidence;
180
- /**
181
- * Row 6 trust root: the declared jurisdiction as an allow-set of region codes
182
- * (`@zanii/residency`'s `allowedRegions`, e.g. `['AE', 'me-central-1']`). READER
183
- * configuration (deployment-side), never producer-supplied: the same trust rule as
184
- * `coverage.trustedSigners`, or a producer could declare the whole world in bounds.
185
- * When absent, residency claims can reach `cannot determine`, never `proven`. An
186
- * empty array allows no region: every verified claim is a crossing.
187
- */
188
- jurisdiction?: string[];
189
- /**
190
- * Row 7 trust root: the declared subprocessor list (the parties onward transfers
191
- * may name). READER configuration, never producer-supplied, same trust rule as
192
- * `coverage.trustedSigners`. When absent, transfers or junctions on record reach
193
- * `cannot determine`, never `proven`. An empty array declares no one.
194
- */
195
- declaredSubprocessors?: string[];
196
- }
197
-
198
- /** Caller-injected verifiers: the enclave quote verifier for row 3, the region
199
- * signal verifier for row 6. */
200
- export interface VerdictOptions {
201
- /**
202
- * Platform-specific enclave quote verifier (`@zanii/attest`'s `QuoteVerifier`).
203
- * Without it a signed coverage attestation can reach `cannot determine`, never
204
- * `proven`: the quote stays unknown, and unknown is not verified.
205
- */
206
- verifyQuote?: QuoteVerifier;
207
- /**
208
- * Region signal verifier (`@zanii/residency`'s `RegionVerifier`): authenticates a
209
- * claim's region attestation (a cloud provider identity document, a TEE geo
210
- * signal) and returns the trusted region. Without it residency claims can reach
211
- * `cannot determine`, never `proven`.
212
- */
213
- verifyRegion?: RegionVerifier;
214
- }
215
-
216
- const LABELS: Record<VerdictId, string> = {
217
- purpose: 'Used only for its declared purpose',
218
- self_sovereign: 'You can check this yourself',
219
- human_read: 'Nobody read it',
220
- trained_on: 'Never used to train a model',
221
- copy_kept: 'No copy was kept',
222
- residency: 'Data stayed in its declared region',
223
- subprocessor: 'Only declared third parties touched it',
224
- minimization: 'Only what was needed was taken',
225
- };
226
-
227
- /** A receipt as served by the ledger: core fields plus the operator-revealed,
228
- * signature-covered embedded payload (the ledger has no payload store). */
229
- type ReceiptWithPayload = Receipt & { payload?: unknown };
230
-
231
- function touchPayloadOf(sr: SubjectReceipt): unknown {
232
- return (sr?.receipt as ReceiptWithPayload | undefined)?.payload;
233
- }
234
-
235
- function asPayload(p: unknown): Record<string, unknown> | undefined {
236
- return p !== null && typeof p === 'object' && !Array.isArray(p)
237
- ? (p as Record<string, unknown>)
238
- : undefined;
239
- }
240
-
241
- const plural = (n: number, word: string, pluralForm?: string) => `${n} ${n === 1 ? word : (pluralForm ?? `${word}s`)}`;
242
-
243
- function parseTime(ts: unknown): number | null {
244
- if (typeof ts !== 'string') return null;
245
- const ms = Date.parse(ts);
246
- return Number.isNaN(ms) ? null : ms;
247
- }
248
-
249
- /** `sha256:<64 hex>` → bytes, null on any other shape. */
250
- function parseHash(s: unknown): Uint8Array | null {
251
- if (typeof s !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(s)) return null;
252
- return hexToBytes(s.slice('sha256:'.length));
253
- }
254
-
255
- // ---------------------------------------------------------------------------
256
- // Row 1 (purpose): "Used only for its declared purpose"
257
- // ---------------------------------------------------------------------------
258
-
259
- // Structural validators for the evidence payloads rows 6-8 consume, shared with row
260
- // 1's closed world. Creation paths throw, verification paths return reasons.
261
-
262
- /** Structural check of a `@zanii/residency` claim (`buildResidencyClaim`'s shape). */
263
- function residencyClaimReasons(p: Record<string, unknown>): string[] {
264
- const reasons: string[] = [];
265
- if (p.v !== 1 || p.type !== 'residency.claim') reasons.push('not a residency claim (v1)');
266
- const ra = asPayload(p.region_attestation);
267
- if (!ra) reasons.push('missing region attestation');
268
- else {
269
- if (typeof ra.region !== 'string' || !ra.region) reasons.push('missing/invalid region');
270
- if (typeof ra.source !== 'string' || !ra.source) reasons.push('missing region source');
271
- }
272
- if (parseTime(p.ts) === null) reasons.push('missing/invalid ts');
273
- return reasons;
274
- }
275
-
276
- /** Structural check of a `@zanii/subprocessor` transfer (`buildTransfer`'s shape). */
277
- function transferReasons(p: Record<string, unknown>): string[] {
278
- const reasons: string[] = [];
279
- if (p.v !== 1 || p.type !== 'subprocessor.transfer') reasons.push('not a subprocessor transfer (v1)');
280
- if (typeof p.from !== 'string' || !p.from) reasons.push('missing from');
281
- if (typeof p.to !== 'string' || !p.to) reasons.push('missing to');
282
- const agreement = asPayload(p.agreement);
283
- if (!agreement || typeof agreement.type !== 'string' || !agreement.type) reasons.push('missing agreement');
284
- if (parseTime(p.ts) === null) reasons.push('missing/invalid ts');
285
- return reasons;
286
- }
287
-
288
- /** Structural check of a `@zanii/minimization` field-set commitment record: the
289
- * `commitFieldSet` output (`{ commitment, fields }`) carried as a payload of type
290
- * `minimization.commit`. */
291
- function commitReasons(p: Record<string, unknown>): string[] {
292
- const reasons: string[] = [];
293
- if (p.type !== 'minimization.commit') reasons.push('not a field-set commitment record');
294
- if (typeof p.commitment !== 'string' || !p.commitment.startsWith('minfields:')) reasons.push('missing/invalid commitment');
295
- if (!Array.isArray(p.fields) || p.fields.some((f) => typeof f !== 'string')) reasons.push('missing/invalid fields');
296
- return reasons;
297
- }
298
-
299
- /** Structural check of a `@zanii/minimization` access proof (`buildAccessProof`'s shape). */
300
- function accessReasons(p: Record<string, unknown>): string[] {
301
- const reasons: string[] = [];
302
- if (p.v !== 1 || p.type !== 'minimization.access') reasons.push('not a minimization access proof (v1)');
303
- if (typeof p.field_set !== 'string' || !p.field_set.startsWith('minfields:')) reasons.push('missing/invalid field_set');
304
- if (!Array.isArray(p.accessed) || p.accessed.some((f) => typeof f !== 'string')) reasons.push('missing/invalid accessed');
305
- return reasons;
306
- }
307
-
308
- /** The evidence payload kinds rows 6-8 consume, identified by their `type` tag. */
309
- function evidenceKindOf(p: Record<string, unknown>): 'residency' | 'transfer' | 'commit' | 'access' | null {
310
- switch (p.type) {
311
- case 'residency.claim': return 'residency';
312
- case 'subprocessor.transfer': return 'transfer';
313
- case 'minimization.commit': return 'commit';
314
- case 'minimization.access': return 'access';
315
- default: return null;
316
- }
317
- }
318
-
319
- /**
320
- * A slice receipt is well-formed custody evidence when its embedded payload is a
321
- * well-formed custody touch (`@zanii/data-custody`) OR a well-formed retention shred
322
- * (`@zanii/retention`) OR one of the evidence payloads rows 6-8 consume (a residency
323
- * claim, a subprocessor transfer, a field-set commitment, a minimization access
324
- * proof). Anything else in a custody slice is a red. Closed world on purpose: new
325
- * custody kinds stay red until wired here. The returned kind lets the row say
326
- * honestly what passed or what failed: a retention payload never gets touch-schema
327
- * reasons.
328
- */
329
- function wellFormedCustodyPayload(
330
- p: unknown,
331
- ): { ok: true; kind: 'touch' | 'shred' | 'residency' | 'transfer' | 'commit' | 'access' } | { ok: false; kind: 'touch' | 'retention' | 'residency' | 'transfer' | 'commit' | 'access'; reasons: string[] } {
332
- const payload = asPayload(p);
333
- // A retention-claimed payload is checked as one, pass or fail.
334
- if (payload && payload['_zr_kind'] === 'retention') {
335
- let shred: { ok: boolean; reasons: string[] };
336
- try {
337
- shred = verifyKeyDestruction(payload);
338
- } catch {
339
- shred = { ok: false, reasons: ['unreadable payload'] };
340
- }
341
- return shred.ok ? { ok: true, kind: 'shred' } : { ok: false, kind: 'retention', reasons: shred.reasons };
342
- }
343
- const kind = payload ? evidenceKindOf(payload) : null;
344
- if (kind !== null && payload) {
345
- const reasons = kind === 'residency' ? residencyClaimReasons(payload)
346
- : kind === 'transfer' ? transferReasons(payload)
347
- : kind === 'commit' ? commitReasons(payload)
348
- : accessReasons(payload);
349
- return reasons.length === 0 ? { ok: true, kind } : { ok: false, kind, reasons };
350
- }
351
- let touch: { ok: boolean; reasons: string[] };
352
- try {
353
- touch = verifyTouch(payload);
354
- } catch {
355
- touch = { ok: false, reasons: ['unreadable payload'] };
356
- }
357
- return touch.ok ? { ok: true, kind: 'touch' } : { ok: false, kind: 'touch', reasons: touch.reasons };
358
- }
359
-
360
- const EVIDENCE_NOUNS: Record<'residency' | 'transfer' | 'commit' | 'access', [string, string?]> = {
361
- residency: ['residency claim'],
362
- transfer: ['transfer record'],
363
- commit: ['field-set commitment'],
364
- access: ['access proof'],
365
- };
366
-
367
- function purposeRow(history: SubjectHistory, receipts: SubjectReceipt[]): VerdictRow {
368
- const id = 'purpose' as const;
369
- if (receipts.length === 0) {
370
- return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
371
- }
372
- if (history.verified !== true) {
373
- return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
374
- }
375
- let touches = 0;
376
- let shreds = 0;
377
- const evidence: Array<'residency' | 'transfer' | 'commit' | 'access'> = [];
378
- for (const sr of receipts) {
379
- const check = wellFormedCustodyPayload(touchPayloadOf(sr));
380
- if (!check.ok) {
381
- const what = check.kind === 'touch' ? 'custody touch'
382
- : check.kind === 'retention' ? 'retention'
383
- : `${EVIDENCE_NOUNS[check.kind][0]}`;
384
- return {
385
- id,
386
- label: LABELS[id],
387
- state: 'failed',
388
- detail: `Receipt ${sr?.hash} (index ${sr?.index}) has a malformed ${what} payload: ${check.reasons.join(', ')}.`,
389
- };
390
- }
391
- if (check.kind === 'shred') shreds += 1;
392
- else if (check.kind === 'touch') touches += 1;
393
- else evidence.push(check.kind);
394
- }
395
- if (evidence.length === 0) {
396
- if (shreds === 0) {
397
- return {
398
- id,
399
- label: LABELS[id],
400
- state: 'proven',
401
- 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.`,
402
- };
403
- }
404
- return {
405
- id,
406
- label: LABELS[id],
407
- state: 'proven',
408
- 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.`,
409
- };
410
- }
411
- // Slices that also carry the rows 6-8 evidence payloads: enumerate them honestly.
412
- const counts = new Map<string, number>();
413
- for (const k of evidence) counts.set(k, (counts.get(k) ?? 0) + 1);
414
- const named = [...counts.entries()].map(([k, n]) => {
415
- const [noun, pluralForm] = EVIDENCE_NOUNS[k as keyof typeof EVIDENCE_NOUNS];
416
- return plural(n, noun, pluralForm);
417
- });
418
- const parts = [`${plural(touches, 'touch', 'touches')} carried a well-formed custody touch payload naming its data class and operation`];
419
- if (shreds > 0) parts.push(`${plural(shreds, 'retention shred')} carried a well-formed key-destruction payload`);
420
- parts.push(`${named.join(', ')} passed structural checks`);
421
- return {
422
- id,
423
- label: LABELS[id],
424
- state: 'proven',
425
- detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope): ${parts.join(', and ')}.`,
426
- };
427
- }
428
-
429
- function offlineFailureDetail(history: SubjectHistory): string {
430
- const failures = Array.isArray(history.failures) ? history.failures : [];
431
- if (failures.length === 0) return 'The slice did not verify offline.';
432
- return `${plural(failures.length, 'receipt')} failed offline verification: ${failures.join('; ')}. A receipt that does not verify is a failure, not an unknown.`;
433
- }
434
-
435
- // ---------------------------------------------------------------------------
436
- // Row 2 (self-sovereign): "You can check this yourself"
437
- // ---------------------------------------------------------------------------
438
-
439
- function selfSovereignRow(history: SubjectHistory, receipts: SubjectReceipt[]): VerdictRow {
440
- const id = 'self_sovereign' as const;
441
- if (receipts.length === 0) {
442
- return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
443
- }
444
- if (history.verified !== true) {
445
- return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
446
- }
447
- if (history.sth === undefined || history.sth === null) {
448
- return {
449
- id,
450
- label: LABELS[id],
451
- state: 'cannot determine',
452
- detail: 'The slice came with no signed tree head, so the anchor commitment could not be verified.',
453
- };
454
- }
455
- let sthOk = false;
456
- try {
457
- sthOk = verifySTH(history.sth as unknown as SignedTreeHead);
458
- } catch {
459
- sthOk = false;
460
- }
461
- if (!sthOk) {
462
- return {
463
- id,
464
- label: LABELS[id],
465
- state: 'failed',
466
- detail: "The signed tree head failed verification: the log's commitment to this slice does not check out.",
467
- };
468
- }
469
- const size = (history.sth as { size?: unknown }).size;
470
- return {
471
- id,
472
- label: LABELS[id],
473
- state: 'proven',
474
- 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.`,
475
- };
476
- }
477
-
478
- // ---------------------------------------------------------------------------
479
- // Row 3 (human_read): "Nobody read it"
480
- // ---------------------------------------------------------------------------
481
-
482
- async function humanReadRow(
483
- receipts: SubjectReceipt[],
484
- evidence: ExtraEvidence,
485
- verifyQuote: QuoteVerifier | undefined,
486
- ): Promise<VerdictRow> {
487
- const id = 'human_read' as const;
488
- // A receipt shows a human actor when its embedded touch payload declares one
489
- // (`actor: "human"`). The check is cheap and always runs, evidence or not, and a
490
- // human actor beats any coverage attestation: failed stays failed.
491
- for (const sr of receipts) {
492
- const p = asPayload(touchPayloadOf(sr));
493
- if (p && p['actor'] === 'human') {
494
- return {
495
- id,
496
- label: LABELS[id],
497
- state: 'failed',
498
- detail: `Receipt ${sr?.hash} (index ${sr?.index}) records a human actor: a person, not only an agent, touched this data.`,
499
- };
500
- }
501
- }
502
- const coverage = evidence.coverage;
503
- if (!coverage) {
504
- return { id, label: LABELS[id], state: 'not covered', detail: 'No coverage attestation for this window' };
505
- }
506
- if (typeof coverage !== 'object' || Array.isArray(coverage) || coverage.v !== 1 || coverage.type !== 'coverage.attestation') {
507
- return { id, label: LABELS[id], state: 'not covered', detail: 'The coverage evidence is not a signed coverage attestation.' };
508
- }
509
- if (receipts.length === 0) {
510
- // Coverage over nothing proves nothing about the subject's data.
511
- return {
512
- id,
513
- label: LABELS[id],
514
- state: 'cannot determine',
515
- 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.",
516
- };
517
- }
518
- // Authenticate before evaluating: an unsigned or badly-signed claim is not evidence.
519
- const signerPub = publicKeyFromDid(typeof coverage.signer === 'string' ? coverage.signer : '');
520
- if (!signerPub || typeof coverage.sig !== 'string' || !/^ed25519:[0-9a-f]{128}$/.test(coverage.sig)) {
521
- return {
522
- id,
523
- label: LABELS[id],
524
- state: 'failed',
525
- detail: 'The coverage attestation is unsigned or carries a malformed signature: an unsigned coverage claim is not evidence.',
526
- };
527
- }
528
- // The trust root: when the deployment pins coverage-signing DIDs, a signer outside
529
- // the list is never proven, however good its signature.
530
- const trustedSigners = coverage.trustedSigners;
531
- if (Array.isArray(trustedSigners) && !trustedSigners.includes(coverage.signer)) {
532
- return {
533
- id,
534
- label: LABELS[id],
535
- state: 'failed',
536
- 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.`,
537
- };
538
- }
539
- // trustedSigners is reader-side config merged in after signing: it is NOT part of
540
- // the signed record, so it is excluded from the canonical preimage here.
541
- const { sig: _omitted, trustedSigners: _ts, ...unsigned } = coverage;
542
- let sigOk = false;
543
- try {
544
- sigOk = ed25519.verify(hexToBytes(coverage.sig.slice('ed25519:'.length)), canonicalBytes(unsigned as Record<string, unknown>), signerPub);
545
- } catch {
546
- sigOk = false;
547
- }
548
- if (!sigOk) {
549
- return {
550
- id,
551
- label: LABELS[id],
552
- state: 'failed',
553
- detail: "The coverage attestation's signature does not verify: a coverage claim that does not verify is a failure, not an unknown.",
554
- };
555
- }
556
- // The claim must be bound to an attested image: attestationHash(embedded) === imageHash.
557
- const attestation = asPayload(coverage.attestation) as Attestation | undefined;
558
- let boundHash: string | null = null;
559
- if (attestation) {
560
- try {
561
- boundHash = attestationHash(attestation);
562
- } catch {
563
- boundHash = null;
564
- }
565
- }
566
- if (!attestation || boundHash === null || coverage.imageHash !== boundHash) {
567
- return {
568
- id,
569
- label: LABELS[id],
570
- state: 'failed',
571
- detail: "The coverage attestation's image hash does not match its embedded attestation (attestationHash convention): the binding to the attested image is broken.",
572
- };
573
- }
574
- // The embedded attestation verifies through @zanii/attest with the injected quote
575
- // verifier. A disproven quote is a red; no verifier injected is an honest grey.
576
- let check: AttestCheck;
577
- try {
578
- check = await verifyAttestation(attestation, verifyQuote ? { verifyQuote } : {});
579
- } catch {
580
- check = { ok: false, structural: false, quoteVerified: false, reasons: ['attestation verification failed'] };
581
- }
582
- if (!check.structural) {
583
- return {
584
- id,
585
- label: LABELS[id],
586
- state: 'failed',
587
- detail: `The coverage attestation's embedded attestation is malformed: ${check.reasons.join(', ')}.`,
588
- };
589
- }
590
- if (check.quoteVerified === false) {
591
- return {
592
- id,
593
- label: LABELS[id],
594
- state: 'failed',
595
- detail: "The coverage attestation's enclave quote failed verification: the code that claimed coverage is not confirmed to be the attested image.",
596
- };
597
- }
598
- if (check.quoteVerified === null) {
599
- return {
600
- id,
601
- label: LABELS[id],
602
- state: 'cannot determine',
603
- detail: 'The coverage attestation is signed and well-formed, but no enclave quote verifier was injected, so the attested image could not be confirmed.',
604
- };
605
- }
606
- if (coverage.allPathsGated !== true) {
607
- return {
608
- id,
609
- label: LABELS[id],
610
- state: 'not covered',
611
- detail: 'The coverage attestation does not assert that all egress paths were receipt-gated.',
612
- };
613
- }
614
- const start = parseTime(coverage.windowStart);
615
- const end = parseTime(coverage.windowEnd);
616
- if (start === null || end === null || start > end) {
617
- return { id, label: LABELS[id], state: 'not covered', detail: 'The coverage attestation window is missing or invalid.' };
618
- }
619
- const times = receipts.map((sr) => parseTime(sr?.receipt?.ts));
620
- if (times.some((t) => t === null || t < start || t > end)) {
621
- const valid = times.filter((t): t is number => t !== null);
622
- const span = valid.length > 0
623
- ? ` (this slice spans ${new Date(Math.min(...valid)).toISOString()} to ${new Date(Math.max(...valid)).toISOString()})`
624
- : '';
625
- return {
626
- id,
627
- label: LABELS[id],
628
- state: 'not covered',
629
- detail: `The coverage attestation (${coverage.windowStart} to ${coverage.windowEnd}) does not cover every receipt in this slice${span}.`,
630
- };
631
- }
632
- return {
633
- id,
634
- label: LABELS[id],
635
- state: 'proven',
636
- 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.`,
637
- };
638
- }
639
-
640
- // ---------------------------------------------------------------------------
641
- // Row 4 (trained_on): "Never used to train a model"
642
- // ---------------------------------------------------------------------------
643
-
644
- function trainedOnRow(history: SubjectHistory, receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
645
- const id = 'trained_on' as const;
646
- const training = evidence.training;
647
- if (!training) {
648
- // Exact wording matters: a missing proof is NOT a clean result. Never "never".
649
- return { id, label: LABELS[id], state: 'not covered', detail: 'No declared training runs to check against' };
650
- }
651
- const anchoredAt = parseTime(training.anchoredAt);
652
- const runAttestedAt = parseTime(training.runAttestedAt);
653
- if (anchoredAt === null || runAttestedAt === null) {
654
- return {
655
- id,
656
- label: LABELS[id],
657
- state: 'cannot determine',
658
- detail: 'The training evidence is missing a valid manifest anchor time or run attestation time, so the ordering cannot be checked.',
659
- };
660
- }
661
- if (anchoredAt > runAttestedAt) {
662
- return {
663
- id,
664
- label: LABELS[id],
665
- state: 'cannot determine',
666
- 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.`,
667
- };
668
- }
669
- if (receipts.length === 0) {
670
- return {
671
- id,
672
- label: LABELS[id],
673
- state: 'not covered',
674
- detail: "This slice has no receipts: a training-absence proof over an empty slice proves nothing about this subject's data.",
675
- };
676
- }
677
- const manifest = training.manifest;
678
- if (
679
- !manifest || typeof manifest !== 'object' || manifest.v !== 1 || manifest.type !== 'training.manifest'
680
- || typeof manifest.run_id !== 'string' || typeof manifest.root !== 'string' || parseHash(manifest.root) === null
681
- || !Number.isInteger(manifest.size) || manifest.size < 0
682
- ) {
683
- return {
684
- id,
685
- label: LABELS[id],
686
- state: 'failed',
687
- detail: 'The training manifest is missing or malformed, so the absence proof cannot be checked against it.',
688
- };
689
- }
690
- // The proof must speak to THIS subject, not someone else.
691
- const proof = training.absenceProof;
692
- if (!proof || typeof proof !== 'object' || proof.ref !== history.tag) {
693
- return {
694
- id,
695
- label: LABELS[id],
696
- state: 'failed',
697
- detail: "The non-membership proof is missing or names a different ref than this subject's tag, so it says nothing about this slice.",
698
- };
699
- }
700
- let ok = false;
701
- try {
702
- ok = verifyAbsence(proof, manifest);
703
- } catch {
704
- ok = false;
705
- }
706
- if (!ok) {
707
- return {
708
- id,
709
- label: LABELS[id],
710
- state: 'failed',
711
- detail: `The non-membership proof does not verify against the declared training manifest (root ${manifest.root}).`,
712
- };
713
- }
714
- return {
715
- id,
716
- label: LABELS[id],
717
- state: 'proven',
718
- 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.`,
719
- };
720
- }
721
-
722
- // ---------------------------------------------------------------------------
723
- // Row 5 (copy_kept): "No copy was kept"
724
- // ---------------------------------------------------------------------------
725
-
726
- /**
727
- * The destruction evidence lives IN the slice: a receipt stamped with this subject's
728
- * tag whose embedded, signature-covered payload is a `@zanii/retention` shred
729
- * (`buildKeyDestruction`'s shape). Slice membership binds the receipt to this subject
730
- * (the ledger serves the receipt only under this tag, and the receipt's own offline
731
- * check re-verifies here); the payload's salted subject commitment can later be
732
- * proven to name the subject by revealing the salt. Binding to the OBJECT is by ref:
733
- * a shred counts only for an object whose custody touches carry the same payload
734
- * `ref`, so a shred for an unrelated object never turns this row green.
735
- */
736
- function shredReceiptsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
737
- const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
738
- for (const sr of receipts) {
739
- const p = asPayload(touchPayloadOf(sr));
740
- if (p && p['_zr_kind'] === 'retention' && p['action'] === 'shred') out.push({ sr, payload: p });
741
- }
742
- return out;
743
- }
744
-
745
- /** The refs of the custody touches in the slice: the objects destruction must bind to. */
746
- function touchRefsOf(receipts: SubjectReceipt[]): string[] {
747
- const refs = new Set<string>();
748
- for (const sr of receipts) {
749
- const p = asPayload(touchPayloadOf(sr));
750
- if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string') refs.add(p['ref']);
751
- }
752
- return [...refs].sort();
753
- }
754
-
755
- function copyKeptRow(receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
756
- const id = 'copy_kept' as const;
757
- const shreds = shredReceiptsOf(receipts);
758
- if (shreds.length === 0) {
759
- return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
760
- }
761
- // A presented-but-broken shred is a red, not a grey: any failure anywhere in the
762
- // slice's destruction records fails the row, however many others verify.
763
- for (const { sr, payload } of shreds) {
764
- if (sr?.check?.ok !== true) {
765
- return {
766
- id,
767
- label: LABELS[id],
768
- state: 'failed',
769
- detail: `Receipt ${sr?.hash} (index ${sr?.index}) carries a key-destruction payload, but the receipt itself failed offline verification.`,
770
- };
771
- }
772
- let check: { ok: boolean; reasons: string[] };
773
- try {
774
- check = verifyKeyDestruction(payload);
775
- } catch {
776
- check = { ok: false, reasons: ['unreadable payload'] };
777
- }
778
- if (!check.ok) {
779
- return {
780
- id,
781
- label: LABELS[id],
782
- state: 'failed',
783
- detail: `The key-destruction payload on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
784
- };
785
- }
786
- }
787
- // Ref binding: a shred is evidence only for the objects this slice touches. A
788
- // shred carrying no ref, or a ref no touch in this slice carries, does not count.
789
- const touchRefs = touchRefsOf(receipts);
790
- const bound = shreds.filter(({ payload }) => typeof payload['ref'] === 'string' && touchRefs.includes(payload['ref'] as string));
791
- if (bound.length === 0) {
792
- return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
793
- }
794
- const coveredRefs = new Set(bound.map(({ payload }) => payload['ref'] as string));
795
- const uncoveredRefs = touchRefs.filter((r) => !coveredRefs.has(r));
796
- if (uncoveredRefs.length > 0) {
797
- return {
798
- id,
799
- label: LABELS[id],
800
- state: 'cannot determine',
801
- 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.`,
802
- };
803
- }
804
- // Optional image evidence: check the claimed attestation hashes to the published
805
- // image hash (bag-internal consistency; it does not bind the shred to the image).
806
- const image = evidence.destructionImage;
807
- let imagePart = '';
808
- if (image !== undefined && image !== null) {
809
- const attestation = asPayload((image as DestructionImageEvidence).attestation) as Attestation | undefined;
810
- const published = (image as DestructionImageEvidence).publishedImageHash;
811
- let attested: string | null = null;
812
- if (attestation) {
813
- try {
814
- attested = attestationHash(attestation);
815
- } catch {
816
- attested = null;
817
- }
818
- }
819
- if (!attestation || attested === null || typeof published !== 'string' || published.length === 0) {
820
- return {
821
- id,
822
- label: LABELS[id],
823
- state: 'failed',
824
- detail: 'The destruction image evidence is incomplete: it must carry the attestation record and the published image hash.',
825
- };
826
- }
827
- if (attested !== published) {
828
- return {
829
- id,
830
- label: LABELS[id],
831
- state: 'failed',
832
- detail: `The attested image hash (${attested}) does not match the published image hash (${published}).`,
833
- };
834
- }
835
- imagePart = ` The attested image hash matches the published one (${published}).`;
836
- }
837
- const first = bound[0]!;
838
- return {
839
- id,
840
- label: LABELS[id],
841
- state: 'proven',
842
- 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}`,
843
- };
844
- }
845
-
846
- // ---------------------------------------------------------------------------
847
- // Row 6 (residency): "Data stayed in its declared region"
848
- // ---------------------------------------------------------------------------
849
-
850
- /** Residency claims carried by the slice (`buildResidencyClaim`'s shape, embedded
851
- * signature-covered exactly like the row-5 shred payloads). */
852
- function residencyClaimsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
853
- const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
854
- for (const sr of receipts) {
855
- const p = asPayload(touchPayloadOf(sr));
856
- if (p && p['type'] === 'residency.claim') out.push({ sr, payload: p });
857
- }
858
- return out;
859
- }
860
-
861
- /** A claim whose region signal is the operator asserting its own region: not an
862
- * attestation, whatever it claims. */
863
- function isSelfDeclared(p: Record<string, unknown>): boolean {
864
- const source = asPayload(p.region_attestation)?.source;
865
- return typeof source === 'string' && source.toLowerCase().startsWith('self');
866
- }
867
-
868
- function receiptDetailPrefix(sr: SubjectReceipt): string {
869
- return `Receipt ${sr?.hash} (index ${sr?.index})`;
870
- }
871
-
872
- async function residencyRow(
873
- receipts: SubjectReceipt[],
874
- evidence: ExtraEvidence,
875
- verifyRegion: RegionVerifier | undefined,
876
- ): Promise<VerdictRow> {
877
- const id = 'residency' as const;
878
- if (receipts.length === 0) {
879
- return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
880
- }
881
- const claims = residencyClaimsOf(receipts);
882
- if (claims.length === 0) {
883
- return { id, label: LABELS[id], state: 'not covered', detail: 'No residency attestations on record' };
884
- }
885
- // A self-declared claim is no attestation: it does not count as evidence, here or
886
- // in the coverage and crossing checks below.
887
- const attested = claims.filter(({ payload }) => !isSelfDeclared(payload));
888
- const selfDeclared = claims.length - attested.length;
889
- if (attested.length === 0) {
890
- return {
891
- id,
892
- label: LABELS[id],
893
- state: 'not covered',
894
- 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.`,
895
- };
896
- }
897
- // A presented-but-broken claim is a red, not a grey: structural check first, then
898
- // the receipt's own offline verification (an unverified receipt's payload is not
899
- // signature-covered evidence).
900
- for (const { sr, payload } of attested) {
901
- const reasons = residencyClaimReasons(payload);
902
- if (reasons.length > 0) {
903
- return {
904
- id,
905
- label: LABELS[id],
906
- state: 'failed',
907
- detail: `${receiptDetailPrefix(sr)} carries a malformed residency claim: ${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 residency claim payload, but the receipt itself failed offline verification.`,
916
- };
917
- }
918
- }
919
- // The jurisdiction allow-set is READER config, never producer-supplied (same trust
920
- // rule as trustedSigners). Absent: unknown, never proven. Empty: allows no region,
921
- // so every verified claim below is a crossing.
922
- const jurisdiction = evidence.jurisdiction;
923
- if (!Array.isArray(jurisdiction)) {
924
- return {
925
- id,
926
- label: LABELS[id],
927
- state: 'cannot determine',
928
- detail: 'Residency claims are on record, but the reader provided no declared jurisdiction allow-set, so there is nothing to check them against.',
929
- };
930
- }
931
- if (verifyRegion === undefined) {
932
- return {
933
- id,
934
- label: LABELS[id],
935
- state: 'cannot determine',
936
- detail: 'Residency claims are on record, but no region verifier was injected, so the region signals could not be authenticated.',
937
- };
938
- }
939
- const verified: Array<{ ts: string; region: string; ref: string | null }> = [];
940
- for (const { sr, payload } of attested) {
941
- let check: { ok: boolean; reasons: string[]; region: string | null; crossed: boolean };
942
- try {
943
- check = await verifyResidency(payload as unknown as ResidencyClaim, { allowedRegions: jurisdiction, verifyRegion });
944
- } catch {
945
- check = { ok: false, reasons: ['the residency check failed'], region: null, crossed: false };
946
- }
947
- if (check.crossed) {
948
- // Do not echo the producer reason: its wording is not reader-facing.
949
- return {
950
- id,
951
- label: LABELS[id],
952
- state: 'failed',
953
- 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.`,
954
- };
955
- }
956
- if (!check.ok || check.region === null) {
957
- return {
958
- id,
959
- label: LABELS[id],
960
- state: 'failed',
961
- detail: `The residency claim on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
962
- };
963
- }
964
- verified.push({
965
- ts: payload.ts as string,
966
- region: check.region,
967
- ref: typeof payload.ref === 'string' && payload.ref ? payload.ref : null,
968
- });
969
- }
970
- // The sequence check over the verified regions: any exit from the allow-set is red.
971
- // (Per-claim verification already rejects an outside region, so this is belt and
972
- // suspenders; it also catches a sequence that STARTS outside, which per-claim
973
- // checks phrase differently.)
974
- const crossings = detectCrossings(verified, jurisdiction);
975
- if (crossings.length > 0) {
976
- const c = crossings[0]!;
977
- return {
978
- id,
979
- label: LABELS[id],
980
- state: 'failed',
981
- detail: `Verified residency claims show the data left the declared jurisdiction at ${c.ts} (moved to region ${c.region}).`,
982
- };
983
- }
984
- // Coverage is per touch, never window-span: EVERY custody touch in the slice must
985
- // be covered by a verified claim. A claim carrying a ref binds to every touch of
986
- // that object; a ref-less claim is time-scoped and covers the touches made at its
987
- // own timestamp. Anything looser would let two claims at the window's ends prove
988
- // green over an uncovered middle.
989
- const touches: Array<{ ref: string; ts: string }> = [];
990
- for (const sr of receipts) {
991
- const p = asPayload(touchPayloadOf(sr));
992
- if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string' && parseTime(p['ts']) !== null) {
993
- touches.push({ ref: p['ref'] as string, ts: p['ts'] as string });
994
- }
995
- }
996
- if (touches.length === 0) {
997
- return {
998
- id,
999
- label: LABELS[id],
1000
- state: 'cannot determine',
1001
- detail: 'This slice carries no custody touches, so per-touch residency coverage cannot be established.',
1002
- };
1003
- }
1004
- const isCovered = (t: { ref: string; ts: string }): boolean =>
1005
- verified.some((c) => (c.ref !== null ? c.ref === t.ref : c.ts === t.ts));
1006
- const uncovered = touches.filter((t) => !isCovered(t));
1007
- if (uncovered.length > 0) {
1008
- const covered = touches.length - uncovered.length;
1009
- return {
1010
- id,
1011
- label: LABELS[id],
1012
- state: 'cannot determine',
1013
- 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.`,
1014
- };
1015
- }
1016
- const selfNote = selfDeclared > 0
1017
- ? ` ${plural(selfDeclared, 'self-declared claim')} ${selfDeclared === 1 ? 'was' : 'were'} not counted as evidence: the region source is self-declared.`
1018
- : '';
1019
- return {
1020
- id,
1021
- label: LABELS[id],
1022
- state: 'proven',
1023
- 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}`,
1024
- };
1025
- }
1026
-
1027
- // ---------------------------------------------------------------------------
1028
- // Row 7 (subprocessor): "Only declared third parties touched it"
1029
- // ---------------------------------------------------------------------------
1030
-
1031
- /** Onward transfers carried by the slice (`buildTransfer`'s shape). */
1032
- function transfersOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
1033
- const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
1034
- for (const sr of receipts) {
1035
- const p = asPayload(touchPayloadOf(sr));
1036
- if (p && p['type'] === 'subprocessor.transfer') out.push({ sr, payload: p });
1037
- }
1038
- return out;
1039
- }
1040
-
1041
- /** Custody-touch junctions: the destinations the touch payloads say data crossed to. */
1042
- function junctionsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; to: string }> {
1043
- const out: Array<{ sr: SubjectReceipt; to: string }> = [];
1044
- for (const sr of receipts) {
1045
- const p = asPayload(touchPayloadOf(sr));
1046
- const j = p ? asPayload(p['junction']) : undefined;
1047
- // Only share/export are onward disclosures to a third party. Store/read/transform
1048
- // may carry a junction for topology (source/destination of the data), but they
1049
- // are not subprocessor transfers.
1050
- const op = typeof p?.['op'] === 'string' ? p['op'] : undefined;
1051
- if (j && typeof j.to === 'string' && j.to && (op === 'share' || op === 'export')) out.push({ sr, to: j.to });
1052
- }
1053
- return out;
1054
- }
1055
-
1056
- function subprocessorRow(receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
1057
- const id = 'subprocessor' as const;
1058
- const transfers = transfersOf(receipts);
1059
- const junctions = junctionsOf(receipts);
1060
- if (transfers.length === 0 && junctions.length === 0) {
1061
- return { id, label: LABELS[id], state: 'not covered', detail: 'No onward transfers declared or recorded' };
1062
- }
1063
- // Presented-but-broken is a red before any trust-root question.
1064
- for (const { sr, payload } of transfers) {
1065
- const reasons = transferReasons(payload);
1066
- if (reasons.length > 0) {
1067
- return {
1068
- id,
1069
- label: LABELS[id],
1070
- state: 'failed',
1071
- detail: `${receiptDetailPrefix(sr)} carries a malformed transfer record: ${reasons.join(', ')}.`,
1072
- };
1073
- }
1074
- if (sr?.check?.ok !== true) {
1075
- return {
1076
- id,
1077
- label: LABELS[id],
1078
- state: 'failed',
1079
- detail: `${receiptDetailPrefix(sr)} carries a transfer record payload, but the receipt itself failed offline verification.`,
1080
- };
1081
- }
1082
- // Stricter than verifyTransfers (which requires an id only for dpa/scc): any
1083
- // onward transfer without an agreement id is a red here.
1084
- const agreement = asPayload(payload.agreement);
1085
- const agreementId = agreement?.id;
1086
- if (typeof agreementId !== 'string' || !agreementId) {
1087
- return {
1088
- id,
1089
- label: LABELS[id],
1090
- state: 'failed',
1091
- 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.`,
1092
- };
1093
- }
1094
- }
1095
- // The declared subprocessor list is READER config, never producer-supplied (same
1096
- // trust rule as trustedSigners). Without it there is nothing to check against.
1097
- const declared = evidence.declaredSubprocessors;
1098
- if (!Array.isArray(declared)) {
1099
- return {
1100
- id,
1101
- label: LABELS[id],
1102
- state: 'cannot determine',
1103
- 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.',
1104
- };
1105
- }
1106
- let check: { ok: boolean; reasons: string[] };
1107
- try {
1108
- check = verifyTransfers(transfers.map(({ payload }) => payload as unknown as Transfer), declared);
1109
- } catch {
1110
- check = { ok: false, reasons: ['the transfer check failed'] };
1111
- }
1112
- if (!check.ok) {
1113
- return {
1114
- id,
1115
- label: LABELS[id],
1116
- state: 'failed',
1117
- detail: `The declared-subprocessor check failed: ${check.reasons.join('; ')}.`,
1118
- };
1119
- }
1120
- // Divergence is red, never silent: a junction naming a destination with no matching
1121
- // declared transfer means the custody trail and the transfer record disagree.
1122
- for (const { sr, to } of junctions) {
1123
- const matched = transfers.some(({ payload }) => payload.to === to && declared.includes(payload.to as string));
1124
- if (!matched) {
1125
- return {
1126
- id,
1127
- label: LABELS[id],
1128
- state: 'failed',
1129
- 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.`,
1130
- };
1131
- }
1132
- }
1133
- return {
1134
- id,
1135
- label: LABELS[id],
1136
- state: 'proven',
1137
- 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.`,
1138
- };
1139
- }
1140
-
1141
- // ---------------------------------------------------------------------------
1142
- // Row 8 (minimization): "Only what was needed was taken"
1143
- // ---------------------------------------------------------------------------
1144
-
1145
- /** Field-set commitments carried by the slice (`commitFieldSet` records under type
1146
- * `minimization.commit`). */
1147
- function commitsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
1148
- const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
1149
- for (const sr of receipts) {
1150
- const p = asPayload(touchPayloadOf(sr));
1151
- if (p && p['type'] === 'minimization.commit') out.push({ sr, payload: p });
1152
- }
1153
- return out;
1154
- }
1155
-
1156
- /** Access proofs carried by the slice (`buildAccessProof`'s shape). */
1157
- function accessProofsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
1158
- const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
1159
- for (const sr of receipts) {
1160
- const p = asPayload(touchPayloadOf(sr));
1161
- if (p && p['type'] === 'minimization.access') out.push({ sr, payload: p });
1162
- }
1163
- return out;
1164
- }
1165
-
1166
- function minimizationRow(receipts: SubjectReceipt[]): VerdictRow {
1167
- const id = 'minimization' as const;
1168
- if (receipts.length === 0) {
1169
- return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
1170
- }
1171
- const commits = commitsOf(receipts);
1172
- const accesses = accessProofsOf(receipts);
1173
- for (const { sr, payload } of commits) {
1174
- const reasons = commitReasons(payload);
1175
- if (reasons.length > 0) {
1176
- return {
1177
- id,
1178
- label: LABELS[id],
1179
- state: 'failed',
1180
- detail: `${receiptDetailPrefix(sr)} carries a malformed field-set commitment record: ${reasons.join(', ')}.`,
1181
- };
1182
- }
1183
- if (sr?.check?.ok !== true) {
1184
- return {
1185
- id,
1186
- label: LABELS[id],
1187
- state: 'failed',
1188
- detail: `${receiptDetailPrefix(sr)} carries a field-set commitment payload, but the receipt itself failed offline verification.`,
1189
- };
1190
- }
1191
- // Recompute the commitment from the claimed fields: a mismatch is a set widened
1192
- // (or swapped) after the commitment was recorded.
1193
- let matches = false;
1194
- try {
1195
- matches = verifyFieldSet(payload.fields as string[], payload.commitment as string);
1196
- } catch {
1197
- matches = false;
1198
- }
1199
- if (!matches) {
1200
- return {
1201
- id,
1202
- label: LABELS[id],
1203
- state: 'failed',
1204
- 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.`,
1205
- };
1206
- }
1207
- }
1208
- // Access proofs are validated before the commits-empty branch: a presented but
1209
- // broken proof is a red even when no commitments are on record.
1210
- for (const { sr, payload } of accesses) {
1211
- const reasons = accessReasons(payload);
1212
- if (reasons.length > 0) {
1213
- return {
1214
- id,
1215
- label: LABELS[id],
1216
- state: 'failed',
1217
- detail: `${receiptDetailPrefix(sr)} carries a malformed minimization access proof: ${reasons.join(', ')}.`,
1218
- };
1219
- }
1220
- if (sr?.check?.ok !== true) {
1221
- return {
1222
- id,
1223
- label: LABELS[id],
1224
- state: 'failed',
1225
- detail: `${receiptDetailPrefix(sr)} carries an access proof payload, but the receipt itself failed offline verification.`,
1226
- };
1227
- }
1228
- }
1229
- if (commits.length === 0) {
1230
- if (accesses.length > 0) {
1231
- // Symmetric with commits-without-accesses: an access proof whose declared
1232
- // minimum is not on record cannot be checked, and unchecked is unknown.
1233
- return {
1234
- id,
1235
- label: LABELS[id],
1236
- state: 'cannot determine',
1237
- 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.',
1238
- };
1239
- }
1240
- return { id, label: LABELS[id], state: 'not covered', detail: 'No minimum-field commitments on record' };
1241
- }
1242
- if (accesses.length === 0) {
1243
- return {
1244
- id,
1245
- label: LABELS[id],
1246
- state: 'cannot determine',
1247
- detail: 'Field-set commitments are on record, but no access proofs: no recorded access was checked against the declared minimum.',
1248
- };
1249
- }
1250
- for (const { sr, payload } of accesses) {
1251
- const commit = commits.find(({ payload: c }) => c.commitment === payload.field_set);
1252
- if (!commit) {
1253
- return {
1254
- id,
1255
- label: LABELS[id],
1256
- state: 'cannot determine',
1257
- 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.`,
1258
- };
1259
- }
1260
- let check: { ok: boolean; reasons: string[]; excess: string[] };
1261
- try {
1262
- check = verifyMinimization(payload as unknown as AccessProof, commit.payload.fields as string[]);
1263
- } catch {
1264
- check = { ok: false, reasons: ['the minimization check failed'], excess: [] };
1265
- }
1266
- if (!check.ok) {
1267
- return {
1268
- id,
1269
- label: LABELS[id],
1270
- state: 'failed',
1271
- detail: `The minimization check failed for receipt ${sr?.hash} (index ${sr?.index}): ${check.reasons.join(', ')}.`,
1272
- };
1273
- }
1274
- }
1275
- return {
1276
- id,
1277
- label: LABELS[id],
1278
- state: 'proven',
1279
- 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.`,
1280
- };
1281
- }
1282
-
1283
- // ---------------------------------------------------------------------------
1284
- // The one function both surfaces call.
1285
- // ---------------------------------------------------------------------------
1286
-
1287
- /**
1288
- * Compute the eight verdict rows from an offline-verified subject history. Pure: no
1289
- * network, no clock. Async only because row 3's coverage attestation is checked
1290
- * through `@zanii/attest`'s `verifyAttestation` and row 6's residency claims through
1291
- * `@zanii/residency`'s `verifyResidency`, whose injected verifiers may be async;
1292
- * pass them via `options.verifyQuote` / `options.verifyRegion`. Never throws:
1293
- * malformed input yields honest `cannot determine` / `not covered` / `failed` rows
1294
- * with reasons. Rows are always returned in this fixed order: purpose,
1295
- * self_sovereign, human_read, trained_on, copy_kept, residency, subprocessor,
1296
- * minimization.
1297
- */
1298
- export async function computeVerdicts(
1299
- history: SubjectHistory,
1300
- evidence: ExtraEvidence = {},
1301
- options: VerdictOptions = {},
1302
- ): Promise<VerdictRow[]> {
1303
- const h = (history && typeof history === 'object' ? history : {}) as SubjectHistory;
1304
- const receipts = Array.isArray(h.receipts) ? h.receipts : [];
1305
- const ev = (evidence && typeof evidence === 'object' ? evidence : {}) as ExtraEvidence;
1306
- const verifyQuote = options && typeof options === 'object' ? options.verifyQuote : undefined;
1307
- const verifyRegion = options && typeof options === 'object' ? options.verifyRegion : undefined;
1308
- const rows: Array<() => VerdictRow | Promise<VerdictRow>> = [
1309
- () => purposeRow(h, receipts),
1310
- () => selfSovereignRow(h, receipts),
1311
- () => humanReadRow(receipts, ev, verifyQuote),
1312
- () => trainedOnRow(h, receipts, ev),
1313
- () => copyKeptRow(receipts, ev),
1314
- () => residencyRow(receipts, ev, verifyRegion),
1315
- () => subprocessorRow(receipts, ev),
1316
- () => minimizationRow(receipts),
1317
- ];
1318
- const ids: VerdictId[] = ['purpose', 'self_sovereign', 'human_read', 'trained_on', 'copy_kept', 'residency', 'subprocessor', 'minimization'];
1319
- const out: VerdictRow[] = [];
1320
- for (let i = 0; i < rows.length; i++) {
1321
- try {
1322
- out.push(await rows[i]!());
1323
- } catch (err) {
1324
- // Verification paths never crash: an unexpected error is an honest grey.
1325
- const id = ids[i]!;
1326
- out.push({
1327
- id,
1328
- label: LABELS[id],
1329
- state: 'cannot determine',
1330
- detail: `This line could not be computed: ${err instanceof Error ? err.message : String(err)}.`,
1331
- });
1332
- }
1333
- }
1334
- return out;
1335
- }
1336
-
1337
- /**
1338
- * Seconds between the signed tree head's timestamp and `now` (the caller supplies
1339
- * `now`; this package has no clock). The page shows anchor lag: four minutes vs
1340
- * four hours is a property the reader is entitled to see. Null when unknown (no sth,
1341
- * no timestamp, unparseable). Negative means the sth is newer than the caller's
1342
- * clock: possible skew, shown as-is.
1343
- */
1344
- export function computeAnchorLag(
1345
- sth: Record<string, unknown> | SignedTreeHead | undefined | null,
1346
- now: Date | string | number,
1347
- ): number | null {
1348
- try {
1349
- const ts = parseTime((sth as Record<string, unknown> | undefined | null)?.ts);
1350
- if (ts === null) return null;
1351
- const nowMs = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : parseTime(now);
1352
- if (nowMs === null || Number.isNaN(nowMs)) return null;
1353
- return Math.round((nowMs - ts) / 1000);
1354
- } catch {
1355
- return null;
1356
- }
1357
- }
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 {
52
+ canonicalBytes, publicKeyFromDid, verifySTH,
53
+ type Receipt, type SignedTreeHead,
54
+ } from '@zanii/core';
55
+ import { verifyTouch } from '@zanii/data-custody';
56
+ import { verifyKeyDestruction } from '@zanii/retention';
57
+ import { verifyAbsence, type AbsenceProof, type TrainingManifest } from '@zanii/train';
58
+ import {
59
+ attestationHash, verifyAttestation,
60
+ type Attestation, type AttestCheck, type QuoteVerifier,
61
+ } from '@zanii/attest';
62
+ import {
63
+ detectCrossings, verifyResidency,
64
+ type RegionVerifier, type ResidencyClaim,
65
+ } from '@zanii/residency';
66
+ import { verifyTransfers, type Agreement, type Transfer } from '@zanii/subprocessor';
67
+ import { verifyFieldSet, verifyMinimization, type AccessProof } from '@zanii/minimization';
68
+ import type { SubjectHistory, SubjectReceipt } from '@zanii/subject';
69
+
70
+ export type { AbsenceProof, TrainingManifest } from '@zanii/train';
71
+ export type { Attestation, QuoteVerifier } from '@zanii/attest';
72
+ export type { RegionVerifier, ResidencyClaim } from '@zanii/residency';
73
+ export type { Agreement, Transfer } from '@zanii/subprocessor';
74
+ export type { AccessProof } from '@zanii/minimization';
75
+
76
+ /**
77
+ * The well-known ledger tag under which declared training manifests are published.
78
+ * The subject page and the CLI fetch that slice to build the row-4 training evidence
79
+ * bag (`ExtraEvidence.training`).
80
+ */
81
+ export const TRAINING_MANIFEST_TAG = 'zanii:training-manifests';
82
+
83
+ /** The four verdict states. The last three are as loud as the first. */
84
+ export type VerdictState = 'proven' | 'not covered' | 'failed' | 'cannot determine';
85
+
86
+ export type VerdictId =
87
+ | 'purpose' | 'self_sovereign' | 'human_read' | 'trained_on' | 'copy_kept'
88
+ | 'residency' | 'subprocessor' | 'minimization';
89
+
90
+ export interface VerdictRow {
91
+ id: VerdictId;
92
+ label: string;
93
+ state: VerdictState;
94
+ /** Plain-language working: which receipts were computed over, why this state. Rendered verbatim. */
95
+ detail: string;
96
+ }
97
+
98
+ /**
99
+ * Coverage attestation for the "nobody read it" line: a SIGNED record asserting that
100
+ * every egress path to the data was receipt-gated (the `@zanii/broker` invariant) for
101
+ * the whole window, bound to the attested image whose code enforced it. The signature
102
+ * covers every field but `sig`; `imageHash` must equal `attestationHash(attestation)`
103
+ * (`@zanii/attest`'s convention); the attestation's `nonce` must equal `signer`, which
104
+ * is what binds the SIGNING KEY to the attested image (without it a legitimately
105
+ * published attestation is replayable under an attacker's key, and `trustedSigners`
106
+ * is only an optional defence); and the embedded attestation is verified through
107
+ * `verifyAttestation` with the caller's injected quote verifier. Anything unsigned or
108
+ * malformed is never `proven`. The producer side ships with the enclave phase
109
+ * (`@zanii/attest` + `@zanii/broker`); until then this evidence does not exist and the
110
+ * row answers `not covered`, by design.
111
+ */
112
+ export interface CoverageEvidence {
113
+ v: 1;
114
+ type: 'coverage.attestation';
115
+ /** Every egress path was receipt-gated; partial coverage is no coverage. */
116
+ allPathsGated: boolean;
117
+ /** ISO timestamp: start of the gated window. */
118
+ windowStart: string;
119
+ /** ISO timestamp: end of the gated window. */
120
+ windowEnd: string;
121
+ /** `attestationHash` of the embedded attestation: the image the claim is bound to. */
122
+ imageHash: string;
123
+ /** The full attestation record (`@zanii/attest`'s `Attestation`). */
124
+ attestation: Attestation;
125
+ /**
126
+ * did:key of the enclave key that signed this attestation. MUST equal
127
+ * `attestation.nonce`: the enclave generates this keypair inside the measured
128
+ * image and commits its DID to the quote, so the binding is covered by
129
+ * `attestationHash` and cannot be reassigned to another key.
130
+ */
131
+ signer: string;
132
+ /** `ed25519:<hex>` over the canonical record minus `sig`. */
133
+ sig: string;
134
+ /**
135
+ * Optional trust root: the deployment's pinned coverage-signing DIDs. The reader
136
+ * (subject page / CLI) sets this from its OWN configuration, never from
137
+ * producer-supplied data; otherwise an attacker could attest with their own key and
138
+ * name it trusted. Not part of the signed record (excluded from the signature
139
+ * preimage). When present, a signer not in the list is `failed`; an empty list
140
+ * trusts no one. When absent, any well-formed did:key signer is accepted.
141
+ */
142
+ trustedSigners?: string[];
143
+ }
144
+
145
+ /**
146
+ * A declared, anchored training run plus `@zanii/train`'s absence proof. Ordering is
147
+ * the whole claim: the manifest anchor must PRECEDE the run attestation. A manifest
148
+ * anchored afterwards could have been written to exclude the subject.
149
+ */
150
+ export interface TrainingEvidence {
151
+ /** The run's sorted-Merkle training manifest (`@zanii/train`'s `TrainingManifest`). */
152
+ manifest: TrainingManifest;
153
+ /** ISO timestamp when the manifest root was anchored. */
154
+ anchoredAt: string;
155
+ /** ISO timestamp of the run attestation. */
156
+ runAttestedAt: string;
157
+ /** `@zanii/train`'s `AbsenceProof` for this subject's tag, verified against the manifest. */
158
+ absenceProof: AbsenceProof;
159
+ }
160
+
161
+ /**
162
+ * Optional image evidence for the "no copy was kept" line: a CLAIMED destruction-image
163
+ * attestation plus the image hash the operator published for it. What is checked here
164
+ * is bag-internal consistency only, via `@zanii/attest`'s `attestationHash` convention
165
+ * (the published image hash is `attestationHash` of the published attestation record):
166
+ * a mismatch is `failed`. A match does NOT by itself prove the destruction ran under
167
+ * that image: nothing yet binds the shred receipt to the attestation, and the
168
+ * attestation's quote is not verified on this path. Full binding ships with the
169
+ * enclave phase.
170
+ */
171
+ export interface DestructionImageEvidence {
172
+ /** The attestation (`@zanii/attest`'s `Attestation`) claimed for the destruction image. */
173
+ attestation: Attestation;
174
+ /** The published image hash: `attestationHash` of the published attestation record. */
175
+ publishedImageHash: string;
176
+ }
177
+
178
+ /**
179
+ * Optional evidence bag for rows 3–5. Row 5 needs no bag entry for the destruction
180
+ * record itself: the shred receipts are read from the subject's slice, each already
181
+ * signature-covered and offline-verified. `destructionImage` only checks a claimed
182
+ * destruction-image attestation against the published image hash.
183
+ */
184
+ export interface ExtraEvidence {
185
+ /**
186
+ * One coverage attestation, or an ordered CHAIN of them. A real deployment
187
+ * restarts, redeploys and upgrades its image, and each of those ends a window,
188
+ * so coverage of a subject's whole history is a chain of contiguous windows —
189
+ * the `@zanii/succession` shape, applied to enclave lifetime instead of keys.
190
+ * Every window must authenticate on its own AND abut the previous one: a gap
191
+ * between windows is a gap in coverage, and a gap in coverage is not coverage.
192
+ */
193
+ coverage?: CoverageEvidence | CoverageEvidence[];
194
+ training?: TrainingEvidence;
195
+ destructionImage?: DestructionImageEvidence;
196
+ /**
197
+ * Row 6 trust root: the declared jurisdiction as an allow-set of region codes
198
+ * (`@zanii/residency`'s `allowedRegions`, e.g. `['AE', 'me-central-1']`). READER
199
+ * configuration (deployment-side), never producer-supplied: the same trust rule as
200
+ * `coverage.trustedSigners`, or a producer could declare the whole world in bounds.
201
+ * When absent, residency claims can reach `cannot determine`, never `proven`. An
202
+ * empty array allows no region: every verified claim is a crossing.
203
+ */
204
+ jurisdiction?: string[];
205
+ /**
206
+ * Row 7 trust root: the declared subprocessor list (the parties onward transfers
207
+ * may name). READER configuration, never producer-supplied, same trust rule as
208
+ * `coverage.trustedSigners`. When absent, transfers or junctions on record reach
209
+ * `cannot determine`, never `proven`. An empty array declares no one.
210
+ */
211
+ declaredSubprocessors?: string[];
212
+ }
213
+
214
+ /** Caller-injected verifiers: the enclave quote verifier for row 3, the region
215
+ * signal verifier for row 6. */
216
+ export interface VerdictOptions {
217
+ /**
218
+ * Platform-specific enclave quote verifier (`@zanii/attest`'s `QuoteVerifier`).
219
+ * Without it a signed coverage attestation can reach `cannot determine`, never
220
+ * `proven`: the quote stays unknown, and unknown is not verified.
221
+ */
222
+ verifyQuote?: QuoteVerifier;
223
+ /**
224
+ * Region signal verifier (`@zanii/residency`'s `RegionVerifier`): authenticates a
225
+ * claim's region attestation (a cloud provider identity document, a TEE geo
226
+ * signal) and returns the trusted region. Without it residency claims can reach
227
+ * `cannot determine`, never `proven`.
228
+ */
229
+ verifyRegion?: RegionVerifier;
230
+ }
231
+
232
+ const LABELS: Record<VerdictId, string> = {
233
+ purpose: 'Used only for its declared purpose',
234
+ self_sovereign: 'You can check this yourself',
235
+ human_read: 'Nobody read it',
236
+ trained_on: 'Never used to train a model',
237
+ copy_kept: 'No copy was kept',
238
+ residency: 'Data stayed in its declared region',
239
+ subprocessor: 'Only declared third parties touched it',
240
+ minimization: 'Only what was needed was taken',
241
+ };
242
+
243
+ /** A receipt as served by the ledger: core fields plus the operator-revealed,
244
+ * signature-covered embedded payload (the ledger has no payload store). */
245
+ type ReceiptWithPayload = Receipt & { payload?: unknown };
246
+
247
+ function touchPayloadOf(sr: SubjectReceipt): unknown {
248
+ return (sr?.receipt as ReceiptWithPayload | undefined)?.payload;
249
+ }
250
+
251
+ function asPayload(p: unknown): Record<string, unknown> | undefined {
252
+ return p !== null && typeof p === 'object' && !Array.isArray(p)
253
+ ? (p as Record<string, unknown>)
254
+ : undefined;
255
+ }
256
+
257
+ const plural = (n: number, word: string, pluralForm?: string) => `${n} ${n === 1 ? word : (pluralForm ?? `${word}s`)}`;
258
+
259
+ function parseTime(ts: unknown): number | null {
260
+ if (typeof ts !== 'string') return null;
261
+ const ms = Date.parse(ts);
262
+ return Number.isNaN(ms) ? null : ms;
263
+ }
264
+
265
+ /** `sha256:<64 hex>` → bytes, null on any other shape. */
266
+ function parseHash(s: unknown): Uint8Array | null {
267
+ if (typeof s !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(s)) return null;
268
+ return hexToBytes(s.slice('sha256:'.length));
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Row 1 (purpose): "Used only for its declared purpose"
273
+ // ---------------------------------------------------------------------------
274
+
275
+ // Structural validators for the evidence payloads rows 6-8 consume, shared with row
276
+ // 1's closed world. Creation paths throw, verification paths return reasons.
277
+
278
+ /** Structural check of a `@zanii/residency` claim (`buildResidencyClaim`'s shape). */
279
+ function residencyClaimReasons(p: Record<string, unknown>): string[] {
280
+ const reasons: string[] = [];
281
+ if (p.v !== 1 || p.type !== 'residency.claim') reasons.push('not a residency claim (v1)');
282
+ const ra = asPayload(p.region_attestation);
283
+ if (!ra) reasons.push('missing region attestation');
284
+ else {
285
+ if (typeof ra.region !== 'string' || !ra.region) reasons.push('missing/invalid region');
286
+ if (typeof ra.source !== 'string' || !ra.source) reasons.push('missing region source');
287
+ }
288
+ if (parseTime(p.ts) === null) reasons.push('missing/invalid ts');
289
+ return reasons;
290
+ }
291
+
292
+ /** Structural check of a `@zanii/subprocessor` transfer (`buildTransfer`'s shape). */
293
+ function transferReasons(p: Record<string, unknown>): string[] {
294
+ const reasons: string[] = [];
295
+ if (p.v !== 1 || p.type !== 'subprocessor.transfer') reasons.push('not a subprocessor transfer (v1)');
296
+ if (typeof p.from !== 'string' || !p.from) reasons.push('missing from');
297
+ if (typeof p.to !== 'string' || !p.to) reasons.push('missing to');
298
+ const agreement = asPayload(p.agreement);
299
+ if (!agreement || typeof agreement.type !== 'string' || !agreement.type) reasons.push('missing agreement');
300
+ if (parseTime(p.ts) === null) reasons.push('missing/invalid ts');
301
+ return reasons;
302
+ }
303
+
304
+ /** Structural check of a `@zanii/minimization` field-set commitment record: the
305
+ * `commitFieldSet` output (`{ commitment, fields }`) carried as a payload of type
306
+ * `minimization.commit`. */
307
+ function commitReasons(p: Record<string, unknown>): string[] {
308
+ const reasons: string[] = [];
309
+ if (p.type !== 'minimization.commit') reasons.push('not a field-set commitment record');
310
+ if (typeof p.commitment !== 'string' || !p.commitment.startsWith('minfields:')) reasons.push('missing/invalid commitment');
311
+ if (!Array.isArray(p.fields) || p.fields.some((f) => typeof f !== 'string')) reasons.push('missing/invalid fields');
312
+ return reasons;
313
+ }
314
+
315
+ /** Structural check of a `@zanii/minimization` access proof (`buildAccessProof`'s shape). */
316
+ function accessReasons(p: Record<string, unknown>): string[] {
317
+ const reasons: string[] = [];
318
+ if (p.v !== 1 || p.type !== 'minimization.access') reasons.push('not a minimization access proof (v1)');
319
+ if (typeof p.field_set !== 'string' || !p.field_set.startsWith('minfields:')) reasons.push('missing/invalid field_set');
320
+ if (!Array.isArray(p.accessed) || p.accessed.some((f) => typeof f !== 'string')) reasons.push('missing/invalid accessed');
321
+ return reasons;
322
+ }
323
+
324
+ /** The evidence payload kinds rows 6-8 consume, identified by their `type` tag. */
325
+ function evidenceKindOf(p: Record<string, unknown>): 'residency' | 'transfer' | 'commit' | 'access' | null {
326
+ switch (p.type) {
327
+ case 'residency.claim': return 'residency';
328
+ case 'subprocessor.transfer': return 'transfer';
329
+ case 'minimization.commit': return 'commit';
330
+ case 'minimization.access': return 'access';
331
+ default: return null;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * A slice receipt is well-formed custody evidence when its embedded payload is a
337
+ * well-formed custody touch (`@zanii/data-custody`) OR a well-formed retention shred
338
+ * (`@zanii/retention`) OR one of the evidence payloads rows 6-8 consume (a residency
339
+ * claim, a subprocessor transfer, a field-set commitment, a minimization access
340
+ * proof). Anything else in a custody slice is a red. Closed world on purpose: new
341
+ * custody kinds stay red until wired here. The returned kind lets the row say
342
+ * honestly what passed or what failed: a retention payload never gets touch-schema
343
+ * reasons.
344
+ */
345
+ function wellFormedCustodyPayload(
346
+ p: unknown,
347
+ ): { ok: true; kind: 'touch' | 'shred' | 'residency' | 'transfer' | 'commit' | 'access' } | { ok: false; kind: 'touch' | 'retention' | 'residency' | 'transfer' | 'commit' | 'access'; reasons: string[] } {
348
+ const payload = asPayload(p);
349
+ // A retention-claimed payload is checked as one, pass or fail.
350
+ if (payload && payload['_zr_kind'] === 'retention') {
351
+ let shred: { ok: boolean; reasons: string[] };
352
+ try {
353
+ shred = verifyKeyDestruction(payload);
354
+ } catch {
355
+ shred = { ok: false, reasons: ['unreadable payload'] };
356
+ }
357
+ return shred.ok ? { ok: true, kind: 'shred' } : { ok: false, kind: 'retention', reasons: shred.reasons };
358
+ }
359
+ const kind = payload ? evidenceKindOf(payload) : null;
360
+ if (kind !== null && payload) {
361
+ const reasons = kind === 'residency' ? residencyClaimReasons(payload)
362
+ : kind === 'transfer' ? transferReasons(payload)
363
+ : kind === 'commit' ? commitReasons(payload)
364
+ : accessReasons(payload);
365
+ return reasons.length === 0 ? { ok: true, kind } : { ok: false, kind, reasons };
366
+ }
367
+ let touch: { ok: boolean; reasons: string[] };
368
+ try {
369
+ touch = verifyTouch(payload);
370
+ } catch {
371
+ touch = { ok: false, reasons: ['unreadable payload'] };
372
+ }
373
+ return touch.ok ? { ok: true, kind: 'touch' } : { ok: false, kind: 'touch', reasons: touch.reasons };
374
+ }
375
+
376
+ const EVIDENCE_NOUNS: Record<'residency' | 'transfer' | 'commit' | 'access', [string, string?]> = {
377
+ residency: ['residency claim'],
378
+ transfer: ['transfer record'],
379
+ commit: ['field-set commitment'],
380
+ access: ['access proof'],
381
+ };
382
+
383
+ function purposeRow(history: SubjectHistory, receipts: SubjectReceipt[]): VerdictRow {
384
+ const id = 'purpose' as const;
385
+ if (receipts.length === 0) {
386
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
387
+ }
388
+ if (history.verified !== true) {
389
+ return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
390
+ }
391
+ let touches = 0;
392
+ let shreds = 0;
393
+ const evidence: Array<'residency' | 'transfer' | 'commit' | 'access'> = [];
394
+ for (const sr of receipts) {
395
+ const check = wellFormedCustodyPayload(touchPayloadOf(sr));
396
+ if (!check.ok) {
397
+ const what = check.kind === 'touch' ? 'custody touch'
398
+ : check.kind === 'retention' ? 'retention'
399
+ : `${EVIDENCE_NOUNS[check.kind][0]}`;
400
+ return {
401
+ id,
402
+ label: LABELS[id],
403
+ state: 'failed',
404
+ detail: `Receipt ${sr?.hash} (index ${sr?.index}) has a malformed ${what} payload: ${check.reasons.join(', ')}.`,
405
+ };
406
+ }
407
+ if (check.kind === 'shred') shreds += 1;
408
+ else if (check.kind === 'touch') touches += 1;
409
+ else evidence.push(check.kind);
410
+ }
411
+ if (evidence.length === 0) {
412
+ if (shreds === 0) {
413
+ return {
414
+ id,
415
+ label: LABELS[id],
416
+ state: 'proven',
417
+ 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.`,
418
+ };
419
+ }
420
+ return {
421
+ id,
422
+ label: LABELS[id],
423
+ state: 'proven',
424
+ 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.`,
425
+ };
426
+ }
427
+ // Slices that also carry the rows 6-8 evidence payloads: enumerate them honestly.
428
+ const counts = new Map<string, number>();
429
+ for (const k of evidence) counts.set(k, (counts.get(k) ?? 0) + 1);
430
+ const named = [...counts.entries()].map(([k, n]) => {
431
+ const [noun, pluralForm] = EVIDENCE_NOUNS[k as keyof typeof EVIDENCE_NOUNS];
432
+ return plural(n, noun, pluralForm);
433
+ });
434
+ const parts = [`${plural(touches, 'touch', 'touches')} carried a well-formed custody touch payload naming its data class and operation`];
435
+ if (shreds > 0) parts.push(`${plural(shreds, 'retention shred')} carried a well-formed key-destruction payload`);
436
+ parts.push(`${named.join(', ')} passed structural checks`);
437
+ return {
438
+ id,
439
+ label: LABELS[id],
440
+ state: 'proven',
441
+ detail: `All ${plural(receipts.length, 'receipt')} in this slice verified offline (signature, delegation, scope): ${parts.join(', and ')}.`,
442
+ };
443
+ }
444
+
445
+ function offlineFailureDetail(history: SubjectHistory): string {
446
+ const failures = Array.isArray(history.failures) ? history.failures : [];
447
+ if (failures.length === 0) return 'The slice did not verify offline.';
448
+ return `${plural(failures.length, 'receipt')} failed offline verification: ${failures.join('; ')}. A receipt that does not verify is a failure, not an unknown.`;
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Row 2 (self-sovereign): "You can check this yourself"
453
+ // ---------------------------------------------------------------------------
454
+
455
+ function selfSovereignRow(history: SubjectHistory, receipts: SubjectReceipt[]): VerdictRow {
456
+ const id = 'self_sovereign' as const;
457
+ if (receipts.length === 0) {
458
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
459
+ }
460
+ if (history.verified !== true) {
461
+ return { id, label: LABELS[id], state: 'failed', detail: offlineFailureDetail(history) };
462
+ }
463
+ if (history.sth === undefined || history.sth === null) {
464
+ return {
465
+ id,
466
+ label: LABELS[id],
467
+ state: 'cannot determine',
468
+ detail: 'The slice came with no signed tree head, so the anchor commitment could not be verified.',
469
+ };
470
+ }
471
+ let sthOk = false;
472
+ try {
473
+ sthOk = verifySTH(history.sth as unknown as SignedTreeHead);
474
+ } catch {
475
+ sthOk = false;
476
+ }
477
+ if (!sthOk) {
478
+ return {
479
+ id,
480
+ label: LABELS[id],
481
+ state: 'failed',
482
+ detail: "The signed tree head failed verification: the log's commitment to this slice does not check out.",
483
+ };
484
+ }
485
+ const size = (history.sth as { size?: unknown }).size;
486
+ return {
487
+ id,
488
+ label: LABELS[id],
489
+ state: 'proven',
490
+ 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.`,
491
+ };
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Row 3 (human_read): "Nobody read it"
496
+ // ---------------------------------------------------------------------------
497
+
498
+ async function humanReadRow(
499
+ receipts: SubjectReceipt[],
500
+ evidence: ExtraEvidence,
501
+ verifyQuote: QuoteVerifier | undefined,
502
+ ): Promise<VerdictRow> {
503
+ const id = 'human_read' as const;
504
+ // A receipt shows a human actor when its embedded touch payload declares one
505
+ // (`actor: "human"`). The check is cheap and always runs, evidence or not, and a
506
+ // human actor beats any coverage attestation: failed stays failed.
507
+ for (const sr of receipts) {
508
+ const p = asPayload(touchPayloadOf(sr));
509
+ if (p && p['actor'] === 'human') {
510
+ return {
511
+ id,
512
+ label: LABELS[id],
513
+ state: 'failed',
514
+ detail: `Receipt ${sr?.hash} (index ${sr?.index}) records a human actor: a person, not only an agent, touched this data.`,
515
+ };
516
+ }
517
+ }
518
+ const bag = evidence.coverage;
519
+ const windows: CoverageEvidence[] = Array.isArray(bag) ? bag : bag ? [bag] : [];
520
+ if (windows.length === 0) {
521
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No coverage attestation for this window' };
522
+ }
523
+ for (const c of windows) {
524
+ if (typeof c !== 'object' || c === null || Array.isArray(c) || c.v !== 1 || c.type !== 'coverage.attestation') {
525
+ return { id, label: LABELS[id], state: 'not covered', detail: 'The coverage evidence is not a signed coverage attestation.' };
526
+ }
527
+ }
528
+ if (receipts.length === 0) {
529
+ // Coverage over nothing proves nothing about the subject's data.
530
+ return {
531
+ id,
532
+ label: LABELS[id],
533
+ state: 'cannot determine',
534
+ 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.",
535
+ };
536
+ }
537
+ // Every window authenticates on its own before any of them counts: one bad link
538
+ // fails the chain, because a chain is only coverage if every part of it holds.
539
+ const spans: Array<{ start: number; end: number; startIso: string; endIso: string }> = [];
540
+ for (const coverage of windows) {
541
+ const checked = await checkCoverageWindow(coverage, verifyQuote);
542
+ if ('state' in checked) return { id, label: LABELS[id], state: checked.state, detail: checked.detail };
543
+ spans.push({ ...checked, startIso: coverage.windowStart, endIso: coverage.windowEnd });
544
+ }
545
+ // Contiguity: sorted by start, each window must begin no later than the running end
546
+ // of the ones before it. Abutting and overlapping are both fine; a gap is not.
547
+ spans.sort((a, b) => a.start - b.start);
548
+ let unionEnd = spans[0]!.end;
549
+ let unionEndIso = spans[0]!.endIso;
550
+ for (let k = 1; k < spans.length; k++) {
551
+ const w = spans[k]!;
552
+ if (w.start > unionEnd) {
553
+ return {
554
+ id,
555
+ label: LABELS[id],
556
+ state: 'not covered',
557
+ detail: `The coverage chain has a gap between ${new Date(unionEnd).toISOString()} and ${new Date(w.start).toISOString()}: egress was unattested in that interval, and an unattested interval is not coverage.`,
558
+ };
559
+ }
560
+ if (w.end > unionEnd) {
561
+ unionEnd = w.end;
562
+ unionEndIso = w.endIso;
563
+ }
564
+ }
565
+ const unionStart = spans[0]!.start;
566
+ const unionStartIso = spans[0]!.startIso;
567
+ const times = receipts.map((sr) => parseTime(sr?.receipt?.ts));
568
+ if (times.some((t) => t === null || t < unionStart || t > unionEnd)) {
569
+ const valid = times.filter((t): t is number => t !== null);
570
+ const span = valid.length > 0
571
+ ? ` (this slice spans ${new Date(Math.min(...valid)).toISOString()} to ${new Date(Math.max(...valid)).toISOString()})`
572
+ : '';
573
+ return {
574
+ id,
575
+ label: LABELS[id],
576
+ state: 'not covered',
577
+ detail: `The coverage attestation (${unionStartIso} to ${unionEndIso}) does not cover every receipt in this slice${span}.`,
578
+ };
579
+ }
580
+ const images = [...new Set(windows.map((w) => w.imageHash))];
581
+ const under = images.length === 1 ? `attested image ${images[0]}` : `${images.length} attested images (${images.join(', ')})`;
582
+ const chain = windows.length === 1
583
+ ? `the whole window (${unionStartIso} to ${unionEndIso})`
584
+ : `an unbroken chain of ${windows.length} windows (${unionStartIso} to ${unionEndIso})`;
585
+ return {
586
+ id,
587
+ label: LABELS[id],
588
+ state: 'proven',
589
+ detail: `Signed coverage attestation verified (signature, signer-to-image binding and enclave quote): all egress paths were receipt-gated for ${chain} under ${under}, and no receipt in this slice shows a human actor.`,
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Authenticate ONE coverage window. Returns its time span, or the verdict state that
595
+ * window fails with. Authenticity failures are `failed` (a claim that does not verify
596
+ * is a red, not an unknown); a missing quote verifier is `cannot determine`; a claim
597
+ * that simply is not asserted is `not covered`.
598
+ */
599
+ async function checkCoverageWindow(
600
+ coverage: CoverageEvidence,
601
+ verifyQuote: QuoteVerifier | undefined,
602
+ ): Promise<{ start: number; end: number } | { state: VerdictState; detail: string }> {
603
+ // Authenticate before evaluating: an unsigned or badly-signed claim is not evidence.
604
+ const signerPub = publicKeyFromDid(typeof coverage.signer === 'string' ? coverage.signer : '');
605
+ if (!signerPub || typeof coverage.sig !== 'string' || !/^ed25519:[0-9a-f]{128}$/.test(coverage.sig)) {
606
+ return {
607
+ state: 'failed',
608
+ detail: 'The coverage attestation is unsigned or carries a malformed signature: an unsigned coverage claim is not evidence.',
609
+ };
610
+ }
611
+ // The trust root: when the deployment pins coverage-signing DIDs, a signer outside
612
+ // the list is never proven, however good its signature.
613
+ const trustedSigners = coverage.trustedSigners;
614
+ if (Array.isArray(trustedSigners) && !trustedSigners.includes(coverage.signer)) {
615
+ return {
616
+ state: 'failed',
617
+ 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.`,
618
+ };
619
+ }
620
+ // trustedSigners is reader-side config merged in after signing: it is NOT part of
621
+ // the signed record, so it is excluded from the canonical preimage here.
622
+ const { sig: _omitted, trustedSigners: _ts, ...unsigned } = coverage;
623
+ let sigOk = false;
624
+ try {
625
+ sigOk = ed25519.verify(hexToBytes(coverage.sig.slice('ed25519:'.length)), canonicalBytes(unsigned as Record<string, unknown>), signerPub);
626
+ } catch {
627
+ sigOk = false;
628
+ }
629
+ if (!sigOk) {
630
+ return {
631
+ state: 'failed',
632
+ detail: "The coverage attestation's signature does not verify: a coverage claim that does not verify is a failure, not an unknown.",
633
+ };
634
+ }
635
+ // The claim must be bound to an attested image: attestationHash(embedded) === imageHash.
636
+ const attestation = asPayload(coverage.attestation) as Attestation | undefined;
637
+ let boundHash: string | null = null;
638
+ if (attestation) {
639
+ try {
640
+ boundHash = attestationHash(attestation);
641
+ } catch {
642
+ boundHash = null;
643
+ }
644
+ }
645
+ if (!attestation || boundHash === null || coverage.imageHash !== boundHash) {
646
+ return {
647
+ state: 'failed',
648
+ detail: "The coverage attestation's image hash does not match its embedded attestation (attestationHash convention): the binding to the attested image is broken.",
649
+ };
650
+ }
651
+ // The embedded attestation verifies through @zanii/attest with the injected quote
652
+ // verifier. A disproven quote is a red; no verifier injected is an honest grey.
653
+ let check: AttestCheck;
654
+ try {
655
+ check = await verifyAttestation(attestation, verifyQuote ? { verifyQuote } : {});
656
+ } catch {
657
+ check = { ok: false, structural: false, quoteVerified: false, reasons: ['attestation verification failed'] };
658
+ }
659
+ if (!check.structural) {
660
+ return { state: 'failed', detail: `The coverage attestation's embedded attestation is malformed: ${check.reasons.join(', ')}.` };
661
+ }
662
+ // And the SIGNING KEY must be bound to that image too. The enclave commits its
663
+ // signing DID to the quote as the attestation nonce, which attestationHash covers.
664
+ // Without this check a real published attestation can be lifted and re-signed by
665
+ // anyone, and only an optional trustedSigners pin would catch it.
666
+ if (attestation.nonce !== coverage.signer) {
667
+ return {
668
+ state: 'failed',
669
+ detail: `The coverage attestation's signing key is not bound to the attested image: the quote commits to ${attestation.nonce ?? '(no nonce)'}, but the record is signed by ${coverage.signer}. A key the enclave never vouched for is not evidence.`,
670
+ };
671
+ }
672
+ if (check.quoteVerified === false) {
673
+ return {
674
+ state: 'failed',
675
+ detail: "The coverage attestation's enclave quote failed verification: the code that claimed coverage is not confirmed to be the attested image.",
676
+ };
677
+ }
678
+ if (check.quoteVerified === null) {
679
+ return {
680
+ state: 'cannot determine',
681
+ detail: 'The coverage attestation is signed and well-formed, but no enclave quote verifier was injected, so the attested image could not be confirmed.',
682
+ };
683
+ }
684
+ if (coverage.allPathsGated !== true) {
685
+ return { state: 'not covered', detail: 'The coverage attestation does not assert that all egress paths were receipt-gated.' };
686
+ }
687
+ const start = parseTime(coverage.windowStart);
688
+ const end = parseTime(coverage.windowEnd);
689
+ if (start === null || end === null || start > end) {
690
+ return { state: 'not covered', detail: 'The coverage attestation window is missing or invalid.' };
691
+ }
692
+ return { start, end };
693
+ }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // Row 4 (trained_on): "Never used to train a model"
697
+ // ---------------------------------------------------------------------------
698
+
699
+ function trainedOnRow(history: SubjectHistory, receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
700
+ const id = 'trained_on' as const;
701
+ const training = evidence.training;
702
+ if (!training) {
703
+ // Exact wording matters: a missing proof is NOT a clean result. Never "never".
704
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No declared training runs to check against' };
705
+ }
706
+ const anchoredAt = parseTime(training.anchoredAt);
707
+ const runAttestedAt = parseTime(training.runAttestedAt);
708
+ if (anchoredAt === null || runAttestedAt === null) {
709
+ return {
710
+ id,
711
+ label: LABELS[id],
712
+ state: 'cannot determine',
713
+ detail: 'The training evidence is missing a valid manifest anchor time or run attestation time, so the ordering cannot be checked.',
714
+ };
715
+ }
716
+ if (anchoredAt > runAttestedAt) {
717
+ return {
718
+ id,
719
+ label: LABELS[id],
720
+ state: 'cannot determine',
721
+ 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.`,
722
+ };
723
+ }
724
+ if (receipts.length === 0) {
725
+ return {
726
+ id,
727
+ label: LABELS[id],
728
+ state: 'not covered',
729
+ detail: "This slice has no receipts: a training-absence proof over an empty slice proves nothing about this subject's data.",
730
+ };
731
+ }
732
+ const manifest = training.manifest;
733
+ if (
734
+ !manifest || typeof manifest !== 'object' || manifest.v !== 1 || manifest.type !== 'training.manifest'
735
+ || typeof manifest.run_id !== 'string' || typeof manifest.root !== 'string' || parseHash(manifest.root) === null
736
+ || !Number.isInteger(manifest.size) || manifest.size < 0
737
+ ) {
738
+ return {
739
+ id,
740
+ label: LABELS[id],
741
+ state: 'failed',
742
+ detail: 'The training manifest is missing or malformed, so the absence proof cannot be checked against it.',
743
+ };
744
+ }
745
+ // The proof must speak to THIS subject, not someone else.
746
+ const proof = training.absenceProof;
747
+ if (!proof || typeof proof !== 'object' || proof.ref !== history.tag) {
748
+ return {
749
+ id,
750
+ label: LABELS[id],
751
+ state: 'failed',
752
+ detail: "The non-membership proof is missing or names a different ref than this subject's tag, so it says nothing about this slice.",
753
+ };
754
+ }
755
+ let ok = false;
756
+ try {
757
+ ok = verifyAbsence(proof, manifest);
758
+ } catch {
759
+ ok = false;
760
+ }
761
+ if (!ok) {
762
+ return {
763
+ id,
764
+ label: LABELS[id],
765
+ state: 'failed',
766
+ detail: `The non-membership proof does not verify against the declared training manifest (root ${manifest.root}).`,
767
+ };
768
+ }
769
+ return {
770
+ id,
771
+ label: LABELS[id],
772
+ state: 'proven',
773
+ 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.`,
774
+ };
775
+ }
776
+
777
+ // ---------------------------------------------------------------------------
778
+ // Row 5 (copy_kept): "No copy was kept"
779
+ // ---------------------------------------------------------------------------
780
+
781
+ /**
782
+ * The destruction evidence lives IN the slice: a receipt stamped with this subject's
783
+ * tag whose embedded, signature-covered payload is a `@zanii/retention` shred
784
+ * (`buildKeyDestruction`'s shape). Slice membership binds the receipt to this subject
785
+ * (the ledger serves the receipt only under this tag, and the receipt's own offline
786
+ * check re-verifies here); the payload's salted subject commitment can later be
787
+ * proven to name the subject by revealing the salt. Binding to the OBJECT is by ref:
788
+ * a shred counts only for an object whose custody touches carry the same payload
789
+ * `ref`, so a shred for an unrelated object never turns this row green.
790
+ */
791
+ function shredReceiptsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
792
+ const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
793
+ for (const sr of receipts) {
794
+ const p = asPayload(touchPayloadOf(sr));
795
+ if (p && p['_zr_kind'] === 'retention' && p['action'] === 'shred') out.push({ sr, payload: p });
796
+ }
797
+ return out;
798
+ }
799
+
800
+ /** The refs of the custody touches in the slice: the objects destruction must bind to. */
801
+ function touchRefsOf(receipts: SubjectReceipt[]): string[] {
802
+ const refs = new Set<string>();
803
+ for (const sr of receipts) {
804
+ const p = asPayload(touchPayloadOf(sr));
805
+ if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string') refs.add(p['ref']);
806
+ }
807
+ return [...refs].sort();
808
+ }
809
+
810
+ function copyKeptRow(receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
811
+ const id = 'copy_kept' as const;
812
+ const shreds = shredReceiptsOf(receipts);
813
+ if (shreds.length === 0) {
814
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
815
+ }
816
+ // A presented-but-broken shred is a red, not a grey: any failure anywhere in the
817
+ // slice's destruction records fails the row, however many others verify.
818
+ for (const { sr, payload } of shreds) {
819
+ if (sr?.check?.ok !== true) {
820
+ return {
821
+ id,
822
+ label: LABELS[id],
823
+ state: 'failed',
824
+ detail: `Receipt ${sr?.hash} (index ${sr?.index}) carries a key-destruction payload, but the receipt itself failed offline verification.`,
825
+ };
826
+ }
827
+ let check: { ok: boolean; reasons: string[] };
828
+ try {
829
+ check = verifyKeyDestruction(payload);
830
+ } catch {
831
+ check = { ok: false, reasons: ['unreadable payload'] };
832
+ }
833
+ if (!check.ok) {
834
+ return {
835
+ id,
836
+ label: LABELS[id],
837
+ state: 'failed',
838
+ detail: `The key-destruction payload on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
839
+ };
840
+ }
841
+ }
842
+ // Ref binding: a shred is evidence only for the objects this slice touches. A
843
+ // shred carrying no ref, or a ref no touch in this slice carries, does not count.
844
+ const touchRefs = touchRefsOf(receipts);
845
+ const bound = shreds.filter(({ payload }) => typeof payload['ref'] === 'string' && touchRefs.includes(payload['ref'] as string));
846
+ if (bound.length === 0) {
847
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No key-destruction record on file.' };
848
+ }
849
+ const coveredRefs = new Set(bound.map(({ payload }) => payload['ref'] as string));
850
+ const uncoveredRefs = touchRefs.filter((r) => !coveredRefs.has(r));
851
+ if (uncoveredRefs.length > 0) {
852
+ return {
853
+ id,
854
+ label: LABELS[id],
855
+ state: 'cannot determine',
856
+ 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.`,
857
+ };
858
+ }
859
+ // Optional image evidence: check the claimed attestation hashes to the published
860
+ // image hash (bag-internal consistency; it does not bind the shred to the image).
861
+ const image = evidence.destructionImage;
862
+ let imagePart = '';
863
+ if (image !== undefined && image !== null) {
864
+ const attestation = asPayload((image as DestructionImageEvidence).attestation) as Attestation | undefined;
865
+ const published = (image as DestructionImageEvidence).publishedImageHash;
866
+ let attested: string | null = null;
867
+ if (attestation) {
868
+ try {
869
+ attested = attestationHash(attestation);
870
+ } catch {
871
+ attested = null;
872
+ }
873
+ }
874
+ if (!attestation || attested === null || typeof published !== 'string' || published.length === 0) {
875
+ return {
876
+ id,
877
+ label: LABELS[id],
878
+ state: 'failed',
879
+ detail: 'The destruction image evidence is incomplete: it must carry the attestation record and the published image hash.',
880
+ };
881
+ }
882
+ if (attested !== published) {
883
+ return {
884
+ id,
885
+ label: LABELS[id],
886
+ state: 'failed',
887
+ detail: `The attested image hash (${attested}) does not match the published image hash (${published}).`,
888
+ };
889
+ }
890
+ imagePart = ` The attested image hash matches the published one (${published}).`;
891
+ }
892
+ const first = bound[0]!;
893
+ return {
894
+ id,
895
+ label: LABELS[id],
896
+ state: 'proven',
897
+ 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}`,
898
+ };
899
+ }
900
+
901
+ // ---------------------------------------------------------------------------
902
+ // Row 6 (residency): "Data stayed in its declared region"
903
+ // ---------------------------------------------------------------------------
904
+
905
+ /** Residency claims carried by the slice (`buildResidencyClaim`'s shape, embedded
906
+ * signature-covered exactly like the row-5 shred payloads). */
907
+ function residencyClaimsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
908
+ const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
909
+ for (const sr of receipts) {
910
+ const p = asPayload(touchPayloadOf(sr));
911
+ if (p && p['type'] === 'residency.claim') out.push({ sr, payload: p });
912
+ }
913
+ return out;
914
+ }
915
+
916
+ /** A claim whose region signal is the operator asserting its own region: not an
917
+ * attestation, whatever it claims. */
918
+ function isSelfDeclared(p: Record<string, unknown>): boolean {
919
+ const source = asPayload(p.region_attestation)?.source;
920
+ return typeof source === 'string' && source.toLowerCase().startsWith('self');
921
+ }
922
+
923
+ function receiptDetailPrefix(sr: SubjectReceipt): string {
924
+ return `Receipt ${sr?.hash} (index ${sr?.index})`;
925
+ }
926
+
927
+ async function residencyRow(
928
+ receipts: SubjectReceipt[],
929
+ evidence: ExtraEvidence,
930
+ verifyRegion: RegionVerifier | undefined,
931
+ ): Promise<VerdictRow> {
932
+ const id = 'residency' as const;
933
+ if (receipts.length === 0) {
934
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
935
+ }
936
+ const claims = residencyClaimsOf(receipts);
937
+ if (claims.length === 0) {
938
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No residency attestations on record' };
939
+ }
940
+ // A self-declared claim is no attestation: it does not count as evidence, here or
941
+ // in the coverage and crossing checks below.
942
+ const attested = claims.filter(({ payload }) => !isSelfDeclared(payload));
943
+ const selfDeclared = claims.length - attested.length;
944
+ if (attested.length === 0) {
945
+ return {
946
+ id,
947
+ label: LABELS[id],
948
+ state: 'not covered',
949
+ 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.`,
950
+ };
951
+ }
952
+ // A presented-but-broken claim is a red, not a grey: structural check first, then
953
+ // the receipt's own offline verification (an unverified receipt's payload is not
954
+ // signature-covered evidence).
955
+ for (const { sr, payload } of attested) {
956
+ const reasons = residencyClaimReasons(payload);
957
+ if (reasons.length > 0) {
958
+ return {
959
+ id,
960
+ label: LABELS[id],
961
+ state: 'failed',
962
+ detail: `${receiptDetailPrefix(sr)} carries a malformed residency claim: ${reasons.join(', ')}.`,
963
+ };
964
+ }
965
+ if (sr?.check?.ok !== true) {
966
+ return {
967
+ id,
968
+ label: LABELS[id],
969
+ state: 'failed',
970
+ detail: `${receiptDetailPrefix(sr)} carries a residency claim payload, but the receipt itself failed offline verification.`,
971
+ };
972
+ }
973
+ }
974
+ // The jurisdiction allow-set is READER config, never producer-supplied (same trust
975
+ // rule as trustedSigners). Absent: unknown, never proven. Empty: allows no region,
976
+ // so every verified claim below is a crossing.
977
+ const jurisdiction = evidence.jurisdiction;
978
+ if (!Array.isArray(jurisdiction)) {
979
+ return {
980
+ id,
981
+ label: LABELS[id],
982
+ state: 'cannot determine',
983
+ detail: 'Residency claims are on record, but the reader provided no declared jurisdiction allow-set, so there is nothing to check them against.',
984
+ };
985
+ }
986
+ if (verifyRegion === undefined) {
987
+ return {
988
+ id,
989
+ label: LABELS[id],
990
+ state: 'cannot determine',
991
+ detail: 'Residency claims are on record, but no region verifier was injected, so the region signals could not be authenticated.',
992
+ };
993
+ }
994
+ const verified: Array<{ ts: string; region: string; ref: string | null }> = [];
995
+ for (const { sr, payload } of attested) {
996
+ let check: { ok: boolean; reasons: string[]; region: string | null; crossed: boolean };
997
+ try {
998
+ check = await verifyResidency(payload as unknown as ResidencyClaim, { allowedRegions: jurisdiction, verifyRegion });
999
+ } catch {
1000
+ check = { ok: false, reasons: ['the residency check failed'], region: null, crossed: false };
1001
+ }
1002
+ if (check.crossed) {
1003
+ // Do not echo the producer reason: its wording is not reader-facing.
1004
+ return {
1005
+ id,
1006
+ label: LABELS[id],
1007
+ state: 'failed',
1008
+ 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.`,
1009
+ };
1010
+ }
1011
+ if (!check.ok || check.region === null) {
1012
+ return {
1013
+ id,
1014
+ label: LABELS[id],
1015
+ state: 'failed',
1016
+ detail: `The residency claim on receipt ${sr?.hash} (index ${sr?.index}) did not verify: ${check.reasons.join(', ')}.`,
1017
+ };
1018
+ }
1019
+ verified.push({
1020
+ ts: payload.ts as string,
1021
+ region: check.region,
1022
+ ref: typeof payload.ref === 'string' && payload.ref ? payload.ref : null,
1023
+ });
1024
+ }
1025
+ // The sequence check over the verified regions: any exit from the allow-set is red.
1026
+ // (Per-claim verification already rejects an outside region, so this is belt and
1027
+ // suspenders; it also catches a sequence that STARTS outside, which per-claim
1028
+ // checks phrase differently.)
1029
+ const crossings = detectCrossings(verified, jurisdiction);
1030
+ if (crossings.length > 0) {
1031
+ const c = crossings[0]!;
1032
+ return {
1033
+ id,
1034
+ label: LABELS[id],
1035
+ state: 'failed',
1036
+ detail: `Verified residency claims show the data left the declared jurisdiction at ${c.ts} (moved to region ${c.region}).`,
1037
+ };
1038
+ }
1039
+ // Coverage is per touch, never window-span: EVERY custody touch in the slice must
1040
+ // be covered by a verified claim. A claim carrying a ref binds to every touch of
1041
+ // that object; a ref-less claim is time-scoped and covers the touches made at its
1042
+ // own timestamp. Anything looser would let two claims at the window's ends prove
1043
+ // green over an uncovered middle.
1044
+ const touches: Array<{ ref: string; ts: string }> = [];
1045
+ for (const sr of receipts) {
1046
+ const p = asPayload(touchPayloadOf(sr));
1047
+ if (p && p['_zdc'] === 1 && typeof p['ref'] === 'string' && parseTime(p['ts']) !== null) {
1048
+ touches.push({ ref: p['ref'] as string, ts: p['ts'] as string });
1049
+ }
1050
+ }
1051
+ if (touches.length === 0) {
1052
+ return {
1053
+ id,
1054
+ label: LABELS[id],
1055
+ state: 'cannot determine',
1056
+ detail: 'This slice carries no custody touches, so per-touch residency coverage cannot be established.',
1057
+ };
1058
+ }
1059
+ const isCovered = (t: { ref: string; ts: string }): boolean =>
1060
+ verified.some((c) => (c.ref !== null ? c.ref === t.ref : c.ts === t.ts));
1061
+ const uncovered = touches.filter((t) => !isCovered(t));
1062
+ if (uncovered.length > 0) {
1063
+ const covered = touches.length - uncovered.length;
1064
+ return {
1065
+ id,
1066
+ label: LABELS[id],
1067
+ state: 'cannot determine',
1068
+ 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.`,
1069
+ };
1070
+ }
1071
+ const selfNote = selfDeclared > 0
1072
+ ? ` ${plural(selfDeclared, 'self-declared claim')} ${selfDeclared === 1 ? 'was' : 'were'} not counted as evidence: the region source is self-declared.`
1073
+ : '';
1074
+ return {
1075
+ id,
1076
+ label: LABELS[id],
1077
+ state: 'proven',
1078
+ 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}`,
1079
+ };
1080
+ }
1081
+
1082
+ // ---------------------------------------------------------------------------
1083
+ // Row 7 (subprocessor): "Only declared third parties touched it"
1084
+ // ---------------------------------------------------------------------------
1085
+
1086
+ /** Onward transfers carried by the slice (`buildTransfer`'s shape). */
1087
+ function transfersOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
1088
+ const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
1089
+ for (const sr of receipts) {
1090
+ const p = asPayload(touchPayloadOf(sr));
1091
+ if (p && p['type'] === 'subprocessor.transfer') out.push({ sr, payload: p });
1092
+ }
1093
+ return out;
1094
+ }
1095
+
1096
+ /** Custody-touch junctions: the destinations the touch payloads say data crossed to. */
1097
+ function junctionsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; to: string }> {
1098
+ const out: Array<{ sr: SubjectReceipt; to: string }> = [];
1099
+ for (const sr of receipts) {
1100
+ const p = asPayload(touchPayloadOf(sr));
1101
+ const j = p ? asPayload(p['junction']) : undefined;
1102
+ // Only share/export are onward disclosures to a third party. Store/read/transform
1103
+ // may carry a junction for topology (source/destination of the data), but they
1104
+ // are not subprocessor transfers.
1105
+ const op = typeof p?.['op'] === 'string' ? p['op'] : undefined;
1106
+ if (j && typeof j.to === 'string' && j.to && (op === 'share' || op === 'export')) out.push({ sr, to: j.to });
1107
+ }
1108
+ return out;
1109
+ }
1110
+
1111
+ function subprocessorRow(receipts: SubjectReceipt[], evidence: ExtraEvidence): VerdictRow {
1112
+ const id = 'subprocessor' as const;
1113
+ const transfers = transfersOf(receipts);
1114
+ const junctions = junctionsOf(receipts);
1115
+ if (transfers.length === 0 && junctions.length === 0) {
1116
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No onward transfers declared or recorded' };
1117
+ }
1118
+ // Presented-but-broken is a red before any trust-root question.
1119
+ for (const { sr, payload } of transfers) {
1120
+ const reasons = transferReasons(payload);
1121
+ if (reasons.length > 0) {
1122
+ return {
1123
+ id,
1124
+ label: LABELS[id],
1125
+ state: 'failed',
1126
+ detail: `${receiptDetailPrefix(sr)} carries a malformed transfer record: ${reasons.join(', ')}.`,
1127
+ };
1128
+ }
1129
+ if (sr?.check?.ok !== true) {
1130
+ return {
1131
+ id,
1132
+ label: LABELS[id],
1133
+ state: 'failed',
1134
+ detail: `${receiptDetailPrefix(sr)} carries a transfer record payload, but the receipt itself failed offline verification.`,
1135
+ };
1136
+ }
1137
+ // Stricter than verifyTransfers (which requires an id only for dpa/scc): any
1138
+ // onward transfer without an agreement id is a red here.
1139
+ const agreement = asPayload(payload.agreement);
1140
+ const agreementId = agreement?.id;
1141
+ if (typeof agreementId !== 'string' || !agreementId) {
1142
+ return {
1143
+ id,
1144
+ label: LABELS[id],
1145
+ state: 'failed',
1146
+ 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.`,
1147
+ };
1148
+ }
1149
+ }
1150
+ // The declared subprocessor list is READER config, never producer-supplied (same
1151
+ // trust rule as trustedSigners). Without it there is nothing to check against.
1152
+ const declared = evidence.declaredSubprocessors;
1153
+ if (!Array.isArray(declared)) {
1154
+ return {
1155
+ id,
1156
+ label: LABELS[id],
1157
+ state: 'cannot determine',
1158
+ 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.',
1159
+ };
1160
+ }
1161
+ let check: { ok: boolean; reasons: string[] };
1162
+ try {
1163
+ check = verifyTransfers(transfers.map(({ payload }) => payload as unknown as Transfer), declared);
1164
+ } catch {
1165
+ check = { ok: false, reasons: ['the transfer check failed'] };
1166
+ }
1167
+ if (!check.ok) {
1168
+ return {
1169
+ id,
1170
+ label: LABELS[id],
1171
+ state: 'failed',
1172
+ detail: `The declared-subprocessor check failed: ${check.reasons.join('; ')}.`,
1173
+ };
1174
+ }
1175
+ // Divergence is red, never silent: a junction naming a destination with no matching
1176
+ // declared transfer means the custody trail and the transfer record disagree.
1177
+ for (const { sr, to } of junctions) {
1178
+ const matched = transfers.some(({ payload }) => payload.to === to && declared.includes(payload.to as string));
1179
+ if (!matched) {
1180
+ return {
1181
+ id,
1182
+ label: LABELS[id],
1183
+ state: 'failed',
1184
+ 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.`,
1185
+ };
1186
+ }
1187
+ }
1188
+ return {
1189
+ id,
1190
+ label: LABELS[id],
1191
+ state: 'proven',
1192
+ 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.`,
1193
+ };
1194
+ }
1195
+
1196
+ // ---------------------------------------------------------------------------
1197
+ // Row 8 (minimization): "Only what was needed was taken"
1198
+ // ---------------------------------------------------------------------------
1199
+
1200
+ /** Field-set commitments carried by the slice (`commitFieldSet` records under type
1201
+ * `minimization.commit`). */
1202
+ function commitsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
1203
+ const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
1204
+ for (const sr of receipts) {
1205
+ const p = asPayload(touchPayloadOf(sr));
1206
+ if (p && p['type'] === 'minimization.commit') out.push({ sr, payload: p });
1207
+ }
1208
+ return out;
1209
+ }
1210
+
1211
+ /** Access proofs carried by the slice (`buildAccessProof`'s shape). */
1212
+ function accessProofsOf(receipts: SubjectReceipt[]): Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> {
1213
+ const out: Array<{ sr: SubjectReceipt; payload: Record<string, unknown> }> = [];
1214
+ for (const sr of receipts) {
1215
+ const p = asPayload(touchPayloadOf(sr));
1216
+ if (p && p['type'] === 'minimization.access') out.push({ sr, payload: p });
1217
+ }
1218
+ return out;
1219
+ }
1220
+
1221
+ function minimizationRow(receipts: SubjectReceipt[]): VerdictRow {
1222
+ const id = 'minimization' as const;
1223
+ if (receipts.length === 0) {
1224
+ return { id, label: LABELS[id], state: 'cannot determine', detail: 'No receipts in this slice, so there is nothing to check.' };
1225
+ }
1226
+ const commits = commitsOf(receipts);
1227
+ const accesses = accessProofsOf(receipts);
1228
+ for (const { sr, payload } of commits) {
1229
+ const reasons = commitReasons(payload);
1230
+ if (reasons.length > 0) {
1231
+ return {
1232
+ id,
1233
+ label: LABELS[id],
1234
+ state: 'failed',
1235
+ detail: `${receiptDetailPrefix(sr)} carries a malformed field-set commitment record: ${reasons.join(', ')}.`,
1236
+ };
1237
+ }
1238
+ if (sr?.check?.ok !== true) {
1239
+ return {
1240
+ id,
1241
+ label: LABELS[id],
1242
+ state: 'failed',
1243
+ detail: `${receiptDetailPrefix(sr)} carries a field-set commitment payload, but the receipt itself failed offline verification.`,
1244
+ };
1245
+ }
1246
+ // Recompute the commitment from the claimed fields: a mismatch is a set widened
1247
+ // (or swapped) after the commitment was recorded.
1248
+ let matches = false;
1249
+ try {
1250
+ matches = verifyFieldSet(payload.fields as string[], payload.commitment as string);
1251
+ } catch {
1252
+ matches = false;
1253
+ }
1254
+ if (!matches) {
1255
+ return {
1256
+ id,
1257
+ label: LABELS[id],
1258
+ state: 'failed',
1259
+ 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.`,
1260
+ };
1261
+ }
1262
+ }
1263
+ // Access proofs are validated before the commits-empty branch: a presented but
1264
+ // broken proof is a red even when no commitments are on record.
1265
+ for (const { sr, payload } of accesses) {
1266
+ const reasons = accessReasons(payload);
1267
+ if (reasons.length > 0) {
1268
+ return {
1269
+ id,
1270
+ label: LABELS[id],
1271
+ state: 'failed',
1272
+ detail: `${receiptDetailPrefix(sr)} carries a malformed minimization access proof: ${reasons.join(', ')}.`,
1273
+ };
1274
+ }
1275
+ if (sr?.check?.ok !== true) {
1276
+ return {
1277
+ id,
1278
+ label: LABELS[id],
1279
+ state: 'failed',
1280
+ detail: `${receiptDetailPrefix(sr)} carries an access proof payload, but the receipt itself failed offline verification.`,
1281
+ };
1282
+ }
1283
+ }
1284
+ if (commits.length === 0) {
1285
+ if (accesses.length > 0) {
1286
+ // Symmetric with commits-without-accesses: an access proof whose declared
1287
+ // minimum is not on record cannot be checked, and unchecked is unknown.
1288
+ return {
1289
+ id,
1290
+ label: LABELS[id],
1291
+ state: 'cannot determine',
1292
+ 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.',
1293
+ };
1294
+ }
1295
+ return { id, label: LABELS[id], state: 'not covered', detail: 'No minimum-field commitments on record' };
1296
+ }
1297
+ if (accesses.length === 0) {
1298
+ return {
1299
+ id,
1300
+ label: LABELS[id],
1301
+ state: 'cannot determine',
1302
+ detail: 'Field-set commitments are on record, but no access proofs: no recorded access was checked against the declared minimum.',
1303
+ };
1304
+ }
1305
+ for (const { sr, payload } of accesses) {
1306
+ const commit = commits.find(({ payload: c }) => c.commitment === payload.field_set);
1307
+ if (!commit) {
1308
+ return {
1309
+ id,
1310
+ label: LABELS[id],
1311
+ state: 'cannot determine',
1312
+ 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.`,
1313
+ };
1314
+ }
1315
+ let check: { ok: boolean; reasons: string[]; excess: string[] };
1316
+ try {
1317
+ check = verifyMinimization(payload as unknown as AccessProof, commit.payload.fields as string[]);
1318
+ } catch {
1319
+ check = { ok: false, reasons: ['the minimization check failed'], excess: [] };
1320
+ }
1321
+ if (!check.ok) {
1322
+ return {
1323
+ id,
1324
+ label: LABELS[id],
1325
+ state: 'failed',
1326
+ detail: `The minimization check failed for receipt ${sr?.hash} (index ${sr?.index}): ${check.reasons.join(', ')}.`,
1327
+ };
1328
+ }
1329
+ }
1330
+ return {
1331
+ id,
1332
+ label: LABELS[id],
1333
+ state: 'proven',
1334
+ 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.`,
1335
+ };
1336
+ }
1337
+
1338
+ // ---------------------------------------------------------------------------
1339
+ // The one function both surfaces call.
1340
+ // ---------------------------------------------------------------------------
1341
+
1342
+ /**
1343
+ * Compute the eight verdict rows from an offline-verified subject history. Pure: no
1344
+ * network, no clock. Async only because row 3's coverage attestation is checked
1345
+ * through `@zanii/attest`'s `verifyAttestation` and row 6's residency claims through
1346
+ * `@zanii/residency`'s `verifyResidency`, whose injected verifiers may be async;
1347
+ * pass them via `options.verifyQuote` / `options.verifyRegion`. Never throws:
1348
+ * malformed input yields honest `cannot determine` / `not covered` / `failed` rows
1349
+ * with reasons. Rows are always returned in this fixed order: purpose,
1350
+ * self_sovereign, human_read, trained_on, copy_kept, residency, subprocessor,
1351
+ * minimization.
1352
+ */
1353
+ export async function computeVerdicts(
1354
+ history: SubjectHistory,
1355
+ evidence: ExtraEvidence = {},
1356
+ options: VerdictOptions = {},
1357
+ ): Promise<VerdictRow[]> {
1358
+ const h = (history && typeof history === 'object' ? history : {}) as SubjectHistory;
1359
+ const receipts = Array.isArray(h.receipts) ? h.receipts : [];
1360
+ const ev = (evidence && typeof evidence === 'object' ? evidence : {}) as ExtraEvidence;
1361
+ const verifyQuote = options && typeof options === 'object' ? options.verifyQuote : undefined;
1362
+ const verifyRegion = options && typeof options === 'object' ? options.verifyRegion : undefined;
1363
+ const rows: Array<() => VerdictRow | Promise<VerdictRow>> = [
1364
+ () => purposeRow(h, receipts),
1365
+ () => selfSovereignRow(h, receipts),
1366
+ () => humanReadRow(receipts, ev, verifyQuote),
1367
+ () => trainedOnRow(h, receipts, ev),
1368
+ () => copyKeptRow(receipts, ev),
1369
+ () => residencyRow(receipts, ev, verifyRegion),
1370
+ () => subprocessorRow(receipts, ev),
1371
+ () => minimizationRow(receipts),
1372
+ ];
1373
+ const ids: VerdictId[] = ['purpose', 'self_sovereign', 'human_read', 'trained_on', 'copy_kept', 'residency', 'subprocessor', 'minimization'];
1374
+ const out: VerdictRow[] = [];
1375
+ for (let i = 0; i < rows.length; i++) {
1376
+ try {
1377
+ out.push(await rows[i]!());
1378
+ } catch (err) {
1379
+ // Verification paths never crash: an unexpected error is an honest grey.
1380
+ const id = ids[i]!;
1381
+ out.push({
1382
+ id,
1383
+ label: LABELS[id],
1384
+ state: 'cannot determine',
1385
+ detail: `This line could not be computed: ${err instanceof Error ? err.message : String(err)}.`,
1386
+ });
1387
+ }
1388
+ }
1389
+ return out;
1390
+ }
1391
+
1392
+ /**
1393
+ * Seconds between the signed tree head's timestamp and `now` (the caller supplies
1394
+ * `now`; this package has no clock). The page shows anchor lag: four minutes vs
1395
+ * four hours is a property the reader is entitled to see. Null when unknown (no sth,
1396
+ * no timestamp, unparseable). Negative means the sth is newer than the caller's
1397
+ * clock: possible skew, shown as-is.
1398
+ */
1399
+ export function computeAnchorLag(
1400
+ sth: Record<string, unknown> | SignedTreeHead | undefined | null,
1401
+ now: Date | string | number,
1402
+ ): number | null {
1403
+ try {
1404
+ const ts = parseTime((sth as Record<string, unknown> | undefined | null)?.ts);
1405
+ if (ts === null) return null;
1406
+ const nowMs = now instanceof Date ? now.getTime() : typeof now === 'number' ? now : parseTime(now);
1407
+ if (nowMs === null || Number.isNaN(nowMs)) return null;
1408
+ return Math.round((nowMs - ts) / 1000);
1409
+ } catch {
1410
+ return null;
1411
+ }
1412
+ }