@git-stunts/git-warp 12.1.0 → 12.2.0

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.
@@ -11,6 +11,7 @@
11
11
  import SyncError from '../errors/SyncError.js';
12
12
  import OperationAbortedError from '../errors/OperationAbortedError.js';
13
13
  import { QueryError, E_NO_STATE_MSG } from '../warp/_internal.js';
14
+ import { validateSyncResponse } from './SyncPayloadSchema.js';
14
15
  import {
15
16
  createSyncRequest as createSyncRequestImpl,
16
17
  processSyncRequest as processSyncRequestImpl,
@@ -25,6 +26,7 @@ import { collectGCMetrics } from './GCMetrics.js';
25
26
  import HttpSyncServer from './HttpSyncServer.js';
26
27
  import { signSyncRequest, canonicalizePath } from './SyncAuthService.js';
27
28
  import { isError } from '../types/WarpErrors.js';
29
+ import SyncTrustGate from './SyncTrustGate.js';
28
30
 
29
31
  /** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
30
32
 
@@ -128,10 +130,14 @@ async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
128
130
  export default class SyncController {
129
131
  /**
130
132
  * @param {SyncHost} host - The WarpGraph instance (or any object satisfying SyncHost)
133
+ * @param {Object} [options]
134
+ * @param {SyncTrustGate} [options.trustGate] - Trust gate for evaluating patch authors
131
135
  */
132
- constructor(host) {
136
+ constructor(host, options = {}) {
133
137
  /** @type {SyncHost} */
134
138
  this._host = host;
139
+ /** @type {SyncTrustGate|null} */
140
+ this._trustGate = options.trustGate || null;
135
141
  }
136
142
 
137
143
  /**
@@ -268,7 +274,7 @@ export default class SyncController {
268
274
  localFrontier,
269
275
  persistence,
270
276
  this._host._graphName,
271
- { codec: this._host._codec }
277
+ { codec: this._host._codec, logger: this._host._logger || undefined }
272
278
  );
273
279
  }
274
280
 
@@ -282,13 +288,48 @@ export default class SyncController {
282
288
  * @returns {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} Result with updated state and frontier
283
289
  * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
284
290
  */
285
- applySyncResponse(response) {
291
+ /**
292
+ * Applies a sync response to the local graph state.
293
+ * Updates the cached state with received patches.
294
+ *
295
+ * When a trust gate is configured, evaluates patch authors (writersApplied)
296
+ * against trust policy. In enforce mode, untrusted writers cause rejection
297
+ * before any state mutation.
298
+ *
299
+ * **Requires a cached state.**
300
+ *
301
+ * @param {import('./SyncProtocol.js').SyncResponse} response - The sync response
302
+ * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[]}>} Result with updated state and frontier
303
+ * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
304
+ * @throws {SyncError} If trust gate rejects untrusted writers (code: `E_SYNC_UNTRUSTED_WRITER`)
305
+ */
306
+ async applySyncResponse(response) {
286
307
  if (!this._host._cachedState) {
287
308
  throw new QueryError(E_NO_STATE_MSG, {
288
309
  code: 'E_NO_STATE',
289
310
  });
290
311
  }
291
312
 
313
+ // Extract actual patch authors for trust evaluation (B1)
314
+ const writersApplied = SyncTrustGate.extractWritersFromPatches(response.patches || []);
315
+
316
+ // Evaluate trust BEFORE applying any patches
317
+ if (this._trustGate && writersApplied.length > 0) {
318
+ const verdict = await this._trustGate.evaluate(writersApplied, {
319
+ graphName: this._host._graphName,
320
+ });
321
+ if (!verdict.allowed) {
322
+ throw new SyncError('Sync rejected: untrusted writer(s)', {
323
+ code: 'E_SYNC_UNTRUSTED_WRITER',
324
+ context: {
325
+ writersApplied,
326
+ untrustedWriters: verdict.untrustedWriters,
327
+ verdict: verdict.verdict,
328
+ },
329
+ });
330
+ }
331
+ }
332
+
292
333
  const currentFrontier = this._host._lastFrontier || createFrontier();
293
334
  const result = /** @type {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._host._cachedState, currentFrontier));
294
335
 
@@ -301,10 +342,32 @@ export default class SyncController {
301
342
  // Track patches for GC
302
343
  this._host._patchesSinceGC += result.applied;
303
344
 
345
+ // Invalidate derived caches (C1) — sync changes underlying state
346
+ this._invalidateDerivedCaches();
347
+
304
348
  // State is now in sync with the frontier -- clear dirty flag
305
349
  this._host._stateDirty = false;
306
350
 
307
- return result;
351
+ return { ...result, writersApplied };
352
+ }
353
+
354
+ /**
355
+ * Invalidates all derived caches on the host graph.
356
+ *
357
+ * Called after sync apply or join to ensure stale index/provider/view
358
+ * data is not returned to callers. The next query or traversal will
359
+ * trigger a rebuild.
360
+ *
361
+ * @private
362
+ */
363
+ _invalidateDerivedCaches() {
364
+ const h = /** @type {import('../WarpGraph.js').default} */ (this._host);
365
+ h._materializedGraph = null;
366
+ h._logicalIndex = null;
367
+ h._propertyReader = null;
368
+ h._cachedViewHash = null;
369
+ h._cachedIndexTree = null;
370
+ h._stateDirty = true;
308
371
  }
309
372
 
310
373
  /**
@@ -468,12 +531,12 @@ export default class SyncController {
468
531
  }
469
532
  }
470
533
 
471
- if (!response || typeof response !== 'object' ||
472
- response.type !== 'sync-response' ||
473
- !response.frontier || typeof response.frontier !== 'object' || Array.isArray(response.frontier) ||
474
- !Array.isArray(response.patches)) {
475
- throw new SyncError('Invalid sync response', {
476
- code: 'E_SYNC_PROTOCOL',
534
+ // Validate response shape + resource limits via Zod schema (B64).
535
+ // For HTTP responses, always validate — untrusted boundary.
536
+ const validation = validateSyncResponse(response);
537
+ if (!validation.ok) {
538
+ throw new SyncError(`Invalid sync response: ${validation.error}`, {
539
+ code: 'E_SYNC_PAYLOAD_INVALID',
477
540
  });
478
541
  }
479
542
 
@@ -482,7 +545,7 @@ export default class SyncController {
482
545
  emit('materialized');
483
546
  }
484
547
 
485
- const result = this.applySyncResponse(response);
548
+ const result = await this.applySyncResponse(response);
486
549
  emit('applied', { applied: result.applied });
487
550
 
488
551
  const durationMs = this._host._clock.now() - attemptStart;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * SyncPayloadSchema -- Zod schemas for sync protocol messages.
3
+ *
4
+ * Validates both shape and resource limits for sync requests and responses
5
+ * at the trust boundary (HTTP ingress/egress). Prevents malformed or
6
+ * oversized payloads from reaching the CRDT merge engine.
7
+ *
8
+ * @module domain/services/SyncPayloadSchema
9
+ * @see B64 -- Sync ingress payload validation
10
+ */
11
+
12
+ import { z } from 'zod';
13
+
14
+ // ── Resource Limits ─────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Default resource limits for sync payload validation.
18
+ * Configurable per-deployment via `createSyncResponseSchema(limits)`.
19
+ *
20
+ * @typedef {Object} SyncPayloadLimits
21
+ * @property {number} maxWritersInFrontier - Maximum writers in a frontier object
22
+ * @property {number} maxPatches - Maximum patches in a sync response
23
+ * @property {number} maxOpsPerPatch - Maximum operations per patch
24
+ * @property {number} maxStringBytes - Maximum bytes for string values (writer ID, node ID, etc.)
25
+ * @property {number} maxBlobBytes - Maximum bytes for blob values
26
+ */
27
+
28
+ /** @type {Readonly<SyncPayloadLimits>} */
29
+ export const DEFAULT_LIMITS = Object.freeze({
30
+ maxWritersInFrontier: 10_000,
31
+ maxPatches: 100_000,
32
+ maxOpsPerPatch: 50_000,
33
+ maxStringBytes: 4096,
34
+ maxBlobBytes: 16 * 1024 * 1024,
35
+ });
36
+
37
+ // ── Schema Version ──────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Current sync protocol schema version.
41
+ * Responses with unknown versions are rejected.
42
+ */
43
+ export const SYNC_SCHEMA_VERSION = 1;
44
+
45
+ // ── Shared Primitives ───────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Bounded string: rejects strings exceeding maxStringBytes.
49
+ * @param {number} maxBytes
50
+ * @returns {z.ZodString}
51
+ */
52
+ function boundedString(maxBytes) {
53
+ return z.string().max(maxBytes);
54
+ }
55
+
56
+ // ── Frontier Schema ─────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Normalizes a frontier value that may be a Map (cbor-x decodes maps)
60
+ * or a plain object into a validated plain object.
61
+ *
62
+ * @param {unknown} value
63
+ * @returns {Record<string, string>|null} Normalized object, or null if invalid
64
+ */
65
+ export function normalizeFrontier(value) {
66
+ if (value instanceof Map) {
67
+ /** @type {Record<string, string>} */
68
+ const obj = {};
69
+ for (const [k, v] of value) {
70
+ if (typeof k !== 'string' || typeof v !== 'string') {
71
+ return null;
72
+ }
73
+ obj[k] = v;
74
+ }
75
+ return obj;
76
+ }
77
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
78
+ return /** @type {Record<string, string>} */ (value);
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Creates a frontier schema with a size limit.
85
+ *
86
+ * Frontier values are strings (typically hex SHAs) but we don't enforce
87
+ * hex format here — semantic SHA validation happens at a higher level.
88
+ * This schema validates shape + resource limits only.
89
+ *
90
+ * @param {number} maxWriters
91
+ * @returns {z.ZodType<Record<string, string>>}
92
+ */
93
+ function frontierSchema(maxWriters) {
94
+ return /** @type {z.ZodType<Record<string, string>>} */ (z.record(
95
+ boundedString(256),
96
+ z.string(),
97
+ ).refine(
98
+ (obj) => Object.keys(obj).length <= maxWriters,
99
+ (obj) => ({ message: `Frontier exceeds max writers: ${Object.keys(obj).length} > ${maxWriters}` }),
100
+ ));
101
+ }
102
+
103
+ // ── Op Schema ───────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Minimal op validation — checks for type field and basic shape.
107
+ * Deeper semantic validation happens in JoinReducer/WarpMessageCodec.
108
+ */
109
+ const opSchema = z.object({
110
+ type: z.string(),
111
+ }).passthrough();
112
+
113
+ // ── Patch Schema ────────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Creates a patch schema with ops limit.
117
+ * @param {SyncPayloadLimits} limits
118
+ */
119
+ function patchSchema(limits) {
120
+ return z.object({
121
+ schema: z.number().int().min(1).optional(),
122
+ writer: boundedString(limits.maxStringBytes).optional(),
123
+ lamport: z.number().int().min(0).optional(),
124
+ ops: z.array(opSchema).max(limits.maxOpsPerPatch),
125
+ context: z.unknown().optional(),
126
+ }).passthrough();
127
+ }
128
+
129
+ /**
130
+ * Creates a patches-array entry schema.
131
+ * @param {SyncPayloadLimits} limits
132
+ */
133
+ function patchEntrySchema(limits) {
134
+ return z.object({
135
+ writerId: boundedString(limits.maxStringBytes),
136
+ sha: z.string(),
137
+ patch: patchSchema(limits),
138
+ });
139
+ }
140
+
141
+ // ── Sync Request Schema ─────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Creates a validated SyncRequest schema.
145
+ * @param {SyncPayloadLimits} [limits]
146
+ * @returns {z.ZodType}
147
+ */
148
+ export function createSyncRequestSchema(limits = DEFAULT_LIMITS) {
149
+ return z.object({
150
+ type: z.literal('sync-request'),
151
+ frontier: frontierSchema(limits.maxWritersInFrontier),
152
+ }).strict();
153
+ }
154
+
155
+ /** Default SyncRequest schema with default limits */
156
+ export const SyncRequestSchema = createSyncRequestSchema();
157
+
158
+ // ── Sync Response Schema ────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Creates a validated SyncResponse schema.
162
+ * @param {SyncPayloadLimits} [limits]
163
+ * @returns {z.ZodType}
164
+ */
165
+ export function createSyncResponseSchema(limits = DEFAULT_LIMITS) {
166
+ return z.object({
167
+ type: z.literal('sync-response'),
168
+ frontier: frontierSchema(limits.maxWritersInFrontier),
169
+ patches: z.array(patchEntrySchema(limits)).max(limits.maxPatches),
170
+ }).passthrough();
171
+ }
172
+
173
+ /** Default SyncResponse schema with default limits */
174
+ export const SyncResponseSchema = createSyncResponseSchema();
175
+
176
+ // ── Validation Helpers ──────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Validates a sync request payload. Returns the parsed value or throws.
180
+ *
181
+ * Handles Map→object normalization for cbor-x compatibility.
182
+ *
183
+ * @param {unknown} payload - Raw parsed payload (from JSON.parse or cbor-x decode)
184
+ * @param {SyncPayloadLimits} [limits] - Resource limits
185
+ * @returns {{ ok: true, value: { type: 'sync-request', frontier: Record<string, string> } } | { ok: false, error: string }}
186
+ */
187
+ export function validateSyncRequest(payload, limits = DEFAULT_LIMITS) {
188
+ // Normalize Map frontier from cbor-x
189
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
190
+ const p = /** @type {Record<string, unknown>} */ (payload);
191
+ if (p.frontier instanceof Map) {
192
+ const normalized = normalizeFrontier(p.frontier);
193
+ if (!normalized) {
194
+ return { ok: false, error: 'Invalid frontier: Map contains non-string entries' };
195
+ }
196
+ p.frontier = normalized;
197
+ }
198
+ }
199
+
200
+ const schema = limits === DEFAULT_LIMITS ? SyncRequestSchema : createSyncRequestSchema(limits);
201
+ const result = schema.safeParse(payload);
202
+ if (!result.success) {
203
+ return { ok: false, error: result.error.message };
204
+ }
205
+ return { ok: true, value: /** @type {{ type: 'sync-request', frontier: Record<string, string> }} */ (result.data) };
206
+ }
207
+
208
+ /**
209
+ * Validates a sync response payload. Returns the parsed value or an error.
210
+ *
211
+ * Handles Map→object normalization for cbor-x compatibility.
212
+ *
213
+ * @param {unknown} payload - Raw parsed payload
214
+ * @param {SyncPayloadLimits} [limits] - Resource limits
215
+ * @returns {{ ok: true, value: unknown } | { ok: false, error: string }}
216
+ */
217
+ export function validateSyncResponse(payload, limits = DEFAULT_LIMITS) {
218
+ // Normalize Map frontier from cbor-x
219
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
220
+ const p = /** @type {Record<string, unknown>} */ (payload);
221
+ if (p.frontier instanceof Map) {
222
+ const normalized = normalizeFrontier(p.frontier);
223
+ if (!normalized) {
224
+ return { ok: false, error: 'Invalid frontier: Map contains non-string entries' };
225
+ }
226
+ p.frontier = normalized;
227
+ }
228
+ }
229
+
230
+ const schema = limits === DEFAULT_LIMITS ? SyncResponseSchema : createSyncResponseSchema(limits);
231
+ const result = schema.safeParse(payload);
232
+ if (!result.success) {
233
+ return { ok: false, error: result.error.message };
234
+ }
235
+ return { ok: true, value: result.data };
236
+ }
@@ -37,6 +37,7 @@
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
42
  import { join, cloneStateV5 } from './JoinReducer.js';
42
43
  import { cloneFrontier, updateFrontier } from './Frontier.js';
@@ -267,11 +268,9 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
267
268
  newWritersForRemote.push(writerId);
268
269
  } else if (remoteSha !== localSha) {
269
270
  // 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
- }
271
+ // Always add both directions ancestry is verified during loadPatchRange()
272
+ // which will throw E_SYNC_DIVERGENCE if neither side descends from the other (S3)
273
+ needFromLocal.set(writerId, { from: remoteSha, to: localSha });
275
274
  }
276
275
  }
