@git-stunts/git-warp 10.8.0 → 11.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +6 -0
  24. package/bin/presenters/text.js +136 -0
  25. package/bin/warp-graph.js +5 -2346
  26. package/index.d.ts +32 -2
  27. package/index.js +2 -0
  28. package/package.json +8 -7
  29. package/src/domain/WarpGraph.js +106 -3252
  30. package/src/domain/errors/QueryError.js +2 -2
  31. package/src/domain/errors/TrustError.js +29 -0
  32. package/src/domain/errors/index.js +1 -0
  33. package/src/domain/services/AuditMessageCodec.js +137 -0
  34. package/src/domain/services/AuditReceiptService.js +471 -0
  35. package/src/domain/services/AuditVerifierService.js +693 -0
  36. package/src/domain/services/HttpSyncServer.js +36 -22
  37. package/src/domain/services/MessageCodecInternal.js +3 -0
  38. package/src/domain/services/MessageSchemaDetector.js +2 -2
  39. package/src/domain/services/SyncAuthService.js +69 -3
  40. package/src/domain/services/WarpMessageCodec.js +4 -1
  41. package/src/domain/trust/TrustCanonical.js +42 -0
  42. package/src/domain/trust/TrustCrypto.js +111 -0
  43. package/src/domain/trust/TrustEvaluator.js +180 -0
  44. package/src/domain/trust/TrustRecordService.js +274 -0
  45. package/src/domain/trust/TrustStateBuilder.js +209 -0
  46. package/src/domain/trust/canonical.js +68 -0
  47. package/src/domain/trust/reasonCodes.js +64 -0
  48. package/src/domain/trust/schemas.js +160 -0
  49. package/src/domain/trust/verdict.js +42 -0
  50. package/src/domain/types/git-cas.d.ts +20 -0
  51. package/src/domain/utils/RefLayout.js +59 -0
  52. package/src/domain/warp/PatchSession.js +18 -0
  53. package/src/domain/warp/Writer.js +18 -3
  54. package/src/domain/warp/_internal.js +26 -0
  55. package/src/domain/warp/_wire.js +58 -0
  56. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  57. package/src/domain/warp/checkpoint.methods.js +397 -0
  58. package/src/domain/warp/fork.methods.js +323 -0
  59. package/src/domain/warp/materialize.methods.js +188 -0
  60. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  61. package/src/domain/warp/patch.methods.js +529 -0
  62. package/src/domain/warp/provenance.methods.js +284 -0
  63. package/src/domain/warp/query.methods.js +279 -0
  64. package/src/domain/warp/subscribe.methods.js +272 -0
  65. package/src/domain/warp/sync.methods.js +549 -0
  66. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  67. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  68. package/src/ports/CommitPort.js +10 -0
  69. package/src/ports/RefPort.js +17 -0
  70. package/src/hooks/post-merge.sh +0 -60
