@zanii/certify 0.0.0-stage → 0.5.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.
Files changed (133) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +112 -2
  3. package/dist/appliance-execution.d.ts +18 -0
  4. package/dist/appliance-execution.js +12 -0
  5. package/dist/appliance.d.ts +135 -0
  6. package/dist/appliance.js +253 -0
  7. package/dist/assurance-status.d.ts +58 -0
  8. package/dist/assurance-status.js +86 -0
  9. package/dist/audit-renewal.d.ts +39 -0
  10. package/dist/audit-renewal.js +62 -0
  11. package/dist/audit.d.ts +69 -0
  12. package/dist/audit.js +112 -0
  13. package/dist/beacon-client.d.ts +38 -0
  14. package/dist/beacon-client.js +52 -0
  15. package/dist/beacon.d.ts +84 -0
  16. package/dist/beacon.js +106 -0
  17. package/dist/bitstring-status.d.ts +56 -0
  18. package/dist/bitstring-status.js +135 -0
  19. package/dist/board.d.ts +218 -0
  20. package/dist/board.js +428 -0
  21. package/dist/bundle.d.ts +92 -0
  22. package/dist/bundle.js +109 -0
  23. package/dist/certificate.d.ts +161 -0
  24. package/dist/certificate.js +345 -0
  25. package/dist/cli-messages.json +148 -0
  26. package/dist/cli.d.ts +16 -0
  27. package/dist/cli.js +369 -0
  28. package/dist/current-issuer-vc.d.ts +20 -0
  29. package/dist/current-issuer-vc.js +60 -0
  30. package/dist/evidence-share.d.ts +89 -0
  31. package/dist/evidence-share.js +479 -0
  32. package/dist/federation.d.ts +103 -0
  33. package/dist/federation.js +240 -0
  34. package/dist/feed-snapshot.d.ts +97 -0
  35. package/dist/feed-snapshot.js +186 -0
  36. package/dist/fingerprint.d.ts +23 -0
  37. package/dist/fingerprint.js +48 -0
  38. package/dist/formats/status-v2.schema.json +31 -0
  39. package/dist/governance.d.ts +242 -0
  40. package/dist/governance.js +411 -0
  41. package/dist/identity.d.ts +79 -0
  42. package/dist/identity.js +146 -0
  43. package/dist/impartiality.d.ts +47 -0
  44. package/dist/impartiality.js +85 -0
  45. package/dist/index.d.ts +58 -0
  46. package/dist/index.js +59 -0
  47. package/dist/issuer-authorization.d.ts +4 -0
  48. package/dist/issuer-authorization.js +52 -0
  49. package/dist/issuer-resolution.d.ts +25 -0
  50. package/dist/issuer-resolution.js +89 -0
  51. package/dist/licence-owner-entry.d.ts +20 -0
  52. package/dist/licence-owner-entry.js +33 -0
  53. package/dist/licence-owner.d.ts +67 -0
  54. package/dist/licence-owner.js +180 -0
  55. package/dist/licence.d.ts +174 -0
  56. package/dist/licence.js +400 -0
  57. package/dist/live-evidence.d.ts +135 -0
  58. package/dist/live-evidence.js +210 -0
  59. package/dist/nitro-roots.d.ts +3 -0
  60. package/dist/nitro-roots.js +20 -0
  61. package/dist/nitro-x509.d.ts +16 -0
  62. package/dist/nitro-x509.js +141 -0
  63. package/dist/nitro.d.ts +74 -0
  64. package/dist/nitro.js +329 -0
  65. package/dist/normalization.d.ts +8 -0
  66. package/dist/normalization.js +10 -0
  67. package/dist/pq.d.ts +32 -0
  68. package/dist/pq.js +69 -0
  69. package/dist/profile.d.ts +20 -0
  70. package/dist/profile.js +161 -0
  71. package/dist/register.d.ts +79 -0
  72. package/dist/register.js +134 -0
  73. package/dist/rotation.d.ts +49 -0
  74. package/dist/rotation.js +124 -0
  75. package/dist/royalty-reconciliation.d.ts +24 -0
  76. package/dist/royalty-reconciliation.js +88 -0
  77. package/dist/runtime-client.d.ts +38 -0
  78. package/dist/runtime-client.js +185 -0
  79. package/dist/runtime-gateway.d.ts +24 -0
  80. package/dist/runtime-gateway.js +167 -0
  81. package/dist/runtime-knowledge.d.ts +28 -0
  82. package/dist/runtime-knowledge.js +75 -0
  83. package/dist/runtime-nitro.d.ts +30 -0
  84. package/dist/runtime-nitro.js +57 -0
  85. package/dist/runtime.d.ts +226 -0
  86. package/dist/runtime.js +254 -0
  87. package/dist/sandbox-delegation.d.ts +38 -0
  88. package/dist/sandbox-delegation.js +61 -0
  89. package/dist/schema-dialect.d.ts +1 -0
  90. package/dist/schema-dialect.js +97 -0
  91. package/dist/schemas/clause-map.schema.json +52 -0
  92. package/dist/schemas/profile.schema.json +80 -0
  93. package/dist/schemas/scenarios.schema.json +573 -0
  94. package/dist/schemas/suite.schema.json +138 -0
  95. package/dist/scheme-authority.d.ts +5 -0
  96. package/dist/scheme-authority.js +15 -0
  97. package/dist/scheme-entry.d.ts +25 -0
  98. package/dist/scheme-entry.js +85 -0
  99. package/dist/scheme-history.d.ts +5 -0
  100. package/dist/scheme-history.js +87 -0
  101. package/dist/scheme-log.d.ts +98 -0
  102. package/dist/scheme-log.js +266 -0
  103. package/dist/scheme-merkle.d.ts +2 -0
  104. package/dist/scheme-merkle.js +20 -0
  105. package/dist/scheme-text.d.ts +25 -0
  106. package/dist/scheme-text.js +46 -0
  107. package/dist/scheme-timing.d.ts +16 -0
  108. package/dist/scheme-timing.js +22 -0
  109. package/dist/scheme-transition.d.ts +47 -0
  110. package/dist/scheme-transition.js +101 -0
  111. package/dist/scoring.d.ts +35 -0
  112. package/dist/scoring.js +50 -0
  113. package/dist/shared.d.ts +16 -0
  114. package/dist/shared.js +20 -0
  115. package/dist/signer.d.ts +18 -0
  116. package/dist/signer.js +49 -0
  117. package/dist/signing-identity.d.ts +1 -0
  118. package/dist/signing-identity.js +11 -0
  119. package/dist/status.d.ts +113 -0
  120. package/dist/status.js +204 -0
  121. package/dist/succession.d.ts +21 -0
  122. package/dist/succession.js +67 -0
  123. package/dist/suite.d.ts +257 -0
  124. package/dist/suite.js +441 -0
  125. package/dist/vc.d.ts +29 -0
  126. package/dist/vc.js +96 -0
  127. package/dist/verification-bounds.d.ts +5 -0
  128. package/dist/verification-bounds.js +76 -0
  129. package/dist/verification-policy.d.ts +32 -0
  130. package/dist/verification-policy.js +85 -0
  131. package/dist/witness.d.ts +19 -0
  132. package/dist/witness.js +80 -0
  133. package/package.json +57 -3
