@izagood/avcs 0.15.1 → 0.17.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/dist/api/repo.js CHANGED
@@ -60,13 +60,18 @@ export class Repo {
60
60
  // the rare mutations that can invalidate them (gc deletes; redaction overwrites bytes).
61
61
  #opCache = new Map();
62
62
  #blobCache = new Map();
63
- // Last full reduction's snapshot, for opt-in incremental reduce (docs/11 A6b). Only the
64
- // main materialize path updates it; reduceIncremental is correct for ANY append-superset
65
- // (harness-proven) and throws NonIncrementalError otherwise (→ fall back to full reduce),
66
- // so no filter key is needed. Opt-in via AVCS_INCREMENTAL=1 (default off = full reduce);
67
- // AVCS_VERIFY_INCREMENTAL=1 cross-checks every incremental result against a full reduce.
63
+ // Last full reduction's snapshot, for incremental reduce (docs/11 A6b DEFAULT ON since
64
+ // Phase 13.3). Only the main materialize path updates it; reduceIncremental is correct for
65
+ // ANY append-superset (harness-proven) and throws NonIncrementalError otherwise (→ fall
66
+ // back to full reduce), so no filter key is needed. Opt OUT via AVCS_INCREMENTAL=0;
67
+ // AVCS_VERIFY_INCREMENTAL=1 cross-checks every incremental result against a full reduce
68
+ // (run as a dedicated CI job, not recommended on the hot path).
68
69
  #incSnap = null;
69
- #forceSnapshot = false; // set by compact() to capture a snapshot regardless of env flags
70
+ #forceSnapshot = false; // set by compact() to capture a snapshot even when opted out
71
+ // Op count of the last PERSISTED base per view (Phase 13.3 amortized auto-compaction):
72
+ // once the live snapshot is ≥ AUTO_COMPACT_DELTA ops past it, materialize re-persists.
73
+ #persistedBaseOps = new Map();
74
+ static AUTO_COMPACT_DELTA = 256;
70
75
  constructor(dir, store) {
71
76
  this.dir = dir;
72
77
  this.store = store;
@@ -770,6 +775,336 @@ export class Repo {
770
775
  }
771
776
  return result;
772
777
  }
778
+ // ── integration queue (Phase 14, docs/17) ──────────────────────────────────
779
+ // The end of "head moved — pull and re-reduce first": since ops are an append-only
780
+ // union and reduce is deterministic, a stale submission is never rejected for
781
+ // staleness — the queue re-reduces the frontier UNION on the submitter's behalf.
782
+ // This is a repo API (not hub-only): it also kills the local multi-process funnel.
783
+ /** Causal closure (op oids) of a frontier. Missing objects are skipped — callers gate
784
+ * completeness separately via #missingCausalDeps. */
785
+ async #closureOf(heads) {
786
+ const seen = new Set();
787
+ const stack = [...heads];
788
+ while (stack.length) {
789
+ const id = stack.pop();
790
+ if (seen.has(id))
791
+ continue;
792
+ seen.add(id);
793
+ if (!(await this.store.has(id)))
794
+ continue;
795
+ const op = await this.store.get(id);
796
+ for (const d of op.causalDeps)
797
+ if (!seen.has(d))
798
+ stack.push(d);
799
+ }
800
+ return seen;
801
+ }
802
+ #queueRel(view) {
803
+ return join("queue", `${view}.json`);
804
+ }
805
+ async #readReservation(view) {
806
+ const raw = await this.store.readAux(this.#queueRel(view));
807
+ if (!raw)
808
+ return null;
809
+ try {
810
+ const r = JSON.parse(raw.toString("utf8"));
811
+ return r && typeof r.ticketId === "string" ? r : null;
812
+ }
813
+ catch {
814
+ return null;
815
+ }
816
+ }
817
+ async #writeReservation(view, resv) {
818
+ await this.store.writeAux(this.#queueRel(view), JSON.stringify(resv) + "\n");
819
+ }
820
+ /** Record an Integration verdict (append-only audit) and point the idempotency ref at it. */
821
+ async #recordIntegration(fields) {
822
+ const integ = { type: "integration", ...fields, createdAt: new Date().toISOString() };
823
+ const oid = await this.store.put(integ);
824
+ await this.store.setRef(`integration:${fields.view}:${fields.ticketId}`, oid);
825
+ return oid;
826
+ }
827
+ /** Author a checkpoint AT an integrated frontier (never `materialize(view)` — §1-(A):
828
+ * a view materialize would sweep in un-submitted third-party ops). */
829
+ async #authorIntegratedCheckpoint(view, integrated, evidence, evidenceBinding, summary) {
830
+ const v = await this.getView(view);
831
+ const cp = {
832
+ type: "checkpoint",
833
+ viewOid: v.oid,
834
+ headOps: integrated.headOps,
835
+ treeHash: integrated.treeHash,
836
+ policyOid: (await this.store.getRef("policy")),
837
+ materializerVersion: MATERIALIZER_VERSION,
838
+ evidence,
839
+ ...(evidenceBinding && Object.keys(evidenceBinding).length ? { evidenceBinding } : {}),
840
+ status: integrated.conflicts.length === 0 ? "verified" : "draft",
841
+ summary,
842
+ createdAt: new Date().toISOString(),
843
+ };
844
+ return this.store.put(cp);
845
+ }
846
+ /** Verified (non-agent, canonically ordered) evidence bound to exactly `treeHash`. */
847
+ async #boundEvidenceFor(treeHash) {
848
+ const out = {};
849
+ const all = this.#verifiedEvidence(await this.store.collect("evidence")).sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0) ||
850
+ ((a.oid ?? "") < (b.oid ?? "") ? -1 : 1));
851
+ for (const ev of all) {
852
+ if (ev.producedBy.kind === "ai_agent")
853
+ continue;
854
+ if (ev.treeHash === treeHash)
855
+ out[ev.kind] = ev.result;
856
+ }
857
+ return out;
858
+ }
859
+ /** Keys touched by a set of op oids (contention surface of a delta). */
860
+ async #keysOfOps(oids) {
861
+ const keys = new Set();
862
+ for (const oid of oids) {
863
+ if (!(await this.store.has(oid)))
864
+ continue;
865
+ const op = await this.store.get(oid);
866
+ for (const k of keysOf(op))
867
+ keys.add(k);
868
+ }
869
+ return keys;
870
+ }
871
+ /**
872
+ * Submit a draft checkpoint to the integration queue (docs/17 §14.2). Runs under the
873
+ * same `finalize:<view>` lock as finalize — the existing mkdir lock IS the serializer
874
+ * (no separate queue structure in v1). The outcome is always one of the four verdicts;
875
+ * "pull and redo" does not exist on any path.
876
+ *
877
+ * Idempotency: an `advanced` ticket replays its recorded verdict forever. Non-terminal
878
+ * verdicts (conflict/needs_evidence/rejected/expired) re-evaluate on resubmission —
879
+ * the world legitimately changes under them (a decision lands, evidence arrives, a
880
+ * missing object syncs), and a frozen replay would wedge the ticket.
881
+ */
882
+ async submitIntegration(args) {
883
+ const view = args.view;
884
+ const ticketId = args.ticketId ?? sha256hex(`${view}:${args.checkpoint}`);
885
+ const result = await this.store.withLock(`finalize:${view}`, async () => {
886
+ // 1. Idempotency — a terminal success replays as-is (safe resubmission).
887
+ const priorRef = await this.store.getRef(`integration:${view}:${ticketId}`);
888
+ if (priorRef && (await this.store.has(priorRef))) {
889
+ const prior = await this.store.get(priorRef);
890
+ if (prior.verdict === "advanced") {
891
+ return { verdict: "advanced", head: prior.resultCheckpoint, integration: priorRef };
892
+ }
893
+ }
894
+ // 2. Reservation — one in-flight needs_evidence ticket at a time (TTL-bounded).
895
+ let resv = await this.#readReservation(view);
896
+ if (resv && Date.parse(resv.expiresAt) <= Date.now()) {
897
+ // Expired: audit it and let the queue move on (docs/17 §14 contract test).
898
+ await this.#recordIntegration({
899
+ view, ticketId: resv.ticketId, submittedCheckpoint: resv.submittedCheckpoint,
900
+ baseHead: await this.protectedHead(view), resultCheckpoint: resv.integratedCheckpoint,
901
+ verdict: "expired", reason: `needs_evidence reservation expired at ${resv.expiresAt}`, by: resv.by,
902
+ });
903
+ await this.#writeReservation(view, null);
904
+ resv = null;
905
+ }
906
+ if (resv && resv.ticketId !== ticketId) {
907
+ return { verdict: "queued", behindTicket: resv.ticketId, retryAfterMs: 1000 + Math.floor(Math.random() * 500) };
908
+ }
909
+ // 3. Causal completeness — never judge (or advance to) a partially-synced tree.
910
+ const cp = await this.store.get(args.checkpoint);
911
+ const missing = await this.#missingCausalDeps(cp.headOps);
912
+ if (missing.length) {
913
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead: await this.protectedHead(view), verdict: "rejected", reason: `incomplete causal history: ${missing.length} object(s) missing`, by: args.by });
914
+ return { verdict: "rejected", reason: `incomplete causal history: ${missing.length} object(s) missing — push them first (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""})` };
915
+ }
916
+ // Role gate (same as finalize).
917
+ const prot = await this.getProtection(view);
918
+ if (prot && !(await this.hasRole(args.by, prot.finalizeRole ?? "maintainer"))) {
919
+ const reason = `${args.by} lacks role ${prot.finalizeRole ?? "maintainer"} to integrate ${view}`;
920
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead: await this.protectedHead(view), verdict: "rejected", reason, by: args.by });
921
+ return { verdict: "rejected", reason };
922
+ }
923
+ // 4. Integration reduce — the frontier UNION via the materializeAt path (NEVER
924
+ // materialize(view): §1-(A), un-submitted third-party ops must stay out).
925
+ const baseHead = await this.protectedHead(view);
926
+ const curHeads = baseHead ? (await this.store.get(baseHead)).headOps : [];
927
+ const unionHeads = [...new Set([...curHeads, ...cp.headOps])];
928
+ const integrated = await this.materializeAt(unionHeads);
929
+ const subClosure = await this.#closureOf(cp.headOps);
930
+ const fastForward = curHeads.every((h) => subClosure.has(h));
931
+ // 5. Conflicts — the ONLY outcome that needs a human/agent decision, and it
932
+ // arrives as a minimal repair packet with decision memory, not "pull and redo".
933
+ if (integrated.conflicts.length > 0) {
934
+ const packet = { conflicts: [] };
935
+ for (const c of integrated.conflicts) {
936
+ const fc = integrated.fileConflicts.find((f) => `file:${f.file}` === c.key);
937
+ packet.conflicts.push({
938
+ key: c.key,
939
+ reason: c.reason,
940
+ options: c.options.map((o) => ({ op: o.opOid, actor: o.actor, purpose: o.purpose })),
941
+ ...(fc ? { regions: fc.regions } : {}),
942
+ priorDecisions: await this.recallDecisions(c.key),
943
+ });
944
+ }
945
+ const integration = await this.#recordIntegration({
946
+ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
947
+ verdict: "conflict", conflictKeys: packet.conflicts.map((c) => c.key), by: args.by,
948
+ });
949
+ return { verdict: "conflict", packet, integration };
950
+ }
951
+ // 6. Gates: approvals carry from the SUBMITTED checkpoint to the integrated one
952
+ // (GitHub counts PR approvals independently of the merge commit — same isomorphism).
953
+ let carriedApprovals;
954
+ if (prot && (prot.requiredApprovals > 0 || prot.requireOwnerApproval)) {
955
+ const verdicts = await this.#approvalVerdicts(args.checkpoint);
956
+ if ([...verdicts.values()].includes("request_changes")) {
957
+ const reason = "changes requested by a reviewer";
958
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
959
+ return { verdict: "rejected", reason };
960
+ }
961
+ const approvers = [...verdicts].filter(([, v]) => v === "approve").map(([id]) => id);
962
+ if (approvers.length < prot.requiredApprovals) {
963
+ const reason = `needs ${prot.requiredApprovals} approval(s), have ${approvers.length}`;
964
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
965
+ return { verdict: "rejected", reason };
966
+ }
967
+ if (prot.requireOwnerApproval) {
968
+ let owner = false;
969
+ for (const id of approvers)
970
+ if (await this.hasRole(id, "maintainer")) {
971
+ owner = true;
972
+ break;
973
+ }
974
+ if (!owner) {
975
+ const reason = "requires an owner (maintainer+) approval";
976
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
977
+ return { verdict: "rejected", reason };
978
+ }
979
+ }
980
+ if (prot.integration?.carryApprovals !== false) {
981
+ carriedApprovals = (await this.store.collect("approval"))
982
+ .filter((a) => a.checkpointOid === args.checkpoint)
983
+ .map((a) => a.oid);
984
+ }
985
+ }
986
+ const waived = await this.#activeWaivers(view);
987
+ const required = (prot?.requiredChecks ?? []).filter((k) => !waived.has(k));
988
+ const advance = async (headCp, evidenceBinding, resultIsSubmitted = false) => {
989
+ await this.store.setRef(`head:${view}`, headCp);
990
+ const integration = await this.#recordIntegration({
991
+ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
992
+ resultCheckpoint: resultIsSubmitted ? undefined : headCp,
993
+ verdict: "advanced", evidenceBinding, ...(carriedApprovals ? { carriedApprovals } : {}), by: args.by,
994
+ });
995
+ if (resv?.ticketId === ticketId)
996
+ await this.#writeReservation(view, null);
997
+ this.logger.info("integrate.advanced", { view, ticketId, head: headCp, evidenceBinding });
998
+ return { verdict: "advanced", head: headCp, integration };
999
+ };
1000
+ // 7a. Fast-forward — the current head is inside the submission's causal closure:
1001
+ // classic finalize semantics, fresh binding, no re-authored checkpoint.
1002
+ if (fastForward) {
1003
+ for (const k of required) {
1004
+ if (cp.evidence[k] !== "pass") {
1005
+ const reason = `required check ${k} not pass`;
1006
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
1007
+ return { verdict: "rejected", reason };
1008
+ }
1009
+ if (prot?.requireBoundEvidence && cp.evidenceBinding?.[k] !== "bound") {
1010
+ const reason = `required check ${k} passed but its evidence is not bound to this tree`;
1011
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
1012
+ return { verdict: "rejected", reason };
1013
+ }
1014
+ }
1015
+ return advance(args.checkpoint, "fresh", true);
1016
+ }
1017
+ // 7b. Head moved: the integrated tree differs from the submitted one, so the
1018
+ // submitted evidence does NOT prove it (docs/17 §14.5). Decide by evidence mode.
1019
+ const T = integrated.treeHash;
1020
+ const needsEvidence = async () => {
1021
+ const draft = resv?.ticketId === ticketId && resv.treeHash === T
1022
+ ? resv.integratedCheckpoint
1023
+ : await this.#authorIntegratedCheckpoint(view, integrated, {}, undefined, `integration ${ticketId.slice(0, 12)} (awaiting evidence)`);
1024
+ const ttl = prot?.integration?.reserveTtlMs ?? 10 * 60_000;
1025
+ await this.#writeReservation(view, {
1026
+ ticketId, submittedCheckpoint: args.checkpoint, integratedCheckpoint: draft,
1027
+ treeHash: T, requiredChecks: required, by: args.by,
1028
+ expiresAt: new Date(Date.now() + ttl).toISOString(),
1029
+ });
1030
+ // What the submitter is missing locally to reproduce T: the head-side delta ops
1031
+ // plus the blobs they reference (determinism does the rest — docs/17 §14.5 fresh).
1032
+ const headClosure = await this.#closureOf(curHeads);
1033
+ const missingLocally = [];
1034
+ for (const oid of headClosure) {
1035
+ if (subClosure.has(oid))
1036
+ continue;
1037
+ missingLocally.push(oid);
1038
+ if (await this.store.has(oid)) {
1039
+ const op = await this.store.get(oid);
1040
+ for (const b of [op.body.blobOid, op.body.baseBlobOid])
1041
+ if (b)
1042
+ missingLocally.push(b);
1043
+ }
1044
+ }
1045
+ const integration = await this.#recordIntegration({
1046
+ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead,
1047
+ resultCheckpoint: draft, verdict: "needs_evidence", by: args.by,
1048
+ });
1049
+ return { verdict: "needs_evidence", integratedCheckpoint: draft, treeHash: T, requiredChecks: required, missingLocally, ticketId, integration };
1050
+ };
1051
+ // Resubmission holding the reservation: accept iff fresh evidence bound to the
1052
+ // reserved tree now covers the required checks — exactly one validation run.
1053
+ if (resv && resv.ticketId === ticketId && resv.treeHash === T) {
1054
+ const bound = await this.#boundEvidenceFor(T);
1055
+ if (required.every((k) => bound[k] === "pass")) {
1056
+ const binding = {};
1057
+ for (const k of Object.keys(bound))
1058
+ binding[k] = "bound";
1059
+ const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, bound, binding, `integration ${ticketId.slice(0, 12)}`);
1060
+ return advance(finalCp, "fresh");
1061
+ }
1062
+ return needsEvidence(); // reservation refreshed; still exactly one validation owed
1063
+ }
1064
+ if (required.length === 0) {
1065
+ // Nothing to prove — integrate directly (evidence-less views).
1066
+ const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, {}, undefined, `integration ${ticketId.slice(0, 12)}`);
1067
+ return advance(finalCp, "fresh");
1068
+ }
1069
+ const mode = prot?.integration?.evidenceMode ?? "carry-disjoint";
1070
+ let carry = mode === "carry-always";
1071
+ if (mode === "carry-disjoint") {
1072
+ const headClosure = await this.#closureOf(curHeads);
1073
+ const oursOnly = [...subClosure].filter((o) => !headClosure.has(o));
1074
+ const theirsOnly = [...headClosure].filter((o) => !subClosure.has(o));
1075
+ const ourKeys = await this.#keysOfOps(oursOnly);
1076
+ const theirKeys = await this.#keysOfOps(theirsOnly);
1077
+ // Disjoint deltas + zero new conflicts (checked above) ⇒ the same risk a git
1078
+ // user already accepts when merging two independently-green branches — but
1079
+ // machine-checked, recorded, and opt-out-able (docs/17 §14.5).
1080
+ carry = [...ourKeys].every((k) => !theirKeys.has(k));
1081
+ }
1082
+ // Carried evidence is not tree-bound; a protection that demands bound evidence
1083
+ // therefore forces the fresh path whenever the head has moved.
1084
+ if (prot?.requireBoundEvidence)
1085
+ carry = false;
1086
+ if (carry) {
1087
+ for (const k of required) {
1088
+ if (cp.evidence[k] !== "pass") {
1089
+ const reason = `required check ${k} not pass`;
1090
+ await this.#recordIntegration({ view, ticketId, submittedCheckpoint: args.checkpoint, baseHead, verdict: "rejected", reason, by: args.by });
1091
+ return { verdict: "rejected", reason };
1092
+ }
1093
+ }
1094
+ // The carry is never silent: recorded on BOTH the checkpoint and the Integration.
1095
+ const binding = {};
1096
+ for (const k of Object.keys(cp.evidence))
1097
+ binding[k] = "carried";
1098
+ const finalCp = await this.#authorIntegratedCheckpoint(view, integrated, cp.evidence, binding, `integration ${ticketId.slice(0, 12)} (carried evidence)`);
1099
+ return advance(finalCp, "carried");
1100
+ }
1101
+ return needsEvidence();
1102
+ });
1103
+ if (result.verdict !== "advanced") {
1104
+ this.logger.info("integrate.verdict", { view, ticketId, verdict: result.verdict });
1105
+ }
1106
+ return result;
1107
+ }
773
1108
  // ── security (Phase 12) ────────────────────────────────────────────────────
