@git-stunts/git-warp 12.1.0 → 12.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 (54) hide show
  1. package/README.md +8 -4
  2. package/bin/cli/commands/trust.js +37 -1
  3. package/bin/cli/infrastructure.js +14 -1
  4. package/bin/cli/schemas.js +4 -4
  5. package/bin/warp-graph.js +9 -2
  6. package/index.d.ts +18 -2
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +4 -1
  9. package/src/domain/crdt/Dot.js +5 -0
  10. package/src/domain/crdt/LWW.js +3 -1
  11. package/src/domain/crdt/ORSet.js +63 -27
  12. package/src/domain/crdt/VersionVector.js +12 -0
  13. package/src/domain/errors/PatchError.js +27 -0
  14. package/src/domain/errors/StorageError.js +8 -0
  15. package/src/domain/errors/SyncError.js +1 -0
  16. package/src/domain/errors/TrustError.js +2 -0
  17. package/src/domain/errors/WriterError.js +5 -0
  18. package/src/domain/errors/index.js +1 -0
  19. package/src/domain/services/AuditVerifierService.js +32 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/CheckpointService.js +12 -8
  22. package/src/domain/services/Frontier.js +18 -0
  23. package/src/domain/services/GCPolicy.js +25 -4
  24. package/src/domain/services/GraphTraversal.js +11 -50
  25. package/src/domain/services/HttpSyncServer.js +18 -29
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +164 -31
  28. package/src/domain/services/MaterializedViewService.js +13 -2
  29. package/src/domain/services/PatchBuilderV2.js +210 -145
  30. package/src/domain/services/QueryBuilder.js +67 -30
  31. package/src/domain/services/SyncController.js +62 -18
  32. package/src/domain/services/SyncPayloadSchema.js +236 -0
  33. package/src/domain/services/SyncProtocol.js +102 -40
  34. package/src/domain/services/SyncTrustGate.js +146 -0
  35. package/src/domain/services/TranslationCost.js +2 -2
  36. package/src/domain/trust/TrustRecordService.js +161 -34
  37. package/src/domain/utils/CachedValue.js +34 -5
  38. package/src/domain/utils/EventId.js +4 -1
  39. package/src/domain/utils/LRUCache.js +3 -1
  40. package/src/domain/utils/RefLayout.js +4 -0
  41. package/src/domain/utils/canonicalStringify.js +48 -18
  42. package/src/domain/utils/matchGlob.js +7 -0
  43. package/src/domain/warp/PatchSession.js +30 -24
  44. package/src/domain/warp/Writer.js +12 -5
  45. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  46. package/src/domain/warp/checkpoint.methods.js +102 -16
  47. package/src/domain/warp/materialize.methods.js +47 -5
  48. package/src/domain/warp/materializeAdvanced.methods.js +52 -10
  49. package/src/domain/warp/patch.methods.js +24 -8
  50. package/src/domain/warp/query.methods.js +4 -4
  51. package/src/domain/warp/subscribe.methods.js +11 -19
  52. package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
  53. package/src/infrastructure/codecs/CborCodec.js +2 -0
  54. package/src/domain/utils/fnv1a.js +0 -20
@@ -37,8 +37,10 @@
37
37
  */
38
38
 
39
39
  import defaultCodec from '../utils/defaultCodec.js';
40
+ import nullLogger from '../utils/nullLogger.js';
40
41
  import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
41
- import { join, cloneStateV5 } from './JoinReducer.js';
42
+ import { join, cloneStateV5, isKnownOp } from './JoinReducer.js';
43
+ import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js';
42
44
  import { cloneFrontier, updateFrontier } from './Frontier.js';
43
45
  import { vvDeserialize } from '../crdt/VersionVector.js';
44
46
 
@@ -80,6 +82,33 @@ function normalizePatch(patch) {
80
82
  return patch;
81
83
  }
82
84
 