@@ -0,0 +1,266 @@
1
+ // spec/scheme-log.md: the scheme owner's logs (boards, releases, licence status, release withdrawals,
2
+ // body registers) as one checkpointed append-only log, like the status log (spec/witness.md):
3
+ // signed tree heads, inclusion and consistency proofs, witness co-signatures, and an append
4
+ // receipt binding each entry to the Zanii ledger receipt that anchors it.
5
+ import { canonicalBytes, consistencyProof, createSTHAsync, inclusionProof, jcsHash, leafHash, merkleRoot, receiptHash, verifyChain, verifyConsistency, verifyInclusion, verifyReceiptSignature, verifySignedObject, verifySTH, } from "@zanii/core";
6
+ import { verifyQuorum } from "@zanii/witness";
7
+ import { isBoard, isRelease, isWithdrawal, verifyBoardLog, verifyReleaseLog, verifyWithdrawalLog } from "./board.js";
8
+ import { verifyLicenceLog } from "./licence.js";
9
+ import { isBodyRegister, verifyBodyRegisterLog } from "./register.js";
10
+ import { isDid, isInt, isIso, isObj, isSha, ms } from "./shared.js";
11
+ import { signerDid, signWith } from "./signer.js";
12
+ import { signingIdentityReasons } from "./signing-identity.js";
13
+ import { schemeHistoryReasons } from "./scheme-history.js";
14
+ import { schemePrefixStatements } from "./scheme-merkle.js";
15
+ import { withinVerificationBudget } from "./verification-bounds.js";
16
+ export const SCHEME_LOG_TYPES = ["certify.board", "certify.release", "certify.licence-status", "certify.release-withdrawal", "certify.body-register", "certify.board-transition"];
17
+ const HEX32 = /^[0-9a-f]{64}$/;
18
+ const hex = (b) => Buffer.from(b).toString("hex");
19
+ const bytes = (h) => new Uint8Array(Buffer.from(h, "hex"));
20
+ const leaves = (entries) => entries.map((e) => leafHash(canonicalBytes(e)));
21
+ const rootOf = (entries) => `sha256:${hex(merkleRoot(leaves(entries)))}`;
22
+ const hexList = (p) => Array.isArray(p) && p.every((h) => typeof h === "string" && HEX32.test(h));
23
+ const known = (e) => isObj(e) && SCHEME_LOG_TYPES.includes(e.type);
24
+ /** Section 2: the owner's signed tree head over the scheme log (leaf i = entry i). */
25
+ export async function schemeCheckpoint(entries, ts, signer) {
26
+ if (!Array.isArray(entries) || entries.length === 0 || !entries.every(known))
27
+ throw new Error("the scheme log must be 1 or more scheme log entries");
28
+ if (!isIso(ts))
29
+ throw new Error("ts must be an ISO UTC timestamp");
30
+ return createSTHAsync({ logId: signerDid(signer), size: entries.length, root: rootOf(entries), ts }, signer.sign);
31
+ }
32
+ /** Section 2: the inclusion proof of entry `index` in the checkpoint over `entries`, hex. */
33
+ export const schemeInclusionProof = (entries, index) => inclusionProof(index, leaves(entries)).map(hex);
34
+ /** Section 2: the proof that the log over `entries` extends its first `fromSize` entries, hex. */
35
+ export const schemeConsistencyProof = (entries, fromSize) => consistencyProof(fromSize, leaves(entries)).map(hex);
36
+ const checkpointOk = (cp, owner) => isObj(cp) && verifySTH(cp) && (owner === undefined || cp.log_id === owner) && isInt(cp.size, 1, Number.MAX_SAFE_INTEGER) && isSha(cp.root);
37
+ /** Section 2: one entry is at `index` of a checkpoint signed by the owner (a light client's check). */
38
+ export function verifySchemeInclusion(entry, index, checkpoint, proof, opts = {}) {
39
+ const reasons = [];
40
+ if (opts.owner === undefined)
41
+ reasons.push("no trusted scheme owner supplied: the checkpoint signer was NOT checked");
42
+ if (!checkpointOk(checkpoint, opts.owner))
43
+ return { ok: false, reasons: [...reasons, "scheme checkpoint is not signed by the scheme owner"] };
44
+ const included = known(entry) && isInt(index, 0, checkpoint.size - 1) && hexList(proof) &&
45
+ verifyInclusion(leafHash(canonicalBytes(entry)), index, checkpoint.size, proof.map(bytes), bytes(checkpoint.root.slice(7)));
46
+ if (!included)
47
+ reasons.push("the entry is not in the scheme checkpoint at that index");
48
+ return { ok: reasons.length === 0, reasons };
49
+ }
50
+ /** Section 3: the statement for entry `index` of `entries` (the server records it on the ledger). */
51
+ export function schemeAppendStatement(entries, index, owner) {
52
+ if (!Array.isArray(entries) || !isInt(index, 0, entries.length - 1) || !known(entries[index]))
53
+ throw new Error("index is not an entry of the scheme log");
54
+ if (!isDid(owner))
55
+ throw new Error("owner must be a did:key");
56
+ const prefix = entries.slice(0, index + 1);
57
+ return { v: 1, type: "certify.scheme-append", log: owner, index, entry: jcsHash(entries[index]), size: index + 1, root: rootOf(prefix) };
58
+ }
59
+ /** Why a ledger receipt does not anchor `statement`; empty when it does. */
60
+ function ledgerReasons(statement, receipt, anchoredAt) {
61
+ const r = isObj(receipt) ? receipt : null;
62
+ if (!r || !verifyReceiptSignature(r))
63
+ return ["ledger receipt signature is invalid"];
64
+ if (r.payload_hash !== jcsHash(statement) || r.payload_encoding !== "jcs")
65
+ return ["ledger receipt does not commit to the append"];
66
+ if (anchoredAt !== undefined && r.ts !== anchoredAt)
67
+ return ["append time is not the ledger receipt's"];
68
+ return [];
69
+ }
70
+ /** Section 3: the receipt's agent is delegated, at the receipt's time, by the owner's ledger identity. */
71
+ const ledgerIdentity = (r, ledger) => Array.isArray(r.delegation) && isIso(r.ts) && verifyChain(r.delegation, { agentId: r.agent_id, expectedRoot: ledger, at: new Date(r.ts) }).ok;
72
+ /** Section 3: the owner's append receipt for entry `index`, bound to the ledger receipt that anchors it. */
73
+ export async function buildSchemeAppend(entries, index, ledgerReceipt, signer) {
74
+ const statement = schemeAppendStatement(entries, index, signerDid(signer));
75
+ return buildSchemeAppendFromStatement(statement, ledgerReceipt, signer);
76
+ }
77
+ /** The bounded equivalent when the caller maintains authenticated Merkle subtrees. */
78
+ export async function buildSchemeAppendFromStatement(statement, ledgerReceipt, signer) {
79
+ if (!withinVerificationBudget(ledgerReceipt, { maxBytes: 524_288, maxNodes: 50_000 }))
80
+ throw new Error("scheme ledger receipt exceeds the verification budget");
81
+ if (!isObj(statement) || Object.keys(statement).length !== 7 || statement.v !== 1 || statement.type !== "certify.scheme-append"
82
+ || statement.log !== signerDid(signer) || !isInt(statement.index, 0, Number.MAX_SAFE_INTEGER - 1) || statement.size !== statement.index + 1
83
+ || !isSha(statement.entry) || !isSha(statement.root))
84
+ throw new Error("invalid scheme append statement");
85
+ const bad = ledgerReasons(statement, ledgerReceipt);
86
+ if (bad.length)
87
+ throw new Error(bad[0]);
88
+ if (!isIso(ledgerReceipt.ts))
89
+ throw new Error("the ledger receipt ts is not an ISO UTC timestamp");
90
+ return signWith({ ...statement, ledger_receipt: receiptHash(ledgerReceipt), anchored_at: ledgerReceipt.ts }, signer);
91
+ }
92
+ /** One append against the authenticated entry/tree prefix; no history arrays are needed. */
93
+ export function verifySchemeAppend(statement, entry, append, receipt, opts = {}) {
94
+ const reasons = [];
95
+ if (!withinVerificationBudget(entry, { maxBytes: 1_048_576, maxNodes: 100_000 }) || !withinVerificationBudget(append, { maxBytes: 65_536, maxNodes: 10_000 })
96
+ || !withinVerificationBudget(receipt, { maxBytes: 524_288, maxNodes: 50_000 }))
97
+ return { ok: false, reasons: ["scheme append exceeds the verification budget"] };
98
+ if (!isObj(append) || Object.keys(append).length !== 11 || append.type !== "certify.scheme-append" || !verifySignedObject(append) || append.signer !== statement.log)
99
+ return { ok: false, reasons: ["entry has no append receipt signed by the scheme owner"] };
100
+ const { ledger_receipt, anchored_at, signer: _s, sig: _g, ...given } = append;
101
+ if (jcsHash(given) !== jcsHash(statement) || statement.entry !== jcsHash(entry))
102
+ reasons.push("the append receipt is not for this entry at this position");
103
+ if (!isIso(anchored_at) || opts.previousAt && ms(anchored_at) < ms(opts.previousAt))
104
+ reasons.push("the append time goes back");
105
+ if (opts.ledger && (isRelease(entry) || isWithdrawal(entry)) && isIso(anchored_at) && ms(entry.effective_from) < ms(anchored_at))
106
+ reasons.push("entry takes effect before it was appended");
107
+ if (!isObj(receipt) || ledger_receipt !== receiptHash(receipt))
108
+ reasons.push("its ledger receipt was not supplied");
109
+ else {
110
+ reasons.push(...ledgerReasons(statement, receipt, anchored_at));
111
+ if (opts.ledger && !ledgerIdentity(receipt, opts.ledger))
112
+ reasons.push("ledger receipt is not signed by the scheme owner's ledger identity");
113
+ }
114
+ return { ok: reasons.length === 0, reasons };
115
+ }
116
+ const NOUN = { "certify.release": "release", "certify.release-withdrawal": "withdrawal", "certify.body-register": "body register" };
117
+ const cosigShape = (c) => isObj(c) && typeof c.witnessed_log_id === "string" && isObj(c.cosignature) && typeof c.cosignature.log_id === "string";
118
+ /**
119
+ * Section 4: the scheme log verifies: each kind of entry forms its own log (governance-board.md,
120
+ * licensing.md, body-register.md); the append rules hold in leaf order (each board-signed entry by
121
+ * the newest board appended before it, each later board taking effect after every board-signed
122
+ * entry before it); the checkpoint is the owner's over exactly these entries and extends every
123
+ * previous one; with `witness`, k trusted witnesses co-signed it; with `appends`, each entry has the
124
+ * owner's append receipt with append times never going back; with `ledgerReceipts`, each append is
125
+ * bound to its ledger receipt.
126
+ */
127
+ export function verifySchemeLogs(input, opts = {}) {
128
+ const entries = Array.isArray(input?.entries) ? input.entries : [];
129
+ const out = (reasons, extra = {}) => {
130
+ const r = [...new Set(reasons)];
131
+ return { ok: r.length === 0, reasons: r, size: entries.length, anchored: 0, ...extra, ...(extra.canceled ? { canceled: r.length ? [] : extra.canceled } : {}) };
132
+ };
133
+ if (entries.length === 0)
134
+ return out(["the scheme log is empty"]);
135
+ const transitions = entries.some((e) => isObj(e) && e.type === "certify.board-transition");
136
+ if (transitions && (!withinVerificationBudget(input) || entries.length > 10_000))
137
+ return out(["transition-aware scheme history exceeds the verification budget"], { canceled: [] });
138
+ const reasons = [];
139
+ entries.forEach((e, i) => {
140
+ if (!known(e))
141
+ reasons.push(`entry ${i} is not a scheme log entry`);
142
+ });
143
+ const of = (t) => entries.filter((e) => isObj(e) && e.type === t);
144
+ const boards = of("certify.board");
145
+ const releases = of("certify.release");
146
+ const statuses = of("certify.licence-status");
147
+ const withdrawals = of("certify.release-withdrawal");
148
+ const registers = of("certify.body-register");
149
+ const prepared = transitions ? schemePrefixStatements(entries, opts.owner ?? "") : null;
150
+ const semantics = transitions ? schemeHistoryReasons(input, opts.owner, opts.statusIssuers, prepared) : null;
151
+ if (semantics)
152
+ reasons.push(...semantics.reasons);
153
+ if (!transitions) {
154
+ reasons.push(...verifyBoardLog(boards, opts).reasons);
155
+ if (releases.length)
156
+ reasons.push(...verifyReleaseLog(releases, boards, opts).reasons);
157
+ if (statuses.length)
158
+ reasons.push(...verifyLicenceLog(statuses, opts).reasons);
159
+ if (withdrawals.length)
160
+ reasons.push(...verifyWithdrawalLog(withdrawals, releases, boards, opts).reasons);
161
+ if (registers.length)
162
+ reasons.push(...verifyBodyRegisterLog(registers, boards, opts).reasons);
163
+ // the append rules, replayed in leaf order.
164
+ let newest = null;
165
+ const signedAt = [];
166
+ entries.forEach((e, i) => {
167
+ if (!isObj(e))
168
+ return;
169
+ const o = e;
170
+ const signed = isRelease(o) || isWithdrawal(o) || isBodyRegister(o);
171
+ if (isBoard(e)) {
172
+ if (newest !== null && signedAt.some((t) => !(ms(e.effective_from) > t)))
173
+ reasons.push(`entry ${i}: the board does not take effect after every board-signed entry appended before it`);
174
+ newest = e;
175
+ }
176
+ else if (signed) {
177
+ if (newest === null)
178
+ reasons.push(`entry ${i}: a ${NOUN[o.type]} is appended before any board`);
179
+ else if (o.board !== jcsHash(newest))
180
+ reasons.push(`entry ${i}: the ${NOUN[o.type]} is not signed by the newest board when it was appended`);
181
+ signedAt.push(ms(o.effective_from));
182
+ }
183
+ });
184
+ }
185
+ const owner = opts.owner ?? (isBoard(boards[0]) ? boards[0].owner : undefined);
186
+ if (opts.statusIssuers !== undefined)
187
+ reasons.push(...signingIdentityReasons(owner, [], opts.statusIssuers));
188
+ const cp = input.checkpoint;
189
+ if (!checkpointOk(cp, owner))
190
+ reasons.push("scheme checkpoint is not signed by the scheme owner");
191
+ else if (cp.size !== entries.length || cp.root !== rootOf(entries))
192
+ reasons.push("scheme checkpoint is not over these entries");
193
+ for (const [n, p] of (Array.isArray(input.previous) ? input.previous : []).entries()) {
194
+ const old = isObj(p) ? p.checkpoint : null;
195
+ if (!checkpointOk(old, owner))
196
+ reasons.push(`previous checkpoint ${n} is not signed by the scheme owner`);
197
+ else if (!checkpointOk(cp, owner) || !hexList(p.proof) || old.size > cp.size ||
198
+ !verifyConsistency(old.size, cp.size, bytes(old.root.slice(7)), bytes(cp.root.slice(7)), p.proof.map(bytes)))
199
+ reasons.push(`the scheme log does not extend previous checkpoint ${n}`);
200
+ }
201
+ let witnesses;
202
+ if (input.witness) {
203
+ const w = input.witness;
204
+ if (!isInt(w.k, 1, 1000))
205
+ reasons.push("witness policy k must be an integer from 1");
206
+ else if (checkpointOk(cp, owner)) {
207
+ const cosigs = (Array.isArray(w.cosignatures) ? w.cosignatures : []).filter(cosigShape);
208
+ const q = verifyQuorum(cp, cosigs, { trusted: Array.isArray(w.trusted) ? [...w.trusted] : [], k: w.k });
209
+ witnesses = q.witnesses;
210
+ if (!q.ok)
211
+ reasons.push(`scheme checkpoint has ${q.witnesses.length} of the ${w.k} trusted witness co-signatures required`);
212
+ }
213
+ }
214
+ const extra = { ...(witnesses ? { witnesses } : {}), ...(semantics ? { canceled: semantics.canceled } : {}) };
215
+ if (input.appends === undefined)
216
+ return out(reasons, extra);
217
+ const appends = Array.isArray(input.appends) ? input.appends : [];
218
+ if (appends.length !== entries.length)
219
+ reasons.push(`${appends.length} append receipts for ${entries.length} entries`);
220
+ const receipts = new Map();
221
+ for (const r of Array.isArray(input.ledgerReceipts) ? input.ledgerReceipts : [])
222
+ if (isObj(r))
223
+ receipts.set(receiptHash(r), r);
224
+ const appended = [];
225
+ let anchored = 0;
226
+ let last = Number.NEGATIVE_INFINITY;
227
+ // section 3: the owner's ledger identity named by the newest board appended at or before each entry
228
+ let ledger;
229
+ entries.forEach((e, i) => {
230
+ if (isBoard(e) && typeof e.ledger === "string")
231
+ ledger = e.ledger;
232
+ const a = appends[i];
233
+ if (!isObj(a) || a.type !== "certify.scheme-append" || Object.keys(a).length !== 11 || !verifySignedObject(a) || (owner !== undefined && a.signer !== owner)) {
234
+ reasons.push(`entry ${i} has no append receipt signed by the scheme owner`);
235
+ return;
236
+ }
237
+ if (!known(e) || !owner)
238
+ return;
239
+ const statement = prepared?.[i] ?? schemeAppendStatement(entries, i, owner);
240
+ const { ledger_receipt, anchored_at, signer: _s, sig: _g, ...given } = a;
241
+ if (jcsHash(given) !== jcsHash(statement))
242
+ reasons.push(`entry ${i}: the append receipt is not for this entry at this position`);
243
+ if (!isIso(anchored_at) || !(ms(anchored_at) >= last))
244
+ reasons.push(`entry ${i}: the append time goes back`);
245
+ else
246
+ last = ms(anchored_at);
247
+ appended.push(String(anchored_at));
248
+ const o = e;
249
+ if (ledger && (isRelease(o) || isWithdrawal(o)) && isIso(anchored_at) && ms(o.effective_from) < ms(anchored_at))
250
+ reasons.push(`entry ${i}: the ${NOUN[o.type]} takes effect before it was appended`);
251
+ if (input.ledgerReceipts === undefined)
252
+ return;
253
+ const r = typeof ledger_receipt === "string" ? receipts.get(ledger_receipt) : undefined;
254
+ if (r === undefined) {
255
+ reasons.push(`entry ${i}: its ledger receipt was not supplied`);
256
+ return;
257
+ }
258
+ const bad = ledgerReasons(statement, r, anchored_at);
259
+ if (!bad.length && ledger && !ledgerIdentity(r, ledger))
260
+ bad.push("ledger receipt is not signed by the scheme owner's ledger identity");
261
+ reasons.push(...bad.map((x) => `entry ${i}: ${x}`));
262
+ if (!bad.length)
263
+ anchored++;
264
+ });
265
+ return out(reasons, { ...extra, anchored, appended });
266
+ }
@@ -0,0 +1,2 @@
1
+ import type { SchemeAppendStatement } from "./scheme-log.ts";
2
+ export declare function schemePrefixStatements(entries: readonly unknown[], owner: string): SchemeAppendStatement[];
@@ -0,0 +1,20 @@
1
+ // One preparation for complete offline verification; the server uses persisted indexed nodes.
2
+ import { canonicalBytes, jcsHash, leafHash, merkleRoot } from "@zanii/core";
3
+ export function schemePrefixStatements(entries, owner) {
4
+ const frontier = [];
5
+ return entries.map((entry, index) => {
6
+ let level = 0, position = index, node = leafHash(canonicalBytes(entry));
7
+ while (position % 2 === 1) {
8
+ node = merkleRoot([frontier[level], node]);
9
+ frontier[level] = undefined;
10
+ position = Math.floor(position / 2);
11
+ level++;
12
+ }
13
+ frontier[level] = node;
14
+ let root;
15
+ for (const left of frontier)
16
+ if (left)
17
+ root = root ? merkleRoot([left, root]) : left;
18
+ return { v: 1, type: "certify.scheme-append", log: owner, index, entry: jcsHash(entry), size: index + 1, root: `sha256:${Buffer.from(root).toString("hex")}` };
19
+ });
20
+ }
@@ -0,0 +1,25 @@
1
+ export interface SchemeTextFile {
2
+ path: string;
3
+ text: string;
4
+ }
5
+ export interface SchemeTextBundle {
6
+ v: 1;
7
+ type: "certify.scheme-text";
8
+ scheme: string;
9
+ files: SchemeTextFile[];
10
+ }
11
+ export declare const SCHEME_TEXT_MAX_BYTES = 4194304;
12
+ /** LF normalization is explicit; Unicode content is retained byte for byte otherwise. */
13
+ export declare function buildSchemeTextBundle(input: {
14
+ scheme: string;
15
+ files: readonly SchemeTextFile[];
16
+ }): SchemeTextBundle;
17
+ /** The released file is these canonical JSON bytes; a digest alone is not board approval. */
18
+ export declare function schemeTextBundleHash(doc: unknown): string;
19
+ export declare function verifySchemeTextBundle(doc: unknown, expected: {
20
+ scheme: string;
21
+ sha256: string;
22
+ }): {
23
+ ok: boolean;
24
+ reasons: string[];
25
+ };
@@ -0,0 +1,46 @@
1
+ import { jcsHash } from "@zanii/core";
2
+ import { isObj, isSha } from "./shared.js";
3
+ import { withinVerificationBudget } from "./verification-bounds.js";
4
+ export const SCHEME_TEXT_MAX_BYTES = 4_194_304;
5
+ const scheme = (value) => typeof value === "string" && /^zanii-certify\/(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/.exec(value)?.[0] === value;
6
+ const path = (value) => typeof value === "string" && /^[a-z0-9][a-z0-9._/-]{0,127}$/.exec(value)?.[0] === value && !value.endsWith("/") && value.split("/").every(part => part !== "" && part !== "." && part !== "..");
7
+ const text = (value) => typeof value === "string" && value.length <= 524_288 && value.isWellFormed() && Buffer.byteLength(value, "utf8") <= 524_288 && !value.startsWith("\uFEFF") && !/[\x00-\x08\x0b-\x1f\x7f]/.test(value);
8
+ function reasons(doc) {
9
+ if (!withinVerificationBudget(doc, { maxBytes: SCHEME_TEXT_MAX_BYTES, maxNodes: 1000, maxDepth: 4 }))
10
+ return ["scheme text exceeds its 4 MiB JSON budget"];
11
+ if (!isObj(doc) || Object.keys(doc).length !== 4 || doc.v !== 1 || doc.type !== "certify.scheme-text" || !scheme(doc.scheme) || !Array.isArray(doc.files) || doc.files.length < 1 || doc.files.length > 64)
12
+ return ["invalid scheme text bundle or file count"];
13
+ let previous = "";
14
+ for (const file of doc.files) {
15
+ if (!isObj(file) || Object.keys(file).length !== 2 || !path(file.path) || file.path <= previous || !text(file.text) || file.text.includes("\r"))
16
+ return ["scheme files must be sorted distinct relative paths and bounded UTF-8 text with LF endings"];
17
+ previous = file.path;
18
+ }
19
+ return [];
20
+ }
21
+ /** LF normalization is explicit; Unicode content is retained byte for byte otherwise. */
22
+ export function buildSchemeTextBundle(input) {
23
+ if (!withinVerificationBudget(input, { maxBytes: SCHEME_TEXT_MAX_BYTES, maxNodes: 1000, maxDepth: 4 }) || !isObj(input) || Object.keys(input).length !== 2 || !scheme(input.scheme) || !Array.isArray(input.files) || input.files.length < 1 || input.files.length > 64)
24
+ throw new Error("invalid bounded scheme text input");
25
+ const files = input.files.map(file => { if (!isObj(file) || Object.keys(file).length !== 2 || !path(file.path) || typeof file.text !== "string")
26
+ throw new Error("invalid scheme file"); return { path: file.path, text: file.text.replace(/\r\n?/g, "\n") }; }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
27
+ const bundle = { v: 1, type: "certify.scheme-text", scheme: input.scheme, files }, bad = reasons(bundle);
28
+ if (bad.length)
29
+ throw new Error(bad[0]);
30
+ return bundle;
31
+ }
32
+ /** The released file is these canonical JSON bytes; a digest alone is not board approval. */
33
+ export function schemeTextBundleHash(doc) { const bad = reasons(doc); if (bad.length)
34
+ throw new Error(bad[0]); return jcsHash(doc); }
35
+ export function verifySchemeTextBundle(doc, expected) {
36
+ if (!isObj(expected) || Object.keys(expected).length !== 2 || !scheme(expected.scheme) || typeof expected.sha256 !== "string" || expected.sha256.length !== 71 || !isSha(expected.sha256))
37
+ return { ok: false, reasons: ["independently selected scheme and released digest are required"] };
38
+ const bad = reasons(doc);
39
+ if (bad.length)
40
+ return { ok: false, reasons: bad };
41
+ if (doc.scheme !== expected.scheme)
42
+ bad.push("scheme text names another scheme version");
43
+ if (jcsHash(doc) !== expected.sha256)
44
+ bad.push("scheme text does not match the released content digest");
45
+ return { ok: bad.length === 0, reasons: bad };
46
+ }
@@ -0,0 +1,16 @@
1
+ export interface SchemeTimingPolicy {
2
+ v: 1;
3
+ type: "certify.scheme-timing-policy";
4
+ max_future_s: number;
5
+ }
6
+ export type SchemeTimingCode = "SCHEME_TIMING_POLICY" | "SCHEME_EFFECTIVE_INVALID" | "SCHEME_EFFECTIVE_PAST" | "SCHEME_EFFECTIVE_TOO_FAR";
7
+ export interface SchemeTimingCheck {
8
+ ok: boolean;
9
+ codes: SchemeTimingCode[];
10
+ reasons: string[];
11
+ }
12
+ /** Check again under the append lock using the trusted commit clock. No clock skew allowance. */
13
+ export declare function verifySchemeEffectiveTime(doc: unknown, opts: {
14
+ policy: unknown;
15
+ now: string;
16
+ }): SchemeTimingCheck;
@@ -0,0 +1,22 @@
1
+ // Admission policy for NEW owner submissions, never a reinterpretation of signed history.
2
+ import { isInt, isIso, isObj, ms } from "./shared.js";
3
+ import { withinVerificationBudget } from "./verification-bounds.js";
4
+ const TYPES = ["certify.board", "certify.release", "certify.release-withdrawal", "certify.body-register"];
5
+ /** Check again under the append lock using the trusted commit clock. No clock skew allowance. */
6
+ export function verifySchemeEffectiveTime(doc, opts) {
7
+ const codes = [], reasons = [];
8
+ const fail = (code, reason) => { codes.push(code); reasons.push(reason); };
9
+ const p = opts?.policy;
10
+ if (!isObj(p) || Object.keys(p).length !== 3 || p.v !== 1 || p.type !== "certify.scheme-timing-policy" || !isInt(p.max_future_s, 1, 2_592_000))
11
+ fail("SCHEME_TIMING_POLICY", "a closed scheme timing policy with max_future_s from 1 to 2592000 is required");
12
+ if (!isIso(opts?.now) || !withinVerificationBudget(doc, { maxBytes: 1_048_576, maxNodes: 100_000 }) || !isObj(doc) || !TYPES.includes(doc.type) || !isIso(doc.effective_from))
13
+ fail("SCHEME_EFFECTIVE_INVALID", "a supported scheme entry, effective_from and trusted reference time are required");
14
+ if (!codes.length && isObj(doc) && isObj(p)) {
15
+ const delta = ms(doc.effective_from) - ms(opts.now);
16
+ if (delta < 0)
17
+ fail("SCHEME_EFFECTIVE_PAST", "effective_from must not precede the protected append time");
18
+ else if (delta > Number(p.max_future_s) * 1000)
19
+ fail("SCHEME_EFFECTIVE_TOO_FAR", "effective_from exceeds the scheme timing policy's future limit");
20
+ }
21
+ return { ok: codes.length === 0, codes, reasons };
22
+ }
@@ -0,0 +1,47 @@
1
+ import { type BodySignature } from "./board.ts";
2
+ import { type SchemeAppendStatement } from "./scheme-log.ts";
3
+ import { type Signer } from "./signer.ts";
4
+ export interface SchemeTransitionBody {
5
+ v: 1;
6
+ type: "certify.board-transition";
7
+ owner: string;
8
+ from: string;
9
+ to: string;
10
+ epoch: number;
11
+ at: string;
12
+ cancel: string[];
13
+ }
14
+ export type SchemeTransition = SchemeTransitionBody & {
15
+ signatures: BodySignature[];
16
+ };
17
+ export interface SchemeTransitionAnchor {
18
+ statement: SchemeAppendStatement;
19
+ append: unknown;
20
+ receipt: unknown;
21
+ }
22
+ export interface SchemeTransitionContext {
23
+ owner: string;
24
+ /** Authenticated newest board, exact proposed next board and next scheme-log position. */
25
+ board: unknown;
26
+ nextBoard: unknown;
27
+ index: number;
28
+ previousAt: string;
29
+ /** ONLY authenticated prefix entries. Canceled records remain in this history. */
30
+ entry: (hash: string) => unknown;
31
+ anchor: SchemeTransitionAnchor;
32
+ nextAnchor: SchemeTransitionAnchor;
33
+ }
34
+ export declare function buildSchemeTransition(fields: Omit<SchemeTransitionBody, "v" | "type">, signers: readonly Signer[]): Promise<SchemeTransition>;
35
+ /** Strict effect verification: both authenticated append receipts are mandatory. */
36
+ export declare function verifySchemeTransition(doc: unknown, c: SchemeTransitionContext): {
37
+ ok: boolean;
38
+ reasons: string[];
39
+ canceled: string[];
40
+ };
41
+ /** Admission only. This does NOT authorize cancellation or a new board before anchoring. */
42
+ export declare function verifySchemeTransitionProposal(doc: unknown, c: Omit<SchemeTransitionContext, "anchor" | "nextAnchor"> & {
43
+ now: string;
44
+ }): {
45
+ ok: boolean;
46
+ reasons: string[];
47
+ };
@@ -0,0 +1,101 @@
1
+ // An explicit anchored cancellation of future entries, followed immediately by one exact board.
2
+ import { jcsHash } from "@zanii/core";
3
+ import { boardFieldError, bodySignature, isBoard, isRelease, isWithdrawal, releaseFieldError, signersOf, withSignatures, withdrawalFieldError } from "./board.js";
4
+ import { isBodyRegister, registerFieldError } from "./register.js";
5
+ import { verifySchemeAppend } from "./scheme-log.js";
6
+ import { isDid, isInt, isIso, isObj, isSha, ms } from "./shared.js";
7
+ import {} from "./signer.js";
8
+ import { withinVerificationBudget } from "./verification-bounds.js";
9
+ function fieldError(d) {
10
+ if (!isObj(d) || Object.keys(d).length !== 9 || d.v !== 1 || d.type !== "certify.board-transition")
11
+ return "not a closed board-transition (v1)";
12
+ if (!isDid(d.owner) || !isSha(d.from) || !isSha(d.to) || !isInt(d.epoch, 1, Number.MAX_SAFE_INTEGER) || !isIso(d.at))
13
+ return "invalid transition authority, board binding, epoch or time";
14
+ if (!Array.isArray(d.cancel) || d.cancel.length < 1 || d.cancel.length > 100 || !d.cancel.every((h, i, xs) => isSha(h) && (i === 0 || xs[i - 1] < h)))
15
+ return "cancel must contain 1 to 100 sorted distinct entry hashes";
16
+ return null;
17
+ }
18
+ export async function buildSchemeTransition(fields, signers) {
19
+ if (!withinVerificationBudget(fields, { maxBytes: 32_768, maxNodes: 1_000 }) || !Array.isArray(signers) || signers.length < 1 || signers.length > 65)
20
+ throw new Error("transition exceeds its signing budget");
21
+ const body = { v: 1, type: "certify.board-transition", owner: fields.owner, from: fields.from, to: fields.to, epoch: fields.epoch, at: fields.at, cancel: Array.isArray(fields.cancel) ? [...fields.cancel] : fields.cancel };
22
+ const err = fieldError({ ...body, signatures: [] });
23
+ if (err)
24
+ throw new Error(err);
25
+ return withSignatures(body, await Promise.all(signers.map((s) => bodySignature(body, s))));
26
+ }
27
+ /** The caller must atomically accept this entry and its immediately following exact next board. */
28
+ function transitionCheck(doc, c, proposalNow) {
29
+ const reasons = [];
30
+ const out = () => ({ ok: reasons.length === 0, reasons, canceled: reasons.length || !isObj(doc) ? [] : [...doc.cancel] });
31
+ if (!withinVerificationBudget(doc, { maxBytes: 65_536, maxNodes: 3_000 }) || !withinVerificationBudget(c?.board, { maxBytes: 65_536, maxNodes: 3_000 }) || !withinVerificationBudget(c?.nextBoard, { maxBytes: 65_536, maxNodes: 3_000 }) || proposalNow === undefined && (!withinVerificationBudget(c?.anchor, { maxBytes: 655_360, maxNodes: 60_000 }) || !withinVerificationBudget(c?.nextAnchor, { maxBytes: 655_360, maxNodes: 60_000 })))
32
+ return { ok: false, reasons: ["board transition exceeds the verification budget"], canceled: [] };
33
+ const err = fieldError(doc);
34
+ if (err || !isObj(doc))
35
+ return { ok: false, reasons: [err], canceled: [] };
36
+ const b = c?.board, next = c?.nextBoard;
37
+ if (!isDid(c?.owner) || doc.owner !== c.owner || !isBoard(b) || boardFieldError(b) || b.owner !== c.owner || !isBoard(next) || boardFieldError(next) || next.owner !== c.owner)
38
+ return { ok: false, reasons: ["trusted outgoing and next boards are required"], canceled: [] };
39
+ if (doc.from !== jcsHash(b) || doc.to !== jcsHash(next) || doc.epoch !== next.seq || next.seq !== Number(b.seq) + 1 || next.prev !== doc.from)
40
+ reasons.push("transition is not bound to the exact outgoing and next board epoch");
41
+ if (!isDid(b.ledger) || next.ledger !== b.ledger)
42
+ reasons.push("transition requires the same explicit ledger identity on both boards");
43
+ if (!(ms(next.effective_from) > ms(b.effective_from)))
44
+ reasons.push("next board does not take effect after the outgoing board");
45
+ if (!isIso(c.previousAt))
46
+ reasons.push("the authenticated previous append time is required");
47
+ const allowed = [c.owner, ...b.members];
48
+ const signed = signersOf(doc, allowed), members = signed.signers.filter((s) => b.members.includes(s));
49
+ if (!signed.wellFormed || !signed.signers.includes(c.owner) || members.length < Number(b.threshold))
50
+ reasons.push("transition requires the owner and outgoing board threshold signatures");
51
+ const nextSigned = signersOf(next, b.members);
52
+ if (!nextSigned.wellFormed || nextSigned.signers.length < Number(b.threshold))
53
+ reasons.push("next board is not signed by the outgoing threshold");
54
+ let anchoredAt = proposalNow ?? "";
55
+ if (proposalNow !== undefined && (!isIso(proposalNow) || ms(proposalNow) < ms(doc.at) || ms(proposalNow) - ms(doc.at) > 60_000))
56
+ reasons.push("transition proposal must be within 60 seconds after its authorization time");
57
+ if (proposalNow === undefined) {
58
+ for (const [offset, target, a] of [[0, doc, c.anchor], [1, next, c.nextAnchor]]) {
59
+ if (!a || !isObj(a.statement) || Object.keys(a.statement).length !== 7 || a.statement.v !== 1 || a.statement.type !== "certify.scheme-append" || !isInt(c.index, 1, Number.MAX_SAFE_INTEGER - 2) || a.statement?.index !== c.index + offset || a.statement?.size !== c.index + offset + 1 || a.statement?.log !== c.owner || a.statement?.entry !== jcsHash(target) || !isSha(a.statement?.root))
60
+ reasons.push("authenticated transition and next-board appends at the exact prefix positions are required");
61
+ else
62
+ reasons.push(...verifySchemeAppend(a.statement, target, a.append, a.receipt, { ledger: b.ledger }).reasons);
63
+ if (!isObj(a?.receipt) || a.receipt.v !== 1 || a.receipt.action !== "certify.scheme-append" || a.receipt.target !== `certify.scheme/${c.index + offset}`)
64
+ reasons.push("transition anchor receipt has another purpose or target");
65
+ const time = isObj(a?.append) ? a.append.anchored_at : undefined;
66
+ if (!isIso(time) || ms(time) < ms(doc.at) || ms(time) - ms(doc.at) > 60_000 || ms(time) < ms(c.previousAt) || anchoredAt && ms(time) < ms(anchoredAt))
67
+ reasons.push("transition anchors must be ordered within 60 seconds after their authorization time");
68
+ if (isIso(time))
69
+ anchoredAt = time;
70
+ }
71
+ }
72
+ if (!isIso(anchoredAt) || ms(next.effective_from) < ms(anchoredAt))
73
+ reasons.push("next board takes effect before the authenticated transition anchors");
74
+ for (const hash of doc.cancel) {
75
+ let entry;
76
+ try {
77
+ entry = c.entry(hash);
78
+ }
79
+ catch {
80
+ reasons.push("transition prefix lookup failed");
81
+ break;
82
+ }
83
+ if (!withinVerificationBudget(entry, { maxBytes: 1_048_576, maxNodes: 100_000 }) || !isObj(entry)) {
84
+ reasons.push("cancellation target is absent from the authenticated prefix");
85
+ continue;
86
+ }
87
+ const invalid = isRelease(entry) ? releaseFieldError(entry) : isWithdrawal(entry) ? withdrawalFieldError(entry) : isBodyRegister(entry) ? registerFieldError(entry) : "not cancellable";
88
+ if (invalid || jcsHash(entry) !== hash || entry.board !== doc.from)
89
+ reasons.push("cancellation target is not an outgoing-board release, withdrawal or register");
90
+ if (!isIso(anchoredAt) || !(ms(entry.effective_from) > ms(anchoredAt)))
91
+ reasons.push("cancellation target is already effective at the authenticated transition anchor");
92
+ }
93
+ return out();
94
+ }
95
+ /** Strict effect verification: both authenticated append receipts are mandatory. */
96
+ export function verifySchemeTransition(doc, c) { return transitionCheck(doc, c); }
97
+ /** Admission only. This does NOT authorize cancellation or a new board before anchoring. */
98
+ export function verifySchemeTransitionProposal(doc, c) {
99
+ const { ok, reasons } = transitionCheck(doc, c, c?.now ?? "");
100
+ return { ok, reasons };
101
+ }
@@ -0,0 +1,35 @@
1
+ export type Verdict = "pass" | "fail" | "inconclusive";
2
+ export interface CategoryScore {
3
+ rate_ppm: number;
4
+ low_ppm: number;
5
+ high_ppm: number;
6
+ verdict: Verdict;
7
+ }
8
+ /** Section 1: the 95% Wilson range of `passes` in `trials`, judged against `thresholdPpm`. */
9
+ export declare function scoreCategory(passes: number, trials: number, thresholdPpm: number): CategoryScore;
10
+ export interface ScoredResult {
11
+ category: string;
12
+ trials: number;
13
+ threshold_ppm: number;
14
+ verdict: Verdict;
15
+ }
16
+ export interface ProfileCategoryRule {
17
+ id: string;
18
+ required_levels: number[];
19
+ min_trials: number;
20
+ threshold_ppm: number;
21
+ }
22
+ export interface Overall {
23
+ pass: boolean;
24
+ /** Codes `<RULE>:<category>` (or `LEVEL_NOT_IN_PROFILE`), sorted. Empty on a pass. */
25
+ reasons: string[];
26
+ }
27
+ /**
28
+ * Section 2: pass only when the level is in the profile and every category the profile requires
29
+ * at that level is present, passes, ran at least min_trials and was judged at a threshold no
30
+ * lower than the profile's.
31
+ */
32
+ export declare function overallVerdict(results: readonly ScoredResult[], profile: {
33
+ levels: number[];
34
+ categories: readonly ProfileCategoryRule[];
35
+ }, level: number): Overall;
@@ -0,0 +1,50 @@
1
+ // spec/scoring.md: per-category verdicts from repeated trials, and the overall pass rule.
2
+ import { successRange } from "@zanii/blackbox";
3
+ import { isInt } from "./shared.js";
4
+ /** Section 1: the 95% Wilson range of `passes` in `trials`, judged against `thresholdPpm`. */
5
+ export function scoreCategory(passes, trials, thresholdPpm) {
6
+ if (!isInt(trials, 1, 1_000_000))
7
+ throw new Error("trials must be an integer 1-1000000");
8
+ if (!isInt(passes, 0, trials))
9
+ throw new Error("passes must be an integer 0-trials");
10
+ if (!isInt(thresholdPpm, 0, 1_000_000))
11
+ throw new Error("threshold_ppm must be 0-1000000");
12
+ const [low, high] = successRange(passes, trials);
13
+ const verdict = low >= thresholdPpm ? "pass" : high < thresholdPpm ? "fail" : "inconclusive";
14
+ return {
15
+ rate_ppm: Math.floor((passes * 1_000_000) / trials),
16
+ low_ppm: low,
17
+ high_ppm: high,
18
+ verdict,
19
+ };
20
+ }
21
+ /**
22
+ * Section 2: pass only when the level is in the profile and every category the profile requires
23
+ * at that level is present, passes, ran at least min_trials and was judged at a threshold no
24
+ * lower than the profile's.
25
+ */
26
+ export function overallVerdict(results, profile, level) {
27
+ const reasons = [];
28
+ if (!profile.levels.includes(level))
29
+ reasons.push("LEVEL_NOT_IN_PROFILE");
30
+ const by = new Map(results.map((r) => [r.category, r]));
31
+ for (const c of profile.categories) {
32
+ if (!c.required_levels.includes(level))
33
+ continue;
34
+ const r = by.get(c.id);
35
+ if (!r) {
36
+ reasons.push(`CATEGORY_MISSING:${c.id}`);
37
+ continue;
38
+ }
39
+ if (r.trials < c.min_trials)
40
+ reasons.push(`TOO_FEW_TRIALS:${c.id}`);
41
+ if (r.threshold_ppm < c.threshold_ppm)
42
+ reasons.push(`THRESHOLD_BELOW_PROFILE:${c.id}`);
43
+ if (r.verdict === "fail")
44
+ reasons.push(`CATEGORY_FAILED:${c.id}`);
45
+ if (r.verdict === "inconclusive")
46
+ reasons.push(`CATEGORY_INCONCLUSIVE:${c.id}`);
47
+ }
48
+ reasons.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
49
+ return { pass: reasons.length === 0, reasons };
50
+ }