@zanii/custody-verdicts
v0.1.0
Published
The one custody verifier: computes the eight subject-page verdict rows (purpose, self-sovereign, nobody-read-it, never-trained-on, no-copy-kept, residency, subprocessor, minimization) from an offline-verified SubjectHistory. Pure, synchronous, no network.
Readme
@zanii/custody-verdicts
The one verifier. Zanii Custody's hard rule is one verifier, two surfaces: the
subject page (zanii-id) and @zanii/cli import this same package and call the same
function. Never two implementations of the verdict logic: two drift, and the drift
always favours the vendor.
Verdicts are computed, never stored. computeVerdicts derives eight verdict rows
at view time, in the reader's browser or the auditor's terminal, from the evidence
itself: a SubjectHistory from @zanii/subject whose receipts were already verified
offline (signature + delegation + scope), plus an optional evidence bag.
import { computeVerdicts, computeAnchorLag, TRAINING_MANIFEST_TAG } from '@zanii/custody-verdicts';
const rows = await computeVerdicts(history); // history = await fetchSubjectHistory(tag)
const lag = computeAnchorLag(history.sth, new Date()); // seconds; null when unknownTRAINING_MANIFEST_TAG ("zanii:training-manifests") is the well-known ledger tag
under which declared training manifests are published. The page and the CLI fetch that
slice to build the row-4 training evidence bag (ExtraEvidence.training).
computeVerdicts is async for one reason: rows 3 and 6 check claims through injected
verifiers (@zanii/attest's quote verifier, @zanii/residency's region verifier)
that may be async. Still pure: no network, no clock.
The eight rows
Always in this order; each { id, label, state, detail }, with detail the
plain-language working the page renders verbatim:
| id | label | proven when | failed when | otherwise |
|---|---|---|---|---|
| purpose | Used only for its declared purpose | history verified AND every embedded payload is a well-formed custody touch (verifyTouch), retention shred (verifyKeyDestruction), or one of the rows 6-8 evidence payloads (structurally checked) | any offline check failed or any payload malformed | empty slice → cannot determine |
| self_sovereign | You can check this yourself | every receipt verified offline AND the sth verifies | any offline check fails; bad sth | empty slice / no sth → cannot determine |
| human_read | Nobody read it | a SIGNED coverage attestation (sig + trusted signer when pinned + attestationHash binding + enclave quote via injected verifier) proves all egress paths receipt-gated for the whole window | any receipt shows a human actor; the attestation is unsigned, badly signed, from an unrecognized signer, unbound, malformed, or its quote is disproven | not covered: "No coverage attestation for this window"; no quote verifier injected → cannot determine |
| trained_on | Never used to train a model | @zanii/train's verifyAbsence passes for this subject's tag against a declared manifest anchored BEFORE the run attestation | the proof does not verify, names another ref, or the manifest is malformed | no declared run → not covered: "No declared training runs to check against"; wrong anchor order / missing times → cannot determine |
| copy_kept | No copy was kept | every object touched in the slice has a shred receipt bound to its ref (payload ref = the touch's ref) that passes its own offline check AND verifyKeyDestruction, and any image evidence matches | a presented shred receipt fails either check; image evidence mismatches or is incomplete | none bound to a touched object's ref → not covered: "No key-destruction record on file."; some objects covered, some not → cannot determine |
| residency | Data stayed in its declared region | EVERY custody touch has a verified in-set claim (a claim with a ref binds every touch of that object; a ref-less claim covers the touches at its own timestamp), every region signal authenticates (injected verifyRegion), and detectCrossings finds no exit | a verified claim's region is outside the allow-set (a border crossing), or a claim is malformed, invalid, or rides on an unverified receipt | no claims → not covered: "No residency attestations on record" (self-declared claims are not attestations and say so); empty slice, any touch without a claim, no jurisdiction config, or no injected verifyRegion → cannot determine |
| subprocessor | Only declared third parties touched it | every custody-touch junction and recorded transfer resolves to a declared party under a named agreement (verifyTransfers against the reader-pinned list, plus a junction/transfer divergence check) | any undeclared receiver, any transfer without an agreement id, any malformed record, or a junction naming a destination with no matching declared transfer | none recorded → not covered: "No onward transfers declared or recorded"; destinations on record but no reader-provided declaredSubprocessors → cannot determine |
| minimization | Only what was needed was taken | every recorded access proof verifies (verifyMinimization) against its committed minimum field set, and every commitment record recomputes to its recorded commitment (verifyFieldSet) | any excess access (a field outside the declared minimum), a widened-after-commitment set, or a malformed record | no commitments → not covered: "No minimum-field commitments on record"; empty slice, commitments without access proofs, access proofs without commitments, or an access proof against an unknown commitment → cannot determine |
Four states: proven, not covered, failed, cannot determine. The last three are
as first-class as the first: a missing proof is not a clean result, and nothing here
ever renders "never" out of a database column.
On an empty slice (zero receipts) rows 1-2 answer cannot determine and rows 3-5
stay grey regardless of evidence: there is nothing to cover. Row 3 with a coverage
attestation over an empty slice answers cannot determine (coverage over nothing
proves nothing about this subject's data); row 4 answers not covered; row 5's
evidence lives in the slice, so an empty slice has none. Rows 6 and 8 answer
cannot determine on an empty slice (there is no window to cover); row 7's evidence
lives in the slice, so an empty slice answers not covered.
Evidence shapes
ExtraEvidence ({ coverage?, training?, destructionImage?, jurisdiction?,
declaredSubprocessors? }) carries the evidence and reader-side trust roots that do
not live in the slice; everything is checked offline, here, in this one place,
against the real producers' shapes:
training(row 4) is{ manifest, anchoredAt, runAttestedAt, absenceProof }:@zanii/train'sTrainingManifestandAbsenceProof(sorted-Merkle over covering-gap pair leaves, reusing core's RFC 6962 inclusion proofs). The manifest anchor must precede the run attestation, or the ordering cannot be trusted.destructionImage(row 5, optional) is{ attestation, publishedImageHash }: a claimed destruction-image attestation plus the image hash the operator published. What is checked here is bag-internal consistency only, via@zanii/attest'sattestationHashconvention (the published image hash isattestationHashof the published attestation record): a mismatch isfailed, a match appends the attested-image line to the proven detail. A match does NOT by itself prove the destruction ran under that image: nothing yet binds the shred receipt to the attestation, and the attestation's quote is not verified on this path. The full binding ships with the enclave phase. The destruction record itself needs no bag entry: it is the@zanii/retentionshred receipt carried in the subject's slice (signature-covered embedded payload, targetdata.retention.shred), bound to the subject by slice membership and to the OBJECT by ref: a shred counts only for an object whose custody touches carry the same payloadref, andprovenrequires a verified bound shred for every object the slice touches (partial coverage answerscannot determine; a shred bound only to unrelated refs answersnot covered). Its saltedsubject_commitmentcan later be proven to name the subject by revealing the salt.coverage(row 3) is a signed coverage attestation ({ v: 1, type: 'coverage.attestation', allPathsGated, windowStart, windowEnd, imageHash, attestation, signer, sig, trustedSigners? }): every egress path receipt-gated (the@zanii/brokerinvariant) for a window, bound to an attested image hash, signed by the platform key. Verified fail-closed: signature and (when pinned) trusted signer, then theattestationHashbinding, thenverifyAttestationwith the caller's injected quote verifier (pass it ascomputeVerdicts(history, evidence, { verifyQuote })). Without an injected verifier the quote stays unknown and the row can only reachcannot determine, neverproven.jurisdiction(row 6) isstring[]: the declared jurisdiction as an allow-set of region codes (@zanii/residency'sallowedRegions). Reader-side configuration, never producer-supplied (same trust rule ascoverage.trustedSigners).declaredSubprocessors(row 7) isstring[]: the declared subprocessor list that onward transfers and custody junctions are checked against. Reader-side configuration, never producer-supplied.
Row 6: "Data stayed in its declared region"
Mechanism: residency claim payloads in the slice (@zanii/residency's
buildResidencyClaim shape, embedded signature-covered exactly like the row-5 shred
payloads) are checked with verifyResidency against the reader-pinned
jurisdiction allow-set and the caller's injected verifyRegion (pass it as
computeVerdicts(history, evidence, { verifyRegion })), which authenticates the
region signal (a cloud provider identity document, a TEE geo signal) and returns the
trusted region. detectCrossings then runs over the verified touch sequence: any
exit from the allow-set is a border crossing and a red, never silent.
proven requires per-touch coverage, never a window span: EVERY custody touch in the
slice must be covered by a verified in-set claim. A claim carrying a ref binds to
every touch of that object; a ref-less claim is time-scoped and covers the touches
made at its own timestamp. Every region signal must authenticate inside the
allow-set, no claim may be malformed or unverified, and no crossings may be
detected. A claim whose region source is self-declared (the operator asserted its
own region) is not an attestation: it is not counted as evidence, and when every
claim is self-declared the row answers not covered and says so.
The limit: the proof is only as strong as the injected geo trust root. A proven row
means "signals your verifier trusts placed every touch inside your declared
jurisdiction"; it cannot prove residency your geo source cannot. A touch with no
claim answers cannot determine, never proven.
Row 7: "Only declared third parties touched it"
Mechanism: transfer records in the slice (@zanii/subprocessor's buildTransfer
shape) are checked with verifyTransfers against the reader-pinned
declaredSubprocessors list, and every custody-touch junction
(@zanii/data-custody's junction field) must name a destination with a matching
declared transfer: a divergence between the custody trail and the transfer record is
a red, never silent.
proven requires: every recorded transfer's receiver is declared, every transfer
carries an agreement id (stricter than the producer, which requires an id only for
dpa/scc), no record is malformed, and every junction resolves to a declared party
under a named agreement.
The limit: this proves the transfers that were recorded. An off-record leak (a
processor that received plaintext and emitted no transfer receipt) cannot be caught
here; that residual is the enclave problem, unchanged. And an agreement id is a
reference, not a guarantee the counterparty honored it. Senders are not checked:
verifyTransfers runs without a controller, so an undeclared from is
indistinguishable from the origin and goes unchecked.
Row 8: "Only what was needed was taken"
Mechanism: field-set commitment records in the slice (@zanii/minimization's
commitFieldSet output carried as a minimization.commit payload) are recomputed
with verifyFieldSet (a mismatch means the declared minimum was widened after the
commitment was recorded), and every access proof (buildAccessProof shape) is
checked with verifyMinimization against its committed field set: any accessed
field outside the declared minimum is a flagged excess, a red.
proven requires: every commitment record recomputes to its recorded commitment and
every recorded access stayed inside its committed minimum. Commitments without
access proofs, access proofs without commitments, or an access proof whose
commitment is not on record answer cannot determine: an unchecked access is
unknown, not clean.
The limit: this proves declared-vs-accessed: that the agent stayed inside the minimum that was committed. It does not prove the minimum was well-chosen (a judgement, not a cryptographic fact), and it proves the recorded accesses: an off-record read is the enclave problem, unchanged.
Trust roots: who may attest coverage
The same reader-side rule now guards three trust roots: coverage.trustedSigners
(row 3), jurisdiction (row 6), and declaredSubprocessors (row 7). All three are
reader-side configuration, merged into the evidence by the subject page or the CLI
from its OWN deployment config, never from producer-supplied data: an attacker who
could name their own signer, their own jurisdiction, or their own subprocessor list
would attest anything.
A coverage attestation is only as good as the key that signs it. The producer side
(enclave phase) signs with the platform's coverage-signing key; the reader side, the
subject page and the CLI, pins the platform's coverage-signing DID(s) and merges them
into the evidence as coverage.trustedSigners before calling computeVerdicts. Two
rules make this a real trust root rather than a formality:
- The list is reader-side configuration. It must never come from
producer-supplied data: an attacker who could name their own key trusted would
attest anything. For the same reason
trustedSignersis NOT part of the signed record; it is excluded from the signature preimage and merged in after signing. - An unknown signer is never
proven. WhentrustedSignersis present, a coverage attestation signed by any other DID isfailed("coverage attestation from an unrecognized signer"); an empty list trusts no one. When it is absent (day one), any well-formed did:key signer authenticates, and the remaining checks decide. Deployments should always pin once the enclave phase ships.
Row 3's producer side ships with the enclave phase. @zanii/attest +
@zanii/broker produce signed coverage attestations there. Until then the evidence
does not exist, the row answers not covered, and proven is unreachable: that is
the design, not a gap. A "nobody read it" claim without hardware attestation would be
our word, not evidence.
Guarantees
- Pure: no network, no clock (
computeAnchorLagtakesnowfrom the caller). Async only for the injected verifiers (enclave quote, region signal). - Never throws: malformed input yields honest
cannot determine/not covered/failedrows with reasons (verification paths return results; creation paths throw). - No verdict is ever stored or served; the ledger serves raw evidence only.
