@mikeargento/bitgraph-player 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/SPEC.md +234 -0
- package/dist/__tests__/cast-order.test.d.ts +2 -0
- package/dist/__tests__/cast-order.test.d.ts.map +1 -0
- package/dist/__tests__/cast-order.test.js +434 -0
- package/dist/__tests__/cast-order.test.js.map +1 -0
- package/dist/__tests__/evaluate-verdict.test.d.ts +2 -0
- package/dist/__tests__/evaluate-verdict.test.d.ts.map +1 -0
- package/dist/__tests__/evaluate-verdict.test.js +228 -0
- package/dist/__tests__/evaluate-verdict.test.js.map +1 -0
- package/dist/__tests__/fixtures.d.ts +63 -0
- package/dist/__tests__/fixtures.d.ts.map +1 -0
- package/dist/__tests__/fixtures.js +150 -0
- package/dist/__tests__/fixtures.js.map +1 -0
- package/dist/__tests__/logic-rule.test.d.ts +2 -0
- package/dist/__tests__/logic-rule.test.d.ts.map +1 -0
- package/dist/__tests__/logic-rule.test.js +190 -0
- package/dist/__tests__/logic-rule.test.js.map +1 -0
- package/dist/cast.d.ts +29 -0
- package/dist/cast.d.ts.map +1 -0
- package/dist/cast.js +85 -0
- package/dist/cast.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +130 -0
- package/dist/cli.js.map +1 -0
- package/dist/evaluate.d.ts +30 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +177 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/logic.d.ts +16 -0
- package/dist/logic.d.ts.map +1 -0
- package/dist/logic.js +29 -0
- package/dist/logic.js.map +1 -0
- package/dist/order.d.ts +50 -0
- package/dist/order.d.ts.map +1 -0
- package/dist/order.js +290 -0
- package/dist/order.js.map +1 -0
- package/dist/rule.d.ts +40 -0
- package/dist/rule.d.ts.map +1 -0
- package/dist/rule.js +324 -0
- package/dist/rule.js.map +1 -0
- package/dist/types.d.ts +205 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +14 -0
- package/dist/verdict.d.ts.map +1 -0
- package/dist/verdict.js +146 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +42 -0
- package/src/__tests__/cast-order.test.ts +473 -0
- package/src/__tests__/evaluate-verdict.test.ts +266 -0
- package/src/__tests__/fixtures.ts +206 -0
- package/src/__tests__/logic-rule.test.ts +221 -0
- package/src/cast.ts +120 -0
- package/src/cli.ts +143 -0
- package/src/evaluate.ts +238 -0
- package/src/index.ts +38 -0
- package/src/logic.ts +39 -0
- package/src/order.ts +380 -0
- package/src/rule.ts +340 -0
- package/src/types.ts +224 -0
- package/src/verdict.ts +159 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
import { strict as assert } from "node:assert";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { resolveCast } from "../cast.js";
|
|
6
|
+
import { evaluate } from "../evaluate.js";
|
|
7
|
+
import { parseRule } from "../rule.js";
|
|
8
|
+
import { buildVerdict, serializeVerdict } from "../verdict.js";
|
|
9
|
+
import { digestFor, makeAudit } from "./fixtures.js";
|
|
10
|
+
import type { Rule } from "../types.js";
|
|
11
|
+
|
|
12
|
+
const PO = digestFor("purchase-order");
|
|
13
|
+
const DEL = digestFor("delivery");
|
|
14
|
+
const APPR = digestFor("approval");
|
|
15
|
+
const CANCEL = digestFor("cancellation");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The flagship shape: po -> delivery -> approval in one chain-linked
|
|
19
|
+
* component; a cancellation digest that was never recorded, declared
|
|
20
|
+
* optional.
|
|
21
|
+
*/
|
|
22
|
+
function poAudit() {
|
|
23
|
+
return makeAudit({
|
|
24
|
+
proofs: [
|
|
25
|
+
{ name: "po", digestB64: PO, epochId: "E1", counter: "10", slotCounter: "9" },
|
|
26
|
+
{ name: "del", digestB64: DEL, epochId: "E1", counter: "20", slotCounter: "19", prev: "po" },
|
|
27
|
+
{ name: "appr", digestB64: APPR, epochId: "E1", counter: "30", slotCounter: "29", prev: "del" },
|
|
28
|
+
],
|
|
29
|
+
partitions: [
|
|
30
|
+
{ epochId: "E1", members: ["po", "del", "appr"], components: [["po", "del", "appr"]] },
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function poRule(overrides?: { ordering?: string; cancellationDigest?: string }): Rule {
|
|
36
|
+
return parseRule(
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
rule: "bitgraph-player/1",
|
|
39
|
+
id: "po-release-payment",
|
|
40
|
+
cast: {
|
|
41
|
+
purchase_order: { digest: PO, means: "PO-4471" },
|
|
42
|
+
delivery: { digest: DEL },
|
|
43
|
+
approval: { digest: APPR, signedBy: { kind: "c2pa", manifest: "m-123" } },
|
|
44
|
+
cancellation: { digest: overrides?.cancellationDigest ?? CANCEL, optional: true },
|
|
45
|
+
},
|
|
46
|
+
world: "closed",
|
|
47
|
+
requires: { ordering: overrides?.ordering ?? "hash-linked" },
|
|
48
|
+
claim: {
|
|
49
|
+
all: [
|
|
50
|
+
{ exists: "purchase_order" },
|
|
51
|
+
{ after: ["delivery", "purchase_order"] },
|
|
52
|
+
{ after: ["approval", "delivery"] },
|
|
53
|
+
{ not: { before: ["cancellation", "approval"] } },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
then: { label: "release_payment" },
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("the purchase-order rule holds over a chain-linked fixture", () => {
|
|
62
|
+
const audit = poAudit();
|
|
63
|
+
const rule = poRule();
|
|
64
|
+
const evaluation = evaluate(rule, resolveCast(rule.cast, audit), audit);
|
|
65
|
+
assert.equal(evaluation.result, "TRUE");
|
|
66
|
+
assert.equal(evaluation.weakestEvidence, "hash-linked");
|
|
67
|
+
// Full walk: every sub-claim recorded.
|
|
68
|
+
assert.equal(evaluation.steps.length, 4);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("absent-optional supports the negative: no cancellation precedes approval", () => {
|
|
72
|
+
const audit = poAudit();
|
|
73
|
+
const rule = poRule();
|
|
74
|
+
const evaluation = evaluate(rule, resolveCast(rule.cast, audit), audit);
|
|
75
|
+
const negStep = evaluation.steps.find((s) => s.claim.startsWith("before(cancellation"));
|
|
76
|
+
assert.ok(negStep);
|
|
77
|
+
assert.equal(negStep.result, "FALSE"); // so not(...) is TRUE
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("PLAN test: counter-order evidence under a hash-linked floor is UNDETERMINED, not TRUE and not FALSE", () => {
|
|
81
|
+
const audit = makeAudit({
|
|
82
|
+
proofs: [
|
|
83
|
+
{ name: "po", digestB64: PO, epochId: "E1", counter: "10" },
|
|
84
|
+
{ name: "del", digestB64: DEL, epochId: "E1", counter: "20" },
|
|
85
|
+
],
|
|
86
|
+
// Separate components: counter-order only.
|
|
87
|
+
partitions: [{ epochId: "E1", members: ["po", "del"], components: [["po"], ["del"]] }],
|
|
88
|
+
});
|
|
89
|
+
const rule = parseRule(
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
rule: "bitgraph-player/1",
|
|
92
|
+
id: "floor-test",
|
|
93
|
+
cast: { po: { digest: PO }, del: { digest: DEL } },
|
|
94
|
+
world: "closed",
|
|
95
|
+
requires: { ordering: "hash-linked" },
|
|
96
|
+
claim: { before: ["po", "del"] },
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
const evaluation = evaluate(rule, resolveCast(rule.cast, audit), audit);
|
|
100
|
+
assert.equal(evaluation.result, "UNDETERMINED");
|
|
101
|
+
// And the same evidence under the permissive floor answers TRUE.
|
|
102
|
+
const permissive = parseRule(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
rule: "bitgraph-player/1",
|
|
105
|
+
id: "floor-test-2",
|
|
106
|
+
cast: { po: { digest: PO }, del: { digest: DEL } },
|
|
107
|
+
world: "closed",
|
|
108
|
+
requires: { ordering: "assumption-dependent" },
|
|
109
|
+
claim: { before: ["po", "del"] },
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
const evaluation2 = evaluate(permissive, resolveCast(permissive.cast, audit), audit);
|
|
113
|
+
assert.equal(evaluation2.result, "TRUE");
|
|
114
|
+
assert.equal(evaluation2.weakestEvidence, "assumption-dependent");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the floor gates FALSE outcomes too: distrusted evidence is silent in both directions", () => {
|
|
118
|
+
const audit = makeAudit({
|
|
119
|
+
proofs: [
|
|
120
|
+
{ name: "po", digestB64: PO, epochId: "E1", counter: "10" },
|
|
121
|
+
{ name: "del", digestB64: DEL, epochId: "E1", counter: "20" },
|
|
122
|
+
],
|
|
123
|
+
partitions: [{ epochId: "E1", members: ["po", "del"], components: [["po"], ["del"]] }],
|
|
124
|
+
});
|
|
125
|
+
const rule = parseRule(
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
rule: "bitgraph-player/1",
|
|
128
|
+
id: "floor-false",
|
|
129
|
+
cast: { po: { digest: PO }, del: { digest: DEL } },
|
|
130
|
+
world: "closed",
|
|
131
|
+
requires: { ordering: "hash-linked" },
|
|
132
|
+
claim: { before: ["del", "po"] }, // would be FALSE on counter-order evidence
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
const evaluation = evaluate(rule, resolveCast(rule.cast, audit), audit);
|
|
136
|
+
assert.equal(evaluation.result, "UNDETERMINED");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("PLAN test: a negative predicate naming an undeclared role is UNDETERMINED, not FALSE", () => {
|
|
140
|
+
const audit = poAudit();
|
|
141
|
+
const rule = parseRule(
|
|
142
|
+
JSON.stringify({
|
|
143
|
+
rule: "bitgraph-player/1",
|
|
144
|
+
id: "open-world-negative",
|
|
145
|
+
cast: { approval: { digest: APPR } },
|
|
146
|
+
world: "closed",
|
|
147
|
+
requires: { ordering: "hash-linked" },
|
|
148
|
+
claim: { not: { before: ["ghost", "approval"] } },
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
const evaluation = evaluate(rule, resolveCast(rule.cast, audit), audit);
|
|
152
|
+
assert.equal(evaluation.result, "UNDETERMINED");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("PLAN test: two recordings of the same bits and no pin is UNDETERMINED", () => {
|
|
156
|
+
const audit = makeAudit({
|
|
157
|
+
proofs: [
|
|
158
|
+
{ name: "x1", digestB64: PO, epochId: "E1", counter: "100" },
|
|
159
|
+
{ name: "other", digestB64: DEL, epochId: "E1", counter: "200", prev: "x1" },
|
|
160
|
+
{ name: "x2", digestB64: PO, epochId: "E1", counter: "300", prev: "other" },
|
|
161
|
+
],
|
|
162
|
+
partitions: [
|
|
163
|
+
{ epochId: "E1", members: ["x1", "x2", "other"], components: [["x1", "x2", "other"]] },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
const rule = parseRule(
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
rule: "bitgraph-player/1",
|
|
169
|
+
id: "multiplicity",
|
|
170
|
+
cast: { x: { digest: PO }, other: { digest: DEL } },
|
|
171
|
+
world: "closed",
|
|
172
|
+
requires: { ordering: "hash-linked" },
|
|
173
|
+
claim: { after: ["x", "other"] }, // TRUE and FALSE at once without an occurrence pin
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
const evaluation = evaluate(rule, resolveCast(rule.cast, audit), audit);
|
|
177
|
+
assert.equal(evaluation.result, "UNDETERMINED");
|
|
178
|
+
|
|
179
|
+
// A pin resolves the ambiguity and the claim becomes decidable.
|
|
180
|
+
const pinned = parseRule(
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
rule: "bitgraph-player/1",
|
|
183
|
+
id: "multiplicity-pinned",
|
|
184
|
+
cast: {
|
|
185
|
+
x: { digest: PO, at: { epochId: "E1", counter: "300" } },
|
|
186
|
+
other: { digest: DEL },
|
|
187
|
+
},
|
|
188
|
+
world: "closed",
|
|
189
|
+
requires: { ordering: "hash-linked" },
|
|
190
|
+
claim: { after: ["x", "other"] },
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
const evaluation2 = evaluate(pinned, resolveCast(pinned.cast, audit), audit);
|
|
194
|
+
assert.equal(evaluation2.result, "TRUE");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("required-absent is UNDETERMINED; between works over a chain", () => {
|
|
198
|
+
const audit = poAudit();
|
|
199
|
+
const missingRequired = parseRule(
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
rule: "bitgraph-player/1",
|
|
202
|
+
id: "missing-required",
|
|
203
|
+
cast: { ghost: { digest: digestFor("never-recorded") } },
|
|
204
|
+
world: "closed",
|
|
205
|
+
requires: { ordering: "hash-linked" },
|
|
206
|
+
claim: { exists: "ghost" },
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
assert.equal(
|
|
210
|
+
evaluate(missingRequired, resolveCast(missingRequired.cast, audit), audit).result,
|
|
211
|
+
"UNDETERMINED"
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const between = parseRule(
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
rule: "bitgraph-player/1",
|
|
217
|
+
id: "between",
|
|
218
|
+
cast: { po: { digest: PO }, del: { digest: DEL }, appr: { digest: APPR } },
|
|
219
|
+
world: "closed",
|
|
220
|
+
requires: { ordering: "hash-linked" },
|
|
221
|
+
claim: { between: ["del", "po", "appr"] },
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
const evaluation = evaluate(between, resolveCast(between.cast, audit), audit);
|
|
225
|
+
assert.equal(evaluation.result, "TRUE");
|
|
226
|
+
assert.equal(evaluation.steps.length, 2); // between records both halves
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Verdict shape and determinism.
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
test("verdict carries the trust boundary: declared holds means, signedBy, and the closed world", () => {
|
|
234
|
+
const audit = poAudit();
|
|
235
|
+
const rule = poRule();
|
|
236
|
+
const resolutions = resolveCast(rule.cast, audit);
|
|
237
|
+
const evaluation = evaluate(rule, resolutions, audit);
|
|
238
|
+
const verdict = buildVerdict(rule, "ab".repeat(32), resolutions, evaluation, audit);
|
|
239
|
+
|
|
240
|
+
const assertions = verdict.declared.map((d) => d.assertion);
|
|
241
|
+
assert.ok(assertions.includes("means"));
|
|
242
|
+
assert.ok(assertions.includes("signedBy"));
|
|
243
|
+
assert.equal(assertions[assertions.length - 1], "closed-world");
|
|
244
|
+
|
|
245
|
+
const closedWorld = verdict.declared[verdict.declared.length - 1] as Record<string, unknown>;
|
|
246
|
+
assert.equal(closedWorld["castSize"], 4);
|
|
247
|
+
assert.equal(closedWorld["recordingsInBundle"], 3);
|
|
248
|
+
for (const d of verdict.declared) assert.equal(d.verifiedHere, false);
|
|
249
|
+
assert.equal(verdict.network, "none");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("verdict bytes are deterministic and carry no timestamp", () => {
|
|
253
|
+
const audit = poAudit();
|
|
254
|
+
const rule = poRule();
|
|
255
|
+
const run = () => {
|
|
256
|
+
const resolutions = resolveCast(rule.cast, audit);
|
|
257
|
+
const evaluation = evaluate(rule, resolutions, audit);
|
|
258
|
+
return serializeVerdict(buildVerdict(rule, "ab".repeat(32), resolutions, evaluation, audit));
|
|
259
|
+
};
|
|
260
|
+
const first = run();
|
|
261
|
+
const second = run();
|
|
262
|
+
assert.equal(first, second);
|
|
263
|
+
assert.ok(!/generatedAt|startedAt|timestamp":/.test(first), "no run timestamp in verdict bytes");
|
|
264
|
+
assert.ok(!first.includes("fixture-path"), "no machine paths in verdict bytes");
|
|
265
|
+
assert.ok(first.endsWith("\n"));
|
|
266
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Synthetic AuditResult fixtures for unit tests.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT real proofs and NOT run through the verifier: Player is
|
|
7
|
+
* a pure function over runAudit's OUTPUT, so unit tests exercise it against
|
|
8
|
+
* hand-built AuditResult shapes. No test ever writes to the ledger — the
|
|
9
|
+
* ledger is compliance-locked and recordings are permanent — and the
|
|
10
|
+
* integration/acceptance path uses existing exports read-only instead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AuditResult, ObservedProof, SegmentBound } from "@mikeargento/bitgraph-audit";
|
|
14
|
+
|
|
15
|
+
export interface FixtureProofSpec {
|
|
16
|
+
/** Short name; expands deterministically into hashes. */
|
|
17
|
+
name: string;
|
|
18
|
+
digestB64: string;
|
|
19
|
+
epochId?: string;
|
|
20
|
+
chainId?: string;
|
|
21
|
+
publicKeyB64?: string;
|
|
22
|
+
counter?: string;
|
|
23
|
+
slotCounter?: string;
|
|
24
|
+
/** Name of the predecessor proof this one links to via prevB64. */
|
|
25
|
+
prev?: string;
|
|
26
|
+
verified?: boolean;
|
|
27
|
+
tier?: "full" | "integrity";
|
|
28
|
+
status?: "verified" | "failed" | "artifact-unavailable";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function proofHashOf(name: string): string {
|
|
32
|
+
return `proofhash-${name}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function makeProof(spec: FixtureProofSpec): ObservedProof {
|
|
36
|
+
const tier = spec.tier ?? "full";
|
|
37
|
+
const status =
|
|
38
|
+
spec.status ??
|
|
39
|
+
(spec.verified === false
|
|
40
|
+
? "failed"
|
|
41
|
+
: tier === "integrity"
|
|
42
|
+
? "artifact-unavailable"
|
|
43
|
+
: "verified");
|
|
44
|
+
const proof = {
|
|
45
|
+
proofHash: proofHashOf(spec.name),
|
|
46
|
+
chainHash: `chainhash-${spec.name}`,
|
|
47
|
+
proof: {
|
|
48
|
+
version: "bitgraph/1",
|
|
49
|
+
artifact: { hashAlg: "sha256", digestB64: spec.digestB64 },
|
|
50
|
+
},
|
|
51
|
+
sources: [],
|
|
52
|
+
version: "bitgraph/1",
|
|
53
|
+
chainId: spec.chainId ?? "bitgraph:main",
|
|
54
|
+
publicKeyB64: spec.publicKeyB64 ?? "key-A",
|
|
55
|
+
hasSlotAllocation: true,
|
|
56
|
+
hasAttestation: false,
|
|
57
|
+
hasAgency: false,
|
|
58
|
+
hasEpochLink: false,
|
|
59
|
+
embeddedProofHash: "absent",
|
|
60
|
+
chainless: spec.counter === undefined && spec.epochId === undefined,
|
|
61
|
+
verification: { tier, status },
|
|
62
|
+
} as Record<string, unknown>;
|
|
63
|
+
if (spec.epochId !== undefined) proof["epochId"] = spec.epochId;
|
|
64
|
+
if (spec.counter !== undefined) proof["counter"] = spec.counter;
|
|
65
|
+
if (spec.slotCounter !== undefined) proof["slotCounter"] = spec.slotCounter;
|
|
66
|
+
if (spec.prev !== undefined) proof["prevB64"] = `chainhash-${spec.prev}`;
|
|
67
|
+
return proof as unknown as ObservedProof;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FixturePartitionSpec {
|
|
71
|
+
publicKeyB64?: string;
|
|
72
|
+
epochId?: string;
|
|
73
|
+
chainId?: string;
|
|
74
|
+
/** Member proof NAMES (expanded via proofHashOf). */
|
|
75
|
+
members: string[];
|
|
76
|
+
/** Component groupings by proof name; defaults to one component per member. */
|
|
77
|
+
components?: string[][];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FixtureSegmentSpec {
|
|
81
|
+
/** Member proof names. */
|
|
82
|
+
members: string[];
|
|
83
|
+
lowerBounds?: Partial<SegmentBound>[];
|
|
84
|
+
upperBounds?: Partial<SegmentBound>[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function makeBound(partial: Partial<SegmentBound>): SegmentBound {
|
|
88
|
+
return {
|
|
89
|
+
kind: partial.kind ?? "not-before",
|
|
90
|
+
anchorProofHash: partial.anchorProofHash ?? "anchor-x",
|
|
91
|
+
blockHash: partial.blockHash ?? "0x" + "0".repeat(64),
|
|
92
|
+
timestamp: partial.timestamp ?? 0,
|
|
93
|
+
evidence: partial.evidence ?? "chain-link",
|
|
94
|
+
weaker: partial.weaker ?? false,
|
|
95
|
+
basis: partial.basis ?? "block-hash-unpredictability",
|
|
96
|
+
claim: partial.claim ?? "fixture bound",
|
|
97
|
+
...(partial.blockNumber !== undefined ? { blockNumber: partial.blockNumber } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface FixtureAuditSpec {
|
|
102
|
+
proofs: FixtureProofSpec[];
|
|
103
|
+
partitions?: FixturePartitionSpec[];
|
|
104
|
+
/** Epoch lineage pairs, transitive, as audit reports them. */
|
|
105
|
+
orderedPairs?: { beforeEpochId: string; afterEpochId: string }[];
|
|
106
|
+
/** Hard epochLink edges by proof NAME: predecessor and via. */
|
|
107
|
+
edges?: { predecessor: string; via: string }[];
|
|
108
|
+
segments?: FixtureSegmentSpec[];
|
|
109
|
+
unchained?: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function makeAudit(spec: FixtureAuditSpec): AuditResult {
|
|
113
|
+
const proofs = spec.proofs.map(makeProof);
|
|
114
|
+
const partitions = (spec.partitions ?? []).map((p) => {
|
|
115
|
+
const members = p.members.map(proofHashOf);
|
|
116
|
+
const components = (p.components ?? p.members.map((m) => [m])).map((group) => ({
|
|
117
|
+
memberProofHashes: group.map(proofHashOf),
|
|
118
|
+
}));
|
|
119
|
+
const key: Record<string, unknown> = {
|
|
120
|
+
publicKeyB64: p.publicKeyB64 ?? "key-A",
|
|
121
|
+
chainId: p.chainId ?? "bitgraph:main",
|
|
122
|
+
};
|
|
123
|
+
if (p.epochId !== undefined) key["epochId"] = p.epochId;
|
|
124
|
+
return { key, memberProofHashes: members, components };
|
|
125
|
+
});
|
|
126
|
+
const segments = (spec.segments ?? []).map((s) => ({
|
|
127
|
+
partition: { publicKeyB64: "key-A", chainId: "bitgraph:main" },
|
|
128
|
+
memberProofHashes: s.members.map(proofHashOf),
|
|
129
|
+
status: "bracketed",
|
|
130
|
+
lowerBounds: (s.lowerBounds ?? []).map((b) => makeBound({ ...b, kind: "not-before" })),
|
|
131
|
+
upperBounds: (s.upperBounds ?? []).map((b) => makeBound({ ...b, kind: "not-after" })),
|
|
132
|
+
}));
|
|
133
|
+
const audit = {
|
|
134
|
+
runMetadata: {
|
|
135
|
+
toolVersion: "fixture",
|
|
136
|
+
startedAt: "1970-01-01T00:00:00.000Z",
|
|
137
|
+
bundlePath: "fixture",
|
|
138
|
+
container: "directory",
|
|
139
|
+
},
|
|
140
|
+
ingest: {
|
|
141
|
+
bundlePath: "fixture",
|
|
142
|
+
container: "directory",
|
|
143
|
+
entriesScanned: proofs.length,
|
|
144
|
+
proofs,
|
|
145
|
+
unsupportedVersions: [],
|
|
146
|
+
artifacts: [],
|
|
147
|
+
witnesses: [],
|
|
148
|
+
computedContentsHashB64: "fixture",
|
|
149
|
+
findings: [],
|
|
150
|
+
counts: {
|
|
151
|
+
observed: proofs.length,
|
|
152
|
+
proofFiles: proofs.length,
|
|
153
|
+
exactDuplicates: 0,
|
|
154
|
+
semanticDuplicates: 0,
|
|
155
|
+
unsupportedVersion: 0,
|
|
156
|
+
artifacts: 0,
|
|
157
|
+
witnesses: 0,
|
|
158
|
+
skippedUnsafePaths: 0,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
verification: {},
|
|
162
|
+
reconstruction: {
|
|
163
|
+
partitions,
|
|
164
|
+
unchainedProofHashes: (spec.unchained ?? []).map(proofHashOf),
|
|
165
|
+
unpartitionedProofHashes: [],
|
|
166
|
+
epochRelationships: {
|
|
167
|
+
epochs: [],
|
|
168
|
+
edges: (spec.edges ?? []).map((e) => {
|
|
169
|
+
const pred = spec.proofs.find((p) => p.name === e.predecessor);
|
|
170
|
+
const via = spec.proofs.find((p) => p.name === e.via);
|
|
171
|
+
return {
|
|
172
|
+
fromEpochId: pred?.epochId ?? "?",
|
|
173
|
+
toEpochId: via?.epochId ?? "?",
|
|
174
|
+
viaProofHash: proofHashOf(e.via),
|
|
175
|
+
link: {},
|
|
176
|
+
resolution: "matched",
|
|
177
|
+
predecessorProofHash: proofHashOf(e.predecessor),
|
|
178
|
+
metadataConsistent: true,
|
|
179
|
+
viaProofValid: true,
|
|
180
|
+
predecessorValid: true,
|
|
181
|
+
hardEdge: true,
|
|
182
|
+
};
|
|
183
|
+
}),
|
|
184
|
+
orderedPairs: spec.orderedPairs ?? [],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
anomalies: {},
|
|
188
|
+
authorities: {},
|
|
189
|
+
anchors: {},
|
|
190
|
+
witnesses: {},
|
|
191
|
+
temporal: {
|
|
192
|
+
segments,
|
|
193
|
+
anchorOrderedPairs: [],
|
|
194
|
+
verifiedAnchorProofHashes: [],
|
|
195
|
+
unverifiedAnchorProofHashes: [],
|
|
196
|
+
},
|
|
197
|
+
attestations: {},
|
|
198
|
+
};
|
|
199
|
+
return audit as unknown as AuditResult;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A digest spelled the way rules spell it, unique per tag. */
|
|
203
|
+
export function digestFor(tag: string): string {
|
|
204
|
+
// 32 bytes: the tag padded; deterministic and valid base64 once encoded.
|
|
205
|
+
return Buffer.from(tag.padEnd(32, ".")).toString("base64");
|
|
206
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
import { strict as assert } from "node:assert";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { kleeneAll, kleeneAny, kleeneNot } from "../logic.js";
|
|
6
|
+
import { normalizeDigest, parseRule, RuleError } from "../rule.js";
|
|
7
|
+
import type { ThreeValued } from "../types.js";
|
|
8
|
+
|
|
9
|
+
const T: ThreeValued = "TRUE";
|
|
10
|
+
const F: ThreeValued = "FALSE";
|
|
11
|
+
const U: ThreeValued = "UNDETERMINED";
|
|
12
|
+
const VALUES: readonly ThreeValued[] = [T, F, U];
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Kleene tables, exhaustively for the binary projections.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
test("kleeneNot table", () => {
|
|
19
|
+
assert.equal(kleeneNot(T), F);
|
|
20
|
+
assert.equal(kleeneNot(F), T);
|
|
21
|
+
assert.equal(kleeneNot(U), U);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("kleeneAll pairwise table", () => {
|
|
25
|
+
const expect: Record<string, ThreeValued> = {
|
|
26
|
+
"TRUE,TRUE": T,
|
|
27
|
+
"TRUE,FALSE": F,
|
|
28
|
+
"TRUE,UNDETERMINED": U,
|
|
29
|
+
"FALSE,TRUE": F,
|
|
30
|
+
"FALSE,FALSE": F,
|
|
31
|
+
"FALSE,UNDETERMINED": F,
|
|
32
|
+
"UNDETERMINED,TRUE": U,
|
|
33
|
+
"UNDETERMINED,FALSE": F,
|
|
34
|
+
"UNDETERMINED,UNDETERMINED": U,
|
|
35
|
+
};
|
|
36
|
+
for (const a of VALUES) {
|
|
37
|
+
for (const b of VALUES) {
|
|
38
|
+
assert.equal(kleeneAll([a, b]), expect[`${a},${b}`], `all([${a}, ${b}])`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
assert.equal(kleeneAll([]), T, "empty conjunction is vacuously TRUE");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("kleeneAny pairwise table", () => {
|
|
45
|
+
const expect: Record<string, ThreeValued> = {
|
|
46
|
+
"TRUE,TRUE": T,
|
|
47
|
+
"TRUE,FALSE": T,
|
|
48
|
+
"TRUE,UNDETERMINED": T,
|
|
49
|
+
"FALSE,TRUE": T,
|
|
50
|
+
"FALSE,FALSE": F,
|
|
51
|
+
"FALSE,UNDETERMINED": U,
|
|
52
|
+
"UNDETERMINED,TRUE": T,
|
|
53
|
+
"UNDETERMINED,FALSE": U,
|
|
54
|
+
"UNDETERMINED,UNDETERMINED": U,
|
|
55
|
+
};
|
|
56
|
+
for (const a of VALUES) {
|
|
57
|
+
for (const b of VALUES) {
|
|
58
|
+
assert.equal(kleeneAny([a, b]), expect[`${a},${b}`], `any([${a}, ${b}])`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
assert.equal(kleeneAny([]), F, "empty disjunction is vacuously FALSE");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("UNDETERMINED never launders into FALSE through not", () => {
|
|
65
|
+
// The single easiest way to make Player dishonest is a boolean evaluator.
|
|
66
|
+
assert.notEqual(kleeneNot(U), T);
|
|
67
|
+
assert.notEqual(kleeneNot(U), F);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Digest normalization: four spellings, one canonical form.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test("normalizeDigest accepts hex, sha256:hex, base64, base64url identically", () => {
|
|
75
|
+
const bytes = Buffer.alloc(32, 7);
|
|
76
|
+
const hex = bytes.toString("hex");
|
|
77
|
+
const b64 = bytes.toString("base64");
|
|
78
|
+
const b64url = bytes.toString("base64url");
|
|
79
|
+
assert.equal(normalizeDigest(hex), b64);
|
|
80
|
+
assert.equal(normalizeDigest(`sha256:${hex}`), b64);
|
|
81
|
+
assert.equal(normalizeDigest(b64), b64);
|
|
82
|
+
assert.equal(normalizeDigest(b64url), b64);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("normalizeDigest rejects malformed input", () => {
|
|
86
|
+
assert.equal(normalizeDigest("not-a-digest"), undefined);
|
|
87
|
+
assert.equal(normalizeDigest("sha256:abcd"), undefined);
|
|
88
|
+
assert.equal(normalizeDigest(""), undefined);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Rule parsing.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const DIGEST = Buffer.alloc(32, 1).toString("base64");
|
|
96
|
+
|
|
97
|
+
function baseRule(): Record<string, unknown> {
|
|
98
|
+
return {
|
|
99
|
+
rule: "bitgraph-player/1",
|
|
100
|
+
id: "test-rule",
|
|
101
|
+
cast: { a: { digest: DIGEST } },
|
|
102
|
+
world: "closed",
|
|
103
|
+
requires: { ordering: "hash-linked" },
|
|
104
|
+
claim: { exists: "a" },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("a well-formed rule parses", () => {
|
|
109
|
+
const rule = parseRule(JSON.stringify(baseRule()));
|
|
110
|
+
assert.equal(rule.id, "test-rule");
|
|
111
|
+
assert.equal(rule.requires.ordering, "hash-linked");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a rule with no requires.ordering does not parse (mandatory trust floor)", () => {
|
|
115
|
+
const raw = baseRule();
|
|
116
|
+
delete raw["requires"];
|
|
117
|
+
assert.throws(() => parseRule(JSON.stringify(raw)), RuleError);
|
|
118
|
+
|
|
119
|
+
const raw2 = baseRule();
|
|
120
|
+
raw2["requires"] = {};
|
|
121
|
+
assert.throws(() => parseRule(JSON.stringify(raw2)), RuleError);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("world is mandatory and must be closed", () => {
|
|
125
|
+
const raw = baseRule();
|
|
126
|
+
delete raw["world"];
|
|
127
|
+
assert.throws(() => parseRule(JSON.stringify(raw)), RuleError);
|
|
128
|
+
raw["world"] = "open";
|
|
129
|
+
assert.throws(() => parseRule(JSON.stringify(raw)), RuleError);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("unknown fields are rejected at every level", () => {
|
|
133
|
+
const top = baseRule();
|
|
134
|
+
top["webhook"] = "https://example.com";
|
|
135
|
+
assert.throws(() => parseRule(JSON.stringify(top)), RuleError);
|
|
136
|
+
|
|
137
|
+
const castLevel = baseRule();
|
|
138
|
+
(castLevel["cast"] as Record<string, unknown>)["a"] = { digest: DIGEST, url: "x" };
|
|
139
|
+
assert.throws(() => parseRule(JSON.stringify(castLevel)), RuleError);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("then accepts a label and nothing capable of causing an action", () => {
|
|
143
|
+
const ok = baseRule();
|
|
144
|
+
ok["then"] = { label: "release_payment" };
|
|
145
|
+
assert.equal(parseRule(JSON.stringify(ok)).then?.label, "release_payment");
|
|
146
|
+
|
|
147
|
+
// Constraint 3 enforced by the format: the first request for a webhook
|
|
148
|
+
// field is a request to make Player an enforcer.
|
|
149
|
+
const bad = baseRule();
|
|
150
|
+
bad["then"] = { label: "x", webhook: "https://example.com/pay" };
|
|
151
|
+
assert.throws(() => parseRule(JSON.stringify(bad)), RuleError);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("claims must have exactly one known operator", () => {
|
|
155
|
+
const bad = baseRule();
|
|
156
|
+
bad["claim"] = { exists: "a", before: ["a", "a"] };
|
|
157
|
+
assert.throws(() => parseRule(JSON.stringify(bad)), RuleError);
|
|
158
|
+
|
|
159
|
+
const unknown = baseRule();
|
|
160
|
+
unknown["claim"] = { precedes: ["a", "b"] };
|
|
161
|
+
assert.throws(() => parseRule(JSON.stringify(unknown)), RuleError);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a claim referencing an undeclared role still parses (evaluates to UNDETERMINED, not a parse error)", () => {
|
|
165
|
+
const raw = baseRule();
|
|
166
|
+
raw["claim"] = { not: { before: ["ghost", "a"] } };
|
|
167
|
+
const rule = parseRule(JSON.stringify(raw));
|
|
168
|
+
assert.ok(rule);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("malformed digests are parse errors", () => {
|
|
172
|
+
const raw = baseRule();
|
|
173
|
+
(raw["cast"] as Record<string, unknown>)["a"] = { digest: "zzzz" };
|
|
174
|
+
assert.throws(() => parseRule(JSON.stringify(raw)), RuleError);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a role named __proto__ is an ordinary own key, never a prototype assignment", () => {
|
|
178
|
+
// Built as JSON text: a JS object literal cannot even express an own
|
|
179
|
+
// "__proto__" key by assignment, which is exactly the trap the parser
|
|
180
|
+
// must not fall into on its side.
|
|
181
|
+
const json =
|
|
182
|
+
`{"rule":"bitgraph-player/1","id":"t",` +
|
|
183
|
+
`"cast":{"a":{"digest":"${DIGEST}"},"__proto__":{"digest":"${DIGEST}"}},` +
|
|
184
|
+
`"world":"closed","requires":{"ordering":"hash-linked"},"claim":{"exists":"__proto__"}}`;
|
|
185
|
+
const rule = parseRule(json);
|
|
186
|
+
assert.ok(Object.keys(rule.cast).includes("__proto__"), "role survives as an own key");
|
|
187
|
+
assert.ok(Object.keys(rule.cast).includes("a"));
|
|
188
|
+
assert.equal(Object.getPrototypeOf(rule.cast), null);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("pure-integer role names are rejected (declaration order would not survive)", () => {
|
|
192
|
+
const raw = baseRule();
|
|
193
|
+
(raw["cast"] as Record<string, unknown>)["2"] = { digest: DIGEST };
|
|
194
|
+
assert.throws(() => parseRule(JSON.stringify(raw)), RuleError);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("non-canonical base64 spellings are rejected, not silently reinterpreted", () => {
|
|
198
|
+
// 43 chars + pad decodes to 32 bytes but carries nonzero trailing
|
|
199
|
+
// padding bits: Node's lenient decoder drops them, so accepting the
|
|
200
|
+
// spelling would collapse it into different bytes than written.
|
|
201
|
+
const nonCanonical = "A".repeat(42) + "B=";
|
|
202
|
+
assert.equal(normalizeDigest(nonCanonical), undefined);
|
|
203
|
+
const canonical = Buffer.alloc(32, 7).toString("base64");
|
|
204
|
+
assert.equal(normalizeDigest(canonical), canonical);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("at pin must be exactly one of the two forms", () => {
|
|
208
|
+
const both = baseRule();
|
|
209
|
+
(both["cast"] as Record<string, unknown>)["a"] = {
|
|
210
|
+
digest: DIGEST,
|
|
211
|
+
at: { proofHash: "p", epochId: "e", counter: "1" },
|
|
212
|
+
};
|
|
213
|
+
assert.throws(() => parseRule(JSON.stringify(both)), RuleError);
|
|
214
|
+
|
|
215
|
+
const position = baseRule();
|
|
216
|
+
(position["cast"] as Record<string, unknown>)["a"] = {
|
|
217
|
+
digest: DIGEST,
|
|
218
|
+
at: { epochId: "e", counter: "12" },
|
|
219
|
+
};
|
|
220
|
+
assert.ok(parseRule(JSON.stringify(position)));
|
|
221
|
+
});
|