85
+ /**
86
+ * Converts a frontier Map to a plain object for JSON serialization.
87
+ *
88
+ * @param {Map<string, string>} map - Frontier as Map<writerId, sha>
89
+ * @returns {{ [x: string]: string }} Plain object representation
90
+ * @private
91
+ */
92
+ function frontierToObject(map) {
93
+ /** @type {{ [x: string]: string }} */
94
+ const obj = {};
95
+ for (const [writerId, sha] of map) {
96
+ obj[writerId] = sha;
97
+ }
98
+ return obj;
99
+ }
100
+
101
+ /**
102
+ * Converts a frontier plain object back to a Map.
103
+ *
104
+ * @param {{ [x: string]: string }} obj - Frontier as plain object
105
+ * @returns {Map<string, string>} Frontier as Map<writerId, sha>
106
+ * @private
107
+ */
108
+ function objectToFrontier(obj) {
109
+ return new Map(Object.entries(obj));
110
+ }
111
+
83
112
  /**
84
113
  * Loads a patch from a commit.
85
114
  *
@@ -251,7 +280,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
251
280
  newWritersForLocal.push(writerId);
252
281
  } else if (localSha !== remoteSha) {
253
282
  // Different heads - local needs patches from its head to remote head
254
- // Note: We assume remote is ahead; the caller should verify ancestry
283
+ // Direction is intentionally deferred: ancestry is verified by
284
+ // isAncestor() pre-check or loadPatchRange() in processSyncRequest()
255
285
  needFromRemote.set(writerId, { from: localSha, to: remoteSha });
256
286
  }
257
287
  // If localSha === remoteSha, already in sync for this writer
@@ -267,11 +297,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
267
297
  newWritersForRemote.push(writerId);
268
298
  } else if (remoteSha !== localSha) {
269
299
  // Different heads - remote might need patches from its head to local head
270
- // Only add if not already in needFromRemote (avoid double-counting)
271
- // This handles the case where local is ahead of remote
272
- if (!needFromRemote.has(writerId)) {
273
- needFromLocal.set(writerId, { from: remoteSha, to: localSha });
274
- }
300
+ // Always add both directions ancestry is verified during loadPatchRange()
301
+ // which will throw E_SYNC_DIVERGENCE if neither side descends from the other (S3)
302
+ needFromLocal.set(writerId, { from: remoteSha, to: localSha });
275
303
  }
276
304
  }
277
305
 
@@ -315,6 +343,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
315
343
  * - `writerId`: The writer who created this patch
316
344
  * - `sha`: The commit SHA this patch came from (for frontier updates)
317
345
  * - `patch`: The decoded patch object with ops and context
346
+ * @property {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} [skippedWriters] - Writers that were skipped during sync
347
+ * (e.g. due to trust gate filtering, divergence, or missing refs)
318
348
  */
319
349
 
320
350
  /**
@@ -338,16 +368,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
338
368
  * // Send over HTTP: await fetch(url, { body: JSON.stringify(request) })
339
369
  */
340
370
  export function createSyncRequest(frontier) {
341
- // Convert Map to plain object for serialization
342
- /** @type {{ [x: string]: string }} */
343
- const frontierObj = {};
344
- for (const [writerId, sha] of frontier) {
345
- frontierObj[writerId] = sha;
346
- }
347
-
348
371
  return {
349
372
  type: /** @type {'sync-request'} */ ('sync-request'),
350
- frontier: frontierObj,
373
+ frontier: frontierToObject(frontier),
351
374
  };
352
375
  }
353
376
 
@@ -375,6 +398,7 @@ export function createSyncRequest(frontier) {
375
398
  * @param {string} graphName - Graph name for error messages and logging
376
399
  * @param {Object} [options]
377
400
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
401
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for divergence warnings
378
402
  * @returns {Promise<SyncResponse>} Response containing local frontier and patches.
379
403
  * Patches are ordered chronologically within each writer.
380
404
  * @throws {Error} If patch loading fails for reasons other than divergence
@@ -388,18 +412,44 @@ export function createSyncRequest(frontier) {
388
412
  * res.json(response);
389
413
  * });
