@git-stunts/git-warp 12.2.1 → 12.3.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.
package/README.md CHANGED
@@ -8,12 +8,13 @@
8
8
  <img src="docs/images/hero.gif" alt="git-warp CLI demo" width="600">
9
9
  </p>
10
10
 
11
- ## What's New in v12.2.1
11
+ ## What's New in v12.3.0
12
12
 
13
- - **M12 SCALPEL complete** — 42-item STANK audit fully resolved: 15 bug fixes, 20+ JSDoc/documentation improvements, and 6 refactors across CRDT core, services, sync, and CLI.
14
- - **Sync correctness hardened** — `join()` state install, `applySyncResponse` cache coherence, unknown-op rejection (fail-closed), and divergence pre-check all fixed.
15
- - **Incremental index improvements** — stale label ID collision fix, re-add edge restoration via adjacency cache, and bitmap churn reduction for node removal.
16
- - **canonicalStringify shared-reference fix** — cycle detection now correctly allows valid DAG structures (shared non-circular references).
13
+ - **M13 ADR 1 — canonical edge property ops** — internal model now uses honest `NodePropSet`/`EdgePropSet` semantics. Legacy raw `PropSet` is normalized at reducer entry points and lowered back at write time. No wire-format change — persisted patches remain backward-compatible.
14
+ - **Wire gate hardened** — sync boundary now explicitly rejects canonical-only op types (`NodePropSet`, `EdgePropSet`) arriving over the wire, preventing premature schema migration before ADR 2 capability cutover.
15
+ - **Reserved-byte validation** — new writes reject node IDs containing `\0` or starting with `\x01`, preventing ambiguous legacy edge-property encoding.
16
+ - **Version namespace separation** — patch schema and checkpoint schema constants are now distinct (`PATCH_SCHEMA_V2`/`V3` vs `CHECKPOINT_SCHEMA_STANDARD`/`INDEX_TREE`).
17
+ - **ADR governance** — ADR 3 readiness gates formalize when the persisted wire-format migration may proceed, with GitHub issue template and go/no-go checklist.
17
18
 
18
19
  See the [full changelog](CHANGELOG.md) for details.
19
20
 