277
276
 
@@ -315,6 +314,8 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
315
314
  * - `writerId`: The writer who created this patch
316
315
  * - `sha`: The commit SHA this patch came from (for frontier updates)
317
316
  * - `patch`: The decoded patch object with ops and context
317
+ * @property {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} [skippedWriters] - Writers that were skipped during sync
318
+ * (e.g. due to trust gate filtering, divergence, or missing refs)
318
319
  */
319
320
 
320
321
  /**
@@ -375,6 +376,7 @@ export function createSyncRequest(frontier) {
375
376
  * @param {string} graphName - Graph name for error messages and logging
376
377
  * @param {Object} [options]
377
378
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
379
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for divergence warnings
378
380
  * @returns {Promise<SyncResponse>} Response containing local frontier and patches.
379
381
  * Patches are ordered chronologically within each writer.
380
382
  * @throws {Error} If patch loading fails for reasons other than divergence
@@ -388,7 +390,9 @@ export function createSyncRequest(frontier) {
388
390
  * res.json(response);
389
391
  * });
390
392
  */
391
- export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {{ codec?: import('../../ports/CodecPort.js').default }} */ ({})) {
393
+ export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec, logger } = /** @type {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ ({})) {
394
+ const log = logger || nullLogger;
395
+
392
396
  // Convert incoming frontier from object to Map
393
397
  const remoteFrontier = new Map(Object.entries(request.frontier));
394
398
 
@@ -397,6 +401,8 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
397
401
 
398
402
  // Load patches that the requester needs (from local to requester)
399
403
  const patches = [];
404
+ /** @type {Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>} */
405
+ const skippedWriters = [];
400
406
 
