@izagood/avcs 0.31.2 → 0.32.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.
@@ -29,6 +29,22 @@ export interface AutoDecision {
29
29
  rejectedOps: string[];
30
30
  reason: string;
31
31
  policyVersion: string;
32
+ /** Set when the decision settled one contended REGION of a file rather than a whole op
33
+ * (docs/22 §3.3): the base line range `[baseStart, baseEnd)` the winner took. Together
34
+ * with `key` and `chosenOp` this is the decision's idempotent identity — the same region
35
+ * never mints a second record on a re-reduce. */
36
+ region?: {
37
+ baseStart: number;
38
+ baseEnd: number;
39
+ };
40
+ /** Per-option score breakdown, in option order: which option each op represented, the
41
+ * score that represented it, and whether it was out of candidacy. Answers "why did my
42
+ * change lose this region". Free — `evaluateOp` already computed all of it. */
43
+ optionScores?: {
44
+ opOid: string;
45
+ score: number;
46
+ excluded: boolean;
47
+ }[];
32
48
  }
33
49
  export interface ReductionResult {
34
50
  /** path → blobOid */
@@ -89,6 +105,10 @@ export interface FileConflict {
89
105
  file: string;
90
106
  ops: string[];
91
107
  regions: ConflictRegion[];
108
+ /** Binary/non-line-mergeable content: the whole file is ONE opaque contest and the
109
+ * region's `sides` index distinct contents, not `ops`. Never arbitrated (docs/22 §3.1)
110
+ * — side → op is not recoverable, so only a human can settle it. */
111
+ atomic?: boolean;
92
112
  }
93
113
  /**
94
114
  * Detect line-level merge conflicts among CONCURRENT accepted edit_file ops on the same
@@ -107,6 +127,29 @@ export interface FileConflict {
107
127
  * resolution is the identity and the buckets are exactly the declared paths, as before.
108
128
  */
109
129
  export declare function detectFileConflicts(ops: Operation[], result: ReductionResult, blobContent: Map<string, Buffer>): FileConflict[];
130
+ /** The authoritative conflict set after region arbitration (docs/22 §3.4). */
131
+ export interface FileConflictArbitration {
132
+ /** Files that still hold at least one region policy could not decide. A file whose every
133
+ * region was decided drops out entirely — there is nothing left to ask. */
134
+ remaining: FileConflict[];
135
+ /** One {@link AutoDecision} per decided region (docs/22 §3.3). */
136
+ decisions: AutoDecision[];
137
+ }
138
+ /**
139
+ * Arbitrate the regions {@link detectFileConflicts} found — the authoritative pass, and the
140
+ * one place where side → op is exact: `FileConflict.ops` is the file's concurrent frontier in
141
+ * canonical order and `region.options[].sides` index straight into it.
142
+ *
143
+ * A decided region leaves the conflict set (policy decided it, so it is no longer a question
144
+ * for a human) and is recorded as an `AutoDecision` — an automatic decision without an audit
145
+ * trail is not allowed (docs/00 principle 4, at region granularity). An undecided region
146
+ * stays exactly as it was: the tree holds the deterministic fallback content and the conflict
147
+ * still reaches the release gate.
148
+ *
149
+ * Scores are lazy and memoized per op, so only the ops of a file that actually contended are
150
+ * ever evaluated (docs/22 R-c).
151
+ */
152
+ export declare function arbitrateFileConflicts(fileConflicts: FileConflict[], input: ReduceInput): FileConflictArbitration;
110
153
  /** A single group's locally-decided statuses + the conflicts/autoDecisions it emitted. */
111
154
  export interface PerKeyDecision {
112
155
  local: Map<string, OperationStatus>;
@@ -164,4 +207,22 @@ export declare class NonIncrementalError extends Error {
164
207
  export declare function reduceIncremental(snap: ReduceSnapshot, next: ReduceInput): ReduceSnapshot;
165
208
  export declare function serializeSnapshot(snap: ReduceSnapshot): unknown;
166
209
  export declare function deserializeSnapshot(raw: unknown): ReduceSnapshot;
210
+ /** One op's policy verdict, as region arbitration needs it (docs/22 §3.2). */
211
+ export interface OpScore {
212
+ score: number;
213
+ /** Out of candidacy: a failed evidence gate, or a rule that reserves the call for a
214
+ * human. An excluded op's content must never take a region — that exclusion is the
215
+ * real effect of this whole track (docs/22 §3.2-3). */
216
+ excluded: boolean;
217
+ }
218
+ /** opOid → verdict. `undefined` for an op the scorer does not know, which makes
219
+ * arbitration abstain rather than guess. */
220
+ export type OpScorer = (opOid: string) => OpScore | undefined;
221
+ /**
222
+ * Build an {@link OpScorer} from a bare `ReduceInput`, for a caller outside `reduce` — the
223
+ * authoritative post-reduce pass (`detectFileConflicts` → {@link arbitrateFileConflicts}).
224
+ * Evaluation is lazy and memoized per op, so only the ops of a file that actually contended
225
+ * are ever scored.
226
+ */
227
+ export declare function buildOpScorer(input: ReduceInput): OpScorer;
167
228
  //# sourceMappingURL=reducer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reducer.d.ts","sourceRoot":"","sources":["../../src/reducer/reducer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"reducer.d.ts","sourceRoot":"","sources":["../../src/reducer/reducer.ts"],"names":[],"mappings":"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"}
@@ -12,7 +12,7 @@
12
12
  import { sha256hex, canonicalize } from "../core/canonical.js";
13
13
  import { evaluateOp } from "./policy.js";
14
14
  import { buildAliasMap, resolvePath } from "./aliases.js";
15
- import { merge3 } from "../merge/merge3.js";
15
+ import { merge3, diffHunks } from "../merge/merge3.js";
16
16
  import { ownersFor } from "../policy/owners.js";
17
17
  import { isBinary } from "../core/bytes.js";
18
18
  import { Buffer } from "node:buffer";
@@ -179,7 +179,7 @@ export function detectFileConflicts(ops, result, blobContent) {
179
179
  options: distinct.map((text, i) => ({ sides: [i], text })),
180
180
  },
181
181
  ];
182
- out.push({ file, ops: ordered.map((o) => o.oid), regions });
182
+ out.push({ file, ops: ordered.map((o) => o.oid), regions, atomic: true });
183
183
  continue;
184
184
  }
185
185
  const base = baseBuf.toString("utf8");
@@ -190,6 +190,64 @@ export function detectFileConflicts(ops, result, blobContent) {
190
190
  }
191
191
  return out;
192
192
  }
