@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,471 @@
1
+ /**
2
+ * AuditReceiptService — persistent, chained, tamper-evident audit receipts.
3
+ *
4
+ * When audit mode is enabled, each data commit produces a corresponding
5
+ * audit commit recording per-operation outcomes. Audit commits form an
6
+ * independent chain per (graphName, writerId) pair, linked via
7
+ * `prevAuditCommit` and Git commit parents.
8
+ *
9
+ * @module domain/services/AuditReceiptService
10
+ * @see docs/specs/AUDIT_RECEIPT.md
11
+ */
12
+
13
+ import { buildAuditRef } from '../utils/RefLayout.js';
14
+ import { encodeAuditMessage } from './AuditMessageCodec.js';
15
+
16
+ // ============================================================================
17
+ // Constants
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Domain-separated prefix for opsDigest computation.
22
+ * The trailing \0 is a literal null byte (U+0000) acting as an
23
+ * unambiguous delimiter between the prefix and the JSON payload.
24
+ * @type {string}
25
+ */
26
+ export const OPS_DIGEST_PREFIX = 'git-warp:opsDigest:v1\0';
27
+
28
+ // ============================================================================
29
+ // Normative Canonicalization Helpers (DO NOT ALTER — tied to spec Sections 5.2-5.3)
30
+ // ============================================================================
31
+
32
+ /**
33
+ * JSON.stringify replacer that sorts object keys lexicographically
34
+ * at every nesting level. Produces canonical JSON per spec Section 5.2.
35
+ *
36
+ * @param {string} _key
37
+ * @param {unknown} value
38
+ * @returns {unknown}
39
+ */
40
+ export function sortedReplacer(_key, value) {
41
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
42
+ const sorted = /** @type {Record<string, unknown>} */ ({});
43
+ const obj = /** @type {Record<string, unknown>} */ (value);
44
+ for (const k of Object.keys(obj).sort()) {
45
+ sorted[k] = obj[k];
46
+ }
47
+ return sorted;
48
+ }
49
+ return value;
50
+ }
51
+
52
+ /**
53
+ * Produces canonical JSON string of an ops array per spec Section 5.2.
54
+ * Exported for testing.
55
+ *
56
+ * @param {ReadonlyArray<Readonly<import('../types/TickReceipt.js').OpOutcome>>} ops
57
+ * @returns {string}
58
+ */
59
+ export function canonicalOpsJson(ops) {
60
+ return JSON.stringify(ops, sortedReplacer);
61
+ }
62
+
63
+ /** @type {TextEncoder} */
64
+ const textEncoder = new TextEncoder();
65
+
66
+ /**
67
+ * Computes the domain-separated SHA-256 opsDigest per spec Section 5.3.
68
+ *
69
+ * @param {ReadonlyArray<Readonly<import('../types/TickReceipt.js').OpOutcome>>} ops
70
+ * @param {import('../../ports/CryptoPort.js').default} crypto - Crypto adapter
71
+ * @returns {Promise<string>} Lowercase hex SHA-256 digest
72
+ */
73
+ export async function computeOpsDigest(ops, crypto) {
74
+ const json = canonicalOpsJson(ops);
75
+ const prefix = textEncoder.encode(OPS_DIGEST_PREFIX);
76
+ const payload = textEncoder.encode(json);
77
+ const combined = new Uint8Array(prefix.length + payload.length);
78
+ combined.set(prefix);
79
+ combined.set(payload, prefix.length);
80
+ return await crypto.hash('sha256', combined);
81
+ }
82
+
83
+ // ============================================================================
84
+ // Receipt Construction
85
+ // ============================================================================
86
+
87
+ /** @type {RegExp} */
88
+ const OID_HEX_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
89
+
90
+ /**
91
+ * Validates and builds a frozen receipt record with keys in sorted order.
92
+ *
93
+ * @param {Object} fields
94
+ * @param {number} fields.version
95
+ * @param {string} fields.graphName
96
+ * @param {string} fields.writerId
97
+ * @param {string} fields.dataCommit
98
+ * @param {number} fields.tickStart
99
+ * @param {number} fields.tickEnd
100
+ * @param {string} fields.opsDigest
101
+ * @param {string} fields.prevAuditCommit
102
+ * @param {number} fields.timestamp
103
+ * @returns {Readonly<Record<string, unknown>>}
104
+ * @throws {Error} If any field is invalid
105
+ */
106
+ export function buildReceiptRecord(fields) {
107
+ const {
108
+ version, graphName, writerId, dataCommit,
109
+ tickStart, tickEnd, opsDigest, prevAuditCommit, timestamp,
110
+ } = fields;
111
+
112
+ // version
113
+ if (version !== 1) {
114
+ throw new Error(`Invalid version: must be 1, got ${version}`);
115
+ }
116
+
117
+ // graphName — validated by RefLayout
118
+ if (typeof graphName !== 'string' || graphName.length === 0) {
119
+ throw new Error('Invalid graphName: must be a non-empty string');
120
+ }
121
+
122
+ // writerId — validated by RefLayout
123
+ if (typeof writerId !== 'string' || writerId.length === 0) {
124
+ throw new Error('Invalid writerId: must be a non-empty string');
125
+ }
126
+
127
+ // dataCommit
128
+ const dc = dataCommit.toLowerCase();
129
+ if (!OID_HEX_PATTERN.test(dc)) {
130
+ throw new Error(`Invalid dataCommit OID: ${dataCommit}`);
131
+ }
132
+
133
+ // opsDigest
134
+ const od = opsDigest.toLowerCase();
135
+ if (!/^[0-9a-f]{64}$/.test(od)) {
136
+ throw new Error(`Invalid opsDigest: must be 64-char lowercase hex, got ${opsDigest}`);
137
+ }
138
+
139
+ // prevAuditCommit
140
+ const pac = prevAuditCommit.toLowerCase();
141
+ if (!OID_HEX_PATTERN.test(pac)) {
142
+ throw new Error(`Invalid prevAuditCommit OID: ${prevAuditCommit}`);
143
+ }
144
+
145
+ // OID length consistency
146
+ const oidLen = dc.length;
147
+ if (pac.length !== oidLen) {
148
+ throw new Error(`OID length mismatch: dataCommit=${dc.length}, prevAuditCommit=${pac.length}`);
149
+ }
150
+
151
+ // tick constraints
152
+ if (!Number.isInteger(tickStart) || tickStart < 1) {
153
+ throw new Error(`Invalid tickStart: must be integer >= 1, got ${tickStart}`);
154
+ }
155
+ if (!Number.isInteger(tickEnd) || tickEnd < tickStart) {
156
+ throw new Error(`Invalid tickEnd: must be integer >= tickStart, got ${tickEnd}`);
157
+ }
158
+ if (version === 1 && tickStart !== tickEnd) {
159
+ throw new Error(`v1 requires tickStart === tickEnd, got ${tickStart} !== ${tickEnd}`);
160
+ }
161
+
162
+ // Zero-hash sentinel only for genesis (tickStart === 1)
163
+ const zeroHash = '0'.repeat(oidLen);
164
+ if (pac === zeroHash && tickStart > 1) {
165
+ throw new Error('Non-genesis receipt cannot use zero-hash sentinel');
166
+ }
167
+
168
+ // timestamp
169
+ if (!Number.isInteger(timestamp) || timestamp < 0) {
170
+ throw new Error(`Invalid timestamp: must be non-negative safe integer, got ${timestamp}`);
171
+ }
172
+ if (!Number.isSafeInteger(timestamp)) {
173
+ throw new Error(`Invalid timestamp: exceeds Number.MAX_SAFE_INTEGER: ${timestamp}`);
174
+ }
175
+
176
+ // Build with keys in sorted order (canonical for CBOR)
177
+ return Object.freeze({
178
+ dataCommit: dc,
179
+ graphName,
180
+ opsDigest: od,
181
+ prevAuditCommit: pac,
182
+ tickEnd,
183
+ tickStart,
184
+ timestamp,
185
+ version,
186
+ writerId,
187
+ });
188
+ }
189
+
190
+ // ============================================================================
191
+ // Service
192
+ // ============================================================================
193
+
194
+ /**
195
+ * AuditReceiptService manages the audit receipt chain for a single writer.
196
+ *
197
+ * ## Lifecycle
198
+ * 1. Construct with dependencies
199
+ * 2. Call `init()` to read the current audit ref tip
200
+ * 3. Call `commit(tickReceipt)` after each data commit succeeds
201
+ *
202
+ * ## Error handling
203
+ * All errors are caught, logged with structured codes, and never propagated.
204
+ * The data commit has already succeeded — audit failures create gaps that
205
+ * are detectable by M4 verification.
206
+ */
207
+ export class AuditReceiptService {
208
+ /**
209
+ * @param {Object} options
210
+ * @param {import('../../ports/RefPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default & import('../../ports/CommitPort.js').default} options.persistence
211
+ * @param {string} options.graphName
212
+ * @param {string} options.writerId
213
+ * @param {import('../../ports/CodecPort.js').default} options.codec
214
+ * @param {import('../../ports/CryptoPort.js').default} options.crypto
215
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger]
216
+ */
217
+ constructor({ persistence, graphName, writerId, codec, crypto, logger }) {
218
+ this._persistence = persistence;
219
+ this._graphName = graphName;
220
+ this._writerId = writerId;
221
+ this._codec = codec;
222
+ this._crypto = crypto;
223
+ this._logger = logger || null;
224
+ this._auditRef = buildAuditRef(graphName, writerId);
225
+
226
+ /** @type {string|null} Previous audit commit SHA (null = genesis) */
227
+ this._prevAuditCommit = null;
228
+
229
+ /** @type {string|null} Expected old ref value for CAS (null = ref doesn't exist) */
230
+ this._expectedOldRef = null;
231
+
232
+ /** @type {boolean} If true, service is degraded — skip all commits */
233
+ this._degraded = false;
234
+
235
+ /** @type {boolean} If true, currently retrying — prevents recursive retry */
236
+ this._retrying = false;
237
+
238
+ // Stats
239
+ this._committed = 0;
240
+ this._skipped = 0;
241
+ this._failed = 0;
242
+ }
243
+
244
+ /**
245
+ * Initializes the service by reading the current audit ref tip.
246
+ * Must be called before `commit()`.
247
+ * @returns {Promise<void>}
248
+ */
249
+ async init() {
250
+ try {
251
+ const tip = await this._persistence.readRef(this._auditRef);
252
+ if (tip) {
253
+ this._prevAuditCommit = tip;
254
+ this._expectedOldRef = tip;
255
+ // We don't know the tick counter from a cold start without walking the chain.
256
+ // Use 0 and let the first commit set it from the lamport clock.
257
+ }
258
+ } catch {
259
+ // Log so operators see unexpected cold starts, then start fresh
260
+ this._logger?.warn('[warp:audit]', {
261
+ code: 'AUDIT_INIT_READ_FAILED',
262
+ writerId: this._writerId,
263
+ ref: this._auditRef,
264
+ });
265
+ this._prevAuditCommit = null;
266
+ this._expectedOldRef = null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Creates an audit commit for the given tick receipt.
272
+ *
273
+ * DESIGN NOTE: Data commit has already succeeded at this point.
274
+ * If audit commit fails, the data is persisted but the audit chain
275
+ * has a gap. This is acceptable by design in M3 — gaps are detected
276
+ * by M4 verification coverage rules (receipt count vs data commit count).
277
+ *
278
+ * @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
279
+ * @returns {Promise<string|null>} The audit commit SHA, or null on failure
280
+ */
281
+ async commit(tickReceipt) {
282
+ if (this._degraded) {
283
+ this._skipped++;
284
+ this._logger?.warn('[warp:audit]', {
285
+ code: 'AUDIT_DEGRADED_ACTIVE',
286
+ writerId: this._writerId,
287
+ });
288
+ return null;
289
+ }
290
+
291
+ try {
292
+ return await this._commitInner(tickReceipt);
293
+ } catch (err) {
294
+ this._failed++;
295
+ this._logger?.warn('[warp:audit]', {
296
+ code: 'AUDIT_COMMIT_FAILED',
297
+ writerId: this._writerId,
298
+ error: err instanceof Error ? err.message : String(err),
299
+ });
300
+ return null;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Returns audit stats for coverage probing.
306
+ * @returns {{ committed: number, skipped: number, failed: number, degraded: boolean }}
307
+ */
308
+ getStats() {
309
+ return {
310
+ committed: this._committed,
311
+ skipped: this._skipped,
312
+ failed: this._failed,
313
+ degraded: this._degraded,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Inner commit logic. Throws on failure (caught by `commit()`).
319
+ * @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
320
+ * @returns {Promise<string>}
321
+ * @private
322
+ */
323
+ async _commitInner(tickReceipt) {
324
+ const { patchSha, writer, lamport, ops } = tickReceipt;
325
+
326
+ // Guard: reject cross-writer attribution
327
+ if (writer !== this._writerId) {
328
+ this._logger?.warn('[warp:audit]', {
329
+ code: 'AUDIT_WRITER_MISMATCH',
330
+ expected: this._writerId,
331
+ actual: writer,
332
+ patchSha,
333
+ });
334
+ throw new Error(
335
+ `Audit writer mismatch: expected '${this._writerId}', got '${writer}'`,
336
+ );
337
+ }
338
+
339
+ // Compute opsDigest
340
+ const opsDigest = await computeOpsDigest(ops, this._crypto);
341
+
342
+ // Timestamp
343
+ const timestamp = Date.now();
344
+
345
+ // Determine prevAuditCommit
346
+ const oidLen = patchSha.length;
347
+ const prevAuditCommit = this._prevAuditCommit || '0'.repeat(oidLen);
348
+
349
+ // Build receipt record
350
+ const receipt = buildReceiptRecord({
351
+ version: 1,
352
+ graphName: this._graphName,
353
+ writerId: writer,
354
+ dataCommit: patchSha,
355
+ tickStart: lamport,
356
+ tickEnd: lamport,
357
+ opsDigest,
358
+ prevAuditCommit,
359
+ timestamp,
360
+ });
361
+
362
+ // Encode to CBOR
363
+ const cborBytes = this._codec.encode(receipt);
364
+
365
+ // Write blob
366
+ let blobOid;
367
+ try {
368
+ blobOid = await this._persistence.writeBlob(Buffer.from(cborBytes));
369
+ } catch (err) {
370
+ this._logger?.warn('[warp:audit]', {
371
+ code: 'AUDIT_WRITE_BLOB_FAILED',
372
+ writerId: this._writerId,
373
+ error: err instanceof Error ? err.message : String(err),
374
+ });
375
+ throw err;
376
+ }
377
+
378
+ // Write tree
379
+ let treeOid;
380
+ try {
381
+ treeOid = await this._persistence.writeTree([
382
+ `100644 blob ${blobOid}\treceipt.cbor`,
383
+ ]);
384
+ } catch (err) {
385
+ this._logger?.warn('[warp:audit]', {
386
+ code: 'AUDIT_WRITE_TREE_FAILED',
387
+ writerId: this._writerId,
388
+ error: err instanceof Error ? err.message : String(err),
389
+ });
390
+ throw err;
391
+ }
392
+
393
+ // Encode commit message with trailers
394
+ const message = encodeAuditMessage({
395
+ graph: this._graphName,
396
+ writer,
397
+ dataCommit: patchSha.toLowerCase(),
398
+ opsDigest,
399
+ });
400
+
401
+ // Determine parents
402
+ const parents = this._prevAuditCommit ? [this._prevAuditCommit] : [];
403
+
404
+ // Create commit
405
+ const commitSha = await this._persistence.commitNodeWithTree({
406
+ treeOid,
407
+ parents,
408
+ message,
409
+ });
410
+
411
+ // CAS ref update
412
+ try {
413
+ await this._persistence.compareAndSwapRef(
414
+ this._auditRef,
415
+ commitSha,
416
+ this._expectedOldRef,
417
+ );
418
+ } catch {
419
+ if (this._retrying) {
420
+ // Second CAS failure during retry → degrade
421
+ throw new Error('CAS failed during retry');
422
+ }
423
+ // CAS mismatch — retry once with refreshed tip
424
+ return await this._retryAfterCasConflict(commitSha, tickReceipt);
425
+ }
426
+
427
+ // Success — update cached state
428
+ this._prevAuditCommit = commitSha;
429
+ this._expectedOldRef = commitSha;
430
+ this._committed++;
431
+ return commitSha;
432
+ }
433
+
434
+ /**
435
+ * Retry-once after CAS conflict. Reads fresh tip, rebuilds receipt, retries.
436
+ * @param {string} _failedCommitSha - The commit that failed CAS (unused, for logging)
437
+ * @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
438
+ * @returns {Promise<string>}
439
+ * @private
440
+ */
441
+ async _retryAfterCasConflict(_failedCommitSha, tickReceipt) {
442
+ this._logger?.warn('[warp:audit]', {
443
+ code: 'AUDIT_REF_CAS_CONFLICT',
444
+ writerId: this._writerId,
445
+ ref: this._auditRef,
446
+ });
447
+
448
+ // Read fresh tip
449
+ const freshTip = await this._persistence.readRef(this._auditRef);
450
+ this._prevAuditCommit = freshTip;
451
+ this._expectedOldRef = freshTip;
452
+
453
+ // Rebuild and retry (with guard against recursive retry)
454
+ this._retrying = true;
455
+ try {
456
+ const result = await this._commitInner(tickReceipt);
457
+ return result;
458
+ } catch {
459
+ // Second failure → degraded mode
460
+ this._degraded = true;
461
+ this._logger?.warn('[warp:audit]', {
462
+ code: 'AUDIT_DEGRADED_ACTIVE',
463
+ writerId: this._writerId,
464
+ reason: 'second CAS failure',
465
+ });
466
+ throw new Error('Audit service degraded after second CAS failure');
467
+ } finally {
468
+ this._retrying = false;
469
+ }
470
+ }
471
+ }