774
1109
  /**
775
1110
  * Redact (tombstone) a blob's bytes — for a leaked secret. Admin-only. The oid is
@@ -997,6 +1332,47 @@ export class Repo {
997
1332
  throw new Error(`unknown remote: ${nameOrUrl} (run \`avcs remote add ${nameOrUrl} <url>\`)`);
998
1333
  return r.url;
999
1334
  }
1335
+ /** Public remote-name → hub-URL resolution (a literal URL passes through). */
1336
+ async remoteUrl(nameOrUrl) {
1337
+ return this.#resolveRemote(nameOrUrl);
1338
+ }
1339
+ /**
1340
+ * Submit a draft checkpoint to a REMOTE hub's integration queue (Phase 14, docs/17
1341
+ * §14.4), with capability detection: a hub advertising `integrate` on GET /version
1342
+ * gets the queue path (one judgment, no redo); an older hub falls back to the legacy
1343
+ * finalize + pull retry funnel (bounded) — the exact loop the queue exists to kill,
1344
+ * kept only for backward compatibility.
1345
+ */
1346
+ async integrateHub(remoteOrUrl, args) {
1347
+ const url = await this.#resolveRemote(remoteOrUrl);
1348
+ const signWith = args.signWith ?? (await this.#resolveHubSigner(args.by));
1349
+ let hasIntegrate = false;
1350
+ try {
1351
+ const v = (await (await fetch(`${url}/version`)).json());
1352
+ hasIntegrate = v.integrate === true;
1353
+ }
1354
+ catch { /* unreachable /version → treat as legacy */ }
1355
+ if (hasIntegrate) {
1356
+ const { integrateWithHub } = await import("../hub/hubClient.js");
1357
+ const r = await integrateWithHub(this.dir, url, { ...args, signWith });
1358
+ // needs_evidence pulled delta objects through a separate store — refresh caches.
1359
+ this.#blobCache.clear();
1360
+ return r;
1361
+ }
1362
+ // Legacy fallback (old hub): finalize CAS + pull, bounded retries. This CAN lose
1363
+ // races (that is exactly the funnel Phase 14 removes) — surfaced honestly.
1364
+ for (let attempt = 0; attempt < 3; attempt++) {
1365
+ await this.pullHub(url);
1366
+ const parentHead = await this.protectedHead(args.view);
1367
+ await this.pushHub(url, { as: args.by });
1368
+ const r = await this.finalizeHub(url, { view: args.view, newCheckpoint: args.checkpoint, parentHead, by: args.by, signWith });
1369
+ if (r.finalized)
1370
+ return { verdict: "advanced", head: r.head ?? args.checkpoint, legacy: true };
1371
+ if (!/head moved/.test(r.reason ?? ""))
1372
+ return { verdict: "rejected", reason: r.reason ?? `finalize failed (${r.status})`, legacy: true };
1373
+ }
1374
+ return { verdict: "rejected", reason: "legacy hub: lost the finalize CAS race 3 times — retry, or upgrade the hub for queue semantics", legacy: true };
1375
+ }
1000
1376
  /**
1001
1377
  * Bidirectional convergence with a named remote (default "origin"): pull what the hub
1002
1378
  * has that we lack, then push what we have that it lacks. Pure object gossip — union
@@ -1047,9 +1423,11 @@ export class Repo {
1047
1423
  }
1048
1424
  async materialize(viewName = "main", opts) {
1049
1425
  this.metrics.inc("materialize.calls");
1050
- // Compaction (B3): on a cold instance, seed the incremental base from the persisted
1051
- // snapshot so this materialize re-reduces only ops added since it, not all history.
1052
- if (process.env.AVCS_COMPACT === "1" && !this.#incSnap)
1426
+ // Compaction (B3, default since 13.3): on a cold instance, seed the incremental base
1427
+ // from the persisted snapshot so this materialize re-reduces only ops added since it,
1428
+ // not all history. A corrupt/stale/version-mismatched snapshot is discarded (→ full
1429
+ // reduce), so correctness never depends on the file.
1430
+ if (process.env.AVCS_INCREMENTAL !== "0" && !this.#incSnap)
1053
1431
  await this.#loadPersistedSnapshot(viewName);
1054
1432
  const view = await this.getView(viewName);
1055
1433
  const q = view.query;
@@ -1101,11 +1479,37 @@ export class Repo {
1101
1479
  // A caller may override the view's default status filter (e.g. to project pending/gated
1102
1480
  // ops so their computed 3-way merge can be inspected before acceptance — issue #13).
1103
1481
  const includeStatuses = opts?.includeStatuses ?? q.includeStatuses;
1104
- const res = await this.#reduceOpSet(kept, includeStatuses, true); // main path: opt-in incremental
1482
+ const res = await this.#reduceOpSet(kept, includeStatuses, true); // main path: incremental by default
1105
1483
  for (const oid of quarantined)
1106
1484
  res.statuses.set(oid, "quarantined");
1485
+ await this.#maybeAutoCompact(viewName, res);
1107
1486
  return res;
1108
1487
  }
1488
+ /**
1489
+ * Amortized compaction (Phase 13.3): after a main-path materialize, re-persist the base
1490
+ * snapshot once the live snapshot is ≥ AUTO_COMPACT_DELTA ops past the last persisted
1491
+ * base, so a cold start never replays an unbounded history. The treeHash guard ties the
1492
+ * in-memory snapshot to THIS view's result (a reduce-cache hit may have left #incSnap
1493
+ * pointing at another view's reduction). Best-effort: a persist failure only logs — the
1494
+ * read path never depends on it.
1495
+ */
1496
+ async #maybeAutoCompact(viewName, res) {
1497
+ if (process.env.AVCS_INCREMENTAL === "0")
1498
+ return;
1499
+ const snap = this.#incSnap;
1500
+ if (!snap || snap.result.treeHash !== res.treeHash)
1501
+ return;
1502
+ const base = this.#persistedBaseOps.get(viewName) ?? 0;
1503
+ if (snap.input.ops.length - base < _a.AUTO_COMPACT_DELTA)
1504
+ return;
1505
+ try {
1506
+ await this.store.withLock(`snapshot:${viewName}`, () => this.#persistSnapshot(viewName, snap));
1507
+ this.metrics.inc("snapshot.auto.persisted");
1508
+ }
1509
+ catch (e) {
1510
+ this.logger.warn("snapshot.auto.failed", { view: viewName, error: e.message });
1511
+ }
1512
+ }
1109
1513
  /**
1110
1514
  * Partition candidate ops into those whose transitive causalDeps are all PRESENT in
1111
1515
  * the store vs those still waiting on a missing dep (E4). A dep absent from the store
@@ -1236,19 +1640,18 @@ export class Repo {
1236
1640
  };
1237
1641
  }
1238
1642
  /**
1239
- * Pass-1 reduce (docs/11 A6b). With AVCS_INCREMENTAL=1 and a prior snapshot, re-reduce
1240
- * only the delta via `reduceIncremental` (falling back to a full `snapshotReduce` if the
1241
- * preconditions don't hold — e.g. policy changed, or `base` is not an append-superset of
1242
- * the snapshot). Default (flag off) is the plain full `reduce`, untouched. Only the main
1243
- * materialize path passes `useInc`, so subset reducers (materializeAt/history/bisect)
1244
- * never read or pollute the snapshot. AVCS_VERIFY_INCREMENTAL=1 cross-checks each
1245
- * incremental result against a full reduce and throws on any divergence (CI safety net).
1643
+ * Pass-1 reduce (docs/11 A6b — incremental is the DEFAULT since Phase 13.3). With a prior
1644
+ * snapshot, re-reduce only the delta via `reduceIncremental` (falling back to a full
1645
+ * `snapshotReduce` if the preconditions don't hold — e.g. policy changed, or `base` is not
1646
+ * an append-superset of the snapshot). Opt OUT with AVCS_INCREMENTAL=0 (plain full
1647
+ * `reduce`, the pre-13.3 default). Only the main materialize path passes `useInc`, so
1648
+ * subset reducers (materializeAt/history/bisect) never read or pollute the snapshot.
1649
+ * AVCS_VERIFY_INCREMENTAL=1 cross-checks each incremental result against a full reduce
1650
+ * and throws on any divergence (runs as a dedicated CI job).
1246
1651
  */
1247
1652
  #pass1Reduce(base, useInc) {
1248
- // Opt-in incremental: AVCS_INCREMENTAL (warm in-process delta), AVCS_COMPACT (cold start
1249
- // from a persisted snapshot base, B3), or compact() capturing a fresh snapshot.
1250
- const optIn = useInc && (process.env.AVCS_INCREMENTAL === "1" || process.env.AVCS_COMPACT === "1" || this.#forceSnapshot);
1251
- if (!optIn)
1653
+ const on = useInc && (process.env.AVCS_INCREMENTAL !== "0" || this.#forceSnapshot);
1654
+ if (!on)
1252
1655
  return reduce(base);
1253
1656
  let snap;
1254
1657
  if (this.#incSnap) {
@@ -1672,7 +2075,7 @@ export class Repo {
1672
2075
  }
1673
2076
  /**
1674
2077
  * Compaction (docs/11 B3): persist the current reduction of `view` as a durable base
1675
- * snapshot. A later COLD materialize (with AVCS_COMPACT=1) loads it and `reduceIncremental`s
2078
+ * snapshot. A COLD materialize loads it BY DEFAULT (Phase 13.3) and `reduceIncremental`s
1676
2079
  * only the ops added since — folding settled history into the base instead of replaying
1677
2080
  * it — while the original ops stay on disk (append-only audit preserved). Correctness is
1678
2081
  * the same invariant as Track A: reduceIncremental(base, current) ≡ full reduce, gated by
@@ -1688,25 +2091,45 @@ export class Repo {
1688
2091
  }
1689
2092
  if (!this.#incSnap)
1690
2093
  return { baseOps: 0 };
1691
- // Atomic write (D2): a plain writeFile could leave a torn CBOR snapshot on a crash.
1692
- // writeAux routes through the store's temp→fsync→rename→fsync-dir path. A torn read
1693
- // would still fall back to a full reduce (#loadPersistedSnapshot catches decode
1694
- // errors), but a durable atomic write means the base is never silently corrupt.
1695
- await this.store.writeAux(join("snapshot", `${view}.cbor`), encodeCbor(serializeSnapshot(this.#incSnap)));
2094
+ await this.#persistSnapshot(view, this.#incSnap);
1696
2095
  const baseOps = this.#incSnap.input.ops.length;
1697
2096
  this.logger.info("compact", { view, baseOps });
1698
2097
  return { baseOps };
1699
2098
  }
1700
- /** Load a persisted compaction base into the in-memory incremental snapshot (B3). */
2099
+ /**
2100
+ * Persist a snapshot as the view's durable compaction base, stamped with the
2101
+ * materializer version + active policy oid (Phase 13.3): a cold load rejects the file
2102
+ * when either changed, so a merge-algorithm or policy update silently invalidates stale
2103
+ * bases (the warm path's invalidation is NonIncrementalError, handled in #pass1Reduce).
2104
+ * Atomic write (D2): writeAux routes through the store's temp→fsync→rename→fsync-dir
2105
+ * path, so a reader sees old-or-complete — never a torn CBOR file.
2106
+ */
2107
+ async #persistSnapshot(view, snap) {
2108
+ const header = { materializerVersion: MATERIALIZER_VERSION, policyOid: (await this.store.getRef("policy")) ?? "default" };
2109
+ await this.store.writeAux(join("snapshot", `${view}.cbor`), encodeCbor({ header, snapshot: serializeSnapshot(snap) }));
2110
+ this.#persistedBaseOps.set(view, snap.input.ops.length);
2111
+ }
2112
+ /** Load a persisted compaction base into the in-memory incremental snapshot (B3).
2113
+ * Rejects (and ignores) a corrupt file, a pre-13.3 headerless file, or a header whose
2114
+ * materializer version / policy oid no longer matches — full reduce is always correct. */
1701
2115
  async #loadPersistedSnapshot(view) {
1702
2116
  const p = join(this.dir, ".avcs", "snapshot", `${view}.cbor`);
1703
2117
  if (!existsSync(p))
1704
2118
  return;
1705
2119
  try {
1706
- this.#incSnap = deserializeSnapshot(decodeCbor(await readFile(p)));
2120
+ const raw = decodeCbor(await readFile(p));
2121
+ const policyOid = (await this.store.getRef("policy")) ?? "default";
2122
+ if (raw.header?.materializerVersion !== MATERIALIZER_VERSION || raw.header?.policyOid !== policyOid || raw.snapshot === undefined) {
2123
+ this.metrics.inc("snapshot.cold.rejected");
2124
+ return; // stale/incompatible base → full reduce
2125
+ }
2126
+ this.#incSnap = deserializeSnapshot(raw.snapshot);
2127
+ this.#persistedBaseOps.set(view, this.#incSnap.input.ops.length);
2128
+ this.metrics.inc("snapshot.cold.loaded");
1707
2129
  }
1708
2130
  catch {
1709
- this.#incSnap = null; // corrupt/incompatible snapshot → full reduce (always correct)
2131
+ this.#incSnap = null; // corrupt snapshot → full reduce (always correct)
2132
+ this.metrics.inc("snapshot.cold.rejected");
1710
2133
  }
1711
2134
  }
1712
2135
  /**