@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.
@@ -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
+ }