390
414
  */
391
- export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {{ codec?: import('../../ports/CodecPort.js').default }} */ ({})) {
392
- // Convert incoming frontier from object to Map
393
- const remoteFrontier = new Map(Object.entries(request.frontier));
415
+ export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec, logger } = /** @type {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ ({})) {
416
+ const log = logger || nullLogger;
417
+
418
+ const remoteFrontier = objectToFrontier(request.frontier);
394
419
 
395
420
  // Compute what the requester needs
396
421
  const delta = computeSyncDelta(remoteFrontier, localFrontier);
397
422
 
398
423
  // Load patches that the requester needs (from local to requester)
399
424
  const patches = [];
425
+ /** @type {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} */
426
+ const skippedWriters = [];
400
427
 
401
428
  for (const [writerId, range] of delta.needFromRemote) {
402
429
  try {
430
+ // Pre-check ancestry to avoid expensive chain walk (B107 / S3 fix).
431
+ // If the persistence layer provides isAncestor, use it to detect
432
+ // divergence early without walking the full commit chain.
433
+ const hasIsAncestor = typeof /** @type {{isAncestor?: (...args: unknown[]) => unknown}} */ (persistence).isAncestor === 'function';
434
+ if (range.from && hasIsAncestor) {
435
+ const isAnc = await /** @type {{isAncestor: (a: string, b: string) => Promise<boolean>}} */ (/** @type {unknown} */ (persistence)).isAncestor(range.from, range.to);
436
+ if (!isAnc) {
437
+ const entry = {
438
+ writerId,
439
+ reason: 'E_SYNC_DIVERGENCE',
440
+ localSha: range.to,
441
+ remoteSha: range.from,
442
+ };
443
+ skippedWriters.push(entry);
444
+ log.warn('Sync divergence detected — skipping writer', {
445
+ code: 'E_SYNC_DIVERGENCE',
446
+ graphName,
447
+ ...entry,
448
+ });
449
+ continue;
450
+ }
451
+ }
452
+
403
453
  const writerPatches = await loadPatchRange(
404
454
  persistence,
405
455
  graphName,
@@ -413,26 +463,32 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
413
463
  patches.push({ writerId, sha, patch });
414
464
  }
415
465
  } catch (err) {
416
- // If we detect divergence, skip this writer
417
- // The requester may need to handle this separately
466
+ // If we detect divergence, log and skip this writer (B65).
467
+ // The requester will not receive patches for this writer.
418
468
  if ((err instanceof Error && 'code' in err && /** @type {{ code: string }} */ (err).code === 'E_SYNC_DIVERGENCE') || (err instanceof Error && err.message?.includes('Divergence detected'))) {
469
+ const entry = {
470
+ writerId,
471
+ reason: 'E_SYNC_DIVERGENCE',
472
+ localSha: range.to,
473
+ remoteSha: range.from ?? '',
474
+ };
475
+ skippedWriters.push(entry);
476
+ log.warn('Sync divergence detected — skipping writer', {
477
+ code: 'E_SYNC_DIVERGENCE',
478
+ graphName,
479
+ ...entry,
480
+ });
419
481
  continue;
420
482
  }
421
483
  throw err;
422
484
  }
423
485
  }
424
486
 
425
- // Convert local frontier to plain object
426
- /** @type {{ [x: string]: string }} */
427
- const frontierObj = {};
428
- for (const [writerId, sha] of localFrontier) {
429
- frontierObj[writerId] = sha;
430
- }
431
-
432
487
  return {
433
488
  type: /** @type {'sync-response'} */ ('sync-response'),
434
- frontier: frontierObj,
489
+ frontier: frontierToObject(localFrontier),
435
490
  patches,
491
+ skippedWriters,
436
492
  };
