@colophon-claims/verify 0.2.0 → 0.2.1

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 (47) hide show
  1. package/README.md +18 -8
  2. package/dist/admission/contracts.d.ts +241 -0
  3. package/dist/admission/contracts.js +116 -0
  4. package/dist/admission/index.d.ts +2 -0
  5. package/dist/admission/index.js +2 -0
  6. package/dist/admission/prompted-commitment.d.ts +19 -0
  7. package/dist/admission/prompted-commitment.js +52 -0
  8. package/dist/admission/prompted-selection.d.ts +24 -0
  9. package/dist/admission/prompted-selection.js +85 -0
  10. package/dist/admission/verification.d.ts +1 -0
  11. package/dist/admission/verification.js +88 -42
  12. package/dist/assets.d.ts +21 -1
  13. package/dist/assets.js +60 -3
  14. package/dist/binding/beacon-binding.d.ts +230 -0
  15. package/dist/binding/beacon-binding.js +325 -0
  16. package/dist/binding/report-face.d.ts +45 -0
  17. package/dist/binding/report-face.js +153 -0
  18. package/dist/cli.js +74 -12
  19. package/dist/index.d.ts +14 -3
  20. package/dist/index.js +16 -3
  21. package/dist/manifest.d.ts +30 -4
  22. package/dist/manifest.js +30 -0
  23. package/dist/materialize.d.ts +7 -0
  24. package/dist/materialize.js +7 -0
  25. package/dist/outcome.d.ts +31 -0
  26. package/dist/outcome.js +45 -0
  27. package/dist/profile/claim-consistency.d.ts +8 -1
  28. package/dist/profile/claim-consistency.js +4 -4
  29. package/dist/profile/claim.d.ts +201 -2
  30. package/dist/profile/claim.js +178 -47
  31. package/dist/profile/disclosure.d.ts +273 -0
  32. package/dist/profile/disclosure.js +240 -0
  33. package/dist/profile/run-results.d.ts +8 -2
  34. package/dist/profile/run-results.js +9 -3
  35. package/dist/profile/task-selection.d.ts +69 -0
  36. package/dist/profile/task-selection.js +140 -0
  37. package/dist/reader-instructions.d.ts +38 -1
  38. package/dist/reader-instructions.js +42 -2
  39. package/dist/schema.d.ts +4 -3
  40. package/dist/schema.js +17 -0
  41. package/dist/signers.d.ts +48 -0
  42. package/dist/signers.js +77 -0
  43. package/dist/verify.d.ts +20 -6
  44. package/dist/verify.js +156 -31
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +14 -14
@@ -0,0 +1,325 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * `beacon-binding/1` -- binding a sealed run to public randomness that did not exist when it was
4
+ * sealed (issue #2976).
5
+ *
6
+ * Sealing proves a method document predates its publication. It cannot prove the *run* happened
7
+ * after the seal: a party could run privately, write a method describing what already happened,
8
+ * seal it, and re-run. `verify/src/profile/anchor-claims.ts` says as much in the sealed claim's own
9
+ * words -- a time anchor proves "the design's existence by that time and nothing else about the run
10
+ * -- in particular, not that results were produced after it".
11
+ *
12
+ * This procedure closes that gap by deriving a run property from a value that postdates the seal.
13
+ * Where a slate is drawn from a larger pool, the draw is a function of (seal digest, beacon value),
14
+ * so no post-hoc selection is possible: the operator would have had to predict the beacon. Where
15
+ * the slate is a whole census, there is no draw to bind, and the beacon binds only execution
16
+ * ORDER -- a strictly weaker property that this module names as such rather than dressing up.
17
+ *
18
+ * It is the deliberate sibling of `../admission/screening-sample.ts` (`screening-sample/1`) and
19
+ * reuses that procedure's encoding decisions verbatim, including its identity-set digest. The one
20
+ * substantive difference is the HMAC key: `screening-sample/1` keys on a SEALED seed, which is
21
+ * exactly the property #2976 says is insufficient, and this one keys on the seal digest together
22
+ * with a post-seal beacon value. Everything else is deliberately identical so a second implementer
23
+ * who has already built one has nothing new to get wrong.
24
+ *
25
+ * Every encoding choice, restated here so this paragraph alone is reimplementable in any language:
26
+ *
27
+ * - **The HMAC key is `utf8(sealDigest || beaconValue)`.** `sealDigest` enters in its
28
+ * `sha256:`-prefixed lowercase-hex string form (a fixed 71 characters) and `beaconValue` as its
29
+ * 64 lowercase hex digits. Both are fixed-length, so -- as in `screening-sample/1` -- no
30
+ * delimiter separates them; a delimiter would only be a second convention to get wrong. Never
31
+ * raw digest bytes: the whole procedure is text.
32
+ * - **The HMAC message is `utf8(itemSha256)`**, the same `sha256:`-prefixed lowercase-hex form.
33
+ * - **The order is ascending over the 32 unsigned HMAC-SHA256 bytes**, ties broken by `itemSha256`
34
+ * in code-unit order. This is `compareScreeningStreamEntries`, shared rather than re-derived.
35
+ * - **`poolDigest` binds the identity SET**: `sha256:` followed by the SHA-256 of the canonical-JSON
36
+ * bytes of the `itemSha256` values, code-unit sorted and unique. This is
37
+ * `computeScreeningPoolDigest`, shared for the same reason.
38
+ * - **The sample is the first `sampleSize` of that order.** In census mode there is no sample and
39
+ * the order itself is the execution order.
40
+ *
41
+ * The beacon's postdating is checked, not assumed, and how strongly it can be checked depends on
42
+ * the source. A drand round number maps to a time by published chain parameters
43
+ * (`genesis + (round - 1) * period`), so "this value did not exist at seal time" is arithmetic any
44
+ * reader does offline. A Bitcoin height does not: block times need headers, so that check is
45
+ * attributive and this module says so instead of claiming an offline proof it cannot make.
46
+ *
47
+ * Postdating alone would still leave the operator a choice, and issue #3322 closes it: admitting any
48
+ * round later than the seal makes the VALUE unpredictable but not WHICH realized value applies, so
49
+ * an operator could watch the rounds published between lock and launch and bind the one whose
50
+ * derivation they preferred. For a scheduled source the seal already names one round --
51
+ * `requiredBeaconRound`, the first published strictly after it -- so the commitment needs no
52
+ * separate record, the producer refuses any other round, and `roundBasis` reports which of the two
53
+ * situations a record is in so the report face can say only what is true of it.
54
+ *
55
+ * This module does no filesystem or network I/O and throws `RunBindingError` on any invalid input.
56
+ */
57
+ import { createHmac } from "node:crypto";
58
+ import { z } from "zod";
59
+ import { compareScreeningStreamEntries, computeScreeningPoolDigest, } from "../admission/screening-sample.js";
60
+ /** The procedure identifier a binding record must carry. */
61
+ export const BEACON_BINDING_PROCEDURE = "beacon-binding/1";
62
+ /**
63
+ * The beacons this procedure admits. Values are the sources' own published chain parameters and
64
+ * are part of the derivation: a reader recomputing `beaconRoundInstant` needs exactly these
65
+ * numbers, so they live in the code rather than in a comment.
66
+ */
67
+ export const BEACON_SOURCES = {
68
+ "drand/quicknet": {
69
+ timeBasis: "deterministic-round-time",
70
+ genesisTimeSeconds: 1692803367,
71
+ periodSeconds: 3,
72
+ displayName: "drand quicknet",
73
+ },
74
+ "drand/default": {
75
+ timeBasis: "deterministic-round-time",
76
+ genesisTimeSeconds: 1595431050,
77
+ periodSeconds: 30,
78
+ displayName: "drand default chain",
79
+ },
80
+ "bitcoin/mainnet": {
81
+ timeBasis: "attributive-height",
82
+ displayName: "Bitcoin mainnet",
83
+ },
84
+ };
85
+ export const BEACON_SOURCE_IDS = Object.keys(BEACON_SOURCES).sort();
86
+ /**
87
+ * A schema-level sanity ceiling on a round index. Not a beacon limit, and deliberately not the
88
+ * representability guarantee either: the ceiling that matters is per-source, because it falls out
89
+ * of each source's own period, and one shared number cannot be sound for all of them. Quicknet's
90
+ * 3-second period puts round 1,000,000,000,000 some 95,000 years out and inside what `Date`
91
+ * represents; the default chain's 30-second period puts the same round ten times further out and
92
+ * outside it. So this bound only rejects the absurd, and `beaconRoundInstant` -- not the schema --
93
+ * owns the guarantee that the arithmetic stays representable. Every real Bitcoin height is eight
94
+ * orders of magnitude below this.
95
+ */
96
+ export const MAX_BEACON_ROUND = 1_000_000_000_000;
97
+ /**
98
+ * The widest instant `Date` represents, in milliseconds either side of the epoch (ECMA-262:
99
+ * 8.64e15). Beyond it `toISOString` throws an untyped `RangeError`, which is what
100
+ * `beaconRoundInstant` refuses on this module's own terms instead.
101
+ */
102
+ const MAX_REPRESENTABLE_TIME_MS = 8_640_000_000_000_000;
103
+ const HexValueSchema = z.string().regex(/^[0-9a-f]{64}$/, "must be 64 lowercase hex characters");
104
+ const DigestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "must match ^sha256:[0-9a-f]{64}$");
105
+ const InstantSchema = z.string().datetime({ offset: true });
106
+ /**
107
+ * A public beacon reference: which beacon, which round or height, and the value it published
108
+ * there. `round` is the source's own index -- a drand round number, a Bitcoin block height.
109
+ */
110
+ export const BeaconReferenceSchema = z.strictObject({
111
+ source: z.enum(Object.keys(BEACON_SOURCES)),
112
+ round: z.number().int().positive().max(MAX_BEACON_ROUND),
113
+ value: HexValueSchema,
114
+ });
115
+ const CommonBindingFields = {
116
+ procedure: z.literal(BEACON_BINDING_PROCEDURE),
117
+ /** The digest of the sealed record this binding postdates -- the run's own seal. */
118
+ sealDigest: DigestSchema,
119
+ /** When that seal was taken. The beacon must postdate it. */
120
+ sealedAt: InstantSchema,
121
+ beacon: BeaconReferenceSchema,
122
+ };
123
+ /**
124
+ * The two shapes, disjoint by construction so no reader can mistake the weaker binding for the
125
+ * stronger one:
126
+ *
127
+ * - `sampled` -- a slate drawn from a larger pool. `sample` is the claim; the verifier recomputes
128
+ * it and fails on mismatch.
129
+ * - `census` -- the whole declared population runs, so there is no draw. `order` is the claim, and
130
+ * it binds execution order only.
131
+ */
132
+ export const RunBindingSchema = z.discriminatedUnion("mode", [
133
+ z.strictObject({
134
+ ...CommonBindingFields,
135
+ mode: z.literal("sampled"),
136
+ /** The pool the slate was drawn from. Order is irrelevant; the identity set is what binds. */
137
+ poolItemSha256s: z.array(DigestSchema).min(1),
138
+ sampleSize: z.number().int().positive(),
139
+ /** The drawn slate, in derived order. */
140
+ sample: z.array(DigestSchema).min(1),
141
+ }),
142
+ z.strictObject({
143
+ ...CommonBindingFields,
144
+ mode: z.literal("census"),
145
+ /** The whole population. Order is irrelevant here; `order` below is the derived claim. */
146
+ itemSha256s: z.array(DigestSchema).min(1),
147
+ /** Every item, in beacon-derived execution order. */
148
+ order: z.array(DigestSchema).min(1),
149
+ }),
150
+ ]);
151
+ export class RunBindingError extends Error {
152
+ name = "RunBindingError";
153
+ path;
154
+ constructor(path, detail) {
155
+ super(`${path}: ${detail}`);
156
+ this.path = path;
157
+ }
158
+ }
159
+ function fail(path, detail) {
160
+ throw new RunBindingError(path, detail);
161
+ }
162
+ /**
163
+ * The instant a `deterministic-round-time` beacon published `round`, as an RFC 3339 UTC string, or
164
+ * `undefined` for a source whose round index carries no offline time. The schedule is
165
+ * `genesis + (round - 1) * period`: round 1 is published at genesis.
166
+ *
167
+ * Refuses (throws `RunBindingError`) when that instant falls outside the range `Date` represents.
168
+ * The check lives here rather than on the schema because the largest representable round is a
169
+ * function of the source's own period -- the default chain runs out ten times earlier than
170
+ * quicknet does -- so a single schema ceiling would leave the slower source's tail passing
171
+ * validation and then throwing an untyped `RangeError` from `toISOString` deep inside
172
+ * verification. Guarding the arithmetic where the arithmetic happens makes the typed refusal a
173
+ * property of this function, and therefore true for every source and every caller.
174
+ */
175
+ export function beaconRoundInstant(beacon) {
176
+ const source = BEACON_SOURCES[beacon.source];
177
+ if (source.timeBasis !== "deterministic-round-time")
178
+ return undefined;
179
+ const { genesisTimeSeconds, periodSeconds } = source;
180
+ const instantMs = (genesisTimeSeconds + (beacon.round - 1) * periodSeconds) * 1000;
181
+ if (!Number.isFinite(instantMs) || Math.abs(instantMs) > MAX_REPRESENTABLE_TIME_MS) {
182
+ fail("beacon.round", `${source.displayName} round ${beacon.round} is scheduled outside the range a timestamp can `
183
+ + "represent, so its publication instant cannot be computed");
184
+ }
185
+ return new Date(instantMs).toISOString();
186
+ }
187
+ /**
188
+ * The one round a run sealed at `sealedAt` may bind to on a `deterministic-round-time` source: the
189
+ * first round that source publishes STRICTLY after the seal (issue #3322).
190
+ *
191
+ * The point is that the seal already fixes it. `verifyRunBinding` admits any round whose instant
192
+ * postdates the seal, which makes the beacon VALUE unpredictable but leaves the CHOICE among
193
+ * realized values open: between seal and binding an operator sees many published rounds, can derive
194
+ * what each would produce, and can bind the one they prefer. The standard construction is to commit
195
+ * at seal time to a specific future round -- and for a scheduled source no separate commitment
196
+ * record is needed, because `(source, sealedAt)` already determines exactly one such round, and both
197
+ * are fixed at seal time and carried by the binding itself.
198
+ *
199
+ * From `instant(r) = genesis + (r - 1) * period`, the smallest `r` with `instant(r) > sealedAt` is
200
+ * `floor((sealedAt - genesis) / period) + 2`, clamped to round 1 for a seal that predates genesis.
201
+ *
202
+ * `undefined` -- meaning no round is derivable, so the operator's choice remains and the report face
203
+ * says so -- when the source indexes by block height rather than by a schedule, when `sealedAt` is
204
+ * unparseable, or when the required round leaves `MAX_BEACON_ROUND` or the representable range.
205
+ */
206
+ export function requiredBeaconRound(source, sealedAt) {
207
+ const definition = BEACON_SOURCES[source];
208
+ if (definition.timeBasis !== "deterministic-round-time")
209
+ return undefined;
210
+ const { genesisTimeSeconds, periodSeconds } = definition;
211
+ const sealedAtMs = Date.parse(sealedAt);
212
+ if (!Number.isFinite(sealedAtMs))
213
+ return undefined;
214
+ const round = Math.max(1, Math.floor((sealedAtMs - genesisTimeSeconds * 1000) / (periodSeconds * 1000)) + 2);
215
+ if (!Number.isSafeInteger(round) || round > MAX_BEACON_ROUND)
216
+ return undefined;
217
+ // `beaconRoundInstant` owns the representability refusal; a round derived from a real seal is
218
+ // within one period of it, so this only ever throws on an input `Date` itself cannot represent.
219
+ let publishedAt;
220
+ try {
221
+ publishedAt = beaconRoundInstant({ source, round });
222
+ }
223
+ catch {
224
+ return undefined;
225
+ }
226
+ return publishedAt === undefined ? undefined : { round, publishedAt };
227
+ }
228
+ /**
229
+ * The derivation itself. Refuses (throws `RunBindingError`) when `sealDigest` or `beaconValue` is
230
+ * malformed, or when `itemSha256s` is empty, contains a duplicate, or contains a malformed entry.
231
+ */
232
+ export function computeBeaconOrder(params) {
233
+ const { sealDigest, beaconValue, itemSha256s } = params;
234
+ if (!DigestSchema.safeParse(sealDigest).success) {
235
+ fail("sealDigest", `must match ^sha256:[0-9a-f]{64}$, got ${JSON.stringify(sealDigest)}`);
236
+ }
237
+ if (!HexValueSchema.safeParse(beaconValue).success) {
238
+ fail("beaconValue", `must be 64 lowercase hex characters, got ${JSON.stringify(beaconValue)}`);
239
+ }
240
+ itemSha256s.forEach((itemSha256, index) => {
241
+ if (!DigestSchema.safeParse(itemSha256).success) {
242
+ fail(`itemSha256s[${index}]`, `must match ^sha256:[0-9a-f]{64}$, got ${JSON.stringify(itemSha256)}`);
243
+ }
244
+ });
245
+ if (itemSha256s.length === 0)
246
+ fail("itemSha256s", "identity set must be non-empty");
247
+ if (new Set(itemSha256s).size !== itemSha256s.length) {
248
+ fail("itemSha256s", "identity set must not contain duplicate itemSha256 values");
249
+ }
250
+ const key = Buffer.from(`${sealDigest}${beaconValue}`, "utf8");
251
+ const entries = itemSha256s.map((itemSha256) => ({
252
+ itemSha256,
253
+ stream: new Uint8Array(createHmac("sha256", key).update(Buffer.from(itemSha256, "utf8")).digest()),
254
+ }));
255
+ return {
256
+ poolDigest: computeScreeningPoolDigest(itemSha256s),
257
+ order: [...entries].sort(compareScreeningStreamEntries).map((entry) => entry.itemSha256),
258
+ };
259
+ }
260
+ /**
261
+ * Verifies one binding record: the beacon postdates the seal, and the declared draw or order is
262
+ * exactly what `beacon-binding/1` derives. Throws `RunBindingError` on any disagreement -- the
263
+ * recomputation wins, always; a stored field never does.
264
+ */
265
+ export function verifyRunBinding(candidate) {
266
+ const parsed = RunBindingSchema.safeParse(candidate);
267
+ if (!parsed.success) {
268
+ const issue = parsed.error.issues[0];
269
+ fail(issue.path.length === 0 ? "binding" : issue.path.join("."), issue.message);
270
+ }
271
+ const binding = parsed.data;
272
+ const sealedAtMs = Date.parse(binding.sealedAt);
273
+ const beaconInstant = beaconRoundInstant(binding.beacon);
274
+ let postSeal;
275
+ if (beaconInstant === undefined) {
276
+ postSeal = "attributive";
277
+ }
278
+ else {
279
+ if (Date.parse(beaconInstant) <= sealedAtMs) {
280
+ fail("beacon.round", `${BEACON_SOURCES[binding.beacon.source].displayName} round ${binding.beacon.round} was published at `
281
+ + `${beaconInstant}, which does not postdate the seal at ${binding.sealedAt} — a beacon that existed at `
282
+ + "seal time binds nothing");
283
+ }
284
+ postSeal = "proven-offline";
285
+ }
286
+ const pool = binding.mode === "sampled" ? binding.poolItemSha256s : binding.itemSha256s;
287
+ const derived = computeBeaconOrder({
288
+ sealDigest: binding.sealDigest,
289
+ beaconValue: binding.beacon.value,
290
+ itemSha256s: pool,
291
+ });
292
+ const required = requiredBeaconRound(binding.beacon.source, binding.sealedAt);
293
+ const roundBasis = required !== undefined && required.round === binding.beacon.round
294
+ ? "seal-derived"
295
+ : "operator-chosen";
296
+ const common = {
297
+ procedure: BEACON_BINDING_PROCEDURE,
298
+ beacon: binding.beacon,
299
+ sealDigest: binding.sealDigest,
300
+ sealedAt: binding.sealedAt,
301
+ poolDigest: derived.poolDigest,
302
+ poolSize: pool.length,
303
+ order: derived.order,
304
+ postSeal,
305
+ roundBasis,
306
+ ...(beaconInstant === undefined ? {} : { beaconInstant }),
307
+ };
308
+ if (binding.mode === "census") {
309
+ if (!sameSequence(binding.order, derived.order)) {
310
+ fail("order", "declared execution order differs from the beacon-binding/1 recomputation");
311
+ }
312
+ return { ...common, mode: "census" };
313
+ }
314
+ if (binding.sampleSize > pool.length) {
315
+ fail("sampleSize", `must not exceed the pool size (${pool.length}), got ${binding.sampleSize}`);
316
+ }
317
+ const sample = derived.order.slice(0, binding.sampleSize);
318
+ if (!sameSequence(binding.sample, sample)) {
319
+ fail("sample", "declared sample differs from the beacon-binding/1 recomputation");
320
+ }
321
+ return { ...common, mode: "sampled", sample };
322
+ }
323
+ function sameSequence(left, right) {
324
+ return left.length === right.length && left.every((value, index) => value === right[index]);
325
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The report face for `beacon-binding/1` (issue #2976, acceptance criteria 3 and 4): which binding
3
+ * applied, in plain words, and what it does and does not establish.
4
+ *
5
+ * Two rules carried over from `../profile/anchor-claims.ts`, whose conditional honesty copy this
6
+ * mirrors:
7
+ *
8
+ * - **Single-sourced, never mirrored.** `@colophon-claims/core` already depends on this package, so
9
+ * producer and reader render the same function rather than two copies that can drift.
10
+ * - **The words key on facts, not on configuration.** Every value in a sentence below comes from
11
+ * the verified binding itself, so the text is identical for every reader.
12
+ *
13
+ * A third rule follows from issue #3322: **a sentence claims unchosen-ness only where the seal
14
+ * established it.** A beacon that merely postdates the seal makes the value unpredictable and
15
+ * leaves the operator choosing among the values that postdate it. So both sentences carry
16
+ * `roundChoiceClause`, which asserts the second property under `seal-derived` and retracts it under
17
+ * `operator-chosen` rather than letting either branch imply it.
18
+ *
19
+ * A fourth rule is this module's own, and is the whole point of the originating issue: **the census
20
+ * sentence says it is the weaker binding.** Ordering-only binding shows the run's order was fixed by
21
+ * randomness that postdates the seal; it does not show the population was, because with a census
22
+ * there was no population choice to make. Letting the two modes share one confident sentence would
23
+ * be the failure this feature exists to prevent. Issue #3425 subjects that sentence to the third
24
+ * rule as well: `censusOrderClause` asserts the postdating only under `proven-offline`, and concedes
25
+ * it under `attributive` in the register the sampled opening already uses, because the clauses on
26
+ * either side of it concede exactly that.
27
+ */
28
+ import { type VerifiedRunBinding } from "./beacon-binding.js";
29
+ /**
30
+ * Which binding a run carries. `none` is the historical state every unbound run keeps -- the
31
+ * absence of a binding is a fact about the run, and is reported as one.
32
+ */
33
+ export type RunBindingClass = "none" | "beacon-drawn-slate" | "beacon-ordering-only";
34
+ export declare function runBindingClass(binding: VerifiedRunBinding | undefined): RunBindingClass;
35
+ /**
36
+ * The one sentence that states which binding applied. `undefined` yields the unbound statement,
37
+ * which is a claim about the run too: nothing about it was drawn from post-seal randomness.
38
+ */
39
+ export declare function runBindingSentence(binding: VerifiedRunBinding | undefined): string;
40
+ /**
41
+ * The venue-limits list with the binding statement appended. Returns the list unchanged when the
42
+ * run carries no binding, so every run that predates this feature keeps its exact limits bytes --
43
+ * the same additive posture `anchoredVenueLimits` takes for an unanchored run.
44
+ */
45
+ export declare function runBoundVenueLimits(limits: readonly string[], binding: VerifiedRunBinding | undefined): readonly string[];
@@ -0,0 +1,153 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * The report face for `beacon-binding/1` (issue #2976, acceptance criteria 3 and 4): which binding
4
+ * applied, in plain words, and what it does and does not establish.
5
+ *
6
+ * Two rules carried over from `../profile/anchor-claims.ts`, whose conditional honesty copy this
7
+ * mirrors:
8
+ *
9
+ * - **Single-sourced, never mirrored.** `@colophon-claims/core` already depends on this package, so
10
+ * producer and reader render the same function rather than two copies that can drift.
11
+ * - **The words key on facts, not on configuration.** Every value in a sentence below comes from
12
+ * the verified binding itself, so the text is identical for every reader.
13
+ *
14
+ * A third rule follows from issue #3322: **a sentence claims unchosen-ness only where the seal
15
+ * established it.** A beacon that merely postdates the seal makes the value unpredictable and
16
+ * leaves the operator choosing among the values that postdate it. So both sentences carry
17
+ * `roundChoiceClause`, which asserts the second property under `seal-derived` and retracts it under
18
+ * `operator-chosen` rather than letting either branch imply it.
19
+ *
20
+ * A fourth rule is this module's own, and is the whole point of the originating issue: **the census
21
+ * sentence says it is the weaker binding.** Ordering-only binding shows the run's order was fixed by
22
+ * randomness that postdates the seal; it does not show the population was, because with a census
23
+ * there was no population choice to make. Letting the two modes share one confident sentence would
24
+ * be the failure this feature exists to prevent. Issue #3425 subjects that sentence to the third
25
+ * rule as well: `censusOrderClause` asserts the postdating only under `proven-offline`, and concedes
26
+ * it under `attributive` in the register the sampled opening already uses, because the clauses on
27
+ * either side of it concede exactly that.
28
+ */
29
+ import { BEACON_BINDING_PROCEDURE, BEACON_SOURCES, } from "./beacon-binding.js";
30
+ export function runBindingClass(binding) {
31
+ if (binding === undefined)
32
+ return "none";
33
+ return binding.mode === "sampled" ? "beacon-drawn-slate" : "beacon-ordering-only";
34
+ }
35
+ /** `<source display name> round <n>`, the one rendering every sentence below uses. */
36
+ function beaconName(binding) {
37
+ return `${BEACON_SOURCES[binding.beacon.source].displayName} round ${binding.beacon.round}`;
38
+ }
39
+ /**
40
+ * How the sentence may talk about the beacon postdating the seal. `proven-offline` asserts,
41
+ * because the source's own published schedule turns the round number into a time by arithmetic.
42
+ * `attributive` reports what the chain asserts and names what checking it takes -- the same
43
+ * assertive/attributive split `anchoredPreRegistrationSentence` draws between a timestamp token
44
+ * and an OpenTimestamps commitment.
45
+ */
46
+ function postSealClause(binding) {
47
+ return binding.postSeal === "proven-offline"
48
+ ? `a value that did not exist until ${binding.beaconInstant}, after this run was sealed at ${binding.sealedAt}`
49
+ : `a value the chain places after this run's seal at ${binding.sealedAt} — establishing that ordering `
50
+ + "requires block headers on the reader's side, so it is what the chain asserts rather than something "
51
+ + "this bundle proves";
52
+ }
53
+ /**
54
+ * What the run's choice of ROUND does or does not add (issue #3322). A beacon that merely postdates
55
+ * the seal leaves the operator picking among realized values, and a sentence that names only the
56
+ * value's unpredictability reads as though it had ruled that out. So the clause is not decoration:
57
+ * under `seal-derived` it states the second property and then names the residue that survives it
58
+ * (the source choice), and under `operator-chosen` it retracts the property in the same plain
59
+ * register the census branch uses about its own weaker binding.
60
+ */
61
+ function roundChoiceClause(binding) {
62
+ // Name the thing a chosen round would actually have moved. Saying "the result" for both modes
63
+ // made the census clause read as a retraction of a population claim a census never makes: with a
64
+ // census the population is the whole declared one whatever round applies, and only the ORDER
65
+ // moves. The residue named under `seal-derived` is likewise the SOURCE, not a count of sources:
66
+ // one admitted source is indexed by block height, where no round follows from a seal at all, so
67
+ // its alternatives are every height published since rather than a single candidate.
68
+ const derived = binding.mode === "sampled" ? "slate" : "order";
69
+ if (binding.roundBasis === "seal-derived") {
70
+ // Only a scheduled source reaches here: `requiredBeaconRound` derives nothing for a height, so
71
+ // an `attributive` binding is `operator-chosen` by construction.
72
+ return "The round was not the operator's to pick either: it is the first round this source publishes after "
73
+ + "the seal, so the seal instant alone fixes it. What choosing remains is the source — this procedure "
74
+ + "admits other beacons, one of them indexed by block height, where no round follows from a seal at all — "
75
+ + "so an operator could have bound a different source instead.";
76
+ }
77
+ if (binding.postSeal === "attributive") {
78
+ // Say no more here than the postdating clause two sentences earlier already conceded. Claiming
79
+ // the value was unpredictable would assert exactly what this branch cannot check: nothing in
80
+ // the bundle places this height after the seal, so nothing rules out a height that predates it.
81
+ return `No round follows from a seal on a height-indexed source, so this height was the operator's choice — `
82
+ + `and on the reader's side it is the chain, not this bundle, that places it after the seal at all. Any `
83
+ + `other height would have derived a different ${derived} from the same inputs.`;
84
+ }
85
+ return `Which post-seal value applied was still the operator's choice: this binding names a round selected after `
86
+ + `the seal, and every round the source published in between was an available alternative deriving a `
87
+ + `different ${derived} from the same inputs. The value could not have been predicted; this ${derived} is `
88
+ + `nonetheless one of several the operator could have realized by waiting.`;
89
+ }
90
+ /**
91
+ * What the census binding shows about the ORDER (issue #3425). The postdating half keys on
92
+ * `postSeal` for the same reason the sampled opening does: on a height-indexed source nothing in
93
+ * the bundle places the value after the seal, so asserting that the order was fixed by randomness
94
+ * postdating it would be conceded by `postSealClause` two clauses earlier and again by
95
+ * `roundChoiceClause` two clauses later. The population half is unconditional -- a census makes no
96
+ * population choice on any basis.
97
+ */
98
+ function censusOrderClause(binding) {
99
+ const order = binding.postSeal === "proven-offline"
100
+ ? "It shows the run's order was fixed by randomness postdating the seal"
101
+ : "It shows the run's order was tied to a value this bundle cannot place after the seal";
102
+ return `${order}; it does not show the population was, because a census makes no population choice.`;
103
+ }
104
+ const RECOMPUTE_CLAUSE = `Any reader recomputes the derivation offline from the sealed digest, the published beacon value and the `
105
+ + `item identities alone, by procedure ${BEACON_BINDING_PROCEDURE}; the verifier fails the run when its `
106
+ + "recomputation disagrees.";
107
+ /**
108
+ * The one sentence that states which binding applied. `undefined` yields the unbound statement,
109
+ * which is a claim about the run too: nothing about it was drawn from post-seal randomness.
110
+ */
111
+ export function runBindingSentence(binding) {
112
+ if (binding === undefined) {
113
+ return "This run is bound to no public randomness: its seal shows the design existed by a given time, and "
114
+ + "nothing establishes that execution followed the seal rather than preceding it.";
115
+ }
116
+ if (binding.mode === "sampled") {
117
+ // The "could not have been selected after the fact" claim is the one issue #3322 exists to
118
+ // stop overstating, so it appears ONLY on the branch where the seal named the round. Under
119
+ // `operator-chosen` it is not merely unproven, it is false -- selecting the slate after the
120
+ // fact needed no prediction at all, only waiting for a round whose derivation the operator
121
+ // liked -- so it is dropped rather than hedged.
122
+ //
123
+ // The opening keys on `postSeal` as well, for the same reason `roundChoiceClause` does: under
124
+ // `attributive` nothing in the bundle places the height after the seal, so an opening that
125
+ // called the value unpredictable would assert what the branch cannot check -- and would be
126
+ // retracted two clauses later by the very postdating clause it introduces.
127
+ const opening = binding.roundBasis === "seal-derived"
128
+ ? "This run's slate was drawn, not chosen"
129
+ : binding.postSeal === "attributive"
130
+ ? "This run's slate was drawn from a value this bundle cannot place after the seal"
131
+ : "This run's slate was drawn from a value it could not have predicted";
132
+ const unpredictabilityClause = binding.roundBasis === "seal-derived"
133
+ ? "Selecting the slate after the fact would have required predicting that value. "
134
+ : "";
135
+ return `${opening}: its ${binding.sample.length} items were derived from a `
136
+ + `declared pool of ${binding.poolSize} by procedure ${BEACON_BINDING_PROCEDURE}, keyed on this run's sealed `
137
+ + `digest together with ${beaconName(binding)} — ${postSealClause(binding)}. `
138
+ + `${unpredictabilityClause}${roundChoiceClause(binding)} ${RECOMPUTE_CLAUSE}`;
139
+ }
140
+ return `This run evaluated its whole declared population of ${binding.poolSize} items, so no slate was selected `
141
+ + `and none could be selected after the fact. The beacon binds execution ORDER only: the order was derived by `
142
+ + `procedure ${BEACON_BINDING_PROCEDURE} from this run's sealed digest together with ${beaconName(binding)} — `
143
+ + `${postSealClause(binding)}. This is a weaker binding than a beacon-drawn slate. `
144
+ + `${censusOrderClause(binding)} ${roundChoiceClause(binding)} ${RECOMPUTE_CLAUSE}`;
145
+ }
146
+ /**
147
+ * The venue-limits list with the binding statement appended. Returns the list unchanged when the
148
+ * run carries no binding, so every run that predates this feature keeps its exact limits bytes --
149
+ * the same additive posture `anchoredVenueLimits` takes for an unanchored run.
150
+ */
151
+ export function runBoundVenueLimits(limits, binding) {
152
+ return binding === undefined ? limits : [...limits, runBindingSentence(binding)];
153
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { SUPPORTED_BUNDLE_FORMATS } from "./manifest.js";
3
- import { EVIDENCE_NATIVE_BUNDLE_V5_CHECKS } from "@jinn-network/benchmarking-evidence";
4
- import { PUBLIC_BUNDLE_VERIFICATION_CHECKS, PUBLIC_BUNDLE_V6_CHECKS } from "./reader-instructions.js";
3
+ import { summarizeVerificationOutcome } from "./outcome.js";
5
4
  import { verifyPublicBundle } from "./verify.js";
6
5
  import { VERIFIER_VERSION } from "./version.js";
7
6
  export { VERIFIER_VERSION } from "./version.js";
@@ -75,29 +74,82 @@ function renderAnchorReport(report) {
75
74
  : report.anchors.map(renderAnchor).join("\n");
76
75
  return `\nAnchors\n${anchors}\n\nAnchor subjects\n${report.subjects.map(renderSubject).join("\n")}\n`;
77
76
  }
77
+ const SIGNER_ROLE_NAMES = {
78
+ publisher: "publisher",
79
+ "automated-grader": "automated grader",
80
+ "human-reviewer": "human reviewer",
81
+ "label-admission": "label admission",
82
+ };
83
+ /** The role in plain words. `urn:`/`did:key` identifiers stay in `--json`, where they are the join
84
+ * key a reader actually needs them for; on this surface they are noise a reader has to decode.
85
+ * Undeclared custody is stated rather than left blank: a reader who has seen the same-operator
86
+ * suffix elsewhere would otherwise read its absence as an independence claim, which no bundle
87
+ * format can establish. The publisher takes no suffix -- it *is* the operator the others are
88
+ * measured against. */
89
+ function renderSignerGroup(role, custody, count) {
90
+ const suffix = role === "publisher"
91
+ ? ""
92
+ : custody === "same-operator" ? " \u2014 same operator" : " \u2014 custody not declared";
93
+ return ` ${SIGNER_ROLE_NAMES[role]}${suffix} \u00b7 ${count} ${count === 1 ? "key" : "keys"}`;
94
+ }
95
+ function renderSigners(signers) {
96
+ const counts = new Map();
97
+ for (const signer of signers) {
98
+ const key = `${signer.role} ${signer.custody}`;
99
+ const group = counts.get(key);
100
+ if (group === undefined)
101
+ counts.set(key, { role: signer.role, custody: signer.custody, count: 1 });
102
+ else
103
+ group.count += 1;
104
+ }
105
+ // The role-name record's own key order is the print order; a second parallel list would drift.
106
+ const order = Object.keys(SIGNER_ROLE_NAMES);
107
+ const groups = [...counts.values()]
108
+ .sort((left, right) => order.indexOf(left.role) - order.indexOf(right.role));
109
+ return `\nSigned by\n${groups.map((group) => renderSignerGroup(group.role, group.custody, group.count)).join("\n")}\n`;
110
+ }
78
111
  export function renderVerifiedBundle(result) {
79
- const checks = result.checks.map((check) => `${check.padEnd(24)}passed`).join("\n");
80
- const totalChecks = result.format === "benchmark-product-public-bundle/5"
81
- ? EVIDENCE_NATIVE_BUNDLE_V5_CHECKS.length
82
- : result.format === "benchmark-product-public-bundle/6"
83
- ? PUBLIC_BUNDLE_V6_CHECKS.length
84
- : PUBLIC_BUNDLE_VERIFICATION_CHECKS.length;
112
+ // A metadata-first bundle carries artifact digests without their bytes. Printing "passed" for a
113
+ // check that read nothing would be the one claim this format cannot afford, so the deferred check
114
+ // prints as not fetched and is counted out of the passed total.
115
+ const outcome = summarizeVerificationOutcome(result);
116
+ const artifactContent = outcome.artifactContent;
117
+ const checks = outcome.outcomes
118
+ .map(({ check, state }) => `${check.padEnd(24)}${state}`)
119
+ .join("\n");
120
+ const totalChecks = outcome.total;
85
121
  const identity = result.identity.startsWith("sha256:") ? result.identity : `sha256:${result.identity}`;
86
122
  const anchors = "anchors" in result && result.anchors !== undefined
87
123
  ? renderAnchorReport(result.anchors)
88
124
  : "";
125
+ const signers = result.signers === undefined || result.signers.length === 0
126
+ ? ""
127
+ : renderSigners(result.signers);
128
+ // Naming the digests is what makes the deferred check completable: they are the addresses to
129
+ // fetch and the expectations to check the fetched bytes against. Adding a body to this directory
130
+ // is not the completion path -- it would break the manifest closure the bundle is identified by,
131
+ // so the reader is pointed at the full-evidence bundle instead.
132
+ const artifactContentReport = artifactContent === undefined
133
+ ? ""
134
+ : `\nArtifact content\n ${artifactContent.notFetched} artifact ${artifactContent.notFetched === 1 ? "body was" : "bodies were"} not fetched. This bundle carries their\n exact digests, not their bytes:\n${artifactContent.notFetchedDigests.map((digest) => ` sha256:${digest}`).join("\n")}\n Check fetched bytes against those digests yourself, or verify the\n full-evidence bundle, which carries them.\n`;
135
+ const artifactContentLimit = artifactContent === undefined
136
+ ? ""
137
+ : "\nEverything above was checked against the bytes this bundle carries. The artifact\ncontents themselves were not read, so nothing here says what they contain.";
89
138
  const anchorLimits = anchors === ""
90
139
  ? ""
91
140
  : "\nAn anchor dates the bytes it covers and says nothing else about the run: not\nthat results were produced after it, and not that the anchoring authority is\nindependent of the bundle's owner.";
92
- return `Verified: ${result.checks.length} of ${totalChecks} checks passed
141
+ const verdictLine = outcome.notFetched === 0
142
+ ? `Verified: ${outcome.passed} of ${totalChecks} checks passed`
143
+ : `Verified: ${outcome.passed} of ${totalChecks} checks passed, ${outcome.notFetched} not fetched`;
144
+ return `${verdictLine}
93
145
  Bundle: ${identity}
94
146
  Format: ${result.format}
95
147
 
96
148
  ${checks}
97
- ${anchors}
149
+ ${signers}${artifactContentReport}${anchors}
98
150
  This checks the bundle's integrity, evidence closure, calculations, report,
99
151
  and claim consistency. It does not prove that the machine that produced the
100
- bundle was honest or that the compared identities are independent parties.${anchorLimits}
152
+ bundle was honest or that the compared identities are independent parties.${artifactContentLimit}${anchorLimits}
101
153
  No files were uploaded.
102
154
  Protocol identifiers name https://spec.jinn.network/…. That origin is not hosted yet.
103
155
  Verification uses the exact platform bytes installed from npm.
@@ -132,6 +184,16 @@ function readBlockHeaders(bytes, path) {
132
184
  };
133
185
  });
134
186
  }
187
+ /** `urn:…` and `did:key:z…` as they appear inside a refusal message. The base58btc class stops a
188
+ * `did:key` match before a trailing `:reason` suffix the message appended. */
189
+ const RAW_IDENTIFIER = /urn:[^\s,;)"']+|did:key:z[1-9A-HJ-NP-Za-km-z]+/gu;
190
+ /** A refusal names the signer it refused, and on the machine surface that identifier is the whole
191
+ * point. On the human surface it is a string a reader cannot act on, so the same rule as the
192
+ * verified report applies: the identifier lives in `--json` (issue #3024). What failed, and where,
193
+ * is untouched. */
194
+ function withoutRawIdentifiers(message) {
195
+ return message.replace(RAW_IDENTIFIER, "<identifier: see --json>");
196
+ }
135
197
  function parseArguments(args) {
136
198
  const positional = [];
137
199
  const tsaRoots = [];
@@ -199,7 +261,7 @@ export async function runVerifierCli(args, deps = {}) {
199
261
  const stdout = parsed.json
200
262
  ? `${JSON.stringify({ ok: false, verifierVersion: VERIFIER_VERSION, supportedFormats: SUPPORTED_BUNDLE_FORMATS, code, message: error.message })}\n`
201
263
  : "";
202
- const stderr = parsed.json ? "" : `colophon-verify: ${error.message}\n`;
264
+ const stderr = parsed.json ? "" : `colophon-verify: ${withoutRawIdentifiers(error.message)}\n`;
203
265
  return { exitCode: code === "record-integrity" ? 1 : 2, stdout, stderr };
204
266
  }
205
267
  }