193
+ /**
194
+ * Arbitrate the regions {@link detectFileConflicts} found — the authoritative pass, and the
195
+ * one place where side → op is exact: `FileConflict.ops` is the file's concurrent frontier in
196
+ * canonical order and `region.options[].sides` index straight into it.
197
+ *
198
+ * A decided region leaves the conflict set (policy decided it, so it is no longer a question
199
+ * for a human) and is recorded as an `AutoDecision` — an automatic decision without an audit
200
+ * trail is not allowed (docs/00 principle 4, at region granularity). An undecided region
201
+ * stays exactly as it was: the tree holds the deterministic fallback content and the conflict
202
+ * still reaches the release gate.
203
+ *
204
+ * Scores are lazy and memoized per op, so only the ops of a file that actually contended are
205
+ * ever evaluated (docs/22 R-c).
206
+ */
207
+ export function arbitrateFileConflicts(fileConflicts, input) {
208
+ if (fileConflicts.length === 0)
209
+ return { remaining: [], decisions: [] };
210
+ const scoreOf = buildOpScorer(input);
211
+ const remaining = [];
212
+ const decisions = [];
213
+ const seen = new Set(); // idempotent identity: key + region bounds + winner (R-d)
214
+ for (const fc of fileConflicts) {
215
+ if (fc.atomic) {
216
+ remaining.push(fc);
217
+ continue;
218
+ }
219
+ const key = `file:${fc.file}`;
220
+ const open = [];
221
+ for (const region of fc.regions) {
222
+ const sideOps = (side) => {
223
+ const oid = fc.ops[side];
224
+ return oid === undefined ? [] : [oid];
225
+ };
226
+ const v = judgeRegion(region, sideOps, scoreOf);
227
+ if (!v) {
228
+ open.push(region);
229
+ continue;
230
+ }
231
+ const idem = `${key}|${region.baseStart}|${region.baseEnd}|${v.chosen}`;
232
+ if (seen.has(idem))
233
+ continue;
234
+ seen.add(idem);
235
+ decisions.push({
236
+ key,
237
+ conflictId: conflictIdFor(key),
238
+ chosenOp: v.chosen,
239
+ rejectedOps: v.rejected,
240
+ reason: "region-arbitration",
241
+ policyVersion: input.policy.version,
242
+ region: { baseStart: region.baseStart, baseEnd: region.baseEnd },
243
+ optionScores: v.optionScores,
244
+ });
245
+ }
246
+ if (open.length)
247
+ remaining.push({ ...fc, regions: open });
248
+ }
249
+ return { remaining, decisions };
250
+ }
193
251
  export function reduce(input) {
194
252
  return snapshotReduce(input).result;
195
253
  }
