@unrdf/kgc-substrate 26.4.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/README.md +288 -0
- package/package.json +54 -0
- package/src/Allocator.mjs +321 -0
- package/src/KnowledgeStore.mjs +325 -0
- package/src/ReceiptChain.mjs +292 -0
- package/src/Router.mjs +382 -0
- package/src/TamperDetector.mjs +299 -0
- package/src/Workspace.mjs +556 -0
- package/src/index.mjs +23 -0
- package/src/types.mjs +136 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KnowledgeStore - Deterministic, Hash-Stable, Immutable Append-Only Log
|
|
3
|
+
*
|
|
4
|
+
* Core substrate for KGC multi-agent system. Provides:
|
|
5
|
+
* - Immutable append-only log of indexed triples
|
|
6
|
+
* - Deterministic snapshot generation with BLAKE3 hashing
|
|
7
|
+
* - Hash-stable canonicalization (lexicographic quad ordering)
|
|
8
|
+
* - Query interface: selectTriples(pattern) → Set<Quad>
|
|
9
|
+
* - State commitment: hash(store_state) → stable digest
|
|
10
|
+
*
|
|
11
|
+
* TRANCHE ISOLATION: Only depends on @unrdf/kgc-4d, @unrdf/oxigraph, @unrdf/core
|
|
12
|
+
* NO dependencies on other tranches.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { KGCStore, freezeUniverse, GitBackbone } from '@unrdf/kgc-4d';
|
|
16
|
+
import { dataFactory } from '@unrdf/oxigraph';
|
|
17
|
+
import { blake3 } from 'hash-wasm';
|
|
18
|
+
import {
|
|
19
|
+
validateStorageSnapshot,
|
|
20
|
+
validateQueryPattern,
|
|
21
|
+
validateStateCommitment
|
|
22
|
+
} from './types.mjs';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* KnowledgeStore - Wraps KGCStore with deterministic, hash-stable interface
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* import { KnowledgeStore } from '@unrdf/kgc-substrate';
|
|
29
|
+
* const store = new KnowledgeStore({ nodeId: 'agent-1' });
|
|
30
|
+
* const { index } = await store.appendTriple('add', subject, predicate, object);
|
|
31
|
+
* const snapshot = await store.generateSnapshot();
|
|
32
|
+
* console.assert(snapshot.quads_hash, 'Snapshot has deterministic hash');
|
|
33
|
+
*/
|
|
34
|
+
export class KnowledgeStore {
|
|
35
|
+
/**
|
|
36
|
+
* @param {Object} [options] - Configuration options
|
|
37
|
+
* @param {string} [options.nodeId] - Node ID for vector clock (defaults to random)
|
|
38
|
+
* @param {string} [options.gitDir] - Git directory for snapshot storage (defaults to '.kgc-substrate-git')
|
|
39
|
+
*/
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
// Validate inputs
|
|
42
|
+
if (options !== null && typeof options !== 'object') {
|
|
43
|
+
throw new TypeError('KnowledgeStore: options must be an object');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.nodeId = options.nodeId || this._generateNodeId();
|
|
47
|
+
this.gitDir = options.gitDir || '.kgc-substrate-git';
|
|
48
|
+
|
|
49
|
+
// Initialize KGCStore (4D event logging backend)
|
|
50
|
+
this.store = new KGCStore({ nodeId: this.nodeId });
|
|
51
|
+
|
|
52
|
+
// Initialize GitBackbone (snapshot storage)
|
|
53
|
+
this.git = new GitBackbone(this.gitDir);
|
|
54
|
+
|
|
55
|
+
// Append-only log index (BigInt for overflow protection)
|
|
56
|
+
this.logIndex = 0n;
|
|
57
|
+
|
|
58
|
+
// Epoch counter for snapshots
|
|
59
|
+
this.epoch = 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate unique node ID
|
|
64
|
+
*
|
|
65
|
+
* @returns {string} Node ID with 'ks-' prefix
|
|
66
|
+
* @private
|
|
67
|
+
*/
|
|
68
|
+
_generateNodeId() {
|
|
69
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
70
|
+
return `ks-${crypto.randomUUID().slice(0, 8)}`;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const crypto = require('crypto');
|
|
74
|
+
return `ks-${crypto.randomUUID().slice(0, 8)}`;
|
|
75
|
+
} catch {
|
|
76
|
+
return `ks-${Date.now().toString(36)}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Append triple to immutable log
|
|
82
|
+
*
|
|
83
|
+
* Enforces append-only property: triples are never modified, only added or marked deleted.
|
|
84
|
+
* Each operation gets a sequential index for deterministic replay.
|
|
85
|
+
*
|
|
86
|
+
* @param {'add'|'delete'} operation - Operation type
|
|
87
|
+
* @param {Object} subject - RDF subject term
|
|
88
|
+
* @param {Object} predicate - RDF predicate term
|
|
89
|
+
* @param {Object} object - RDF object term
|
|
90
|
+
* @param {Object} [graph] - RDF graph term (defaults to Universe graph)
|
|
91
|
+
* @returns {Promise<{index: bigint, timestamp_ns: bigint}>} Log entry metadata
|
|
92
|
+
* @throws {TypeError} If parameters are invalid
|
|
93
|
+
* @throws {Error} If append operation fails
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* const s = dataFactory.namedNode('http://ex.org/s');
|
|
97
|
+
* const p = dataFactory.namedNode('http://ex.org/p');
|
|
98
|
+
* const o = dataFactory.literal('value');
|
|
99
|
+
* const { index } = await store.appendTriple('add', s, p, o);
|
|
100
|
+
* console.assert(typeof index === 'bigint', 'Returns BigInt index');
|
|
101
|
+
*/
|
|
102
|
+
async appendTriple(operation, subject, predicate, object, graph = null) {
|
|
103
|
+
// Input validation
|
|
104
|
+
if (operation !== 'add' && operation !== 'delete') {
|
|
105
|
+
throw new TypeError(`appendTriple: operation must be 'add' or 'delete', got '${operation}'`);
|
|
106
|
+
}
|
|
107
|
+
if (!subject || typeof subject.value !== 'string') {
|
|
108
|
+
throw new TypeError('appendTriple: subject must be a valid RDF term');
|
|
109
|
+
}
|
|
110
|
+
if (!predicate || typeof predicate.value !== 'string') {
|
|
111
|
+
throw new TypeError('appendTriple: predicate must be a valid RDF term');
|
|
112
|
+
}
|
|
113
|
+
if (!object || typeof object.value !== 'string') {
|
|
114
|
+
throw new TypeError('appendTriple: object must be a valid RDF term');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// Create delta for KGCStore
|
|
119
|
+
const delta = {
|
|
120
|
+
type: operation,
|
|
121
|
+
subject,
|
|
122
|
+
predicate,
|
|
123
|
+
object,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Append to KGCStore event log
|
|
127
|
+
const { receipt } = await this.store.appendEvent(
|
|
128
|
+
{
|
|
129
|
+
type: operation === 'add' ? 'CREATE' : 'DELETE',
|
|
130
|
+
payload: {
|
|
131
|
+
operation,
|
|
132
|
+
log_index: this.logIndex.toString(),
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
[delta]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Increment log index (immutable sequence)
|
|
139
|
+
const currentIndex = this.logIndex;
|
|
140
|
+
this.logIndex++;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
index: currentIndex,
|
|
144
|
+
timestamp_ns: BigInt(receipt.t_ns),
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
throw new Error(`appendTriple failed: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Select triples matching a pattern (supports wildcards)
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} pattern - Query pattern with subject, predicate, object, graph
|
|
155
|
+
* @param {Object|null} pattern.subject - Subject or null for wildcard
|
|
156
|
+
* @param {Object|null} pattern.predicate - Predicate or null for wildcard
|
|
157
|
+
* @param {Object|null} pattern.object - Object or null for wildcard
|
|
158
|
+
* @param {Object|null} [pattern.graph] - Graph or null for wildcard
|
|
159
|
+
* @returns {Set<Object>} Set of matching quads
|
|
160
|
+
* @throws {Error} If pattern is invalid
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* const results = store.selectTriples({ subject: s, predicate: null, object: null });
|
|
164
|
+
* console.log('Matching triples:', results.size);
|
|
165
|
+
*/
|
|
166
|
+
selectTriples(pattern) {
|
|
167
|
+
try {
|
|
168
|
+
// Validate pattern
|
|
169
|
+
validateQueryPattern(pattern);
|
|
170
|
+
|
|
171
|
+
// Query KGCStore using match()
|
|
172
|
+
const matches = this.store.match(
|
|
173
|
+
pattern.subject,
|
|
174
|
+
pattern.predicate,
|
|
175
|
+
pattern.object,
|
|
176
|
+
pattern.graph || null
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return new Set([...matches]);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new Error(`selectTriples failed: ${error.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Generate deterministic snapshot with hash-stable canonicalization
|
|
187
|
+
*
|
|
188
|
+
* Steps:
|
|
189
|
+
* 1. Extract all quads from Universe graph
|
|
190
|
+
* 2. Sort lexicographically (S-P-O) for canonical ordering
|
|
191
|
+
* 3. Serialize to N-Quads format
|
|
192
|
+
* 4. Hash with BLAKE3 (deterministic)
|
|
193
|
+
* 5. Commit to Git for immutable storage
|
|
194
|
+
* 6. Return snapshot metadata
|
|
195
|
+
*
|
|
196
|
+
* @returns {Promise<Object>} StorageSnapshot with epoch, timestamp_ns, quads_hash, commit_hash, snapshot_id
|
|
197
|
+
* @throws {Error} If snapshot generation fails
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* const snapshot = await store.generateSnapshot();
|
|
201
|
+
* console.assert(snapshot.quads_hash, 'Has deterministic hash');
|
|
202
|
+
* console.assert(snapshot.snapshot_id, 'Has UUID');
|
|
203
|
+
*/
|
|
204
|
+
async generateSnapshot() {
|
|
205
|
+
try {
|
|
206
|
+
// Freeze universe using KGC-4D
|
|
207
|
+
const freezeReceipt = await freezeUniverse(this.store, this.git);
|
|
208
|
+
|
|
209
|
+
// Create snapshot metadata
|
|
210
|
+
const snapshot = {
|
|
211
|
+
epoch: this.epoch,
|
|
212
|
+
timestamp_ns: BigInt(freezeReceipt.t_ns),
|
|
213
|
+
quads_hash: freezeReceipt.universe_hash,
|
|
214
|
+
commit_hash: freezeReceipt.git_ref,
|
|
215
|
+
snapshot_id: freezeReceipt.id,
|
|
216
|
+
quad_count: await this.getQuadCount(),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Validate snapshot schema
|
|
220
|
+
validateStorageSnapshot(snapshot);
|
|
221
|
+
|
|
222
|
+
// Increment epoch
|
|
223
|
+
this.epoch++;
|
|
224
|
+
|
|
225
|
+
return snapshot;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new Error(`generateSnapshot failed: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get current state commitment (hash of store state)
|
|
233
|
+
*
|
|
234
|
+
* Provides cryptographic commitment to current state without full snapshot.
|
|
235
|
+
* Useful for lightweight verification.
|
|
236
|
+
*
|
|
237
|
+
* @returns {Promise<Object>} StateCommitment with state_hash, log_index, timestamp_ns, quad_count
|
|
238
|
+
* @throws {Error} If commitment generation fails
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* const commitment = await store.getStateCommitment();
|
|
242
|
+
* console.assert(commitment.state_hash, 'Has state hash');
|
|
243
|
+
*/
|
|
244
|
+
async getStateCommitment() {
|
|
245
|
+
try {
|
|
246
|
+
// Get all quads from Universe graph
|
|
247
|
+
const universeGraph = dataFactory.namedNode('http://kgc.io/graph/universe');
|
|
248
|
+
const quads = [...this.store.match(null, null, null, universeGraph)];
|
|
249
|
+
|
|
250
|
+
// Sort for canonical ordering
|
|
251
|
+
quads.sort((a, b) => {
|
|
252
|
+
const sCompare = a.subject.value < b.subject.value ? -1 :
|
|
253
|
+
a.subject.value > b.subject.value ? 1 : 0;
|
|
254
|
+
if (sCompare !== 0) return sCompare;
|
|
255
|
+
|
|
256
|
+
const pCompare = a.predicate.value < b.predicate.value ? -1 :
|
|
257
|
+
a.predicate.value > b.predicate.value ? 1 : 0;
|
|
258
|
+
if (pCompare !== 0) return pCompare;
|
|
259
|
+
|
|
260
|
+
return a.object.value < b.object.value ? -1 :
|
|
261
|
+
a.object.value > b.object.value ? 1 : 0;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Serialize to canonical string
|
|
265
|
+
const canonicalString = quads.map(q =>
|
|
266
|
+
`${q.subject.value}|${q.predicate.value}|${q.object.value}`
|
|
267
|
+
).join('\n');
|
|
268
|
+
|
|
269
|
+
// Hash with BLAKE3
|
|
270
|
+
const stateHash = await blake3(canonicalString);
|
|
271
|
+
|
|
272
|
+
const commitment = {
|
|
273
|
+
state_hash: stateHash,
|
|
274
|
+
log_index: this.logIndex,
|
|
275
|
+
timestamp_ns: BigInt(Date.now()) * 1_000_000n, // Convert ms to ns
|
|
276
|
+
quad_count: quads.length,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Validate commitment schema
|
|
280
|
+
validateStateCommitment(commitment);
|
|
281
|
+
|
|
282
|
+
return commitment;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
throw new Error(`getStateCommitment failed: ${error.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get current quad count
|
|
290
|
+
*
|
|
291
|
+
* @returns {Promise<number>} Total number of quads in Universe graph
|
|
292
|
+
*/
|
|
293
|
+
async getQuadCount() {
|
|
294
|
+
const universeGraph = dataFactory.namedNode('http://kgc.io/graph/universe');
|
|
295
|
+
const quads = [...this.store.match(null, null, null, universeGraph)];
|
|
296
|
+
return quads.length;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get current log index
|
|
301
|
+
*
|
|
302
|
+
* @returns {bigint} Current append-only log index
|
|
303
|
+
*/
|
|
304
|
+
getLogIndex() {
|
|
305
|
+
return this.logIndex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get current epoch
|
|
310
|
+
*
|
|
311
|
+
* @returns {number} Current snapshot epoch
|
|
312
|
+
*/
|
|
313
|
+
getEpoch() {
|
|
314
|
+
return this.epoch;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Get node ID
|
|
319
|
+
*
|
|
320
|
+
* @returns {string} Node ID for this store instance
|
|
321
|
+
*/
|
|
322
|
+
getNodeId() {
|
|
323
|
+
return this.nodeId;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReceiptChain - Immutable ledger with merkle tree chaining
|
|
3
|
+
*
|
|
4
|
+
* Provides cryptographically-verifiable chain of receipts where each block
|
|
5
|
+
* commits to the previous block via hash chaining, creating an immutable audit trail.
|
|
6
|
+
*
|
|
7
|
+
* Block structure:
|
|
8
|
+
* - before_hash: Hash of previous block (merkle root)
|
|
9
|
+
* - after_hash: Hash of current block content
|
|
10
|
+
* - timestamp_ns: UTC nanoseconds (monotonic)
|
|
11
|
+
* - agent_id: Agent identifier
|
|
12
|
+
* - toolchain_version: Toolchain version string
|
|
13
|
+
* - artifacts: Array of artifact objects
|
|
14
|
+
*
|
|
15
|
+
* @module ReceiptChain
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { sha256 } from 'hash-wasm';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Artifact schema - individual work products in a block
|
|
23
|
+
*/
|
|
24
|
+
const ArtifactSchema = z.object({
|
|
25
|
+
type: z.string(),
|
|
26
|
+
path: z.string(),
|
|
27
|
+
hash: z.string(),
|
|
28
|
+
size_bytes: z.number().int().nonnegative()
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Block schema - immutable ledger entry
|
|
33
|
+
*/
|
|
34
|
+
const BlockSchema = z.object({
|
|
35
|
+
before_hash: z.string().length(64), // SHA256 hex
|
|
36
|
+
after_hash: z.string().length(64), // SHA256 hex
|
|
37
|
+
timestamp_ns: z.bigint(),
|
|
38
|
+
agent_id: z.string().min(1),
|
|
39
|
+
toolchain_version: z.string().min(1),
|
|
40
|
+
artifacts: z.array(ArtifactSchema)
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Receipt chain configuration schema
|
|
45
|
+
*/
|
|
46
|
+
const ConfigSchema = z.object({
|
|
47
|
+
genesis_hash: z.string().length(64).optional(),
|
|
48
|
+
enforce_monotonic_time: z.boolean().default(true)
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ReceiptChain class - Immutable ledger with merkle tree chaining
|
|
53
|
+
*/
|
|
54
|
+
export class ReceiptChain {
|
|
55
|
+
/**
|
|
56
|
+
* Genesis block hash - conventionally all zeros
|
|
57
|
+
* @type {string}
|
|
58
|
+
*/
|
|
59
|
+
static GENESIS_HASH = '0'.repeat(64);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a new receipt chain
|
|
63
|
+
* @param {Object} config - Configuration options
|
|
64
|
+
* @param {string} [config.genesis_hash] - Custom genesis hash (default: all zeros)
|
|
65
|
+
* @param {boolean} [config.enforce_monotonic_time=true] - Enforce monotonic timestamps
|
|
66
|
+
*/
|
|
67
|
+
constructor(config = {}) {
|
|
68
|
+
const validated = ConfigSchema.parse(config);
|
|
69
|
+
this.blocks = [];
|
|
70
|
+
this.genesis_hash = validated.genesis_hash || ReceiptChain.GENESIS_HASH;
|
|
71
|
+
this.enforce_monotonic_time = validated.enforce_monotonic_time;
|
|
72
|
+
this.last_timestamp_ns = 0n;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get current chain head hash
|
|
77
|
+
* @returns {string} Hash of the last block, or genesis hash if empty
|
|
78
|
+
*/
|
|
79
|
+
getHeadHash() {
|
|
80
|
+
if (this.blocks.length === 0) {
|
|
81
|
+
return this.genesis_hash;
|
|
82
|
+
}
|
|
83
|
+
return this.blocks[this.blocks.length - 1].after_hash;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get chain length
|
|
88
|
+
* @returns {number} Number of blocks in the chain
|
|
89
|
+
*/
|
|
90
|
+
getLength() {
|
|
91
|
+
return this.blocks.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get block by index
|
|
96
|
+
* @param {number} index - Block index (0-based)
|
|
97
|
+
* @returns {Object|null} Block or null if out of bounds
|
|
98
|
+
*/
|
|
99
|
+
getBlock(index) {
|
|
100
|
+
if (index < 0 || index >= this.blocks.length) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return this.blocks[index];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get all blocks (defensive copy to prevent mutation)
|
|
108
|
+
* @returns {Array<Object>} Array of all blocks
|
|
109
|
+
*/
|
|
110
|
+
getAllBlocks() {
|
|
111
|
+
return structuredClone(this.blocks);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compute hash of block content (before appending to chain)
|
|
116
|
+
* Hash includes: timestamp, agent_id, toolchain_version, artifacts
|
|
117
|
+
* Does NOT include before_hash/after_hash (those are chain metadata)
|
|
118
|
+
*
|
|
119
|
+
* @param {Object} content - Block content to hash
|
|
120
|
+
* @param {bigint} content.timestamp_ns - UTC nanoseconds
|
|
121
|
+
* @param {string} content.agent_id - Agent identifier
|
|
122
|
+
* @param {string} content.toolchain_version - Toolchain version
|
|
123
|
+
* @param {Array<Object>} content.artifacts - Array of artifacts
|
|
124
|
+
* @returns {Promise<string>} SHA256 hex digest
|
|
125
|
+
* @private
|
|
126
|
+
*/
|
|
127
|
+
async _computeContentHash(content) {
|
|
128
|
+
// Canonical JSON serialization for deterministic hashing
|
|
129
|
+
const canonical = JSON.stringify({
|
|
130
|
+
timestamp_ns: content.timestamp_ns.toString(),
|
|
131
|
+
agent_id: content.agent_id,
|
|
132
|
+
toolchain_version: content.toolchain_version,
|
|
133
|
+
artifacts: content.artifacts
|
|
134
|
+
});
|
|
135
|
+
return await sha256(canonical);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Compute merkle root of current block + previous hash
|
|
140
|
+
* Merkle root = SHA256(before_hash || after_hash)
|
|
141
|
+
*
|
|
142
|
+
* @param {string} before_hash - Hash of previous block
|
|
143
|
+
* @param {string} after_hash - Hash of current block content
|
|
144
|
+
* @returns {Promise<string>} Merkle root hash
|
|
145
|
+
* @private
|
|
146
|
+
*/
|
|
147
|
+
async _computeMerkleRoot(before_hash, after_hash) {
|
|
148
|
+
return await sha256(before_hash + after_hash);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Append a new block to the chain (immutable operation)
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} blockData - Block data to append
|
|
155
|
+
* @param {string} blockData.agent_id - Agent identifier
|
|
156
|
+
* @param {string} blockData.toolchain_version - Toolchain version
|
|
157
|
+
* @param {Array<Object>} blockData.artifacts - Array of artifacts
|
|
158
|
+
* @param {bigint} [blockData.timestamp_ns] - Custom timestamp (default: now)
|
|
159
|
+
* @returns {Promise<Object>} Appended block with receipt
|
|
160
|
+
* @throws {Error} If validation fails or timestamp not monotonic
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* const chain = new ReceiptChain();
|
|
164
|
+
* const block = await chain.append({
|
|
165
|
+
* agent_id: 'agent-2',
|
|
166
|
+
* toolchain_version: '1.0.0',
|
|
167
|
+
* artifacts: [{ type: 'code', path: 'foo.mjs', hash: 'abc123', size_bytes: 1024 }]
|
|
168
|
+
* });
|
|
169
|
+
*/
|
|
170
|
+
async append(blockData) {
|
|
171
|
+
// Validate input structure
|
|
172
|
+
const { agent_id, toolchain_version, artifacts, timestamp_ns } = blockData;
|
|
173
|
+
|
|
174
|
+
if (!agent_id || typeof agent_id !== 'string') {
|
|
175
|
+
throw new Error('ReceiptChain.append: agent_id is required and must be a string');
|
|
176
|
+
}
|
|
177
|
+
if (!toolchain_version || typeof toolchain_version !== 'string') {
|
|
178
|
+
throw new Error('ReceiptChain.append: toolchain_version is required and must be a string');
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(artifacts)) {
|
|
181
|
+
throw new Error('ReceiptChain.append: artifacts must be an array');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate artifacts
|
|
185
|
+
artifacts.forEach((artifact, idx) => {
|
|
186
|
+
try {
|
|
187
|
+
ArtifactSchema.parse(artifact);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new Error(`ReceiptChain.append: Invalid artifact at index ${idx}: ${err.message}`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Get timestamp (custom or now)
|
|
194
|
+
const timestamp = timestamp_ns || BigInt(Date.now()) * 1_000_000n;
|
|
195
|
+
|
|
196
|
+
// Enforce monotonic time if enabled
|
|
197
|
+
if (this.enforce_monotonic_time && timestamp <= this.last_timestamp_ns) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`ReceiptChain.append: Timestamp not monotonic (${timestamp} <= ${this.last_timestamp_ns})`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Get previous block hash (genesis or last block)
|
|
204
|
+
const before_hash = this.getHeadHash();
|
|
205
|
+
|
|
206
|
+
// Compute content hash
|
|
207
|
+
const content = { timestamp_ns: timestamp, agent_id, toolchain_version, artifacts };
|
|
208
|
+
const after_hash = await this._computeContentHash(content);
|
|
209
|
+
|
|
210
|
+
// Create block
|
|
211
|
+
const block = {
|
|
212
|
+
before_hash,
|
|
213
|
+
after_hash,
|
|
214
|
+
timestamp_ns: timestamp,
|
|
215
|
+
agent_id,
|
|
216
|
+
toolchain_version,
|
|
217
|
+
artifacts
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Validate block schema
|
|
221
|
+
BlockSchema.parse(block);
|
|
222
|
+
|
|
223
|
+
// Append to chain (immutable - no mutation of existing blocks)
|
|
224
|
+
this.blocks.push(Object.freeze(block));
|
|
225
|
+
this.last_timestamp_ns = timestamp;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
block,
|
|
229
|
+
index: this.blocks.length - 1,
|
|
230
|
+
merkle_root: await this._computeMerkleRoot(before_hash, after_hash)
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Serialize chain to JSON
|
|
236
|
+
* @returns {Object} Serialized chain
|
|
237
|
+
*/
|
|
238
|
+
toJSON() {
|
|
239
|
+
return {
|
|
240
|
+
genesis_hash: this.genesis_hash,
|
|
241
|
+
length: this.blocks.length,
|
|
242
|
+
head_hash: this.getHeadHash(),
|
|
243
|
+
blocks: this.blocks.map(block => ({
|
|
244
|
+
...block,
|
|
245
|
+
timestamp_ns: block.timestamp_ns.toString() // BigInt to string
|
|
246
|
+
}))
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Deserialize chain from JSON
|
|
252
|
+
* @param {Object} json - Serialized chain
|
|
253
|
+
* @returns {ReceiptChain} Reconstructed chain
|
|
254
|
+
*/
|
|
255
|
+
static fromJSON(json) {
|
|
256
|
+
const chain = new ReceiptChain({
|
|
257
|
+
genesis_hash: json.genesis_hash,
|
|
258
|
+
enforce_monotonic_time: false // Don't enforce when loading
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Restore blocks
|
|
262
|
+
for (const blockData of json.blocks) {
|
|
263
|
+
const block = {
|
|
264
|
+
...blockData,
|
|
265
|
+
timestamp_ns: BigInt(blockData.timestamp_ns)
|
|
266
|
+
};
|
|
267
|
+
chain.blocks.push(Object.freeze(block));
|
|
268
|
+
chain.last_timestamp_ns = block.timestamp_ns;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return chain;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Encode chain to base64 (for embedding in receipts)
|
|
276
|
+
* @returns {string} Base64-encoded JSON
|
|
277
|
+
*/
|
|
278
|
+
toBase64() {
|
|
279
|
+
const json = JSON.stringify(this.toJSON());
|
|
280
|
+
return Buffer.from(json, 'utf8').toString('base64');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Decode chain from base64
|
|
285
|
+
* @param {string} base64 - Base64-encoded chain
|
|
286
|
+
* @returns {ReceiptChain} Reconstructed chain
|
|
287
|
+
*/
|
|
288
|
+
static fromBase64(base64) {
|
|
289
|
+
const json = JSON.parse(Buffer.from(base64, 'base64').toString('utf8'));
|
|
290
|
+
return ReceiptChain.fromJSON(json);
|
|
291
|
+
}
|
|
292
|
+
}
|