401
407
  for (const [writerId, range] of delta.needFromRemote) {
402
408
  try {
@@ -413,9 +419,21 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
413
419
  patches.push({ writerId, sha, patch });
414
420
  }
415
421
  } catch (err) {
416
- // If we detect divergence, skip this writer
417
- // The requester may need to handle this separately
422
+ // If we detect divergence, log and skip this writer (B65).
423
+ // The requester will not receive patches for this writer.
418
424
  if ((err instanceof Error && 'code' in err && /** @type {{ code: string }} */ (err).code === 'E_SYNC_DIVERGENCE') || (err instanceof Error && err.message?.includes('Divergence detected'))) {
425
+ const entry = {
426
+ writerId,
427
+ reason: 'E_SYNC_DIVERGENCE',
428
+ localSha: range.to,
429
+ remoteSha: range.from ?? '',
430
+ };
431
+ skippedWriters.push(entry);
432
+ log.warn('Sync divergence detected — skipping writer', {
433
+ code: 'E_SYNC_DIVERGENCE',
434
+ graphName,
435
+ ...entry,
436
+ });
419
437
  continue;
420
438
  }
421
439
  throw err;
@@ -433,6 +451,7 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
433
451
  type: /** @type {'sync-response'} */ ('sync-response'),
434
452
  frontier: frontierObj,
435
453
  patches,
454
+ skippedWriters,
436
455
  };
437
456
  }
438
457
 
@@ -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));