@@ -291,7 +291,8 @@ function formatOpSummaryPlain(summary) {
291
291
  const order = [
292
292
  ['NodeAdd', '+', 'node'],
293
293
  ['EdgeAdd', '+', 'edge'],
294
- ['PropSet', '~', 'prop'],
294
+ ['prop', '~', 'prop'], // coalesced PropSet + NodePropSet
295
+ ['EdgePropSet', '~', 'eprop'],
295
296
  ['NodeTombstone', '-', 'node'],
296
297
  ['EdgeTombstone', '-', 'edge'],
297
298
  ['BlobValue', '+', 'blob'],
@@ -299,7 +300,10 @@ function formatOpSummaryPlain(summary) {
299
300
 
300
301
  const parts = [];
301
302
  for (const [opType, symbol, label] of order) {
302
- const n = summary?.[opType];
303
+ // Coalesce PropSet + NodePropSet into one bucket
304
+ const n = opType === 'prop'
305
+ ? (summary?.PropSet || 0) + (summary?.NodePropSet || 0) || undefined
306
+ : summary?.[opType];
303
307
  if (typeof n === 'number' && Number.isFinite(n) && n > 0) {
304
308
  parts.push(`${symbol}${n}${label}`);
305
309
  }
@@ -612,9 +616,12 @@ function formatPatchOp(op) {
612
616
  if (op.type === 'EdgeTombstone') {
613
617
  return ` - edge ${op.from} -[${op.label}]-> ${op.to}`;
614
618
  }
615
- if (op.type === 'PropSet') {
619
+ if (op.type === 'PropSet' || op.type === 'NodePropSet') {
616
620
  return ` ~ ${op.node}.${op.key} = ${JSON.stringify(op.value)}`;
617
621
  }
622
+ if (op.type === 'EdgePropSet') {
623
+ return ` ~ edge(${op.from} -[${op.label}]-> ${op.to}).${op.key} = ${JSON.stringify(op.value)}`;
624
+ }
618
625
  if (op.type === 'BlobValue') {
619
626
  return ` + blob ${op.node}`;
620
627
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-stunts/git-warp",
3
- "version": "12.2.1",
3
+ "version": "12.3.0",
4
4
  "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -116,6 +116,9 @@ export function createORSet() {
116
116
  * @param {import('./Dot.js').Dot} dot - The dot representing this add operation
117
117
  */
118
118
  export function orsetAdd(set, element, dot) {
119
+ if (!dot || typeof dot.writerId !== 'string' || !Number.isInteger(dot.counter)) {
120
+ throw new Error(`orsetAdd: invalid dot -- expected {writerId: string, counter: integer}, got ${JSON.stringify(dot)}`);
121
+ }
119
122
  const encoded = encodeDot(dot);
120
123
 
121
124
  let dots = set.entries.get(element);
@@ -339,7 +339,8 @@ export class AuditReceiptService {
339
339
  // Compute opsDigest
340
340
  const opsDigest = await computeOpsDigest(ops, this._crypto);
341
341
 
342
- // Timestamp
342
+ // Wall-clock timestamp for audit receipt (not a perf timer)
343
+ // eslint-disable-next-line no-restricted-syntax
343
344
  const timestamp = Date.now();
344
345
 
345
346
  // Determine prevAuditCommit
@@ -257,6 +257,7 @@ export class AuditVerifierService {
257
257
 
258
258
  return {
259
259
  graph: graphName,
260
+ // eslint-disable-next-line no-restricted-syntax -- wall-clock timestamp for audit report
260
261
  verifiedAt: new Date().toISOString(),
261
262
  summary: { total: chains.length, valid, partial, invalid },
262
263
  chains,
@@ -164,6 +164,7 @@ export async function createBTR(initialState, payload, options) {
164
164
  throw new TypeError('payload must be a ProvenancePayload');
165
165
  }
166
166
 
167
+ // eslint-disable-next-line no-restricted-syntax -- wall-clock default for BTR timestamp
167
168
  const { key, timestamp = new Date().toISOString(), crypto, codec } = options;
168
169
 
169
170
  // Validate HMAC key is not empty/falsy
@@ -5,6 +5,11 @@
5
5
  * materialized graph state. See {@link module:domain/services/WarpMessageCodec}
6
6
  * for the facade that re-exports all codec functions.
7
7
  *
8
+ * **Schema namespace note:** Checkpoint schema versions (2, 3, 4) are
9
+ * distinct from patch schema versions (PATCH_SCHEMA_V2, PATCH_SCHEMA_V3).
10
+ * See {@link module:domain/services/CheckpointService} for named constants
11
+ * `CHECKPOINT_SCHEMA_STANDARD` and `CHECKPOINT_SCHEMA_INDEX_TREE`.
12
+ *
8
13
  * @module domain/services/CheckpointMessageCodec
9
14
  */
10
15
 
@@ -28,6 +28,24 @@ import { cloneStateV5, reduceV5 } from './JoinReducer.js';
28
28
  import { encodeEdgeKey, encodePropKey, CONTENT_PROPERTY_KEY, decodePropKey, isEdgePropKey, decodeEdgePropKey } from './KeyCodec.js';
29
29
  import { ProvenanceIndex } from './ProvenanceIndex.js';
30
30
 
31
+ // ============================================================================
32
+ // Checkpoint Schema Constants
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Standard checkpoint schema — full V5 state without index tree.
37
+ * Distinct from the patch schema namespace (PATCH_SCHEMA_V2/V3).
38
+ * @type {number}
39
+ */
40
+ export const CHECKPOINT_SCHEMA_STANDARD = 2;
41
+
42
+ /**
43
+ * Index-tree checkpoint schema — full V5 state with bitmap index tree.
44
+ * Distinct from the patch schema namespace (PATCH_SCHEMA_V2/V3).
45
+ * @type {number}
46
+ */
47
+ export const CHECKPOINT_SCHEMA_INDEX_TREE = 4;
48
+
31
49
  // ============================================================================
32
50
  // Internal Helpers
33
51
  // ============================================================================
@@ -244,7 +262,7 @@ export async function createV5({
244
262
  indexOid: treeOid,
245
263
  // Schema 3 was used for edge-property-aware patches but is never emitted
246
264
  // by checkpoint creation. Schema 4 indicates an index tree is present.
247
- schema: indexTree ? 4 : 2,
265
+ schema: indexTree ? CHECKPOINT_SCHEMA_INDEX_TREE : CHECKPOINT_SCHEMA_STANDARD,
248
266
  });
249
267
 
250
268
  // 9. Create the checkpoint commit
@@ -15,7 +15,8 @@ import { lwwSet, lwwMax } from '../crdt/LWW.js';
15
15
  import { createEventId, compareEventIds } from '../utils/EventId.js';
16
16
  import { createTickReceipt, OP_TYPES } from '../types/TickReceipt.js';
17
17
  import { encodeDot } from '../crdt/Dot.js';
18
- import { encodeEdgeKey, decodeEdgeKey, encodePropKey } from './KeyCodec.js';
18
+ import { encodeEdgeKey, decodeEdgeKey, encodePropKey, encodeEdgePropKey, EDGE_PROP_PREFIX } from './KeyCodec.js';
19
+ import { normalizeRawOp } from './OpNormalizer.js';
19
20
  import { createEmptyDiff, mergeDiffs } from '../types/PatchDiff.js';
20
21
  import PatchError from '../errors/PatchError.js';
21
22
 
@@ -27,6 +28,9 @@ export {
27
28
  encodeEdgePropKey, isEdgePropKey, decodeEdgePropKey,
28
29
  } from './KeyCodec.js';
29
30
 
31
+ // Re-export op normalization for consumers that operate on raw patches
32
+ export { normalizeRawOp, lowerCanonicalOp } from './OpNormalizer.js';
33
+
30
34
  /**
31
35
  * @typedef {Object} WarpStateV5
32
36
  * @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes
@@ -92,19 +96,58 @@ export function createEmptyStateV5() {
92
96
  * @returns {void}
93
97
  */
94
98
  /**
95
- * Known V2 operation types. Used for forward-compatibility validation.
99
+ * Known raw (wire-format) V2 operation types. These are the 6 types that
100
+ * appear in persisted patches and on the sync wire.
101
+ * @type {ReadonlySet<string>}
102
+ */
103
+ export const RAW_KNOWN_OPS = new Set([
104
+ 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
105
+ 'PropSet', 'BlobValue',
106
+ ]);
107
+
108
+ /**
109
+ * Known canonical (internal) V2 operation types. Includes the 6 raw types
110
+ * plus the ADR 1 canonical split types `NodePropSet` and `EdgePropSet`.
96
111
  * @type {ReadonlySet<string>}
97
112
  */
98
- const KNOWN_OPS = new Set(['NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue']);
113
+ export const CANONICAL_KNOWN_OPS = new Set([
114
+ 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
115
+ 'PropSet', 'NodePropSet', 'EdgePropSet', 'BlobValue',
116
+ ]);
117
+
118
+ /**
119
+ * Validates that an operation has a known raw (wire-format) type.
120
+ * Use this at sync/wire boundaries to fail-close on unknown or
121
+ * canonical-only types arriving over the wire.
122
+ *
123
+ * @param {{ type: string }} op
124
+ * @returns {boolean} True if the op type is in RAW_KNOWN_OPS
125
+ */
126
+ export function isKnownRawOp(op) {
127
+ return Boolean(op && typeof op.type === 'string' && RAW_KNOWN_OPS.has(op.type));
128
+ }
129
+
130
+ /**
131
+ * Validates that an operation has a known canonical (internal) type.
132
+ * Use this for internal guards after normalization.
133
+ *
134
+ * @param {{ type: string }} op
135
+ * @returns {boolean} True if the op type is in CANONICAL_KNOWN_OPS
136
+ */
137
+ export function isKnownCanonicalOp(op) {
138
+ return Boolean(op && typeof op.type === 'string' && CANONICAL_KNOWN_OPS.has(op.type));
139
+ }
99
140
 
100
141
  /**
101
142
  * Validates that an operation has a known type.
102
143
  *
144
+ * @deprecated Use {@link isKnownRawOp} for wire validation or
145
+ * {@link isKnownCanonicalOp} for internal guards.
103
146
  * @param {{ type: string }} op
104
- * @returns {boolean} True if the op type is in KNOWN_OPS
147
+ * @returns {boolean} True if the op type is a known raw wire type
105
148
  */
106
149
  export function isKnownOp(op) {
107
- return op && typeof op.type === 'string' && KNOWN_OPS.has(op.type);
150
+ return isKnownRawOp(op);
108
151
  }
109
152
 
110
153
  /**
@@ -206,6 +249,16 @@ function validateOp(op) {
206
249
  requireString(op, 'node');
207
250
  requireString(op, 'key');
208
251
  break;
252
+ case 'NodePropSet':
253
+ requireString(op, 'node');
254
+ requireString(op, 'key');
255
+ break;
256
+ case 'EdgePropSet':
257
+ requireString(op, 'from');
258
+ requireString(op, 'to');
259
+ requireString(op, 'label');
260
+ requireString(op, 'key');
261
+ break;
209
262
  default:
210
263
  // BlobValue and unknown types: no validation (forward-compat)
211
264
  break;
@@ -245,8 +298,34 @@ export function applyOpV2(state, op, eventId) {
245
298
  case 'EdgeRemove':
246
299
  orsetRemove(state.edgeAlive, /** @type {Set<string>} */ (/** @type {unknown} */ (op.observedDots)));
247
300
  break;
301
+ case 'NodePropSet': {
302
+ const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
303
+ const current = state.prop.get(key);
304
+ state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
305
+ break;
306
+ }
307
+ case 'EdgePropSet': {
308
+ const key = encodeEdgePropKey(
309
+ /** @type {string} */ (op.from),
310
+ /** @type {string} */ (op.to),
311
+ /** @type {string} */ (op.label),
312
+ /** @type {string} */ (op.key),
313
+ );
314
+ const current = state.prop.get(key);
315
+ state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
316
+ break;
317
+ }
248
318
  case 'PropSet': {
249
- // Uses EventId-based LWW, same as v4
319
+ // Legacy raw PropSet — must NOT carry edge-property encoding at this point.
320
+ // If it does, normalization was skipped.
321
+ if (typeof op.node === 'string' && op.node[0] === EDGE_PROP_PREFIX) {
322
+ throw new PatchError(
323
+ 'Unnormalized legacy edge-property PropSet reached canonical apply path. ' +
324
+ 'Call normalizeRawOp() at the decode boundary.',
325
+ { context: { opType: 'PropSet', node: op.node } },
326
+ );
327
+ }
328
+ // Plain node property (backward-compat for callers that bypass normalization)
250
329
  const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
251
330
  const current = state.prop.get(key);
252
331
  state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
@@ -278,6 +357,8 @@ const RECEIPT_OP_TYPE = {
278
357
  EdgeAdd: 'EdgeAdd',
279
358
  EdgeRemove: 'EdgeTombstone',
280
359
  PropSet: 'PropSet',
360
+ NodePropSet: 'NodePropSet',
361
+ EdgePropSet: 'EdgePropSet',
281
362
  BlobValue: 'BlobValue',
282
363
  };
283
364
 
@@ -409,7 +490,7 @@ function edgeRemoveOutcome(orset, op) {
409
490
  }
410
491
 
411
492
  /**
412
- * Determines the receipt outcome for a PropSet operation.
493
+ * Determines the receipt outcome for a property operation given a pre-computed key.
413
494
  *
414
495
  * Uses LWW (Last-Write-Wins) semantics to determine whether the incoming property
415
496
  * value wins over any existing value. The comparison is based on EventId ordering:
@@ -423,41 +504,61 @@ function edgeRemoveOutcome(orset, op) {
423
504
  * - `redundant`: Exact same write (identical EventId)
424
505
  *
425
506
  * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap - The properties map keyed by encoded prop keys
426
- * @param {Object} op - The PropSet operation
427
- * @param {string} op.node - Node ID owning the property
428
- * @param {string} op.key - Property key/name
429
- * @param {unknown} op.value - Property value to set
507
+ * @param {string} key - Pre-encoded property key (node or edge)
430
508
  * @param {import('../utils/EventId.js').EventId} eventId - The event ID for this operation, used for LWW comparison
431
509
  * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
432
510
  * Outcome with encoded prop key as target; includes reason when superseded
433
511
  */
434
- function propSetOutcome(propMap, op, eventId) {
435
- const key = encodePropKey(op.node, op.key);
512
+ function propOutcomeForKey(propMap, key, eventId) {
436
513
  const current = propMap.get(key);
437
- const target = key;
438
514
 
439
515
  if (!current) {
440
- // No existing value -- this write wins
441
- return { target, result: 'applied' };
516
+ return { target: key, result: 'applied' };
442
517
  }
443
518
 
444
- // Compare the incoming EventId with the existing register's EventId
445
519
  const cmp = compareEventIds(eventId, current.eventId);
446
520
  if (cmp > 0) {
447
- // Incoming write wins
448
- return { target, result: 'applied' };
521
+ return { target: key, result: 'applied' };
449
522
  }
450
523
  if (cmp < 0) {
451
- // Existing write wins
452
524
  const winner = current.eventId;
453
525
  return {
454
- target,
526
+ target: key,
455
527
  result: 'superseded',
456
528
  reason: `LWW: writer ${winner.writerId} at lamport ${winner.lamport} wins`,
457
529
  };
458
530
  }
459
- // Same EventId -- redundant (exact same write)
460
- return { target, result: 'redundant' };
531
+ return { target: key, result: 'redundant' };
532
+ }
533
+
534
+ /**
535
+ * Determines the receipt outcome for a PropSet/NodePropSet operation.
536
+ *
537
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
538
+ * @param {Object} op
539
+ * @param {string} op.node - Node ID owning the property
540
+ * @param {string} op.key - Property key/name
541
+ * @param {import('../utils/EventId.js').EventId} eventId
542
+ * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
543
+ */
544
+ function propSetOutcome(propMap, op, eventId) {
545
+ return propOutcomeForKey(propMap, encodePropKey(op.node, op.key), eventId);
546
+ }
547
+
548
+ /**
549
+ * Determines the receipt outcome for an EdgePropSet operation.
550
+ *
551
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
552
+ * @param {Object} op
553
+ * @param {string} op.from
554
+ * @param {string} op.to
555
+ * @param {string} op.label
556
+ * @param {string} op.key
557
+ * @param {import('../utils/EventId.js').EventId} eventId
558
+ * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
559
+ */
560
+ function edgePropSetOutcome(propMap, op, eventId) {
561
+ return propOutcomeForKey(propMap, encodeEdgePropKey(op.from, op.to, op.label, op.key), eventId);
461
562
  }
462
563
 
463
564
  /**
@@ -504,7 +605,7 @@ function updateFrontierFromPatch(state, patch) {
504
605
  export function applyFast(state, patch, patchSha) {
505
606
  for (let i = 0; i < patch.ops.length; i++) {
506
607
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
507
- applyOpV2(state, patch.ops[i], eventId);
608
+ applyOpV2(state, normalizeRawOp(patch.ops[i]), eventId);
508
609
  }
509
610
  updateFrontierFromPatch(state, patch);
510
611
  return state;
@@ -599,11 +700,17 @@ function snapshotBeforeOp(state, op) {
599
700
  const aliveBeforeEdges = aliveElementsForDots(state.edgeAlive, edgeDots);
600
701
  return { aliveBeforeEdges };
601
702
  }
602
- case 'PropSet': {
703
+ case 'PropSet':
704
+ case 'NodePropSet': {
603
705
  const pk = encodePropKey(op.node, op.key);
604
706
  const reg = state.prop.get(pk);
605
707
  return { prevPropValue: reg ? reg.value : undefined, propKey: pk };
606
708
  }
709
+ case 'EdgePropSet': {
710
+ const epk = encodeEdgePropKey(op.from, op.to, op.label, op.key);
711
+ const ereg = state.prop.get(epk);
712
+ return { prevPropValue: ereg ? ereg.value : undefined, propKey: epk };
713
+ }
607
714
  default:
608
715
  return {};
609
716
  }
@@ -640,7 +747,8 @@ function accumulateOpDiff(diff, state, op, before) {
640
747
  collectEdgeRemovals(diff, state, before);
641
748
  break;
642
749
  }
643
- case 'PropSet': {
750
+ case 'PropSet':
751
+ case 'NodePropSet': {
644
752
  const reg = state.prop.get(/** @type {string} */ (before.propKey));
645
753
  const newVal = reg ? reg.value : undefined;
646
754
  if (newVal !== before.prevPropValue) {
@@ -653,6 +761,19 @@ function accumulateOpDiff(diff, state, op, before) {
653
761
  }
654
762
  break;
655
763
  }
764
+ case 'EdgePropSet': {
765
+ const ereg = state.prop.get(/** @type {string} */ (before.propKey));
766
+ const eNewVal = ereg ? ereg.value : undefined;
767
+ if (eNewVal !== before.prevPropValue) {
768
+ diff.propsChanged.push({
769
+ nodeId: encodeEdgeKey(op.from, op.to, op.label),
770
+ key: op.key,
771
+ value: eNewVal,
772
+ prevValue: before.prevPropValue,
773
+ });
774
+ }
775
+ break;
776
+ }
656
777
  default:
657
778
  break;
658
779
  }
@@ -701,7 +822,7 @@ function collectEdgeRemovals(diff, state, before) {
701
822
  * @param {Object} patch - The patch to apply
702
823
  * @param {string} patch.writer
703
824
  * @param {number} patch.lamport
704
- * @param {Array<Object>} patch.ops
825
+ * @param {Array<import('../types/WarpTypesV2.js').OpV2 | {type: string}>} patch.ops
705
826
  * @param {Map<string, number>|{[x: string]: number}} patch.context
706
827
  * @param {string} patchSha - Git SHA of the patch commit
707
828
  * @returns {{state: WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}}
@@ -710,13 +831,12 @@ export function applyWithDiff(state, patch, patchSha) {
710
831
  const diff = createEmptyDiff();
711
832
 
712
833
  for (let i = 0; i < patch.ops.length; i++) {
713
- const op = patch.ops[i];
714
- validateOp(/** @type {Record<string, unknown>} */ (op));
834
+ const canonOp = /** @type {import('../types/WarpTypesV2.js').CanonicalOpV2} */ (normalizeRawOp(patch.ops[i]));
835
+ validateOp(/** @type {Record<string, unknown>} */ (canonOp));
715
836
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
716
- const typedOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (op);
717
- const before = snapshotBeforeOp(state, typedOp);
718
- applyOpV2(state, typedOp, eventId);
719
- accumulateOpDiff(diff, state, typedOp, before);
837
+ const before = snapshotBeforeOp(state, canonOp);
838
+ applyOpV2(state, canonOp, eventId);
839
+ accumulateOpDiff(diff, state, canonOp, before);
720
840
  }
721
841
 
722
842
  updateFrontierFromPatch(state, patch);
@@ -739,41 +859,47 @@ export function applyWithReceipt(state, patch, patchSha) {
739
859
  /** @type {import('../types/TickReceipt.js').OpOutcome[]} */
740
860
  const opResults = [];
741
861
  for (let i = 0; i < patch.ops.length; i++) {
742
- const op = patch.ops[i];
743
- validateOp(/** @type {Record<string, unknown>} */ (op));
862
+ const canonOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (normalizeRawOp(patch.ops[i]));
863
+ validateOp(/** @type {Record<string, unknown>} */ (canonOp));
744
864
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
745
865
 
746
866
  // Determine outcome BEFORE applying the op (state is pre-op)
747
867
  /** @type {{target: string, result: string, reason?: string}} */
748
868
  let outcome;
749
- switch (op.type) {
869
+ switch (canonOp.type) {
750
870
  case 'NodeAdd':
751
- outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (op));
871
+ outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp));
752
872
  break;
753
873
  case 'NodeRemove':
754
- outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (op));
874
+ outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (canonOp));
755
875
  break;
756
876
  case 'EdgeAdd': {
757
- const edgeKey = encodeEdgeKey(/** @type {string} */ (op.from), /** @type {string} */ (op.to), /** @type {string} */ (op.label));
758
- outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (op), edgeKey);
877
+ const edgeKey = encodeEdgeKey(canonOp.from, canonOp.to, canonOp.label);
878
+ outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp), edgeKey);
759
879
  break;
760
880
  }
761
881
  case 'EdgeRemove':
762
- outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (op));
882
+ outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (canonOp));
763
883
  break;
764
884
  case 'PropSet':
765
- outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (op), eventId);
885
+ case 'NodePropSet':
886
+ outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (canonOp), eventId);
766
887
  break;
767
- default:
888
+ case 'EdgePropSet':
889
+ outcome = edgePropSetOutcome(state.prop, /** @type {{from: string, to: string, label: string, key: string, value: *}} */ (canonOp), eventId);
890
+ break;
891
+ default: {
768
892
  // Unknown or BlobValue — always applied
769
- outcome = { target: op.node || op.oid || '*', result: 'applied' };
893
+ const anyOp = /** @type {Record<string, string>} */ (canonOp);
894
+ outcome = { target: anyOp.node || anyOp.oid || '*', result: 'applied' };
770
895
  break;
896
+ }
771
897
  }
772
898
 
773
899
  // Apply the op (mutates state)
774
- applyOpV2(state, op, eventId);
900
+ applyOpV2(state, canonOp, eventId);
775
901
 
776
- const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[op.type] || op.type;
902
+ const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[canonOp.type] || canonOp.type;
777
903
  // Skip unknown/forward-compatible op types that aren't valid receipt ops
778
904
  if (!VALID_RECEIPT_OPS.has(receiptOp)) {
779
905
  continue;
@@ -91,6 +91,54 @@ export function encodeEdgePropKey(from, to, label, propKey) {
91
91
  return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}\0${propKey}`;
92
92
  }
93
93
 
94
+ // -------------------------------------------------------------------------
95
+ // Legacy edge-property node encoding (raw PropSet ↔ canonical EdgePropSet)
96
+ // -------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Encodes edge identity as the legacy `node` field value for raw PropSet ops.
100
+ *
101
+ * Format: `\x01from\0to\0label`
102
+ *
103
+ * @param {string} from - Source node ID
104
+ * @param {string} to - Target node ID
105
+ * @param {string} label - Edge label
106
+ * @returns {string}
107
+ */
108
+ export function encodeLegacyEdgePropNode(from, to, label) {
109
+ return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
110
+ }
111
+
112
+ /**
113
+ * Returns true if a raw PropSet `node` field encodes an edge identity.
114
+ * @param {string} node - The `node` field from a raw PropSet op
115
+ * @returns {boolean}
116
+ */
117
+ export function isLegacyEdgePropNode(node) {
118
+ return typeof node === 'string' && node.length > 0 && node[0] === EDGE_PROP_PREFIX;
119
+ }
120
+
121
+ /**
122
+ * Decodes a legacy edge-property `node` field back to its components.
123
+ * @param {string} node - The `node` field (must start with \x01)
124
+ * @returns {{from: string, to: string, label: string}}
125
+ * @throws {Error} If the node field is not a valid legacy edge-property encoding
126
+ */
127
+ export function decodeLegacyEdgePropNode(node) {
128
+ if (!isLegacyEdgePropNode(node)) {
129
+ throw new Error('Invalid legacy edge-property node: missing \\x01 prefix');
130
+ }
131
+ const parts = node.slice(1).split('\0');
132
+ if (parts.length !== 3) {
133
+ throw new Error(`Invalid legacy edge-property node: expected 3 segments, got ${parts.length}`);
134
+ }
135
+ const [from, to, label] = parts;
136
+ if (!from || !to || !label) {
137
+ throw new Error('Invalid legacy edge-property node: empty segment in decoded parts');
138
+ }
139
+ return { from, to, label };
140
+ }
141
+
94
142
  /**
95
143
  * Returns true if the encoded key is an edge property key.
96
144
  * @param {string} key - Encoded property key
@@ -354,7 +354,7 @@ export default class MaterializedViewService {
354
354
  * @returns {VerifyResult}
355
355
  */
356
356
  verifyIndex({ state, logicalIndex, options = {} }) {
357
- const seed = options.seed ?? (Date.now() & 0x7FFFFFFF);
357
+ const seed = options.seed ?? (Math.random() * 0x7FFFFFFF >>> 0);
358
358
  const sampleRate = options.sampleRate ?? 0.1;
359
359
  const allNodes = [...orsetElements(state.nodeAlive)].sort();
360
360
  const sampled = sampleNodes(allNodes, sampleRate, seed);
@@ -20,17 +20,33 @@ import { getCodec, TRAILER_KEYS } from './MessageCodecInternal.js';
20
20
  // -----------------------------------------------------------------------------
21
21
 
22
22
  /**
23
- * Schema version for classic node-only patches (V5 format).
23
+ * Patch schema version for classic node-only patches (V5 format).
24
24
  * @type {number}
25
25
  */
26
26
  export const SCHEMA_V2 = 2;
27
27
 
28
28
  /**
29
- * Schema version for patches that may contain edge property PropSet ops.
29
+ * Patch schema version for patches that may contain edge property PropSet ops.
30
30
  * @type {number}
31
31
  */
32
32
  export const SCHEMA_V3 = 3;
33
33
 
34
+ /**
35
+ * Alias: patch schema v2 (classic node-only patches).
36
+ * Use this when you need to be explicit that you mean *patch* schema,
37
+ * not checkpoint schema.
38
+ * @type {number}
39
+ */
40
+ export const PATCH_SCHEMA_V2 = SCHEMA_V2;
41
+
42
+ /**
43
+ * Alias: patch schema v3 (edge-property-aware patches).
44
+ * Use this when you need to be explicit that you mean *patch* schema,
45
+ * not checkpoint schema.
46
+ * @type {number}
47
+ */
48
+ export const PATCH_SCHEMA_V3 = SCHEMA_V3;
49
+
34
50
  // -----------------------------------------------------------------------------
35
51
  // Schema Version Detection
36
52
  // -----------------------------------------------------------------------------
@@ -50,6 +66,14 @@ export function detectSchemaVersion(ops) {
50
66
  return SCHEMA_V2;
51
67
  }
52
68
  for (const op of ops) {
69
+ if (!op || typeof op !== 'object') {
70
+ continue;
71
+ }
72
+ // Canonical EdgePropSet always implies schema 3
73
+ if (op.type === 'EdgePropSet') {
74
+ return SCHEMA_V3;
75
+ }
76
+ // Legacy raw PropSet with edge-property encoding
53
77
  if (op.type === 'PropSet' && typeof op.node === 'string' && op.node.startsWith(EDGE_PROP_PREFIX)) {
54
78
  return SCHEMA_V3;
55
79
  }
@@ -90,10 +114,16 @@ export function assertOpsCompatible(ops, maxSchema) {
90
114
  return;
91
115
  }
92
116
  for (const op of ops) {
117
+ if (!op || typeof op !== 'object') {
118
+ continue;
119
+ }
93
120
  if (
94
- op.type === 'PropSet' &&
95
- typeof op.node === 'string' &&
96
- op.node.startsWith(EDGE_PROP_PREFIX)
121
+ // Canonical EdgePropSet (ADR 1) — should never appear on wire pre-ADR 2,
122
+ // but reject defensively for v2 readers
123
+ op.type === 'EdgePropSet' ||
124
+ (op.type === 'PropSet' &&
125
+ typeof op.node === 'string' &&
126
+ op.node.startsWith(EDGE_PROP_PREFIX))
97
127
  ) {
98
128
  throw new SchemaUnsupportedError(
99
129
  'Upgrade to >=7.3.0 (WEIGHTED) to sync edge properties.',
@@ -0,0 +1,79 @@
1
+ /**
2
+ * OpNormalizer — raw ↔ canonical operation conversion.
3
+ *
4
+ * ADR 1 (Canonicalize Edge Property Operations Internally) requires that
5
+ * reducers, provenance, receipts, and queries operate on canonical ops:
6
+ *
7
+ * Raw (persisted): NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet, BlobValue
8
+ * Canonical (internal): NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, NodePropSet, EdgePropSet, BlobValue
9
+ *
10
+ * **Current normalization location:** Normalization is performed at the
11
+ * reducer entry points (`applyFast`, `applyWithReceipt`, `applyWithDiff`
12
+ * in JoinReducer.js), not at the CBOR decode boundary as originally
13
+ * planned in ADR 1. This is a pragmatic deviation — the reducer calls
14
+ * `normalizeRawOp()` on each op before dispatch. Lowering happens in
15
+ * `PatchBuilderV2.build()`/`commit()` via `lowerCanonicalOp()`.
16
+ *
17
+ * @module domain/services/OpNormalizer
18
+ */
19
+
20
+ import { createNodePropSetV2, createEdgePropSetV2, createPropSetV2 } from '../types/WarpTypesV2.js';
21
+ import { isLegacyEdgePropNode, decodeLegacyEdgePropNode, encodeLegacyEdgePropNode } from './KeyCodec.js';
22
+
23
+ /**
24
+ * Normalizes a single raw (persisted) op into its canonical form.
25
+ *
26
+ * - Raw `PropSet` with \x01-prefixed node → canonical `EdgePropSet`
27
+ * - Raw `PropSet` without prefix → canonical `NodePropSet`
28
+ * - All other op types pass through unchanged.
29
+ *
30
+ * @param {import('../types/WarpTypesV2.js').RawOpV2 | {type: string}} rawOp
31
+ * @returns {import('../types/WarpTypesV2.js').CanonicalOpV2 | {type: string}}
32
+ */
33
+ export function normalizeRawOp(rawOp) {
34
+ if (!rawOp || typeof rawOp !== 'object' || typeof rawOp.type !== 'string') {
35
+ return rawOp;
36
+ }
37
+ if (rawOp.type !== 'PropSet') {
38
+ return rawOp;
39
+ }
40
+ const op = /** @type {import('../types/WarpTypesV2.js').OpV2PropSet} */ (rawOp);
41
+ if (isLegacyEdgePropNode(op.node)) {
42
+ const { from, to, label } = decodeLegacyEdgePropNode(op.node);
43
+ return createEdgePropSetV2(from, to, label, op.key, op.value);
44
+ }
45
+ return createNodePropSetV2(op.node, op.key, op.value);
46
+ }
47
+
48
+ /**
49
+ * Lowers a single canonical op back to raw (persisted) form.
50
+ *
51
+ * - Canonical `NodePropSet` → raw `PropSet`
52
+ * - Canonical `EdgePropSet` → raw `PropSet` with legacy \x01-prefixed node
53
+ * - All other op types pass through unchanged.
54
+ *
55
+ * In M13, this always produces legacy raw PropSet for property ops.
56
+ * A future graph capability cutover (ADR 2) may allow emitting raw
57
+ * `EdgePropSet` directly.
58
+ *
59
+ * @param {import('../types/WarpTypesV2.js').CanonicalOpV2 | {type: string}} canonicalOp
60
+ * @returns {import('../types/WarpTypesV2.js').RawOpV2 | {type: string}}
61
+ */
62
+ export function lowerCanonicalOp(canonicalOp) {
63
+ switch (canonicalOp.type) {
64
+ case 'NodePropSet': {
65
+ const op = /** @type {import('../types/WarpTypesV2.js').OpV2NodePropSet} */ (canonicalOp);
66
+ return createPropSetV2(op.node, op.key, op.value);
67
+ }
68
+ case 'EdgePropSet': {
69
+ const op = /** @type {import('../types/WarpTypesV2.js').OpV2EdgePropSet} */ (canonicalOp);
70
+ return createPropSetV2(
71
+ encodeLegacyEdgePropNode(op.from, op.to, op.label),
72
+ op.key,
73
+ op.value,
74
+ );
75
+ }
76
+ default:
77
+ return canonicalOp;
78
+ }
79
+ }
@@ -20,10 +20,12 @@ import {
20
20
  createNodeRemoveV2,
21
21
  createEdgeAddV2,
22
22
  createEdgeRemoveV2,
23
- createPropSetV2,
23
+ createNodePropSetV2,
24
+ createEdgePropSetV2,
24
25
  createPatchV2,
25
26
  } from '../types/WarpTypesV2.js';
26
- import { encodeEdgeKey, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
27
+ import { encodeEdgeKey, FIELD_SEPARATOR, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
28
+ import { lowerCanonicalOp } from './OpNormalizer.js';
27
29
  import { encodePatchMessage, decodePatchMessage, detectMessageKind } from './WarpMessageCodec.js';
28
30
  import { buildWriterRef } from '../utils/RefLayout.js';
29
31
  import WriterError from '../errors/WriterError.js';
@@ -66,6 +68,30 @@ function findAttachedData(state, nodeId) {
66
68
  return { edges, props, hasData: edges.length > 0 || props.length > 0 };
67
69
  }
68
70
 
71
+ /**
72
+ * Validates that an identifier does not contain reserved bytes that would
73
+ * make the legacy edge-property encoding ambiguous.
74
+ *
75
+ * Rejects:
76
+ * - Identifiers containing \0 (field separator)
77
+ * - Identifiers starting with \x01 (edge property prefix)
78
+ *
79
+ * @param {string} value - Identifier to validate
80
+ * @param {string} label - Human-readable label for error messages
81
+ * @throws {Error} If the identifier contains reserved bytes
82
+ */
83
+ function _assertNoReservedBytes(value, label) {
84
+ if (typeof value !== 'string') {
85
+ throw new Error(`${label} must be a string, got ${typeof value}`);
86
+ }
87
+ if (value.includes(FIELD_SEPARATOR)) {
88
+ throw new Error(`${label} must not contain null bytes (\\0): ${JSON.stringify(value)}`);
89
+ }
90
+ if (value.length > 0 && value[0] === EDGE_PROP_PREFIX) {
91
+ throw new Error(`${label} must not start with reserved prefix \\x01: ${JSON.stringify(value)}`);
92
+ }
93
+ }
94
+
69
95
  /**
70
96
  * Fluent builder for creating WARP v5 patches with dots and observed-remove semantics.
71
97
  */
@@ -85,7 +111,7 @@ export class PatchBuilderV2 {
85
111
  * @param {Function|null} [options.onCommitSuccess] - Callback invoked after successful commit
86
112
  * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
87
113
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
88
- * @param {{ warn: Function }} [options.logger] - Logger for non-fatal warnings
114
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for non-fatal warnings
89
115
  */
90
116
  constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
91
117
  /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} */
@@ -133,7 +159,7 @@ export class PatchBuilderV2 {
133
159
  /** @type {import('../../ports/CodecPort.js').default} */
134
160
  this._codec = codec || defaultCodec;
135
161
 
136
- /** @type {{ warn: Function }} */
162
+ /** @type {import('../../ports/LoggerPort.js').default} */
137
163
  this._logger = logger || nullLogger;
138
164
 
139
165
  /**
@@ -233,6 +259,7 @@ export class PatchBuilderV2 {
233
259
  */
234
260
  addNode(nodeId) {
235
261
  this._assertNotCommitted();
262
+ _assertNoReservedBytes(nodeId, 'nodeId');
236
263
  const dot = vvIncrement(this._vv, this._writerId);
237
264
  this._ops.push(createNodeAddV2(nodeId, dot));
238
265
  // Provenance: NodeAdd writes the node
@@ -305,8 +332,7 @@ export class PatchBuilderV2 {
305
332
  }
306
333
 
307
334
  if (this._onDeleteWithData === 'warn') {
308
- // eslint-disable-next-line no-console
309
- console.warn(
335
+ this._logger.warn(
310
336
  `[warp] Deleting node '${nodeId}' which has attached data (${summary}). ` +
311
337
  `Orphaned data will remain in state.`
312
338
  );
@@ -349,6 +375,9 @@ export class PatchBuilderV2 {
349
375
  */
350
376
  addEdge(from, to, label) {
351
377
  this._assertNotCommitted();
378
+ _assertNoReservedBytes(from, 'from node ID');
379
+ _assertNoReservedBytes(to, 'to node ID');
380
+ _assertNoReservedBytes(label, 'edge label');
352
381
  const dot = vvIncrement(this._vv, this._writerId);
353
382
  this._ops.push(createEdgeAddV2(from, to, label, dot));
354
383
  const edgeKey = encodeEdgeKey(from, to, label);
@@ -428,9 +457,11 @@ export class PatchBuilderV2 {
428
457
  */
429
458
  setProperty(nodeId, key, value) {
430
459
  this._assertNotCommitted();
431
- // Props don't use dots - they use EventId from patch context
432
- this._ops.push(createPropSetV2(nodeId, key, value));
433
- // Provenance: PropSet reads the node (implicit existence check) and writes the node
460
+ _assertNoReservedBytes(nodeId, 'nodeId');
461
+ _assertNoReservedBytes(key, 'property key');
462
+ // Canonical NodePropSet lowered to raw PropSet at commit time
463
+ this._ops.push(createNodePropSetV2(nodeId, key, value));
464
+ // Provenance: NodePropSet reads the node (implicit existence check) and writes the node
434
465
  this._observedOperands.add(nodeId);
435
466
  this._writes.add(nodeId);
436
467
  return this;
@@ -475,6 +506,10 @@ export class PatchBuilderV2 {
475
506
  */
476
507
  setEdgeProperty(from, to, label, key, value) {
477
508
  this._assertNotCommitted();
509
+ _assertNoReservedBytes(from, 'from node ID');
510
+ _assertNoReservedBytes(to, 'to node ID');
511
+ _assertNoReservedBytes(label, 'edge label');
512
+ _assertNoReservedBytes(key, 'property key');
478
513
  // Validate edge exists in this patch or in current state
479
514
  const ek = encodeEdgeKey(from, to, label);
480
515
  if (!this._edgesAdded.has(ek)) {
@@ -484,15 +519,10 @@ export class PatchBuilderV2 {
484
519
  }
485
520
  }
486
521
 
487
- // Encode the edge identity as the "node" field with the \x01 prefix.
488
- // When JoinReducer processes: encodePropKey(op.node, op.key)
489
- // = `\x01from\0to\0label` + `\0` + key
490
- // = `\x01from\0to\0label\0key`
491
- // = encodeEdgePropKey(from, to, label, key)
492
- const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
493
- this._ops.push(createPropSetV2(edgeNode, key, value));
522
+ // Canonical EdgePropSet lowered to legacy raw PropSet at commit time
523
+ this._ops.push(createEdgePropSetV2(from, to, label, key, value));
494
524
  this._hasEdgeProps = true;
495
- // Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
525
+ // Provenance: EdgePropSet reads the edge (implicit existence check) and writes the edge
496
526
  this._observedOperands.add(ek);
497
527
  this._writes.add(ek);
498
528
  return this;
@@ -515,6 +545,9 @@ export class PatchBuilderV2 {
515
545
  */
516
546
  async attachContent(nodeId, content) {
517
547
  this._assertNotCommitted();
548
+ // Validate identifiers before writing blob to avoid orphaned blobs
549
+ _assertNoReservedBytes(nodeId, 'nodeId');
550
+ _assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
518
551
  const oid = await this._persistence.writeBlob(content);
519
552
  this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
520
553
  this._contentBlobs.push(oid);
@@ -533,6 +566,11 @@ export class PatchBuilderV2 {
533
566
  */
534
567
  async attachEdgeContent(from, to, label, content) {
535
568
  this._assertNotCommitted();
569
+ // Validate identifiers before writing blob to avoid orphaned blobs
570
+ _assertNoReservedBytes(from, 'from');
571
+ _assertNoReservedBytes(to, 'to');
572
+ _assertNoReservedBytes(label, 'label');
573
+ _assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
536
574
  const oid = await this._persistence.writeBlob(content);
537
575
  this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
538
576
  this._contentBlobs.push(oid);
@@ -559,12 +597,14 @@ export class PatchBuilderV2 {
559
597
  */
560
598
  build() {
561
599
  const schema = this._hasEdgeProps ? 3 : 2;
600
+ // Lower canonical ops to raw form for the persisted patch
601
+ const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
562
602
  return createPatchV2({
563
603
  schema,
564
604
  writer: this._writerId,
565
605
  lamport: this._lamport,
566
606
  context: vvSerialize(this._vv),
567
- ops: this._ops,
607
+ ops: rawOps,
568
608
  reads: [...this._observedOperands].sort(),
569
609
  writes: [...this._writes].sort(),
570
610
  });
@@ -678,13 +718,14 @@ export class PatchBuilderV2 {
678
718
  // For now, we use the calculated lamport for the patch metadata.
679
719
  // The dots themselves are independent of patch lamport (they use VV counters).
680
720
  const schema = this._hasEdgeProps ? 3 : 2;
681
- // Use createPatchV2 for consistent patch construction (DRY with build())
721
+ // Lower canonical ops to raw form for the persisted patch
722
+ const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
682
723
  const patch = createPatchV2({
683
724
  schema,
684
725
  writer: this._writerId,
685
726
  lamport,
686
727
  context: vvSerialize(this._vv),
687
- ops: this._ops,
728
+ ops: rawOps,
688
729
  reads: [...this._observedOperands].sort(),
689
730
  writes: [...this._writes].sort(),
690
731
  });
@@ -726,7 +767,7 @@ export class PatchBuilderV2 {
726
767
  await this._onCommitSuccess({ patch, sha: newCommitSha });
727
768
  } catch (err) {
728
769
  // Commit is already persisted — log but don't fail the caller.
729
- this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
770
+ this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, { error: err });
730
771
  }
731
772
  }
732
773
 
@@ -68,6 +68,8 @@ export function buildCanonicalPayload({ keyId, method, path, timestamp, nonce, c
68
68
  */
69
69
  export async function signSyncRequest({ method, path, contentType, body, secret, keyId }, { crypto } = {}) {
70
70
  const c = crypto || defaultCrypto;
71
+ // Wall-clock timestamp required for HMAC replay protection (not a perf timer)
72
+ // eslint-disable-next-line no-restricted-syntax
71
73
  const timestamp = String(Date.now());
72
74
  const nonce = globalThis.crypto.randomUUID();
73
75
 
@@ -187,6 +189,7 @@ export default class SyncAuthService {
187
189
  this._mode = mode;
188
190
  this._crypto = crypto || defaultCrypto;
189
191
  this._logger = logger || nullLogger;
192
+ // eslint-disable-next-line no-restricted-syntax -- wall-clock fallback for HMAC verification
190
193
  this._wallClockMs = wallClockMs || (() => Date.now());
191
194
  this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
192
195
  this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
@@ -39,7 +39,7 @@
39
39
  import defaultCodec from '../utils/defaultCodec.js';
40
40
  import nullLogger from '../utils/nullLogger.js';
41
41
  import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
42
- import { join, cloneStateV5, isKnownOp } from './JoinReducer.js';
42
+ import { join, cloneStateV5, isKnownRawOp } from './JoinReducer.js';
43
43
  import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js';
44
44
  import { cloneFrontier, updateFrontier } from './Frontier.js';
45
45
  import { vvDeserialize } from '../crdt/VersionVector.js';
@@ -559,17 +559,18 @@ export function applySyncResponse(response, state, frontier) {
559
559
  // Normalize patch context (in case it came from network serialization)
560
560
  const normalizedPatch = normalizePatch(patch);
561
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.
562
+ // Uses isKnownRawOp to accept only the 6 wire-format types. Canonical-only
563
+ // types (NodePropSet, EdgePropSet) must never appear on the wire before
564
+ // ADR 2 capability cutover — reject them here to fail closed.
564
565
  for (const op of normalizedPatch.ops) {
565
- if (!isKnownOp(op)) {
566
+ if (!isKnownRawOp(op)) {
566
567
  throw new SchemaUnsupportedError(
567
568
  `Patch ${sha} contains unknown op type: ${op.type}`
568
569
  );
569
570
  }
570
571
  }
571
572
  // Guard: reject patches exceeding our maximum supported schema version.
572
- // isKnownOp() above checks op-type recognition; this checks the schema
573
+ // isKnownRawOp() above checks op-type recognition; this checks the schema
573
574
  // version ceiling. Currently SCHEMA_V3 is the max.
574
575
  assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
575
576
  // Apply patch to state
@@ -29,4 +29,6 @@ export {
29
29
  assertOpsCompatible,
30
30
  SCHEMA_V2,
31
31
  SCHEMA_V3,
32
+ PATCH_SCHEMA_V2,
33
+ PATCH_SCHEMA_V3,
32
34
  } from './MessageSchemaDetector.js';
@@ -16,6 +16,13 @@ export const SUPPORTED_ALGORITHMS = new Set(['ed25519']);
16
16
 
17
17
  const ED25519_PUBLIC_KEY_LENGTH = 32;
18
18
 
19
+ /**
20
+ * DER-encoded SPKI prefix for Ed25519 public keys (RFC 8410, Section 4).
21
+ * Prepend to a 32-byte raw key to form a valid SPKI structure for `createPublicKey()`.
22
+ * @see https://www.rfc-editor.org/rfc/rfc8410#section-4
23
+ */
24
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
25
+
19
26
  /**
20
27
  * Decodes a base64-encoded Ed25519 public key and validates its length.
21
28
  *
@@ -81,11 +88,7 @@ export function verifySignature({
81
88
  const raw = decodePublicKey(publicKeyBase64);
82
89
 
83
90
  const keyObject = createPublicKey({
84
- key: Buffer.concat([
85
- // DER prefix for Ed25519 public key (RFC 8410)
86
- Buffer.from('302a300506032b6570032100', 'hex'),
87
- raw,
88
- ]),
91
+ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
89
92
  format: 'der',
90
93
  type: 'spki',
91
94
  });
@@ -25,6 +25,8 @@ export const OP_TYPES = Object.freeze([
25
25
  'EdgeAdd',
26
26
  'EdgeTombstone',
27
27
  'PropSet',
28
+ 'NodePropSet',
29
+ 'EdgePropSet',
28
30
  'BlobValue',
29
31
  ]);
30
32
 
@@ -80,9 +82,9 @@ function validateOp(op, index) {
80
82
  /**
81
83
  * Validates that an operation type is one of the allowed OP_TYPES.
82
84
  *
83
- * Valid operation types correspond to the six patch operations defined
84
- * in PatchBuilderV2: NodeAdd, NodeTombstone, EdgeAdd, EdgeTombstone,
85
- * PropSet, and BlobValue.
85
+ * Valid operation types correspond to the eight receipt operation types:
86
+ * NodeAdd, NodeTombstone, EdgeAdd, EdgeTombstone, PropSet, NodePropSet,
87
+ * EdgePropSet, and BlobValue.
86
88
  *
87
89
  * @param {unknown} value - The operation type to validate
88
90
  * @param {number} i - Index of the operation in the ops array (for error messages)
@@ -156,7 +158,7 @@ function validateOpResult(value, i) {
156
158
 
157
159
  /**
158
160
  * @typedef {Object} OpOutcome
159
- * @property {string} op - Operation type ('NodeAdd' | 'NodeTombstone' | 'EdgeAdd' | 'EdgeTombstone' | 'PropSet' | 'BlobValue')
161
+ * @property {string} op - Operation type ('NodeAdd' | 'NodeTombstone' | 'EdgeAdd' | 'EdgeTombstone' | 'PropSet' | 'NodePropSet' | 'EdgePropSet' | 'BlobValue')
160
162
  * @property {string} target - Node ID or edge key
161
163
  * @property {'applied' | 'superseded' | 'redundant'} result - Outcome of the operation
162
164
  * @property {string} [reason] - Human-readable explanation (e.g., "LWW: writer bob at lamport 43 wins")
@@ -74,18 +74,64 @@
74
74
  */
75
75
 
76
76
  /**
77
- * Property set operation - sets a property value on a node
78
- * Uses EventId for identification (derived from patch context)
77
+ * Property set operation - sets a property value on a node (raw/persisted form).
78
+ * Uses EventId for identification (derived from patch context).
79
+ *
80
+ * In raw patches, edge properties are also encoded as PropSet with the node
81
+ * field carrying a \x01-prefixed edge identity. See {@link OpV2NodePropSet}
82
+ * and {@link OpV2EdgePropSet} for the canonical (internal) representations.
83
+ *
79
84
  * @typedef {Object} OpV2PropSet
80
85
  * @property {'PropSet'} type - Operation type discriminator
86
+ * @property {NodeId} node - Node ID to set property on (may contain \x01 prefix for edge props)
87
+ * @property {string} key - Property key
88
+ * @property {unknown} value - Property value (any JSON-serializable type)
89
+ */
90
+
91
+ /**
92
+ * Canonical node property set operation (internal only — never persisted).
93
+ * @typedef {Object} OpV2NodePropSet
94
+ * @property {'NodePropSet'} type - Operation type discriminator
81
95
  * @property {NodeId} node - Node ID to set property on
82
96
  * @property {string} key - Property key
83
97
  * @property {unknown} value - Property value (any JSON-serializable type)
84
98
  */
85
99
 
86
100
  /**
87
- * Union of all v2 operation types
88
- * @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2PropSet} OpV2
101
+ * Canonical edge property set operation (internal only — never persisted).
102
+ * @typedef {Object} OpV2EdgePropSet
103
+ * @property {'EdgePropSet'} type - Operation type discriminator
104
+ * @property {NodeId} from - Source node ID
105
+ * @property {NodeId} to - Target node ID
106
+ * @property {string} label - Edge label
107
+ * @property {string} key - Property key
108
+ * @property {unknown} value - Property value (any JSON-serializable type)
109
+ */
110
+
111
+ /**
112
+ * Blob value reference operation.
113
+ * @typedef {Object} OpV2BlobValue
114
+ * @property {'BlobValue'} type - Operation type discriminator
115
+ * @property {string} node - Node ID the blob is attached to
116
+ * @property {string} oid - Blob object ID in the Git object store
117
+ */
118
+
119
+ /**
120
+ * Union of all raw (persisted) v2 operation types.
121
+ * @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2PropSet | OpV2BlobValue} RawOpV2
122
+ */
123
+
124
+ /**
125
+ * Union of all canonical (internal) v2 operation types.
126
+ * Reducers, provenance, receipts, and queries operate on canonical ops only.
127
+ * @typedef {OpV2NodeAdd | OpV2NodeRemove | OpV2EdgeAdd | OpV2EdgeRemove | OpV2NodePropSet | OpV2EdgePropSet | OpV2BlobValue} CanonicalOpV2
128
+ */
129
+
130
+ /**
131
+ * Union of all v2 operation types (raw + canonical).
132
+ * Used in patch containers that may hold either raw ops (from disk)
133
+ * or canonical ops (after normalization).
134
+ * @typedef {RawOpV2 | CanonicalOpV2} OpV2
89
135
  */
90
136
 
91
137
  // ============================================================================
@@ -153,7 +199,9 @@ export function createEdgeRemoveV2(from, to, label, observedDots) {
153
199
  }
154
200
 
155
201
  /**
156
- * Creates a PropSet operation (no dot - uses EventId)
202
+ * Creates a raw PropSet operation (no dot - uses EventId).
203
+ * This is the persisted form. For internal use, prefer
204
+ * {@link createNodePropSetV2} or {@link createEdgePropSetV2}.
157
205
  * @param {NodeId} node - Node ID to set property on
158
206
  * @param {string} key - Property key
159
207
  * @param {unknown} value - Property value (any JSON-serializable type)
@@ -163,6 +211,30 @@ export function createPropSetV2(node, key, value) {
163
211
  return { type: 'PropSet', node, key, value };
164
212
  }
165
213
 
214
+ /**
215
+ * Creates a canonical NodePropSet operation (internal only).
216
+ * @param {NodeId} node - Node ID to set property on
217
+ * @param {string} key - Property key
218
+ * @param {unknown} value - Property value (any JSON-serializable type)
219
+ * @returns {OpV2NodePropSet} NodePropSet operation
220
+ */
221
+ export function createNodePropSetV2(node, key, value) {
222
+ return { type: 'NodePropSet', node, key, value };
223
+ }
224
+
225
+ /**
226
+ * Creates a canonical EdgePropSet operation (internal only).
227
+ * @param {NodeId} from - Source node ID
228
+ * @param {NodeId} to - Target node ID
229
+ * @param {string} label - Edge label
230
+ * @param {string} key - Property key
231
+ * @param {unknown} value - Property value (any JSON-serializable type)
232
+ * @returns {OpV2EdgePropSet} EdgePropSet operation
233
+ */
234
+ export function createEdgePropSetV2(from, to, label, key, value) {
235
+ return { type: 'EdgePropSet', from, to, label, key, value };
236
+ }
237
+
166
238
  // ============================================================================
167
239
  // Factory Functions - Patch
168
240
  // ============================================================================
@@ -13,6 +13,7 @@ const defaultClock = {
13
13
  return performance.now();
14
14
  },
15
15
  timestamp() {
16
+ // eslint-disable-next-line no-restricted-syntax -- ClockPort implementation
16
17
  return new Date().toISOString();
17
18
  },
18
19
  };
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import defaultCodec from '../utils/defaultCodec.js';
18
+ import nullLogger from '../utils/nullLogger.js';
18
19
  import { validateWriterId, buildWriterRef } from '../utils/RefLayout.js';
19
20
  import { PatchSession } from './PatchSession.js';
20
21
  import { PatchBuilderV2 } from '../services/PatchBuilderV2.js';
@@ -44,8 +45,9 @@ export class Writer {
44
45
  * @param {(result: {patch: Object, sha: string}) => void | Promise<void>} [options.onCommitSuccess] - Callback invoked after successful commit with { patch, sha }
45
46
  * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
46
47
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec)
48
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
47
49
  */
48
- constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec }) {
50
+ constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec, logger }) {
49
51
  validateWriterId(writerId);
50
52
 
51
53
  /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default} */
@@ -72,6 +74,9 @@ export class Writer {
72
74
  /** @type {import('../../ports/CodecPort.js').default|undefined} */
73
75
  this._codec = codec || defaultCodec;
74
76
 
77
+ /** @type {import('../../ports/LoggerPort.js').default} */
78
+ this._logger = logger || nullLogger;
79
+
75
80
  /** @type {boolean} */
76
81
  this._commitInProgress = false;
77
82
  }
@@ -151,6 +156,7 @@ export class Writer {
151
156
  onCommitSuccess: this._onCommitSuccess,
152
157
  onDeleteWithData: this._onDeleteWithData,
153
158
  codec: this._codec,
159
+ logger: this._logger,
154
160
  });
155
161
 
156
162
  // Return PatchSession wrapping the builder
@@ -104,7 +104,7 @@ export async function fork({ from, at, forkName, forkWriterId }) {
104
104
 
105
105
  // 4. Generate or validate fork name (add random suffix to prevent collisions)
106
106
  const resolvedForkName =
107
- forkName ?? `${this._graphName}-fork-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
107
+ forkName ?? `${this._graphName}-fork-${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`;
108
108
  try {
109
109
  validateGraphName(resolvedForkName);
110
110
  } catch (err) {
@@ -291,6 +291,7 @@ export async function writer(writerId) {
291
291
  onDeleteWithData: this._onDeleteWithData,
292
292
  onCommitSuccess: /** @type {(result: {patch: Object, sha: string}) => void} */ (/** @type {unknown} */ ((/** @type {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} */ opts) => this._onPatchCommitted(resolvedWriterId, opts))),
293
293
  codec: this._codec,
294
+ logger: this._logger || undefined,
294
295
  });
295
296
  }
296
297
 
@@ -349,6 +350,7 @@ export async function createWriter(opts = {}) {
349
350
  onDeleteWithData: this._onDeleteWithData,
350
351
  onCommitSuccess: /** @type {(result: {patch: Object, sha: string}) => void} */ (/** @type {unknown} */ ((/** @type {{patch?: import('../types/WarpTypesV2.js').PatchV2, sha?: string}} */ commitOpts) => this._onPatchCommitted(freshWriterId, commitOpts))),
351
352
  codec: this._codec,
353
+ logger: this._logger || undefined,
352
354
  });
353
355
  }
354
356