@@ -0,0 +1,693 @@
1
+ /**
2
+ * AuditVerifierService — verifies tamper-evident audit receipt chains.
3
+ *
4
+ * Walks audit chains backward from tip to genesis, validating:
5
+ * - Receipt schema (9 fields, correct types, version=1)
6
+ * - Chain linking (prevAuditCommit matches previous commit SHA)
7
+ * - Git parent consistency
8
+ * - Tick monotonicity (strictly decreasing backward)
9
+ * - Writer/graph consistency across the chain
10
+ * - OID format and length consistency
11
+ * - Trailer consistency (commit message trailers match CBOR receipt)
12
+ * - Tree structure (exactly one entry: receipt.cbor)
13
+ * - Genesis/continuation invariants
14
+ *
15
+ * @module domain/services/AuditVerifierService
16
+ * @see docs/specs/AUDIT_RECEIPT.md Section 8
17
+ */
18
+
19
+ import { buildAuditPrefix, buildAuditRef } from '../utils/RefLayout.js';
20
+ import { decodeAuditMessage } from './AuditMessageCodec.js';
21
+ import { TrustRecordService } from '../trust/TrustRecordService.js';
22
+ import { buildState } from '../trust/TrustStateBuilder.js';
23
+ import { evaluateWriters } from '../trust/TrustEvaluator.js';
24
+
25
+ // ============================================================================
26
+ // Constants
27
+ // ============================================================================
28
+
29
+ /** @type {RegExp} */
30
+ const OID_HEX_RE = /^[0-9a-f]+$/;
31
+
32
+ // ── Status codes ──────────────────────────────────────────────────────────────
33
+
34
+ /** Full chain verified from tip to genesis, no errors. */
35
+ const STATUS_VALID = 'VALID';
36
+ /** Chain verified from tip to --since boundary, no errors. */
37
+ const STATUS_PARTIAL = 'PARTIAL';
38
+ /** Structural integrity failure. */
39
+ const STATUS_BROKEN_CHAIN = 'BROKEN_CHAIN';
40
+ /** Content integrity failure (trailer vs CBOR). */
41
+ const STATUS_DATA_MISMATCH = 'DATA_MISMATCH';
42
+ /** Operational failure. */
43
+ const STATUS_ERROR = 'ERROR';
44
+
45
+ // ============================================================================
46
+ // Helpers
47
+ // ============================================================================
48
+
49
+ /**
50
+ * Validates that a string is valid lowercase hex of length 40 or 64.
51
+ * @param {string} value
52
+ * @returns {{ valid: boolean, normalized: string, error?: string }}
53
+ */
54
+ function validateOidFormat(value) {
55
+ if (typeof value !== 'string') {
56
+ return { valid: false, normalized: '', error: 'not a string' };
57
+ }
58
+ const normalized = value.toLowerCase();
59
+ if (!OID_HEX_RE.test(normalized)) {
60
+ return { valid: false, normalized, error: 'contains non-hex characters' };
61
+ }
62
+ if (normalized.length !== 40 && normalized.length !== 64) {
63
+ return { valid: false, normalized, error: `invalid length ${normalized.length}` };
64
+ }
65
+ return { valid: true, normalized };
66
+ }
67
+
68
+ /**
69
+ * Checks whether a receipt object has the expected 9 fields with correct types.
70
+ * @param {*} receipt
71
+ * @returns {string|null} Error message or null if valid
72
+ */
73
+ function validateReceiptSchema(receipt) {
74
+ if (!receipt || typeof receipt !== 'object') {
75
+ return 'receipt is not an object';
76
+ }
77
+ const keys = Object.keys(receipt);
78
+ if (keys.length !== 9) {
79
+ return `expected 9 fields, got ${keys.length}`;
80
+ }
81
+ const required = [
82
+ 'dataCommit', 'graphName', 'opsDigest', 'prevAuditCommit',
83
+ 'tickEnd', 'tickStart', 'timestamp', 'version', 'writerId',
84
+ ];
85
+ for (const k of required) {
86
+ if (!(k in receipt)) {
87
+ return `missing field: ${k}`;
88
+ }
89
+ }
90
+ if (receipt.version !== 1) {
91
+ return `unsupported version: ${receipt.version}`;
92
+ }
93
+ if (typeof receipt.graphName !== 'string' || receipt.graphName.length === 0) {
94
+ return 'graphName must be a non-empty string';
95
+ }
96
+ if (typeof receipt.writerId !== 'string' || receipt.writerId.length === 0) {
97
+ return 'writerId must be a non-empty string';
98
+ }
99
+ if (typeof receipt.dataCommit !== 'string') {
100
+ return 'dataCommit must be a string';
101
+ }
102
+ if (typeof receipt.opsDigest !== 'string') {
103
+ return 'opsDigest must be a string';
104
+ }
105
+ if (typeof receipt.prevAuditCommit !== 'string') {
106
+ return 'prevAuditCommit must be a string';
107
+ }
108
+ if (!Number.isInteger(receipt.tickStart) || receipt.tickStart < 1) {
109
+ return `tickStart must be integer >= 1, got ${receipt.tickStart}`;
110
+ }
111
+ if (!Number.isInteger(receipt.tickEnd) || receipt.tickEnd < receipt.tickStart) {
112
+ return `tickEnd must be integer >= tickStart, got ${receipt.tickEnd}`;
113
+ }
114
+ if (receipt.version === 1 && receipt.tickStart !== receipt.tickEnd) {
115
+ return `v1 requires tickStart === tickEnd, got ${receipt.tickStart} !== ${receipt.tickEnd}`;
116
+ }
117
+ if (!Number.isInteger(receipt.timestamp) || receipt.timestamp < 0) {
118
+ return `timestamp must be non-negative integer, got ${receipt.timestamp}`;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Validates trailers against the CBOR receipt fields.
125
+ * @param {*} receipt
126
+ * @param {{ graph: string, writer: string, dataCommit: string, opsDigest: string, schema: number }} decoded
127
+ * @returns {string|null} Error message or null if consistent
128
+ */
129
+ function validateTrailerConsistency(receipt, decoded) {
130
+ if (decoded.schema !== 1) {
131
+ return `trailer eg-schema must be 1, got ${decoded.schema}`;
132
+ }
133
+ if (decoded.graph !== receipt.graphName) {
134
+ return `trailer eg-graph '${decoded.graph}' !== receipt graphName '${receipt.graphName}'`;
135
+ }
136
+ if (decoded.writer !== receipt.writerId) {
137
+ return `trailer eg-writer '${decoded.writer}' !== receipt writerId '${receipt.writerId}'`;
138
+ }
139
+ if (decoded.dataCommit.toLowerCase() !== receipt.dataCommit.toLowerCase()) {
140
+ return `trailer eg-data-commit '${decoded.dataCommit}' !== receipt dataCommit '${receipt.dataCommit}'`;
141
+ }
142
+ if (decoded.opsDigest.toLowerCase() !== receipt.opsDigest.toLowerCase()) {
143
+ return `trailer eg-ops-digest '${decoded.opsDigest}' !== receipt opsDigest '${receipt.opsDigest}'`;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ // ============================================================================
149
+ // Service
150
+ // ============================================================================
151
+
152
+ /**
153
+ * @typedef {Object} ChainError
154
+ * @property {string} code - Machine-readable error code
155
+ * @property {string} message - Human-readable description
156
+ * @property {string} [commit] - The commit SHA where the error was found
157
+ */
158
+
159
+ /**
160
+ * @typedef {Object} ChainWarning
161
+ * @property {string} code - Machine-readable warning code
162
+ * @property {string} message - Human-readable description
163
+ */
164
+
165
+ /**
166
+ * @typedef {Object} ChainResult
167
+ * @property {string} writerId
168
+ * @property {string} ref
169
+ * @property {string} status - VALID | PARTIAL | BROKEN_CHAIN | DATA_MISMATCH | ERROR
170
+ * @property {number} receiptsVerified
171
+ * @property {number} receiptsScanned
172
+ * @property {string|null} tipCommit
173
+ * @property {string|null} tipAtStart
174
+ * @property {string|null} genesisCommit
175
+ * @property {string|null} stoppedAt
176
+ * @property {string|null} since
177
+ * @property {ChainError[]} errors
178
+ * @property {ChainWarning[]} warnings
179
+ */
180
+
181
+ /**
182
+ * @typedef {Object} TrustWarning
183
+ * @property {string} code
184
+ * @property {string} message
185
+ * @property {string[]} sources
186
+ */
187
+
188
+ /**
189
+ * @typedef {Object} VerifyResult
190
+ * @property {string} graph
191
+ * @property {string} verifiedAt
192
+ * @property {{ total: number, valid: number, partial: number, invalid: number }} summary
193
+ * @property {ChainResult[]} chains
194
+ * @property {TrustWarning|null} trustWarning
195
+ */
196
+
197
+ export class AuditVerifierService {
198
+ /**
199
+ * @param {Object} options
200
+ * @param {import('../../ports/CommitPort.js').default & import('../../ports/RefPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} options.persistence
201
+ * @param {import('../../ports/CodecPort.js').default} options.codec
202
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger]
203
+ */
204
+ constructor({ persistence, codec, logger }) {
205
+ this._persistence = persistence;
206
+ this._codec = codec;
207
+ this._logger = logger || null;
208
+ }
209
+
210
+ /**
211
+ * Lists writer IDs from audit refs for a graph.
212
+ * @param {string} graphName
213
+ * @returns {Promise<string[]>}
214
+ * @private
215
+ */
216
+ async _listWriterIds(graphName) {
217
+ const prefix = buildAuditPrefix(graphName);
218
+ const refs = await this._persistence.listRefs(prefix);
219
+ return refs
220
+ .map((/** @type {string} */ ref) => ref.slice(prefix.length))
221
+ .filter((/** @type {string} */ id) => id.length > 0);
222
+ }
223
+
224
+ /**
225
+ * Verifies all audit chains for a graph.
226
+ * @param {string} graphName
227
+ * @param {{ since?: string, trustWarning?: TrustWarning|null }} [options]
228
+ * @returns {Promise<VerifyResult>}
229
+ */
230
+ async verifyAll(graphName, options = {}) {
231
+ const writerIds = await this._listWriterIds(graphName);
232
+
233
+ const chains = [];
234
+ for (const writerId of writerIds.sort()) {
235
+ const result = await this.verifyChain(graphName, writerId, { since: options.since });
236
+ chains.push(result);
237
+ }
238
+
239
+ const valid = chains.filter((c) => c.status === STATUS_VALID).length;
240
+ const partial = chains.filter((c) => c.status === STATUS_PARTIAL).length;
241
+ const invalid = chains.length - valid - partial;
242
+
243
+ return {
244
+ graph: graphName,
245
+ verifiedAt: new Date().toISOString(),
246
+ summary: { total: chains.length, valid, partial, invalid },
247
+ chains,
248
+ trustWarning: options.trustWarning ?? null,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Verifies a single audit chain for a writer.
254
+ * @param {string} graphName
255
+ * @param {string} writerId
256
+ * @param {{ since?: string }} [options]
257
+ * @returns {Promise<ChainResult>}
258
+ */
259
+ async verifyChain(graphName, writerId, options = {}) {
260
+ const ref = buildAuditRef(graphName, writerId);
261
+ const since = options.since || null;
262
+
263
+ /** @type {ChainResult} */
264
+ const result = {
265
+ writerId,
266
+ ref,
267
+ status: STATUS_VALID,
268
+ receiptsVerified: 0,
269
+ receiptsScanned: 0,
270
+ tipCommit: null,
271
+ tipAtStart: null,
272
+ genesisCommit: null,
273
+ stoppedAt: null,
274
+ since,
275
+ errors: [],
276
+ warnings: [],
277
+ };
278
+
279
+ // Read tip
280
+ let tip;
281
+ try {
282
+ tip = await this._persistence.readRef(ref);
283
+ } catch {
284
+ // ref doesn't exist — no chain to verify
285
+ return result;
286
+ }
287
+ if (!tip) {
288
+ return result;
289
+ }
290
+
291
+ result.tipCommit = tip;
292
+ result.tipAtStart = tip;
293
+
294
+ // Walk the chain
295
+ await this._walkChain(graphName, writerId, tip, since, result);
296
+
297
+ // Ref-race detection: re-read tip after walk
298
+ await this._checkTipMoved(ref, result);
299
+
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * Walks the chain backward from tip, populating result.
305
+ * @param {string} graphName
306
+ * @param {string} writerId
307
+ * @param {string} tip
308
+ * @param {string|null} since
309
+ * @param {ChainResult} result
310
+ * @returns {Promise<void>}
311
+ * @private
312
+ */
313
+ async _walkChain(graphName, writerId, tip, since, result) {
314
+ let current = tip;
315
+ /** @type {Record<string, *>|null} */ let prevReceipt = null;
316
+ /** @type {number|null} */ let chainOidLen = null;
317
+
318
+ while (current) {
319
+ result.receiptsScanned++;
320
+
321
+ // Read commit info
322
+ let commitInfo;
323
+ try {
324
+ commitInfo = await this._persistence.getNodeInfo(current);
325
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
326
+ this._addError(result, 'MISSING_RECEIPT_BLOB', `Cannot read commit ${current}: ${err?.message}`, current);
327
+ return;
328
+ }
329
+
330
+ // Read and validate receipt
331
+ const receiptResult = await this._readReceipt(current, commitInfo, result);
332
+ if (!receiptResult) {
333
+ return; // error already added
334
+ }
335
+
336
+ const { receipt, decodedTrailers } = receiptResult;
337
+
338
+ // Schema validation (before OID checks — catches missing fields early)
339
+ const schemaErr = validateReceiptSchema(receipt);
340
+ if (schemaErr) {
341
+ this._addError(result, 'RECEIPT_SCHEMA_INVALID', schemaErr, current);
342
+ return;
343
+ }
344
+
345
+ // OID format validation
346
+ if (!this._validateOids(receipt, result, current)) {
347
+ return;
348
+ }
349
+
350
+ // OID length consistency
351
+ const oidLen = receipt.dataCommit.length;
352
+ if (chainOidLen === null) {
353
+ chainOidLen = oidLen;
354
+ } else if (oidLen !== chainOidLen) {
355
+ this._addError(result, 'OID_LENGTH_MISMATCH',
356
+ `OID length changed from ${chainOidLen} to ${oidLen}`, current);
357
+ return;
358
+ }
359
+ if (receipt.prevAuditCommit.length !== oidLen) {
360
+ this._addError(result, 'OID_LENGTH_MISMATCH',
361
+ `prevAuditCommit length ${receipt.prevAuditCommit.length} !== dataCommit length ${oidLen}`, current);
362
+ return;
363
+ }
364
+
365
+ // Trailer consistency
366
+ const trailerErr = validateTrailerConsistency(receipt, decodedTrailers);
367
+ if (trailerErr) {
368
+ this._addError(result, 'TRAILER_MISMATCH', trailerErr, current);
369
+ result.status = STATUS_DATA_MISMATCH;
370
+ return;
371
+ }
372
+
373
+ // Chain linking (against previous receipt, which is the NEXT commit in forward time)
374
+ if (prevReceipt) {
375
+ if (!this._validateChainLink(receipt, prevReceipt, current, result)) {
376
+ return;
377
+ }
378
+ }
379
+
380
+ // Writer/graph consistency
381
+ if (receipt.writerId !== writerId) {
382
+ this._addError(result, 'WRITER_CONSISTENCY',
383
+ `receipt writerId '${receipt.writerId}' !== expected '${writerId}'`, current);
384
+ result.status = STATUS_BROKEN_CHAIN;
385
+ return;
386
+ }
387
+ if (receipt.graphName !== graphName) {
388
+ this._addError(result, 'WRITER_CONSISTENCY',
389
+ `receipt graphName '${receipt.graphName}' !== expected '${graphName}'`, current);
390
+ result.status = STATUS_BROKEN_CHAIN;
391
+ return;
392
+ }
393
+
394
+ result.receiptsVerified++;
395
+
396
+ // --since boundary: stop AFTER verifying this commit
397
+ if (since && current === since) {
398
+ result.stoppedAt = current;
399
+ if (result.errors.length === 0) {
400
+ result.status = STATUS_PARTIAL;
401
+ }
402
+ return;
403
+ }
404
+
405
+ // Genesis check
406
+ const zeroHash = '0'.repeat(oidLen);
407
+ if (receipt.prevAuditCommit === zeroHash) {
408
+ result.genesisCommit = current;
409
+ if (commitInfo.parents.length !== 0) {
410
+ this._addError(result, 'GENESIS_HAS_PARENTS',
411
+ `Genesis commit has ${commitInfo.parents.length} parent(s)`, current);
412
+ result.status = STATUS_BROKEN_CHAIN;
413
+ return;
414
+ }
415
+ // Reached genesis — if --since was specified but not found, error
416
+ if (since) {
417
+ this._addError(result, 'SINCE_NOT_FOUND',
418
+ `Commit ${since} not found in chain`, null);
419
+ result.status = STATUS_ERROR;
420
+ return;
421
+ }
422
+ if (result.errors.length === 0) {
423
+ result.status = STATUS_VALID;
424
+ }
425
+ return;
426
+ }
427
+
428
+ // Continuation check
429
+ if (commitInfo.parents.length !== 1) {
430
+ this._addError(result, 'CONTINUATION_NO_PARENT',
431
+ `Continuation commit has ${commitInfo.parents.length} parent(s), expected 1`, current);
432
+ result.status = STATUS_BROKEN_CHAIN;
433
+ return;
434
+ }
435
+ if (commitInfo.parents[0] !== receipt.prevAuditCommit) {
436
+ this._addError(result, 'GIT_PARENT_MISMATCH',
437
+ `Git parent '${commitInfo.parents[0]}' !== prevAuditCommit '${receipt.prevAuditCommit}'`, current);
438
+ result.status = STATUS_BROKEN_CHAIN;
439
+ return;
440
+ }
441
+
442
+ prevReceipt = receipt;
443
+ current = receipt.prevAuditCommit;
444
+ }
445
+
446
+ // If --since was specified but we reached the end without finding it
447
+ if (since) {
448
+ this._addError(result, 'SINCE_NOT_FOUND',
449
+ `Commit ${since} not found in chain`, null);
450
+ result.status = STATUS_ERROR;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Reads and decodes the receipt from a commit.
456
+ * @param {string} commitSha
457
+ * @param {{ message: string }} commitInfo
458
+ * @param {ChainResult} result
459
+ * @returns {Promise<{ receipt: *, decodedTrailers: * }|null>}
460
+ * @private
461
+ */
462
+ async _readReceipt(commitSha, commitInfo, result) {
463
+ // Read tree
464
+ let treeOid;
465
+ try {
466
+ treeOid = await this._persistence.getCommitTree(commitSha);
467
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
468
+ this._addError(result, 'MISSING_RECEIPT_BLOB',
469
+ `Cannot read tree for ${commitSha}: ${err?.message}`, commitSha);
470
+ return null;
471
+ }
472
+
473
+ // Validate tree structure
474
+ let treeEntries;
475
+ try {
476
+ treeEntries = await this._persistence.readTreeOids(treeOid);
477
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
478
+ this._addError(result, 'RECEIPT_TREE_INVALID',
479
+ `Cannot read tree ${treeOid}: ${err?.message}`, commitSha);
480
+ return null;
481
+ }
482
+
483
+ const entryNames = Object.keys(treeEntries);
484
+ if (entryNames.length !== 1 || entryNames[0] !== 'receipt.cbor') {
485
+ this._addError(result, 'RECEIPT_TREE_INVALID',
486
+ `Expected exactly one entry 'receipt.cbor', got [${entryNames.join(', ')}]`, commitSha);
487
+ result.status = STATUS_BROKEN_CHAIN;
488
+ return null;
489
+ }
490
+
491
+ // Read blob
492
+ const blobOid = treeEntries['receipt.cbor'];
493
+ let blobContent;
494
+ try {
495
+ blobContent = await this._persistence.readBlob(blobOid);
496
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
497
+ this._addError(result, 'MISSING_RECEIPT_BLOB',
498
+ `Cannot read receipt blob ${blobOid}: ${err?.message}`, commitSha);
499
+ return null;
500
+ }
501
+
502
+ // Decode CBOR
503
+ let receipt;
504
+ try {
505
+ receipt = this._codec.decode(blobContent);
506
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
507
+ this._addError(result, 'CBOR_DECODE_FAILED',
508
+ `CBOR decode failed: ${err?.message}`, commitSha);
509
+ result.status = STATUS_ERROR;
510
+ return null;
511
+ }
512
+
513
+ // Decode trailers
514
+ let decodedTrailers;
515
+ try {
516
+ decodedTrailers = decodeAuditMessage(commitInfo.message);
517
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
518
+ this._addError(result, 'TRAILER_MISMATCH',
519
+ `Trailer decode failed: ${err?.message}`, commitSha);
520
+ result.status = STATUS_DATA_MISMATCH;
521
+ return null;
522
+ }
523
+
524
+ return { receipt, decodedTrailers };
525
+ }
526
+
527
+ /**
528
+ * Validates OID format for dataCommit, prevAuditCommit, and opsDigest.
529
+ * @param {*} receipt
530
+ * @param {ChainResult} result
531
+ * @param {string} commitSha
532
+ * @returns {boolean} true if valid
533
+ * @private
534
+ */
535
+ _validateOids(receipt, result, commitSha) {
536
+ const dcCheck = validateOidFormat(receipt.dataCommit);
537
+ if (!dcCheck.valid) {
538
+ this._addError(result, 'OID_FORMAT_INVALID',
539
+ `dataCommit OID invalid: ${dcCheck.error}`, commitSha);
540
+ result.status = STATUS_BROKEN_CHAIN;
541
+ return false;
542
+ }
543
+
544
+ const pacCheck = validateOidFormat(receipt.prevAuditCommit);
545
+ // prevAuditCommit may be all-zeros (genesis sentinel)
546
+ const isZero = /^0+$/.test(receipt.prevAuditCommit);
547
+ if (!pacCheck.valid && !isZero) {
548
+ this._addError(result, 'OID_FORMAT_INVALID',
549
+ `prevAuditCommit OID invalid: ${pacCheck.error}`, commitSha);
550
+ result.status = STATUS_BROKEN_CHAIN;
551
+ return false;
552
+ }
553
+
554
+ return true;
555
+ }
556
+
557
+ /**
558
+ * Validates chain linking between current and previous (newer) receipt.
559
+ * @param {*} currentReceipt - The older receipt being validated
560
+ * @param {*} prevReceipt - The newer receipt (closer to tip)
561
+ * @param {string} commitSha
562
+ * @param {ChainResult} result
563
+ * @returns {boolean} true if valid
564
+ * @private
565
+ */
566
+ _validateChainLink(currentReceipt, prevReceipt, commitSha, result) {
567
+ // Tick monotonicity: walking backward, current tick < prev tick
568
+ if (currentReceipt.tickEnd >= prevReceipt.tickStart) {
569
+ this._addError(result, 'TICK_MONOTONICITY',
570
+ `tick ${currentReceipt.tickEnd} >= previous ${prevReceipt.tickStart}`, commitSha);
571
+ result.status = STATUS_BROKEN_CHAIN;
572
+ return false;
573
+ }
574
+
575
+ // Tick gap warning
576
+ if (currentReceipt.tickEnd + 1 < prevReceipt.tickStart) {
577
+ result.warnings.push({
578
+ code: 'TICK_GAP',
579
+ message: `Gap between tick ${currentReceipt.tickEnd} and ${prevReceipt.tickStart}`,
580
+ });
581
+ }
582
+
583
+ // Writer consistency
584
+ if (currentReceipt.writerId !== prevReceipt.writerId) {
585
+ this._addError(result, 'WRITER_CONSISTENCY',
586
+ `writerId changed from '${currentReceipt.writerId}' to '${prevReceipt.writerId}'`, commitSha);
587
+ result.status = STATUS_BROKEN_CHAIN;
588
+ return false;
589
+ }
590
+
591
+ // Graph consistency
592
+ if (currentReceipt.graphName !== prevReceipt.graphName) {
593
+ this._addError(result, 'WRITER_CONSISTENCY',
594
+ `graphName changed from '${currentReceipt.graphName}' to '${prevReceipt.graphName}'`, commitSha);
595
+ result.status = STATUS_BROKEN_CHAIN;
596
+ return false;
597
+ }
598
+
599
+ return true;
600
+ }
601
+
602
+ /**
603
+ * Checks if the ref tip moved during verification (ref-race detection).
604
+ * @param {string} ref
605
+ * @param {ChainResult} result
606
+ * @returns {Promise<void>}
607
+ * @private
608
+ */
609
+ async _checkTipMoved(ref, result) {
610
+ try {
611
+ const currentTip = await this._persistence.readRef(ref);
612
+ if (currentTip && currentTip !== result.tipAtStart) {
613
+ result.warnings.push({
614
+ code: 'TIP_MOVED_DURING_VERIFY',
615
+ message: `Ref tip moved from ${result.tipAtStart} to ${currentTip} during verification`,
616
+ });
617
+ }
618
+ } catch {
619
+ // If we can't re-read, don't add a warning — it's best-effort
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Adds an error to the result and sets status if not already set.
625
+ * @param {ChainResult} result
626
+ * @param {string} code
627
+ * @param {string} message
628
+ * @param {string|null} commit
629
+ * @private
630
+ */
631
+ _addError(result, code, message, commit) {
632
+ result.errors.push({ code, message, ...(commit ? { commit } : {}) });
633
+ if (result.status === STATUS_VALID || result.status === STATUS_PARTIAL) {
634
+ result.status = STATUS_ERROR;
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Evaluates trust for all writers of a graph using signed evidence.
640
+ *
641
+ * Reads the trust record chain, builds state, discovers writers,
642
+ * and returns a TrustAssessment.
643
+ *
644
+ * @param {string} graphName
645
+ * @param {Object} [options]
646
+ * @param {string} [options.pin] - Pinned trust chain commit SHA
647
+ * @param {string} [options.mode] - Policy mode ('warn' or 'enforce')
648
+ * @returns {Promise<Record<string, *>>}
649
+ */
650
+ async evaluateTrust(graphName, options = {}) {
651
+ const recordService = new TrustRecordService({
652
+ persistence: this._persistence,
653
+ codec: this._codec,
654
+ });
655
+
656
+ const records = await recordService.readRecords(graphName, options.pin ? { tip: options.pin } : {});
657
+
658
+ if (records.length === 0) {
659
+ return {
660
+ trustSchemaVersion: 1,
661
+ mode: 'signed_evidence_v1',
662
+ trustVerdict: 'not_configured',
663
+ trust: {
664
+ status: 'not_configured',
665
+ source: 'none',
666
+ sourceDetail: null,
667
+ evaluatedWriters: [],
668
+ untrustedWriters: [],
669
+ explanations: [],
670
+ evidenceSummary: {
671
+ recordsScanned: 0,
672
+ activeKeys: 0,
673
+ revokedKeys: 0,
674
+ activeBindings: 0,
675
+ revokedBindings: 0,
676
+ },
677
+ },
678
+ };
679
+ }
680
+
681
+ const trustState = buildState(records);
682
+ const writerIds = await this._listWriterIds(graphName);
683
+
684
+ const policy = {
685
+ schemaVersion: 1,
686
+ mode: options.mode ?? 'warn',
687
+ writerPolicy: 'all_writers_must_be_trusted',
688
+ };
689
+
690
+ return evaluateWriters(writerIds, trustState, policy);
691
+ }
692
+ }
693
+