437
493
  }
438
494
 
@@ -484,7 +540,10 @@ export function applySyncResponse(response, state, frontier) {
484
540
  const newFrontier = cloneFrontier(frontier);
485
541
  let applied = 0;
486
542
 
487
- // Group patches by writer to ensure proper ordering
543
+ // Patches arrive pre-grouped by writer from the sync response. This
544
+ // re-grouping is defensive — it handles edge cases where patches from
545
+ // multiple writers arrive interleaved (e.g., from a relay that merges
546
+ // streams).
488
547
  const patchesByWriter = new Map();
489
548
  for (const { writerId, sha, patch } of response.patches) {
490
549
  if (!patchesByWriter.has(writerId)) {
@@ -499,10 +558,19 @@ export function applySyncResponse(response, state, frontier) {
499
558
  for (const { sha, patch } of writerPatches) {
500
559
  // Normalize patch context (in case it came from network serialization)
501
560
  const normalizedPatch = normalizePatch(patch);
502
- // Guard: reject patches containing ops we don't understand.
503
- // Currently SCHEMA_V3 is the max, so this is a no-op for this
504
- // codebase. If a future schema adds new op types, this check
505
- // will prevent silent data loss until the reader is upgraded.
561
+ // Guard: reject patches with genuinely unknown op types (B106 / C2 fix).
562
+ // This prevents silent data loss when a newer writer sends ops we
563
+ // don't recognise fail closed rather than silently ignoring.
564
+ for (const op of normalizedPatch.ops) {
565
+ if (!isKnownOp(op)) {
566
+ throw new SchemaUnsupportedError(
567
+ `Patch ${sha} contains unknown op type: ${op.type}`
568
+ );
569
+ }
570
+ }
571
+ // Guard: reject patches exceeding our maximum supported schema version.
572
+ // isKnownOp() above checks op-type recognition; this checks the schema
573
+ // version ceiling. Currently SCHEMA_V3 is the max.
506
574
  assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
507
575
  // Apply patch to state
508
576
  join(newState, /** @type {Parameters<typeof join>[1]} */ (normalizedPatch), sha);
@@ -590,15 +658,9 @@ export function syncNeeded(localFrontier, remoteFrontier) {
590
658
  * }
591
659
  */
592
660
  export function createEmptySyncResponse(frontier) {
593
- /** @type {{ [x: string]: string }} */
594
- const frontierObj = {};
595
- for (const [writerId, sha] of frontier) {
596
- frontierObj[writerId] = sha;
597
- }
598
-
599
661
  return {
600
662
  type: /** @type {'sync-response'} */ ('sync-response'),
601
- frontier: frontierObj,
663
+ frontier: frontierToObject(frontier),
602
664
  patches: [],
603
665
  };
604
666
  }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * SyncTrustGate -- Encapsulates trust evaluation for sync operations.
3
+ *
4
+ * Evaluates whether inbound patch authors are trusted according to the
5
+ * trust record chain. Used by SyncController to validate HTTP sync
6
+ * responses before applying patches.
7
+ *
8
+ * Trust-gates on `writersApplied` (patch authors being ingested), not
9
+ * frontier keys (which are claims, not effects).
10
+ *
11
+ * @module domain/services/SyncTrustGate
12
+ * @see B1 -- Signed sync ingress
13
+ */
14
+
15
+ import nullLogger from '../utils/nullLogger.js';
16
+
17
+ /**
18
+ * @typedef {'enforce'|'log-only'|'off'} TrustMode
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} TrustGateResult
23
+ * @property {boolean} allowed - Whether the writers are trusted
24
+ * @property {string[]} untrustedWriters - Writers that failed trust evaluation
25
+ * @property {string} verdict - Human-readable verdict
26
+ */
27
+
28
+ /** @type {() => TrustGateResult} */
29
+ const PASS = () => ({ allowed: true, untrustedWriters: [], verdict: 'pass' });
30
+
31
+ export default class SyncTrustGate {
32
+ /**
33
+ * @param {Object} options
34
+ * @param {{evaluateWriters: (writerIds: string[]) => Promise<{trusted: Set<string>}>}} [options.trustEvaluator] - Trust evaluator instance
35
+ * @param {TrustMode} [options.trustMode='off'] - Trust enforcement mode
36
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger
37
+ */
38
+ constructor({ trustEvaluator, trustMode = 'off', logger } = {}) {
39
+ this._evaluator = trustEvaluator || null;
40
+ this._mode = trustMode;
41
+ this._logger = logger || nullLogger;
42
+ }
43
+
44
+ /**
45
+ * Evaluates whether the given patch writers are trusted.
46
+ *
47
+ * @param {string[]} writerIds - Writer IDs from patches being applied
48
+ * @param {Object} [context] - Additional context for logging
49
+ * @param {string} [context.graphName] - Graph name
50
+ * @param {string} [context.peerId] - Remote peer identity (if authenticated)
51
+ * @returns {Promise<TrustGateResult>}
52
+ */
53
+ async evaluate(writerIds, context = {}) {
54
+ if (this._mode === 'off' || !this._evaluator) {
55
+ return { allowed: true, untrustedWriters: [], verdict: 'trust_disabled' };
56
+ }
57
+ if (writerIds.length === 0) {
58
+ return { allowed: true, untrustedWriters: [], verdict: 'no_writers' };
59
+ }
60
+
61
+ try {
62
+ const result = await this._evaluator.evaluateWriters(writerIds);
63
+ const untrusted = writerIds.filter((id) => !result.trusted.has(id));
64
+ return this._decide(untrusted, writerIds, context);
65
+ } catch (err) {
66
+ return this._handleError(err, writerIds, context);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Decides the gate result based on untrusted writers and mode.
72
+ * @param {string[]} untrusted
73
+ * @param {string[]} writerIds
74
+ * @param {Object} context
75
+ * @returns {TrustGateResult}
76
+ * @private
77
+ */
78
+ _decide(untrusted, writerIds, context) {
79
+ this._logger.info('Trust gate decision', {
80
+ code: 'SYNC_TRUST_GATE',
81
+ mode: this._mode,
82
+ writersApplied: writerIds,
83
+ untrustedWriters: untrusted,
84
+ verdict: untrusted.length === 0 ? 'pass' : 'fail',
85
+ ...context,
86
+ });
87
+
88
+ if (untrusted.length === 0) {
89
+ return PASS();
90
+ }
91
+
92
+ if (this._mode === 'enforce') {
93
+ this._logger.warn('Trust gate rejected untrusted writers', {
94
+ code: 'SYNC_TRUST_REJECTED',
95
+ untrustedWriters: untrusted,
96
+ ...context,
97
+ });
98
+ return { allowed: false, untrustedWriters: untrusted, verdict: 'rejected' };
99
+ }
100
+
101
+ this._logger.warn('Trust gate: untrusted writers allowed (log-only mode)', {
102
+ code: 'SYNC_TRUST_WARN',
103
+ untrustedWriters: untrusted,
104
+ ...context,
105
+ });
106
+ return { allowed: true, untrustedWriters: untrusted, verdict: 'warn_allowed' };
107
+ }
108
+
109
+ /**
110
+ * Handles trust evaluation errors with fail-open/fail-closed semantics.
111
+ * @param {unknown} err
112
+ * @param {string[]} writerIds
113
+ * @param {Object} context
114
+ * @returns {TrustGateResult}
115
+ * @private
116
+ */
117
+ _handleError(err, writerIds, context) {
118
+ this._logger.error('Trust gate evaluation failed', {
119
+ code: 'SYNC_TRUST_ERROR',
120
+ error: err instanceof Error ? err.message : String(err),
121
+ ...context,
122
+ });
123
+
124
+ if (this._mode === 'enforce') {
125
+ return { allowed: false, untrustedWriters: writerIds, verdict: 'error_rejected' };
126
+ }
127
+ return { allowed: true, untrustedWriters: [], verdict: 'error_allowed' };
128
+ }
129
+
130
+ /**
131
+ * Extracts writer IDs from patches in a sync response.
132
+ * These are the actual data authors being ingested — the trust target.
133
+ *
134
+ * @param {Array<{writerId: string}>} patches - Patches from sync response
135
+ * @returns {string[]} Deduplicated writer IDs
136
+ */
137
+ static extractWritersFromPatches(patches) {
138
+ const writers = new Set();
139
+ for (const { writerId } of patches) {
140
+ if (writerId) {
141
+ writers.add(writerId);
142
+ }
143
+ }
144
+ return [...writers];
145
+ }
146
+ }
@@ -182,10 +182,10 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
182
182
  */
183
183
  export function computeTranslationCost(configA, configB, state) {
184
184
  /** @param {unknown} m */
185
- const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
185
+ const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.length > 0 && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
186
186
  if (!configA || !isValidMatch(configA.match) ||
187
187
  !configB || !isValidMatch(configB.match)) {
188
- throw new Error('configA.match and configB.match must be strings or arrays of strings');
188
+ throw new Error('configA.match and configB.match must be non-empty strings or non-empty arrays of strings');
189
189
  }
190
190
  const allNodes = [...orsetElements(state.nodeAlive)];
191
191
  const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
@@ -14,11 +14,22 @@ import { TrustRecordSchema } from './schemas.js';
14
14
  import { verifyRecordId } from './TrustCanonical.js';
15
15
  import TrustError from '../errors/TrustError.js';
16
16
 
17
+ /**
18
+ * Maximum CAS attempts for _persistRecord before giving up.
19
+ * Handles transient failures (lock contention, I/O race).
20
+ * @type {number}
21
+ */
22
+ const MAX_CAS_ATTEMPTS = 3;
23
+
17
24
  /**
18
25
  * @typedef {Object} AppendOptions
19
26
  * @property {boolean} [skipSignatureVerify=false] - Skip signature verification (for testing)
20
27
  */
21
28
 
29
+ /**
30
+ * @typedef {{ok: true, records: Array<Record<string, unknown>>} | {ok: false, error: Error}} ReadRecordsResult
31
+ */
32
+
22
33
  export class TrustRecordService {
23
34
  /**
24
35
  * @param {Object} options
@@ -95,48 +106,65 @@ export class TrustRecordService {
95
106
  * @param {string} graphName
96
107
  * @param {Object} [options]
97
108
  * @param {string} [options.tip] - Override tip commit (for pinned reads)
98
- * @returns {Promise<Array<Record<string, unknown>>>}
109
+ * @returns {Promise<ReadRecordsResult>}
99
110
  */
100
111
  async readRecords(graphName, options = {}) {
101
112
  const ref = buildTrustRecordRef(graphName);
102
113
  let tip = options.tip ?? null;
103
114
 
104
- if (!tip) {
105
- try {
106
- tip = await this._persistence.readRef(ref);
107
- } catch {
108
- return [];
109
- }
115
+ try {
110
116
  if (!tip) {
111
- return [];
117
+ try {
118
+ tip = await this._persistence.readRef(ref);
119
+ } catch (err) {
120
+ // Distinguish "ref not found" from operational error (J15)
121
+ if (err instanceof Error && (err.message?.includes('not found') || err.message?.includes('does not exist'))) {
122
+ return { ok: true, records: [] };
123
+ }
124
+ return {
125
+ ok: false,
126
+ error: new TrustError(
127
+ `Failed to read trust chain ref: ${err instanceof Error ? err.message : String(err)}`,
128
+ { code: 'E_TRUST_READ_FAILED' },
129
+ ),
130
+ };
131
+ }
132
+ if (!tip) {
133
+ return { ok: true, records: [] };
134
+ }
112
135
  }
113
- }
114
136
 
115
- const records = [];
116
- let current = tip;
117
-
118
- while (current) {
119
- const info = await this._persistence.getNodeInfo(current);
120
- const entries = await this._persistence.readTreeOids(
121
- await this._persistence.getCommitTree(current),
122
- );
123
- const blobOid = entries['record.cbor'];
124
- if (!blobOid) {
125
- break;
126
- }
127
- const record = /** @type {Record<string, unknown>} */ (this._codec.decode(
128
- await this._persistence.readBlob(blobOid),
129
- ));
137
+ const records = [];
138
+ let current = tip;
139
+
140
+ while (current) {
141
+ const info = await this._persistence.getNodeInfo(current);
142
+ const entries = await this._persistence.readTreeOids(
143
+ await this._persistence.getCommitTree(current),
144
+ );
145
+ const blobOid = entries['record.cbor'];
146
+ if (!blobOid) {
147
+ break;
148
+ }
149
+ const record = /** @type {Record<string, unknown>} */ (this._codec.decode(
150
+ await this._persistence.readBlob(blobOid),
151
+ ));
130
152
 
131
- records.unshift(record);
153
+ records.unshift(record);
132
154
 
133
- if (info.parents.length === 0) {
134
- break;
155
+ if (info.parents.length === 0) {
156
+ break;
157
+ }
158
+ current = info.parents[0];
135
159
  }
136
- current = info.parents[0];
137
- }
138
160
 
139
- return records;
161
+ return { ok: true, records };
162
+ } catch (err) {
163
+ return {
164
+ ok: false,
165
+ error: err instanceof Error ? err : new Error(String(err)),
166
+ };
167
+ }
140
168
  }
141
169
 
142
170
  /**
@@ -196,6 +224,62 @@ export class TrustRecordService {
196
224
  return { valid: errors.length === 0, errors };
197
225
  }
198
226
 
227
+ /**
228
+ * Appends a trust record with automatic retry on CAS conflict.
229
+ *
230
+ * On E_TRUST_CAS_CONFLICT, re-reads the chain tip, rebuilds the record
231
+ * with the new prev pointer, re-signs if a signer is provided, and
232
+ * retries. This is the higher-level API callers should use when they
233
+ * want automatic convergence under concurrent appenders.
234
+ *
235
+ * @param {string} graphName
236
+ * @param {Record<string, unknown>} record - Complete signed trust record
237
+ * @param {Object} [options]
238
+ * @param {number} [options.maxRetries=3] - Maximum rebuild-and-retry attempts
239
+ * @param {((record: Record<string, unknown>) => Promise<Record<string, unknown>>)|null} [options.resign] - Function to re-sign a rebuilt record (null for unsigned)
240
+ * @param {boolean} [options.skipSignatureVerify=false] - Skip signature verification
241
+ * @returns {Promise<{commitSha: string, ref: string, attempts: number}>}
242
+ * @throws {TrustError} E_TRUST_CAS_EXHAUSTED if all retries fail
243
+ */
244
+ async appendRecordWithRetry(graphName, record, options = {}) {
245
+ const { maxRetries = 3, resign = null, skipSignatureVerify = false } = options;
246
+ let currentRecord = record;
247
+ let attempts = 0;
248
+
249
+ for (let i = 0; i <= maxRetries; i++) {
250
+ attempts++;
251
+ try {
252
+ const result = await this.appendRecord(graphName, currentRecord, { skipSignatureVerify });
253
+ return { ...result, attempts };
254
+ } catch (err) {
255
+ if (!(err instanceof TrustError) || err.code !== 'E_TRUST_CAS_CONFLICT') {
256
+ throw err;
257
+ }
258
+
259
+ if (i === maxRetries) {
260
+ throw new TrustError(
261
+ `Trust CAS exhausted after ${attempts} attempts (with retry)`,
262
+ { code: 'E_TRUST_CAS_EXHAUSTED' },
263
+ );
264
+ }
265
+
266
+ // Rebuild: re-read chain tip, update prev pointer
267
+ const freshTipRecordId = err.context?.actualTipRecordId ?? null;
268
+
269
+ // Update prev to the new chain tip's recordId
270
+ currentRecord = { ...currentRecord, prev: freshTipRecordId };
271
+
272
+ // Re-sign if signer is provided
273
+ if (resign) {
274
+ currentRecord = await resign(currentRecord);
275
+ }
276
+ }
277
+ }
278
+
279
+ // Unreachable
280
+ throw new TrustError('Trust CAS failed', { code: 'E_TRUST_CAS_EXHAUSTED' });
281
+ }
282
+
199
283
  /**
200
284
  * Validates that a record's signature envelope is structurally complete.
201
285
  *
@@ -246,7 +330,15 @@ export class TrustRecordService {
246
330
  }
247
331
 
248
332
  /**
249
- * Persists a trust record as a Git commit.
333
+ * Persists a trust record as a Git commit with CAS retry.
334
+ *
335
+ * On transient CAS failures (ref unchanged, e.g. lock contention), retries
336
+ * up to MAX_CAS_ATTEMPTS total. On real concurrent appends (ref advanced),
337
+ * throws E_TRUST_CAS_CONFLICT so the caller can rebuild + re-sign the record.
338
+ *
339
+ * The record's prev, recordId, and signature form a cryptographic chain.
340
+ * Only the original signer can rebuild, so we never silently rebase.
341
+ *
250
342
  * @param {string} ref
251
343
  * @param {Record<string, unknown>} record
252
344
  * @param {string|null} parentSha - Resolved tip SHA (null for genesis)
@@ -273,9 +365,44 @@ export class TrustRecordService {
273
365
  message,
274
366
  });
275
367
 
276
- // CAS update ref fails atomically if a concurrent append changed the tip
277
- await this._persistence.compareAndSwapRef(ref, commitSha, parentSha);
368
+ // CAS update ref with retry for transient failures
369
+ for (let attempt = 1; attempt <= MAX_CAS_ATTEMPTS; attempt++) {
370
+ try {
371
+ await this._persistence.compareAndSwapRef(ref, commitSha, parentSha);
372
+ return commitSha;
373
+ } catch {
374
+ // Read fresh tip to distinguish transient vs real conflict
375
+ const { tipSha: freshTipSha, recordId: freshRecordId } = await this._readTip(ref);
376
+
377
+ if (freshTipSha === parentSha) {
378
+ // Ref unchanged — transient failure (lock contention, I/O race).
379
+ // Retry the same CAS with same commit.
380
+ if (attempt === MAX_CAS_ATTEMPTS) {
381
+ throw new TrustError(
382
+ `Trust CAS exhausted after ${MAX_CAS_ATTEMPTS} attempts`,
383
+ { code: 'E_TRUST_CAS_EXHAUSTED' },
384
+ );
385
+ }
386
+ continue;
387
+ }
388
+
389
+ // Ref changed — real concurrent append. Our record's prev no longer
390
+ // matches the chain tip. The caller must rebuild, re-sign, and retry.
391
+ throw new TrustError(
392
+ `Trust CAS conflict: chain advanced from ${parentSha} to ${freshTipSha}`,
393
+ {
394
+ code: 'E_TRUST_CAS_CONFLICT',
395
+ context: {
396
+ expectedTipSha: parentSha,
397
+ actualTipSha: freshTipSha,
398
+ actualTipRecordId: freshRecordId,
399
+ },
400
+ },
401
+ );
402
+ }
403
+ }
278
404
 
279
- return commitSha;
405
+ // Unreachable, but satisfies type checker
406
+ throw new TrustError('Trust CAS failed', { code: 'E_TRUST_CAS_EXHAUSTED' });
280
407
  }
281
408
  }