@twinsearth/w2m-dsh-plugin 0.9.8 → 0.10.2
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/CHANGELOG.md +203 -0
- package/ROADMAP.md +2 -2
- package/package.json +9 -4
- package/src/agent/approval-index.mjs +300 -0
- package/src/agent/blocks.mjs +426 -0
- package/src/agent/health.mjs +330 -0
- package/src/agent/storage.mjs +380 -0
- package/src/plugin/tools.mjs +1 -1
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cognitive memory blocks: field-level acceptance with lineage.
|
|
3
|
+
*
|
|
4
|
+
* ## The two problems this solves
|
|
5
|
+
*
|
|
6
|
+
* The individual and collective memory layers shipped in 0.9.6/0.9.7 accept **everything or nothing**: a
|
|
7
|
+
* claim arriving from a peer is taken whole, or refused whole. That is a blunt instrument, and it has two
|
|
8
|
+
* consequences the roadmap records as P1 and P2.
|
|
9
|
+
*
|
|
10
|
+
* **P1 — field-level acceptance.** A peer's claim may be trustworthy in part. Its *subject* and its
|
|
11
|
+
* *body* might be worth keeping while its *confidence* is an opinion this machine has no reason to adopt,
|
|
12
|
+
* and its *lineage* is not something a receiver gets to choose at all. Accepting the block whole means
|
|
13
|
+
* taking the parts you do not want; refusing it whole means discarding the parts you do.
|
|
14
|
+
*
|
|
15
|
+
* **P2 — provenance.** A claim that comes back around the fleet must be recognisable as the receiver's
|
|
16
|
+
* own earlier thought rather than as independent corroboration. Without that, a rumour and a fact look
|
|
17
|
+
* identical once each has been repeated twice, which is the failure mode that makes a multi-agent memory
|
|
18
|
+
* worse than no memory at all.
|
|
19
|
+
*
|
|
20
|
+
* ## What a block is
|
|
21
|
+
*
|
|
22
|
+
* Seven fields, fixed and named, so that acceptance can be decided per field rather than per block:
|
|
23
|
+
*
|
|
24
|
+
* | Field | Kind | May a receiver choose it? |
|
|
25
|
+
* |---|---|---|
|
|
26
|
+
* | `origin` | who asserted this | no — it is a fact about the sender |
|
|
27
|
+
* | `subject` | what it is about | yes |
|
|
28
|
+
* | `body` | the assertion itself | yes |
|
|
29
|
+
* | `evidence` | what supports it | yes |
|
|
30
|
+
* | `confidence` | how sure the sender is | yes, and **defaults to refusing it** |
|
|
31
|
+
* | `observed_at` | when the sender observed it | no — the receiver did not observe it |
|
|
32
|
+
* | `lineage` | content hashes of its ancestors | no — inherited, never chosen |
|
|
33
|
+
*
|
|
34
|
+
* The three that a receiver may **not** choose are not a policy decision; they are fields whose value
|
|
35
|
+
* would be a lie if the receiver set it. A receiver that stamped its own timestamp on a peer's
|
|
36
|
+
* observation, or replaced the ancestor chain, would be manufacturing provenance — and provenance that
|
|
37
|
+
* can be manufactured is not provenance.
|
|
38
|
+
*
|
|
39
|
+
* ## Lineage is by content hash, not by id
|
|
40
|
+
*
|
|
41
|
+
* An id is assigned by whoever creates the block and can be copied; a hash is the content. So an ancestor
|
|
42
|
+
* reference is `sha256` of the ancestor's canonical form, and a block whose parent cannot be produced
|
|
43
|
+
* **is refused rather than re-rooted** — see `acceptBlock`. Silently re-rooting is how a fabricated claim
|
|
44
|
+
* acquires a respectable history.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
|
|
49
|
+
/** The seven field names, in canonical order. The order is part of the hash. */
|
|
50
|
+
export const CAT7_FIELDS = Object.freeze([
|
|
51
|
+
'origin',
|
|
52
|
+
'subject',
|
|
53
|
+
'body',
|
|
54
|
+
'evidence',
|
|
55
|
+
'confidence',
|
|
56
|
+
'observed_at',
|
|
57
|
+
'lineage',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fields a receiver may decide for itself.
|
|
62
|
+
*
|
|
63
|
+
* The complement of this list is the more important half: `origin`, `observed_at` and `lineage` are
|
|
64
|
+
* **inherited**. A receiver that could set them could assert a provenance it does not have.
|
|
65
|
+
*/
|
|
66
|
+
export const RECEIVER_CHOOSABLE_FIELDS = Object.freeze(['subject', 'body', 'evidence', 'confidence']);
|
|
67
|
+
|
|
68
|
+
/** Fields whose value is a property of the sender and must travel unchanged. */
|
|
69
|
+
export const INHERITED_FIELDS = Object.freeze(['origin', 'observed_at', 'lineage']);
|
|
70
|
+
|
|
71
|
+
/** Why a block was refused. Codes, not sentences, so a caller can branch without matching prose. */
|
|
72
|
+
export const BLOCK_REFUSAL = Object.freeze({
|
|
73
|
+
NOT_AN_OBJECT: 'BLOCK_NOT_AN_OBJECT',
|
|
74
|
+
MISSING_FIELD: 'BLOCK_MISSING_FIELD',
|
|
75
|
+
UNKNOWN_FIELD: 'BLOCK_UNKNOWN_FIELD',
|
|
76
|
+
BAD_ORIGIN: 'BLOCK_BAD_ORIGIN',
|
|
77
|
+
BAD_TIMESTAMP: 'BLOCK_BAD_TIMESTAMP',
|
|
78
|
+
BAD_LINEAGE: 'BLOCK_BAD_LINEAGE',
|
|
79
|
+
BAD_HASH: 'BLOCK_BAD_HASH',
|
|
80
|
+
HASH_MISMATCH: 'BLOCK_HASH_MISMATCH',
|
|
81
|
+
MISSING_PARENT: 'BLOCK_MISSING_PARENT',
|
|
82
|
+
CYCLE: 'BLOCK_CYCLE',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** A refusal carries a code and enough context to explain it without re-reading the block. */
|
|
86
|
+
export class BlockRefusal extends Error {
|
|
87
|
+
constructor(code, detail) {
|
|
88
|
+
super(`${code}: ${detail}`);
|
|
89
|
+
this.name = 'BlockRefusal';
|
|
90
|
+
this.code = code;
|
|
91
|
+
this.detail = detail;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Canonical JSON: sorted keys, no whitespace, arrays in order.
|
|
99
|
+
*
|
|
100
|
+
* Hand-rolled rather than imported because this must be **stable across machines and versions** — it is
|
|
101
|
+
* what the hash is taken over, so two implementations that differ by a key order would compute different
|
|
102
|
+
* hashes for the same block and each would call the other a forgery.
|
|
103
|
+
*/
|
|
104
|
+
export function canonical(value) {
|
|
105
|
+
if (value === null || typeof value === 'number' || typeof value === 'boolean') return JSON.stringify(value);
|
|
106
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
107
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
108
|
+
if (isPlainObject(value)) {
|
|
109
|
+
const keys = Object.keys(value).sort();
|
|
110
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
111
|
+
}
|
|
112
|
+
throw new TypeError(`canonical: unsupported value of type ${typeof value}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `sha256` of a block's canonical form, excluding any `hash` field the caller attached. */
|
|
116
|
+
export function blockHash(block) {
|
|
117
|
+
const { hash: _ignored, ...rest } = block ?? {};
|
|
118
|
+
return createHash('sha256').update(canonical(rest), 'utf8').digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Structural validation. Returns the first problem found, or `null`.
|
|
123
|
+
*
|
|
124
|
+
* Separate from acceptance because the two are different questions: "is this a well-formed block" is
|
|
125
|
+
* answerable without knowing who sent it, and mixing them makes a malformed block indistinguishable from
|
|
126
|
+
* a distrusted one.
|
|
127
|
+
*/
|
|
128
|
+
export function blockProblem(block) {
|
|
129
|
+
if (!isPlainObject(block)) return { code: BLOCK_REFUSAL.NOT_AN_OBJECT, detail: `expected an object, got ${typeof block}` };
|
|
130
|
+
for (const field of CAT7_FIELDS) {
|
|
131
|
+
if (!(field in block)) return { code: BLOCK_REFUSAL.MISSING_FIELD, detail: `missing "${field}"` };
|
|
132
|
+
}
|
|
133
|
+
for (const key of Object.keys(block)) {
|
|
134
|
+
if (!CAT7_FIELDS.includes(key) && key !== 'hash') {
|
|
135
|
+
return { code: BLOCK_REFUSAL.UNKNOWN_FIELD, detail: `unknown field "${key}"; the seven are fixed so acceptance can be per field` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (typeof block.origin !== 'string' || block.origin.trim() === '') {
|
|
139
|
+
return { code: BLOCK_REFUSAL.BAD_ORIGIN, detail: 'origin must be a non-empty string' };
|
|
140
|
+
}
|
|
141
|
+
if (typeof block.observed_at !== 'string' || Number.isNaN(Date.parse(block.observed_at))) {
|
|
142
|
+
return { code: BLOCK_REFUSAL.BAD_TIMESTAMP, detail: `observed_at must be an ISO timestamp, got ${JSON.stringify(block.observed_at)}` };
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(block.lineage)) {
|
|
145
|
+
return { code: BLOCK_REFUSAL.BAD_LINEAGE, detail: 'lineage must be an array of ancestor hashes' };
|
|
146
|
+
}
|
|
147
|
+
for (const ancestor of block.lineage) {
|
|
148
|
+
if (typeof ancestor !== 'string' || !/^[0-9a-f]{64}$/.test(ancestor)) {
|
|
149
|
+
return { code: BLOCK_REFUSAL.BAD_LINEAGE, detail: `ancestor must be a 64-char lowercase hex sha256, got ${JSON.stringify(ancestor)}` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (new Set(block.lineage).size !== block.lineage.length) {
|
|
153
|
+
return { code: BLOCK_REFUSAL.BAD_LINEAGE, detail: 'lineage repeats an ancestor' };
|
|
154
|
+
}
|
|
155
|
+
if (block.hash !== undefined && (typeof block.hash !== 'string' || !/^[0-9a-f]{64}$/.test(block.hash))) {
|
|
156
|
+
return { code: BLOCK_REFUSAL.BAD_HASH, detail: 'hash, when present, must be a 64-char lowercase hex sha256' };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build a block, hashing it on the way out.
|
|
163
|
+
*
|
|
164
|
+
* `lineage` is taken as ancestor **blocks or hashes**. Blocks are hashed here so a caller cannot
|
|
165
|
+
* accidentally attach an ancestor's id instead of its content, which is the mistake that makes a
|
|
166
|
+
* fabricated history look well-formed.
|
|
167
|
+
*
|
|
168
|
+
* @param {{origin: string, subject: unknown, body: unknown, evidence?: unknown, confidence?: unknown,
|
|
169
|
+
* observed_at?: string, lineage?: Array<object|string>}} input
|
|
170
|
+
* @returns {object} the block with `hash`
|
|
171
|
+
*/
|
|
172
|
+
export function makeBlock(input) {
|
|
173
|
+
const lineage = (input?.lineage ?? []).map((a) => (typeof a === 'string' ? a : blockHash(a)));
|
|
174
|
+
const block = {
|
|
175
|
+
origin: input?.origin ?? null,
|
|
176
|
+
subject: input?.subject ?? null,
|
|
177
|
+
body: input?.body ?? null,
|
|
178
|
+
evidence: input?.evidence ?? null,
|
|
179
|
+
confidence: input?.confidence ?? null,
|
|
180
|
+
observed_at: input?.observed_at ?? new Date().toISOString(),
|
|
181
|
+
lineage,
|
|
182
|
+
};
|
|
183
|
+
const problem = blockProblem(block);
|
|
184
|
+
if (problem !== null) throw new BlockRefusal(problem.code, problem.detail);
|
|
185
|
+
return { ...block, hash: blockHash(block) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Verify a block's declared hash against its content.
|
|
190
|
+
*
|
|
191
|
+
* The point of the field: a block whose hash does not cover its content can be edited after the fact
|
|
192
|
+
* without the lineage that references it noticing.
|
|
193
|
+
*/
|
|
194
|
+
export function verifyBlock(block) {
|
|
195
|
+
const problem = blockProblem(block);
|
|
196
|
+
if (problem !== null) return { ok: false, ...problem };
|
|
197
|
+
if (block.hash === undefined) return { ok: false, code: BLOCK_REFUSAL.BAD_HASH, detail: 'block carries no hash to verify' };
|
|
198
|
+
const actual = blockHash(block);
|
|
199
|
+
if (actual !== block.hash) {
|
|
200
|
+
return { ok: false, code: BLOCK_REFUSAL.HASH_MISMATCH, detail: `declared ${block.hash.slice(0, 12)}… but content hashes to ${actual.slice(0, 12)}…` };
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, hash: actual };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Decide, field by field, which of a peer's fields this receiver accepts.
|
|
207
|
+
*
|
|
208
|
+
* Returns a **partition** rather than a filtered copy, because the refused half is evidence: "this
|
|
209
|
+
* machine declined the confidence and kept the body" is a decision worth being able to inspect, and a
|
|
210
|
+
* filtered object silently loses it.
|
|
211
|
+
*
|
|
212
|
+
* `choose` is called once per choosable field with `(field, value, block)`. It may return a boolean, or
|
|
213
|
+
* `{accept: boolean, why?: string}`. The default **refuses `confidence`** and accepts the rest — the
|
|
214
|
+
* conservative reading of a design whose P1 says a receiver decides per field, not that it decides
|
|
215
|
+
* generously.
|
|
216
|
+
*
|
|
217
|
+
* Inherited fields are never offered to `choose`. A caller that wants to override `origin` is not making
|
|
218
|
+
* an acceptance decision, it is forging one, and the returned `accepted` will not carry it.
|
|
219
|
+
*
|
|
220
|
+
* @param {object} block
|
|
221
|
+
* @param {{choose?: Function, lineage?: string[]|null}} [options]
|
|
222
|
+
* @returns {{accepted: object, refused: Array<{field: string, why: string|null}>,
|
|
223
|
+
* accepted_fields: string[], verified: boolean}}
|
|
224
|
+
*/
|
|
225
|
+
export function acceptBlock(block, options = {}) {
|
|
226
|
+
const { choose = defaultChoose, lineage = null } = options;
|
|
227
|
+
const verified = verifyBlock(block);
|
|
228
|
+
if (!verified.ok) {
|
|
229
|
+
// A block that fails its own hash is refused *entirely*: field-level acceptance is about trust, and
|
|
230
|
+
// there is nothing to trust about a block whose content does not match what it claims to be.
|
|
231
|
+
return {
|
|
232
|
+
accepted: null,
|
|
233
|
+
refused: CAT7_FIELDS.map((field) => ({ field, why: `${verified.code}: ${verified.detail}` })),
|
|
234
|
+
accepted_fields: [],
|
|
235
|
+
verified: false,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A block whose parent is not producible is refused rather than re-rooted.
|
|
241
|
+
*
|
|
242
|
+
* This is the single most important refusal in the file. Re-rooting -- keeping the block and emptying
|
|
243
|
+
* its lineage -- turns a claim with an unverifiable history into one with no history, which reads as a
|
|
244
|
+
* fresh, independent assertion. That is precisely how a fabricated claim acquires respectability.
|
|
245
|
+
*/
|
|
246
|
+
if (Array.isArray(lineage)) {
|
|
247
|
+
const known = new Set(lineage);
|
|
248
|
+
const missing = block.lineage.filter((a) => !known.has(a));
|
|
249
|
+
if (missing.length > 0) {
|
|
250
|
+
return {
|
|
251
|
+
accepted: null,
|
|
252
|
+
refused: [{ field: 'lineage', why: `${BLOCK_REFUSAL.MISSING_PARENT}: cannot produce ancestor(s) ${missing.map((m) => m.slice(0, 12)).join(', ')}…; refusing rather than re-rooting` }],
|
|
253
|
+
accepted_fields: [],
|
|
254
|
+
verified: true,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const accepted = {};
|
|
260
|
+
const refused = [];
|
|
261
|
+
const acceptedFields = [];
|
|
262
|
+
|
|
263
|
+
for (const field of INHERITED_FIELDS) {
|
|
264
|
+
accepted[field] = block[field];
|
|
265
|
+
acceptedFields.push(field);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const field of RECEIVER_CHOOSABLE_FIELDS) {
|
|
269
|
+
const value = block[field];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `confidence` is refused whatever the chooser says.
|
|
273
|
+
*
|
|
274
|
+
* Found by a test: a custom `choose` that accepted everything caused `refused` to hold one entry
|
|
275
|
+
* instead of two, because supplying a chooser *replaced* the default rather than refining it. That
|
|
276
|
+
* made the file's central conservative property -- a receiver does not adopt the sender's certainty
|
|
277
|
+
* about the sender's own assertion -- silently optional, and "silently optional" is how a safety
|
|
278
|
+
* default stops being one.
|
|
279
|
+
*
|
|
280
|
+
* So the refusal is a hard invariant here rather than a default a caller can override. A caller that
|
|
281
|
+
* genuinely needs the sender's confidence can read it from the block it was handed; what it cannot do
|
|
282
|
+
* is have this function put it into `accepted` as though the receiver had established it.
|
|
283
|
+
*/
|
|
284
|
+
if (field === 'confidence') {
|
|
285
|
+
refused.push({ field, why: 'a receiver does not adopt the sender\'s certainty (invariant, not a default)' });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let decision;
|
|
290
|
+
try {
|
|
291
|
+
decision = choose(field, value, block);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
decision = { accept: false, why: `chooser threw: ${e instanceof Error ? e.message : String(e)}` };
|
|
294
|
+
}
|
|
295
|
+
const accept = typeof decision === 'boolean' ? decision : decision?.accept === true;
|
|
296
|
+
const why = typeof decision === 'object' && decision !== null ? (decision.why ?? null) : null;
|
|
297
|
+
if (accept) {
|
|
298
|
+
accepted[field] = value;
|
|
299
|
+
acceptedFields.push(field);
|
|
300
|
+
} else {
|
|
301
|
+
refused.push({ field, why });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return { accepted, refused, accepted_fields: acceptedFields, verified: true };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The default per-field decision, kept deliberately conservative.
|
|
310
|
+
*
|
|
311
|
+
* `confidence` is refused. It is the one field whose value is an *opinion about the sender's own
|
|
312
|
+
* assertion*, and adopting it would mean this machine now asserts a certainty it did not establish --
|
|
313
|
+
* which is exactly the kind of quiet transfer of belief that a fleet memory has to prevent.
|
|
314
|
+
*/
|
|
315
|
+
export function defaultChoose(field) {
|
|
316
|
+
if (field === 'confidence') return { accept: false, why: 'a receiver does not adopt the sender\'s certainty' };
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Walk a block's ancestry.
|
|
322
|
+
*
|
|
323
|
+
* Breadth-first with three colours, which is the standard way to tell a **diamond** from a **cycle** and
|
|
324
|
+
* the reason this is not a simple recursive walk: a claim supported by two paths is ordinary and must be
|
|
325
|
+
* walked once, while a claim that descends from itself is malformed and must be named. An earlier draft
|
|
326
|
+
* of this function mixed `chain.includes` with the frontier and would have reported a diamond as a cycle
|
|
327
|
+
* in some orders and a cycle as a diamond in others.
|
|
328
|
+
*
|
|
329
|
+
* `have` maps hash to block. `missing` collects every gap rather than stopping at the first, because
|
|
330
|
+
* knowing how much of a history is absent is more useful than knowing that some of it is.
|
|
331
|
+
*
|
|
332
|
+
* @param {object} block
|
|
333
|
+
* @param {Map<string, object>} have
|
|
334
|
+
* @param {{maxDepth?: number}} [options]
|
|
335
|
+
* @returns {{chain: string[], missing: string[], cycle: string|null, depth: number}}
|
|
336
|
+
*/
|
|
337
|
+
export function ancestry(block, have, { maxDepth = 1000 } = {}) {
|
|
338
|
+
const WHITE = 0, GREY = 1, BLACK = 2;
|
|
339
|
+
const colour = new Map();
|
|
340
|
+
const chain = [];
|
|
341
|
+
const missing = [];
|
|
342
|
+
|
|
343
|
+
/** @returns {string|null} the hash that closed a cycle, or null. */
|
|
344
|
+
function visit(hash, depth) {
|
|
345
|
+
if (depth > maxDepth) return null;
|
|
346
|
+
const state = colour.get(hash) ?? WHITE;
|
|
347
|
+
if (state === GREY) return hash; // a back edge: this hash is on the path being walked
|
|
348
|
+
if (state === BLACK) return null; // already fully explored: a diamond, and ordinary
|
|
349
|
+
|
|
350
|
+
const parent = have.get(hash);
|
|
351
|
+
if (parent === undefined) {
|
|
352
|
+
// Not visited and not available: a gap. Left WHITE so a second path to it reports it once, and
|
|
353
|
+
// `missing` is de-duplicated below.
|
|
354
|
+
missing.push(hash);
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
colour.set(hash, GREY);
|
|
359
|
+
for (const ancestor of parent.lineage ?? []) {
|
|
360
|
+
const closed = visit(ancestor, depth + 1);
|
|
361
|
+
if (closed !== null) return closed;
|
|
362
|
+
}
|
|
363
|
+
colour.set(hash, BLACK);
|
|
364
|
+
chain.push(hash);
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let cycle = null;
|
|
369
|
+
for (const root of block?.lineage ?? []) {
|
|
370
|
+
cycle = visit(root, 0);
|
|
371
|
+
if (cycle !== null) break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
// `chain` is post-order: ancestors appear before the blocks that depend on them, which is the order a
|
|
376
|
+
// reader needs to reconstruct the history from the beginning.
|
|
377
|
+
chain: [...new Set(chain)],
|
|
378
|
+
missing: [...new Set(missing)],
|
|
379
|
+
cycle,
|
|
380
|
+
depth: chain.length,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Is `candidate` an ancestor of `block`, given the blocks available?
|
|
386
|
+
*
|
|
387
|
+
* The question P2 actually asks. A claim that descends from something this machine already thought is an
|
|
388
|
+
* **echo**, not corroboration, and a fleet that cannot tell those apart will confidently agree with
|
|
389
|
+
* itself.
|
|
390
|
+
*/
|
|
391
|
+
export function isDescendantOf(block, candidateHash, have) {
|
|
392
|
+
const { chain, missing } = ancestry(block, have);
|
|
393
|
+
return { descendant: chain.includes(candidateHash), chain, missing, checked: chain.length };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Store blocks under the hash their ancestors refer to, so a fixture's keys and references agree.
|
|
398
|
+
*
|
|
399
|
+
* ## Why this exists rather than being written inline in the test
|
|
400
|
+
*
|
|
401
|
+
* A cycle cannot be built by `makeBlock`: each block's hash covers its `lineage`, so linking two blocks
|
|
402
|
+
* to each other changes the very hashes being linked. The first attempt in the suite mutated `lineage`
|
|
403
|
+
* after hashing and keyed the map by `blockHash(...)`, which produced blocks whose internal references
|
|
404
|
+
* pointed at hashes the map did not contain -- so the walker reported `missing` and **never encountered
|
|
405
|
+
* the cycle it was supposed to test**. The test passed its setup and asserted nothing.
|
|
406
|
+
*
|
|
407
|
+
* This helper makes the reference and the key the same string by construction, so a fixture cannot
|
|
408
|
+
* silently fail to be the shape it claims. It produces blocks that are deliberately **not
|
|
409
|
+
* hash-consistent** -- `verifyBlock` would refuse them, and `acceptBlock` does -- because a cycle is by
|
|
410
|
+
* nature unforgeable; its only job is to exercise the walker, which traverses references rather than
|
|
411
|
+
* re-deriving hashes.
|
|
412
|
+
*
|
|
413
|
+
* @param {object[]} blocks
|
|
414
|
+
* @returns {Map<string, object>} keyed by the reference its own ancestors use
|
|
415
|
+
*/
|
|
416
|
+
export function linkFixtures(blocks) {
|
|
417
|
+
const have = new Map();
|
|
418
|
+
for (const b of blocks) {
|
|
419
|
+
// The key must be what the OTHER blocks reference. A block's own `hash` is what its ancestors
|
|
420
|
+
// reference it by, so that is the key -- not a freshly computed digest, which would differ whenever
|
|
421
|
+
// the block's lineage was mutated after it was built.
|
|
422
|
+
const key = typeof b.hash === 'string' && /^[0-9a-f]{64}$/.test(b.hash) ? b.hash : blockHash(b);
|
|
423
|
+
have.set(key, b);
|
|
424
|
+
}
|
|
425
|
+
return have;
|
|
426
|
+
}
|