@git-stunts/git-warp 10.8.0 → 11.3.3

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