@@ -275,14 +333,15 @@ function frontier(ops, statuses, anc) {
275
333
  covered.add(a);
276
334
  return [...acceptedIds].filter((id) => !covered.has(id));
277
335
  }
278
- function materializeProjection(ops, statuses, anc, materializeStatuses, blobContent) {
336
+ function materializeProjection(ops, statuses, anc, materializeStatuses, blobContent, scoreOf) {
279
337
  const projected = ops.filter((o) => materializeStatuses.has(statuses.get(o.oid)));
280
338
  const ordered = kahnOrder(projected, anc);
281
339
  const alias = aliasCtxFor(projected, anc);
282
340
  const tree = new Map();
283
341
  const synthBlobs = new Map();
342
+ const arb = scoreOf ? { scoreOf, prov: new Map() } : undefined;
284
343
  for (const op of ordered)
285
- applyOp(tree, op, blobContent, synthBlobs, alias);
344
+ applyOp(tree, op, blobContent, synthBlobs, alias, arb);
286
345
  return { tree, treeHash: treeHashOf(tree), headOps: frontier(ops, statuses, anc), synthBlobs: pruneSynth(tree, synthBlobs) };
287
346
  }
288
347
  /**
@@ -295,7 +354,7 @@ function materializeProjection(ops, statuses, anc, materializeStatuses, blobCont
295
354
  * Replayed ops only ever read/write dirty paths, so a fresh replay tree + clean base
296
355
  * entries reconstructs the full tree exactly. Equivalence is enforced by the A0 harness.
297
356
  */
298
- function materializeIncremental(ops, statuses, anc, materializeStatuses, blobContent, base, dirtyPaths) {
357
+ function materializeIncremental(ops, statuses, anc, materializeStatuses, blobContent, base, dirtyPaths, scoreOf) {
299
358
  const projected = ops.filter((o) => materializeStatuses.has(statuses.get(o.oid)));
300
359
  const ordered = kahnOrder(projected, anc);
301
360
  // The alias map is a function of the WHOLE projected rename set, never of the dirty
@@ -309,9 +368,10 @@ function materializeIncremental(ops, statuses, anc, materializeStatuses, blobCon
309
368
  // dropped as dirty. `AVCS_VERIFY_INCREMENTAL=1` and the C22 cases gate it.
310
369
  const replayTree = new Map();
311
370
  const replaySynth = new Map();
371
+ const arb = scoreOf ? { scoreOf, prov: new Map() } : undefined;
312
372
  for (const op of ordered) {
313
373
  if (pathsOf(op).some((p) => dirtyPaths.has(p)))
314
- applyOp(replayTree, op, blobContent, replaySynth, alias);
374
+ applyOp(replayTree, op, blobContent, replaySynth, alias, arb);
315
375
  }
316
376
  // Final tree = clean base paths (not dirty) + replayed dirty paths.
317
377
  const tree = new Map();
@@ -380,7 +440,7 @@ export function snapshotReduce(input) {
380
440
  for (const o of ops)
381
441
  if (keysOf(o).length === 0 && statuses.get(o.oid) === "proposed")
382
442
  statuses.set(o.oid, "accepted");
383
- const { tree, treeHash, headOps, synthBlobs } = materializeProjection(ops, statuses, anc, materializeStatuses, input.blobContent ?? new Map());
443
+ const { tree, treeHash, headOps, synthBlobs } = materializeProjection(ops, statuses, anc, materializeStatuses, input.blobContent ?? new Map(), scorerFrom(ops, evalOf));
384
444
  const result = { tree, treeHash, statuses, conflicts, autoDecisions, fileConflicts: [], headOps, synthBlobs, blockedReasons, untrustedEvidence: 0 };
385
445
  const stats = { groupsTotal: groups.size, groupsRecomputed: groups.size, groupsReused: 0, dirtyKeys: groups.size };
386
446
  return { input, result, perKey, groupOrder, groupMembers, stats };
@@ -597,6 +657,18 @@ export function reduceIncremental(snap, next) {
597
657
  return true; // a delta op became this op's ancestor
598
658
  return false;
599
659
  };
660
+ // A contended region's winner is a function of the SCORES of the ops writing that path
661
+ // (docs/22 §4.2), so a change to a score INPUT dirties the path even when the projection is
662
+ // untouched: new evidence for an op, or a reliability shift for its actor, can flip which
663
+ // option takes the region. Without this the warm path would keep a region the cold path
664
+ // re-decides, and warm/cold would disagree on the tree — the one thing incremental reduce
665
+ // may never do. Group dirtiness already covers both signals; this carries them to PATHS.
666
+ const newEvidenceFor = new Set();
667
+ for (const e of evidence)
668
+ if (!prevEvIds.has(e.oid))
669
+ for (const oid of e.forOps)
670
+ newEvidenceFor.add(oid);
671
+ const scoreChanged = (o) => newEvidenceFor.has(o.oid) || changedActors.has(o.actor.id);
600
672
  const dirtyPaths = new Set();
601
673
  for (const o of ops) {
602
674
  const oid = o.oid;
@@ -619,8 +691,12 @@ export function reduceIncremental(snap, next) {
619
691
  else if (projectedNow(oid) && isCrossPath(o))
620
692
  for (const p of pathsOf(o))
621
693
  dirtyPaths.add(p);
694
+ // Score-input change (docs/22): the op still projects, but what it is WORTH changed.
695
+ else if (projectedNow(oid) && scoreChanged(o))
696
+ for (const p of pathsOf(o))
697
+ dirtyPaths.add(p);
622
698
  }
623
- const { tree, treeHash, headOps, synthBlobs } = materializeIncremental(ops, statuses, anc, materializeStatuses, next.blobContent ?? new Map(), snap.result, dirtyPaths);
699
+ const { tree, treeHash, headOps, synthBlobs } = materializeIncremental(ops, statuses, anc, materializeStatuses, next.blobContent ?? new Map(), snap.result, dirtyPaths, scorerFrom(ops, evalOf));
624
700
  const result = { tree, treeHash, statuses, conflicts, autoDecisions, fileConflicts: [], headOps, synthBlobs, blockedReasons, untrustedEvidence: 0 };
625
701
  const stats = { groupsTotal: groups.size, groupsRecomputed: recomputed, groupsReused: reused, dirtyKeys: dirty.size };
626
702
  return { input: next, result, perKey, groupOrder, groupMembers, stats };
@@ -918,6 +994,125 @@ function kahnOrder(ops, anc) {
918
994
  }
919
995
  return order;
920
996
  }
997
+ /**
998
+ * The arbitration rule (docs/22 §3.2) — the one place it is written down.
999
+ *
1000
+ * `opsForSide` maps a variant index back to the op(s) that produced it. The caller owns
1001
+ * that mapping because it differs between the pairwise merge inside `applyOp` and the
1002
+ * authoritative N-way merge in `detectFileConflicts`.
1003
+ *
1004
+ * - An option several ops agree on is represented by its HIGHEST-scoring op. An average
1005
+ * would let a low-trust actor dilute an option merely by co-signing it.
1006
+ * - Any excluded op excludes its whole option: conservative on blocking, generous on
1007
+ * score. An option that cannot be auto-accepted cannot win a region either.
1008
+ * - A tie is never broken by recency. It returns `null`, the region stays a conflict and
1009
+ * a human decides. docs/00 principle 6 allows recency as a last tie-break for op
1010
+ * PROMOTION; region content is where meaning diverges, so deciding it quietly is worse
1011
+ * than raising it.
1012
+ */
1013
+ function judgeRegion(region, opsForSide, scoreOf) {
1014
+ const cands = [];
1015
+ for (const [i, opt] of region.options.entries()) {
1016
+ const oids = [...new Set(opt.sides.flatMap(opsForSide))];
1017
+ if (oids.length === 0)
1018
+ return null; // no op behind an option ⇒ nothing to weigh
1019
+ let excluded = false;
1020
+ let rep;
1021
+ for (const oid of oids) {
1022
+ const v = scoreOf(oid);
1023
+ if (!v)
1024
+ return null; // an op the policy cannot speak about ⇒ abstain, never guess
1025
+ if (v.excluded)
1026
+ excluded = true;
1027
+ if (!rep || v.score > rep.score || (v.score === rep.score && oid < rep.opOid))
1028
+ rep = { opOid: oid, score: v.score };
1029
+ }
1030
+ cands.push({ i, excluded, opOid: rep.opOid, score: rep.score });
1031
+ }
1032
+ const live = cands.filter((c) => !c.excluded);
1033
+ if (live.length === 0)
1034
+ return null; // every option blocked ⇒ a human, and no unverified
1035
+ const topScore = Math.max(...live.map((c) => c.score)); // content takes the region
1036
+ const top = live.filter((c) => c.score === topScore);
1037
+ if (top.length !== 1)
1038
+ return null; // tie ⇒ a human
1039
+ const win = top[0];
1040
+ return {
1041
+ option: win.i,
1042
+ chosen: win.opOid,
1043
+ rejected: cands.filter((c) => c.i !== win.i).map((c) => c.opOid),
1044
+ optionScores: cands.map((c) => ({ opOid: c.opOid, score: c.score, excluded: c.excluded })),
1045
+ };
1046
+ }
1047
+ /**
1048
+ * side → op for `applyOp`'s PAIRWISE merge, which is not the clean mapping docs/22 §3.2
1049
+ * assumed. Side 1 is the op being applied, but side 0 is the ACCUMULATED tree content —
1050
+ * the composition of every content op already applied at that path.
1051
+ *
1052
+ * So the incumbent side is attributed per REGION: only the contributors whose own change
1053
+ * (measured against this merge's base, so the coordinates match the region's) touches the
1054
+ * contested span may speak for it, and among those the strongest represents the option —
1055
+ * the same rule agreement uses. Without the span filter, a high-trust edit to an unrelated
1056
+ * part of the file would defend a low-trust edit's region, and the tree would disagree with
1057
+ * the authoritative N-way pass, which sees every op separately.
1058
+ *
1059
+ * The per-contributor diff is lazy and memoized per merge, so a file with no contended
1060
+ * region pays nothing at all and a contended one pays at most one line-diff per contributor
1061
+ * that a region actually asks about (docs/22 R-c). The trail only ever holds the ops still
1062
+ * projected at that path — a sequential edit chain is superseded before it gets here — so it
1063
+ * is the concurrent frontier's size, not the file's history, that bounds this.
1064
+ */
1065
+ function pairwiseArbiter(baseText, incumbents, self, contentOf, scoreOf) {
1066
+ const baseLines = baseText.split("\n");
1067
+ const spans = new Map();
1068
+ const spansOf = (op) => {
1069
+ const oid = op.oid;
1070
+ let s = spans.get(oid);
1071
+ if (!s) {
1072
+ s = diffHunks(baseLines, contentOf(op).split("\n"));
1073
+ spans.set(oid, s);
1074
+ }
1075
+ return s;
1076
+ };
1077
+ const touches = (op, start, end) => spansOf(op).some((h) => (h.start === h.end ? h.start >= start && h.start <= end : h.start < end && h.end > start));
1078
+ const selfOid = self.oid;
1079
+ return (region) => {
1080
+ const opsForSide = (side) => {
1081
+ if (side !== 0)
1082
+ return [selfOid];
1083
+ const hit = incumbents.filter((o) => touches(o, region.baseStart, region.baseEnd));
1084
+ // No contributor claims the span (a composition whose hunks shifted): fall back to
1085
+ // the whole trail rather than inventing an attribution.
1086
+ return (hit.length ? hit : incumbents).map((o) => o.oid);
1087
+ };
1088
+ return judgeRegion(region, opsForSide, scoreOf)?.option ?? null;
1089
+ };
1090
+ }
1091
+ /** Wrap a reduce's memoized `evalOf` as an {@link OpScorer}. Arbitration REUSES the scores
1092
+ * the group decision already computed — never a recomputation per region (docs/22 R-c). A
1093
+ * contended region IS a conflict, so ops are evaluated with `inConflict = true`, the same
1094
+ * flag their contended group used. */
1095
+ function scorerFrom(ops, evalOf) {
1096
+ const byId = new Map(ops.map((o) => [o.oid, o]));
1097
+ return (oid) => {
1098
+ const op = byId.get(oid);
1099
+ if (!op)
1100
+ return undefined;
1101
+ const ev = evalOf(op, true);
1102
+ return { score: ev.score, excluded: ev.blocked || ev.requiresHuman };
1103
+ };
1104
+ }
1105
+ /**
1106
+ * Build an {@link OpScorer} from a bare `ReduceInput`, for a caller outside `reduce` — the
1107
+ * authoritative post-reduce pass (`detectFileConflicts` → {@link arbitrateFileConflicts}).
1108
+ * Evaluation is lazy and memoized per op, so only the ops of a file that actually contended
1109
+ * are ever scored.
1110
+ */
1111
+ export function buildOpScorer(input) {
1112
+ const evidence = [...input.evidence].sort((a, b) => cmp(a.createdAt, b.createdAt) || cmp(a.oid, b.oid));
1113
+ const evalOf = makeEvalOf(input.policy, input.intents, buildEvByOp(evidence), input.reliability ?? new Map());
1114
+ return scorerFrom(input.ops, evalOf);
1115
+ }
921
1116
  /**
922
1117
  * Apply one projected op to the tree.
923
1118
  *
@@ -927,14 +1122,16 @@ function kahnOrder(ops, anc) {
927
1122
  * order put first. With no renames in the op set the alias map is empty, resolution is
928
1123
  * the identity, and this function behaves exactly as it did before.
929
1124
  */
930
- function applyOp(tree, op, blobContent, synthBlobs, alias) {
1125
+ function applyOp(tree, op, blobContent, synthBlobs, alias, arb) {
931
1126
  const b = op.body;
932
1127
  const resolve = (oid) => synthBlobs.get(oid) ?? blobContent.get(oid) ?? Buffer.alloc(0);
933
1128
  const at = (declared) => resolvedPath(op, declared, alias);
934
1129
  switch (b.kind) {
935
1130
  case "put_file":
936
- if (b.path && b.blobOid)
1131
+ if (b.path && b.blobOid) {
937
1132
  tree.set(at(b.path), b.blobOid);
1133
+ arb?.prov.set(at(b.path), [op]); // a whole-content write restarts the trail
1134
+ }
938
1135
  break;
939
1136
  case "edit_file": {
940
1137
  if (!b.path || !b.blobOid)
@@ -953,21 +1150,32 @@ function applyOp(tree, op, blobContent, synthBlobs, alias) {
953
1150
  const synthOid = `blob_${sha256hex(opNew).slice(0, 32)}`;
954
1151
  synthBlobs.set(synthOid, opNew);
955
1152
  tree.set(path, synthOid);
1153
+ arb?.prov.set(path, [op]);
956
1154
  break;
957
1155
  }
958
1156
  // Apply this op's patch (opBase→opNew) onto the accumulated content. Disjoint
959
1157
  // line changes compose (order-independent); an overlap with a prior concurrent op
960
1158
  // keeps `current` (deterministic incumbent) — the overlap is reported separately
961
1159
  // by detectFileConflicts over the full op set. Language-neutral: pure text.
962
- const m = merge3(opBase.toString("utf8"), [current.toString("utf8"), opNew.toString("utf8")], { onConflict: "first" });
1160
+ // An overlap is no longer settled by which op got here first: the injected arbiter
1161
+ // asks the policy which option wins the region (docs/22 §3.2). It abstains — leaving
1162
+ // the incumbent and the conflict — whenever policy cannot separate the options.
1163
+ const incumbents = arb?.prov.get(path) ?? [];
1164
+ const arbitrate = arb && incumbents.length
1165
+ ? pairwiseArbiter(opBase.toString("utf8"), incumbents, op, (o) => resolve(o.body.blobOid ?? "").toString("utf8"), arb.scoreOf)
1166
+ : undefined;
1167
+ const m = merge3(opBase.toString("utf8"), [current.toString("utf8"), opNew.toString("utf8")], { onConflict: "first", arbitrate });
963
1168
  const mergedBuf = Buffer.from(m.merged, "utf8");
964
1169
  const synthOid = `blob_${sha256hex(mergedBuf).slice(0, 32)}`;
965
1170
  synthBlobs.set(synthOid, mergedBuf);
966
1171
  tree.set(path, synthOid);
1172
+ if (arb)
1173
+ arb.prov.set(path, [...incumbents, op]);
967
1174
  break;
968
1175
  }
969
1176
  case "delete_file":
970
1177
  tree.delete(at(b.path ?? op.target.entityId));
1178
+ arb?.prov.delete(at(b.path ?? op.target.entityId));
971
1179
  break;
972
1180
  case "rename_file": {
973
1181
  if (!b.fromPath || !b.path)
@@ -987,6 +1195,12 @@ function applyOp(tree, op, blobContent, synthBlobs, alias) {
987
1195
  if (blob !== undefined) {
988
1196
  tree.delete(b.fromPath);
989
1197
  tree.set(b.path, blob);
1198
+ if (arb) {
1199
+ const trail = arb.prov.get(b.fromPath);
1200
+ arb.prov.delete(b.fromPath);
1201
+ if (trail)
1202
+ arb.prov.set(b.path, trail); // the content moved, and so does its trail
1203
+ }
990
1204
  }
991
1205
  break;
992
1206
  }