@izagood/avcs 0.1.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.
Files changed (111) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +221 -0
  3. package/dist/api/repo.d.ts +616 -0
  4. package/dist/api/repo.d.ts.map +1 -0
  5. package/dist/api/repo.js +1772 -0
  6. package/dist/api/repo.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +660 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/concurrency/lease.d.ts +27 -0
  12. package/dist/concurrency/lease.d.ts.map +1 -0
  13. package/dist/concurrency/lease.js +63 -0
  14. package/dist/concurrency/lease.js.map +1 -0
  15. package/dist/core/canonical.d.ts +16 -0
  16. package/dist/core/canonical.d.ts.map +1 -0
  17. package/dist/core/canonical.js +66 -0
  18. package/dist/core/canonical.js.map +1 -0
  19. package/dist/core/cbor.d.ts +9 -0
  20. package/dist/core/cbor.d.ts.map +1 -0
  21. package/dist/core/cbor.js +213 -0
  22. package/dist/core/cbor.js.map +1 -0
  23. package/dist/core/clock.d.ts +18 -0
  24. package/dist/core/clock.d.ts.map +1 -0
  25. package/dist/core/clock.js +32 -0
  26. package/dist/core/clock.js.map +1 -0
  27. package/dist/core/identity.d.ts +34 -0
  28. package/dist/core/identity.d.ts.map +1 -0
  29. package/dist/core/identity.js +62 -0
  30. package/dist/core/identity.js.map +1 -0
  31. package/dist/hub/hubClient.d.ts +38 -0
  32. package/dist/hub/hubClient.d.ts.map +1 -0
  33. package/dist/hub/hubClient.js +157 -0
  34. package/dist/hub/hubClient.js.map +1 -0
  35. package/dist/hub/hubServer.d.ts +28 -0
  36. package/dist/hub/hubServer.d.ts.map +1 -0
  37. package/dist/hub/hubServer.js +365 -0
  38. package/dist/hub/hubServer.js.map +1 -0
  39. package/dist/index.d.ts +13 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/objects/types.d.ts +352 -0
  44. package/dist/objects/types.d.ts.map +1 -0
  45. package/dist/objects/types.js +17 -0
  46. package/dist/objects/types.js.map +1 -0
  47. package/dist/observe/logger.d.ts +27 -0
  48. package/dist/observe/logger.d.ts.map +1 -0
  49. package/dist/observe/logger.js +37 -0
  50. package/dist/observe/logger.js.map +1 -0
  51. package/dist/observe/metrics.d.ts +20 -0
  52. package/dist/observe/metrics.d.ts.map +1 -0
  53. package/dist/observe/metrics.js +42 -0
  54. package/dist/observe/metrics.js.map +1 -0
  55. package/dist/policy/owners.d.ts +5 -0
  56. package/dist/policy/owners.d.ts.map +1 -0
  57. package/dist/policy/owners.js +38 -0
  58. package/dist/policy/owners.js.map +1 -0
  59. package/dist/policy/reliability.d.ts +7 -0
  60. package/dist/policy/reliability.d.ts.map +1 -0
  61. package/dist/policy/reliability.js +41 -0
  62. package/dist/policy/reliability.js.map +1 -0
  63. package/dist/query/diff.d.ts +16 -0
  64. package/dist/query/diff.d.ts.map +1 -0
  65. package/dist/query/diff.js +32 -0
  66. package/dist/query/diff.js.map +1 -0
  67. package/dist/reducer/incremental.d.ts +2 -0
  68. package/dist/reducer/incremental.d.ts.map +1 -0
  69. package/dist/reducer/incremental.js +14 -0
  70. package/dist/reducer/incremental.js.map +1 -0
  71. package/dist/reducer/policy.d.ts +17 -0
  72. package/dist/reducer/policy.d.ts.map +1 -0
  73. package/dist/reducer/policy.js +143 -0
  74. package/dist/reducer/policy.js.map +1 -0
  75. package/dist/reducer/reducer.d.ts +155 -0
  76. package/dist/reducer/reducer.d.ts.map +1 -0
  77. package/dist/reducer/reducer.js +849 -0
  78. package/dist/reducer/reducer.js.map +1 -0
  79. package/dist/release/sbom.d.ts +7 -0
  80. package/dist/release/sbom.d.ts.map +1 -0
  81. package/dist/release/sbom.js +26 -0
  82. package/dist/release/sbom.js.map +1 -0
  83. package/dist/semantic/contract.d.ts +32 -0
  84. package/dist/semantic/contract.d.ts.map +1 -0
  85. package/dist/semantic/contract.js +150 -0
  86. package/dist/semantic/contract.js.map +1 -0
  87. package/dist/semantic/symbols.d.ts +45 -0
  88. package/dist/semantic/symbols.d.ts.map +1 -0
  89. package/dist/semantic/symbols.js +323 -0
  90. package/dist/semantic/symbols.js.map +1 -0
  91. package/dist/store/applyRedactions.d.ts +16 -0
  92. package/dist/store/applyRedactions.d.ts.map +1 -0
  93. package/dist/store/applyRedactions.js +60 -0
  94. package/dist/store/applyRedactions.js.map +1 -0
  95. package/dist/store/lock.d.ts +12 -0
  96. package/dist/store/lock.d.ts.map +1 -0
  97. package/dist/store/lock.js +63 -0
  98. package/dist/store/lock.js.map +1 -0
  99. package/dist/store/objectStore.d.ts +127 -0
  100. package/dist/store/objectStore.d.ts.map +1 -0
  101. package/dist/store/objectStore.js +549 -0
  102. package/dist/store/objectStore.js.map +1 -0
  103. package/dist/validation/repair.d.ts +31 -0
  104. package/dist/validation/repair.d.ts.map +1 -0
  105. package/dist/validation/repair.js +37 -0
  106. package/dist/validation/repair.js.map +1 -0
  107. package/dist/validation/runner.d.ts +20 -0
  108. package/dist/validation/runner.d.ts.map +1 -0
  109. package/dist/validation/runner.js +90 -0
  110. package/dist/validation/runner.js.map +1 -0
  111. package/package.json +83 -0
@@ -0,0 +1,1772 @@
1
+ // High-level repository facade.
2
+ //
3
+ // This is the single API surface that the CLI, the demo, and the MCP server all
4
+ // call. It hides the object store and reducer behind verbs that map 1:1 onto the
5
+ // agent workflow: intent → session → propose op → attach evidence → materialize →
6
+ // decide → checkpoint.
7
+ var _a;
8
+ import { mkdir, writeFile, rm, readdir, readFile } from "node:fs/promises";
9
+ import { existsSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { Buffer } from "node:buffer";
12
+ import { ObjectStore } from "../store/objectStore.js";
13
+ import { LamportClock } from "../core/clock.js";
14
+ import { computeOid, sha256hex, canonicalize } from "../core/canonical.js";
15
+ import { reduce, conflictIdFor, keysOf, detectCrossGranularity } from "../reducer/reducer.js";
16
+ import { reduceIncremental, snapshotReduce, NonIncrementalError, serializeSnapshot, deserializeSnapshot } from "../reducer/incremental.js";
17
+ import { encodeCbor, decodeCbor } from "../core/cbor.js";
18
+ import { detectSemanticConflicts } from "../semantic/contract.js";
19
+ import { computeReliability } from "../policy/reliability.js";
20
+ import { defaultPolicy, MATERIALIZER_VERSION } from "../reducer/policy.js";
21
+ import { Keyring, generateKeypair, signMessage, } from "../core/identity.js";
22
+ import { checkLease, isActive } from "../concurrency/lease.js";
23
+ import { Metrics } from "../observe/metrics.js";
24
+ import { silentLogger } from "../observe/logger.js";
25
+ // Sidecar: ignore EVERYTHING under .avcs/ (the `*` also ignores this file itself), so the
26
+ // directory contributes nothing to git — the team's git history is untouched by AVCS.
27
+ const GITIGNORE_SIDECAR = `# AVCS — sidecar mode (default).
28
+ # AVCS history stays LOCAL; git tracks only the materialized projection, so adopting
29
+ # AVCS needs no team decision. To commit the history team-wide once agreed, run:
30
+ # avcs git-mode committed
31
+ *
32
+ `;
33
+ // Committed: track objects/refs/HEAD/config; ignore only rebuildable caches & local locks.
34
+ // objects/ is immutable & content-addressed (distinct filenames ⇒ git unions cleanly).
35
+ const GITIGNORE_COMMITTED = `# AVCS — committed mode.
36
+ # objects/ and refs/ travel with the repo via git; only rebuildable caches & local
37
+ # working state are ignored (regenerated by \`avcs reindex\` / lazy backfill).
38
+ /indexes/
39
+ /snapshot/
40
+ /locks/
41
+ /packs/
42
+ /oplog
43
+ /objlog
44
+ /.git-pending
45
+ *.lock
46
+ *.tmp*
47
+ `;
48
+ export class Repo {
49
+ dir;
50
+ store;
51
+ keyring = new Keyring();
52
+ metrics = new Metrics();
53
+ /** Structured logger (silent by default; CLI/hub/MCP wire a console/OTel sink). */
54
+ logger = silentLogger();
55
+ #clock = new LamportClock();
56
+ // Warm in-memory caches (docs/11 A6). Operations are tailed from the append-only op-log
57
+ // (so a +1op materialize reads one new object, not every shard); blobs are content-
58
+ // addressed and immutable except via redaction, so they cache by oid. Both are pure
59
+ // optimizations over disk — correctness never depends on them, and they are cleared on
60
+ // the rare mutations that can invalidate them (gc deletes; redaction overwrites bytes).
61
+ #opCache = new Map();
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.
68
+ #incSnap = null;
69
+ #forceSnapshot = false; // set by compact() to capture a snapshot regardless of env flags
70
+ constructor(dir, store) {
71
+ this.dir = dir;
72
+ this.store = store;
73
+ }
74
+ static async init(dir) {
75
+ const store = new ObjectStore(dir);
76
+ await store.init();
77
+ const repo = new _a(dir, store);
78
+ // Seed the default policy and the `main` view if absent.
79
+ if (!(await store.getRef("policy"))) {
80
+ const policyOid = await store.put(defaultPolicy());
81
+ await store.setRef("policy", policyOid);
82
+ }
83
+ if (!(await store.getRef("view:main"))) {
84
+ const view = {
85
+ type: "view",
86
+ name: "main",
87
+ baseViewOid: null,
88
+ query: { includeStatuses: ["accepted"] },
89
+ createdAt: new Date().toISOString(),
90
+ };
91
+ const oid = await store.put(view);
92
+ await store.setRef("view:main", oid);
93
+ }
94
+ return repo;
95
+ }
96
+ static async open(dir) {
97
+ if (!ObjectStore.isRepo(dir)) {
98
+ throw new Error(`not an AVCS repo: ${dir} (run \`avcs init\`)`);
99
+ }
100
+ const store = new ObjectStore(dir);
101
+ const repo = new _a(dir, store);
102
+ // Re-seed the Lamport clock past the highest operation we've seen.
103
+ let max = 0;
104
+ for await (const op of store.list("operation"))
105
+ max = Math.max(max, op.lamport);
106
+ repo.#clock = new LamportClock(max);
107
+ await repo.#loadKeyring();
108
+ return repo;
109
+ }
110
+ async policy() {
111
+ const oid = await this.store.getRef("policy");
112
+ if (!oid)
113
+ return defaultPolicy();
114
+ return this.store.get(oid);
115
+ }
116
+ /** Replace the active policy (new version ⇒ a distinguishable checkpoint). */
117
+ async setPolicy(policy) {
118
+ const oid = await this.store.put(policy);
119
+ await this.store.setRef("policy", oid);
120
+ return oid;
121
+ }
122
+ /** Set code-owner rules (Phase 5), bumping the policy version. */
123
+ async setOwners(owners) {
124
+ const current = await this.policy();
125
+ return this.setPolicy({ ...current, owners, version: `${current.version}+owners`, createdAt: new Date().toISOString() });
126
+ }
127
+ /** actorId → learned reliability nudge, from history. */
128
+ async reliability() {
129
+ const ops = await this.store.collect("operation");
130
+ const evidence = this.#verifiedEvidence(await this.store.collect("evidence"));
131
+ const decisions = await this.store.collect("decision");
132
+ return computeReliability(ops, evidence, decisions);
133
+ }
134
+ // ── identity / keyring (Phase 3) ──────────────────────────────────────────
135
+ #keysDir() {
136
+ return join(this.store.root, "keys");
137
+ }
138
+ async #loadKeyring() {
139
+ const dir = this.#keysDir();
140
+ if (!existsSync(dir))
141
+ return;
142
+ for (const f of await readdir(dir)) {
143
+ if (!f.endsWith(".json"))
144
+ continue;
145
+ const rec = JSON.parse(await readFile(join(dir, f), "utf8"));
146
+ this.keyring.register(rec);
147
+ }
148
+ }
149
+ /** Persist a public key as trusted and load it into the keyring. */
150
+ async registerPublicKey(rec) {
151
+ await mkdir(this.#keysDir(), { recursive: true });
152
+ await writeFile(join(this.#keysDir(), `${rec.keyId}.json`), JSON.stringify(rec), "utf8");
153
+ this.keyring.register(rec);
154
+ }
155
+ /**
156
+ * Mint a keypair for an actor, register the public half as trusted, and return
157
+ * the private half for the caller to hold. (MVP: a real deployment keeps private
158
+ * keys with the actor, never in the repo.)
159
+ */
160
+ async generateActorKey(actor, keyId = actor.id) {
161
+ const { publicKey, privateKey } = generateKeypair();
162
+ await this.registerPublicKey({ keyId, publicKey, actorId: actor.id, actorKind: actor.kind });
163
+ return { keyId, privateKey, publicKey };
164
+ }
165
+ #sign(type, payload, signWith) {
166
+ if (!signWith)
167
+ return undefined;
168
+ const oid = computeOid(type, payload);
169
+ return { keyId: signWith.keyId, alg: "ed25519", sig: signMessage(signWith.privateKey, oid) };
170
+ }
171
+ // ── reading ──────────────────────────────────────────────────────────────
172
+ async readIntent(oid) {
173
+ return this.store.get(oid);
174
+ }
175
+ async listIntents() {
176
+ return this.store.collect("intent");
177
+ }
178
+ // ── authoring ──────────────────────────────────────────────────────────
179
+ async createIntent(args) {
180
+ const intent = {
181
+ type: "intent",
182
+ title: args.title,
183
+ owner: args.owner,
184
+ kind: args.kind ?? "feature",
185
+ priority: args.priority ?? "normal",
186
+ constraints: args.constraints ?? [],
187
+ constraintKinds: args.constraintKinds,
188
+ successCriteria: args.successCriteria ?? [],
189
+ allowedScopes: args.allowedScopes ?? [],
190
+ createdAt: new Date().toISOString(),
191
+ };
192
+ return this.store.put(intent);
193
+ }
194
+ async startSession(args) {
195
+ const session = {
196
+ type: "session",
197
+ intentOid: args.intentOid,
198
+ actor: args.actor,
199
+ baseViewOid: args.baseViewOid ?? (await this.store.getRef("view:main")),
200
+ summary: args.summary ?? "",
201
+ openedEntities: args.openedEntities ?? [],
202
+ toolCalls: [],
203
+ startedAt: new Date().toISOString(),
204
+ };
205
+ return this.store.put(session);
206
+ }
207
+ // Large blobs are chunked so a huge file never inflates one JSON object, and
208
+ // identical chunks dedup by content address. (Phase 9b)
209
+ static CHUNK_THRESHOLD = 256 * 1024;
210
+ static CHUNK_SIZE = 64 * 1024;
211
+ async putBlob(content) {
212
+ const data = Buffer.from(typeof content === "string" ? Buffer.from(content, "utf8") : content);
213
+ if (data.length <= _a.CHUNK_THRESHOLD) {
214
+ return this.store.put({ type: "blob", data: data.toString("base64"), encoding: "base64" });
215
+ }
216
+ const chunks = [];
217
+ for (let i = 0; i < data.length; i += _a.CHUNK_SIZE) {
218
+ const part = data.subarray(i, i + _a.CHUNK_SIZE);
219
+ chunks.push(await this.store.put({ type: "blob", data: part.toString("base64"), encoding: "base64" }));
220
+ }
221
+ return this.store.put({ type: "blob", data: "", encoding: "base64", chunked: true, chunks });
222
+ }
223
+ async readBlob(oid) {
224
+ const cached = this.#blobCache.get(oid);
225
+ if (cached)
226
+ return cached;
227
+ const blob = await this.store.get(oid);
228
+ const buf = blob.chunked && blob.chunks
229
+ ? Buffer.concat(await Promise.all(blob.chunks.map((c) => this.readBlob(c))))
230
+ : Buffer.from(blob.data, "base64");
231
+ this.#blobCache.set(oid, buf);
232
+ return buf;
233
+ }
234
+ /**
235
+ * All operations, tailed from the op-log (docs/11 A6): read only oids not already in
236
+ * the warm cache, in first-write order. Replaces a full `collect("operation")` shard
237
+ * scan on every materialize — a +1op materialize touches one new object file. Tolerates
238
+ * op-log entries whose object was GC'd (skips them; the store is the source of truth)
239
+ * and backfills the log for a store created before A5.
240
+ */
241
+ async #allOpsTailed() {
242
+ let log = await this.store.readOpLog();
243
+ if (log.length === 0) {
244
+ // Pre-A5 store (no log yet) — scan once, backfill the log, warm the cache.
245
+ const scanned = await this.store.collect("operation");
246
+ if (scanned.length === 0)
247
+ return [];
248
+ await this.store.rebuildOpLog();
249
+ for (const o of scanned)
250
+ this.#opCache.set(o.oid, o);
251
+ log = await this.store.readOpLog();
252
+ }
253
+ const ops = [];
254
+ for (const oid of log) {
255
+ let op = this.#opCache.get(oid);
256
+ if (!op) {
257
+ if (!(await this.store.has(oid)))
258
+ continue; // GC'd since logged — skip
259
+ op = await this.store.get(oid);
260
+ this.#opCache.set(oid, op);
261
+ }
262
+ ops.push(op);
263
+ }
264
+ return ops;
265
+ }
266
+ async proposeOperation(args) {
267
+ const op = {
268
+ type: "operation",
269
+ sessionOid: args.sessionOid,
270
+ intentOid: args.intentOid,
271
+ actor: args.actor,
272
+ target: args.target,
273
+ body: args.body,
274
+ declaredPurpose: args.declaredPurpose,
275
+ causalDeps: args.causalDeps ?? [],
276
+ effects: args.effects,
277
+ lamport: this.#clock.tick(),
278
+ createdAt: new Date().toISOString(),
279
+ confidence: args.confidence,
280
+ // Only store `line` when it is non-default, so existing (line-less) repos and
281
+ // their oids stay byte-identical — backward compatibility with "main".
282
+ ...(args.line && args.line !== "main" ? { line: args.line } : {}),
283
+ ...(args.derivedFrom ? { derivedFrom: args.derivedFrom } : {}),
284
+ ...(args.revertOf ? { revertOf: args.revertOf } : {}),
285
+ ...(args.coAuthors && args.coAuthors.length ? { coAuthors: args.coAuthors } : {}),
286
+ ...(args.private ? { private: true } : {}),
287
+ };
288
+ op.sig = this.#sign("operation", op, args.signWith);
289
+ const oid = await this.store.put(op);
290
+ // Maintain the entity index (Phase 9): key → op oids for fast history/blame.
291
+ for (const key of keysOf({ ...op, oid }))
292
+ await this.store.appendEntityIndex(key, oid);
293
+ return oid;
294
+ }
295
+ /** Convenience: write file content as a blob + a put_file operation. */
296
+ async proposeFileWrite(args) {
297
+ const blobOid = await this.putBlob(args.content);
298
+ return this.proposeOperation({
299
+ sessionOid: args.sessionOid,
300
+ intentOid: args.intentOid,
301
+ actor: args.actor,
302
+ target: { entityKind: "file", entityId: args.path },
303
+ body: { kind: "put_file", path: args.path, blobOid },
304
+ declaredPurpose: args.declaredPurpose,
305
+ causalDeps: args.causalDeps,
306
+ effects: args.effects,
307
+ line: args.line,
308
+ signWith: args.signWith,
309
+ });
310
+ }
311
+ /**
312
+ * Phase 2: replace one named top-level symbol within a file. Two such edits to
313
+ * different symbols of the same file auto-merge. Should causally depend on the op
314
+ * that established the file (`causalDeps`) so reconstruction starts from it.
315
+ */
316
+ async proposeSymbolEdit(args) {
317
+ const blobOid = await this.putBlob(args.newText);
318
+ return this.proposeOperation({
319
+ sessionOid: args.sessionOid,
320
+ intentOid: args.intentOid,
321
+ actor: args.actor,
322
+ target: { entityKind: "symbol", entityId: `${args.path}#${args.symbolName}` },
323
+ body: { kind: "set_symbol", path: args.path, symbolName: args.symbolName, blobOid },
324
+ declaredPurpose: args.declaredPurpose,
325
+ causalDeps: args.causalDeps,
326
+ effects: args.effects,
327
+ line: args.line,
328
+ signWith: args.signWith,
329
+ });
330
+ }
331
+ /**
332
+ * M3 AST op: rename a top-level symbol (declaration + same-file references). Contends
333
+ * on both the old and new symbol keys. Should causally depend on the op that
334
+ * established the file. Cross-file references are a follow-up (needs reference analysis).
335
+ */
336
+ async proposeRenameSymbol(args) {
337
+ return this.proposeOperation({
338
+ sessionOid: args.sessionOid,
339
+ intentOid: args.intentOid,
340
+ actor: args.actor,
341
+ target: { entityKind: "symbol", entityId: `${args.path}#${args.from}` },
342
+ body: { kind: "rename_symbol", path: args.path, symbolName: args.from, newName: args.to },
343
+ declaredPurpose: args.declaredPurpose,
344
+ causalDeps: args.causalDeps,
345
+ line: args.line,
346
+ });
347
+ }
348
+ /**
349
+ * M3 AST op: move a top-level symbol from one file to another. Contends on the
350
+ * symbol at both source and destination. Cross-file references are a follow-up.
351
+ */
352
+ async proposeMoveSymbol(args) {
353
+ return this.proposeOperation({
354
+ sessionOid: args.sessionOid,
355
+ intentOid: args.intentOid,
356
+ actor: args.actor,
357
+ target: { entityKind: "symbol", entityId: `${args.fromPath}#${args.symbolName}` },
358
+ body: { kind: "move_symbol", fromPath: args.fromPath, path: args.toPath, symbolName: args.symbolName },
359
+ declaredPurpose: args.declaredPurpose,
360
+ causalDeps: args.causalDeps,
361
+ line: args.line,
362
+ signWith: args.signWith,
363
+ });
364
+ }
365
+ async attachEvidence(args) {
366
+ const ev = {
367
+ type: "evidence",
368
+ forOps: args.forOps,
369
+ kind: args.kind,
370
+ result: args.result,
371
+ producedBy: args.producedBy,
372
+ command: args.command,
373
+ detail: args.detail,
374
+ createdAt: new Date().toISOString(),
375
+ ...(args.fromUntrustedRunner ? { fromUntrustedRunner: true } : {}),
376
+ };
377
+ ev.sig = this.#sign("evidence", ev, args.signWith);
378
+ return this.store.put(ev);
379
+ }
380
+ async recordDecision(args) {
381
+ const dec = {
382
+ type: "decision",
383
+ conflictId: args.conflictId,
384
+ chosenOps: args.chosenOps,
385
+ rejectedOps: args.rejectedOps,
386
+ reason: args.reason,
387
+ decidedBy: args.decidedBy,
388
+ futurePolicy: args.futurePolicy,
389
+ createdAt: new Date().toISOString(),
390
+ };
391
+ dec.sig = this.#sign("decision", dec, args.signWith);
392
+ return this.store.put(dec);
393
+ }
394
+ // ── leases (Phase 3) ───────────────────────────────────────────────────────
395
+ async activeLeases() {
396
+ const now = new Date().toISOString();
397
+ return (await this.store.collect("lease")).filter((l) => isActive(l, now));
398
+ }
399
+ /**
400
+ * Request a soft write-lease over scopes. Returns the granted lease oid, or the
401
+ * conflicts that block it (overlapping active exclusive lease held by another).
402
+ */
403
+ async requestLease(args) {
404
+ const mode = args.mode ?? "exclusive";
405
+ // H-6: check-then-write under a lock so two concurrent requesters cannot both
406
+ // read "no conflict" and both acquire an overlapping exclusive lease (TOCTOU).
407
+ return this.store.withLock("leases", async () => {
408
+ const conflicts = checkLease({ writeScopes: args.writeScopes, mode, actorId: args.actor.id }, await this.activeLeases());
409
+ if (conflicts.length)
410
+ return { granted: false, conflicts };
411
+ const now = Date.now();
412
+ const lease = {
413
+ type: "lease",
414
+ intentOid: args.intentOid,
415
+ sessionOid: args.sessionOid,
416
+ actor: args.actor,
417
+ writeScopes: args.writeScopes,
418
+ mode,
419
+ acquiredAt: new Date(now).toISOString(),
420
+ expiresAt: new Date(now + (args.ttlMs ?? 30 * 60_000)).toISOString(),
421
+ };
422
+ return { granted: true, leaseOid: await this.store.put(lease) };
423
+ });
424
+ }
425
+ /** Build a minimal repair packet for ops whose validation failed. */
426
+ async repairContext(opOids) {
427
+ const { buildRepairContext } = await import("../validation/repair.js");
428
+ const ops = [];
429
+ for (const oid of opOids)
430
+ ops.push(await this.store.get(oid));
431
+ const evidence = await this.store.collect("evidence");
432
+ const decisions = await this.store.collect("decision");
433
+ return buildRepairContext(ops, evidence, decisions);
434
+ }
435
+ /**
436
+ * When a keyring is configured, trust must be earned by signature: evidence that
437
+ * claims a trusted (non-agent) producer is dropped unless it carries a valid
438
+ * signature for that actor. Forged or tampered evidence simply disappears, so the
439
+ * op it vouched for stays gated. With no keyring, fall back to the Phase-1
440
+ * producedBy heuristic (keep everything; the policy ignores agent self-reports).
441
+ */
442
+ #verifiedEvidence(all) {
443
+ if (this.keyring.size === 0)
444
+ return all;
445
+ return all.filter((e) => {
446
+ if (e.producedBy.kind === "ai_agent")
447
+ return true; // policy ignores these anyway
448
+ return this.keyring.verifyFor(e.producedBy.id, e.oid, e.sig);
449
+ });
450
+ }
451
+ // ── views & materialization ──────────────────────────────────────────────
452
+ async getView(name) {
453
+ const oid = await this.store.getRef(`view:${name}`);
454
+ if (!oid)
455
+ throw new Error(`no such view: ${name}`);
456
+ return this.store.get(oid);
457
+ }
458
+ async createView(name, query, baseViewOid = null) {
459
+ const view = {
460
+ type: "view",
461
+ name,
462
+ baseViewOid,
463
+ query,
464
+ createdAt: new Date().toISOString(),
465
+ };
466
+ const oid = await this.store.put(view);
467
+ await this.store.setRef(`view:${name}`, oid);
468
+ return oid;
469
+ }
470
+ // ── lineage (Phase 8) ──────────────────────────────────────────────────────
471
+ async #getLine(name) {
472
+ const oid = await this.store.getRef(`line:${name}`);
473
+ return oid ? this.store.get(oid) : null;
474
+ }
475
+ /** Oids inherited by a line: the causal closure of its fork checkpoint's frontier. */
476
+ async #inheritedOps(lineName, allOps) {
477
+ const line = await this.#getLine(lineName);
478
+ if (!line?.forkCheckpointOid)
479
+ return new Set();
480
+ const cp = await this.store.get(line.forkCheckpointOid);
481
+ const byId = new Map(allOps.map((o) => [o.oid, o]));
482
+ const seen = new Set();
483
+ const stack = [...cp.headOps];
484
+ while (stack.length) {
485
+ const id = stack.pop();
486
+ if (seen.has(id))
487
+ continue;
488
+ seen.add(id);
489
+ for (const dep of byId.get(id)?.causalDeps ?? [])
490
+ if (!seen.has(dep))
491
+ stack.push(dep);
492
+ }
493
+ return seen;
494
+ }
495
+ async listLines() {
496
+ return this.store.collect("line");
497
+ }
498
+ /**
499
+ * Fork a new line from `fromLine` at its current (or a given) checkpoint. The fork
500
+ * checkpoint freezes what the new line inherits; everything the base line does
501
+ * afterwards stays out of the new line. Also creates a same-named view selecting it.
502
+ */
503
+ async createLine(name, fromLine = "main", atCheckpointOid) {
504
+ if (await this.#getLine(name))
505
+ throw new Error(`line already exists: ${name}`);
506
+ const forkCheckpointOid = atCheckpointOid ?? (await this.createCheckpoint(fromLine, `fork point for line ${name}`));
507
+ const line = {
508
+ type: "line",
509
+ name,
510
+ baseLine: fromLine,
511
+ forkCheckpointOid,
512
+ createdAt: new Date().toISOString(),
513
+ };
514
+ const oid = await this.store.put(line);
515
+ await this.store.setRef(`line:${name}`, oid);
516
+ await this.createView(name, { includeStatuses: ["accepted"], line: name });
517
+ return oid;
518
+ }
519
+ /** Frontier (accepted head ops) of a line — the causalDeps a new op should build on. */
520
+ async lineFrontier(lineName) {
521
+ return (await this.materialize(lineName)).headOps;
522
+ }
523
+ /**
524
+ * Port (cherry-pick / backport) an operation onto another line: mint a NEW op on
525
+ * the target line carrying the source's body, based on the target line's current
526
+ * frontier, with `derivedFrom` provenance. set_symbol re-splices against the target
527
+ * line's content automatically at materialize; put_file replaces on the target line.
528
+ */
529
+ async portOp(sourceOpOid, targetLine, actor) {
530
+ const src = await this.store.get(sourceOpOid);
531
+ await this.getView(targetLine); // ensure the target line/view exists
532
+ return this.proposeOperation({
533
+ sessionOid: src.sessionOid,
534
+ intentOid: src.intentOid,
535
+ actor: actor ?? src.actor,
536
+ target: src.target,
537
+ body: src.body,
538
+ declaredPurpose: `backport ${sourceOpOid.slice(0, 16)} → ${targetLine}: ${src.declaredPurpose}`,
539
+ causalDeps: await this.lineFrontier(targetLine),
540
+ effects: src.effects,
541
+ line: targetLine,
542
+ derivedFrom: sourceOpOid,
543
+ });
544
+ }
545
+ // ── governance: membership, roles, protection, finalize (Phase 7) ──────────
546
+ static ROLE_WEIGHT = {
547
+ reader: 0,
548
+ proposer: 1,
549
+ reviewer: 2,
550
+ maintainer: 3,
551
+ admin: 4,
552
+ };
553
+ /** Issue a root-signed membership granting a role; registers the member's key. */
554
+ async registerMembership(args) {
555
+ const m = {
556
+ type: "membership",
557
+ actorId: args.actorId,
558
+ publicKey: args.publicKey,
559
+ role: args.role,
560
+ scopes: args.scopes,
561
+ issuedBy: args.root.keyId,
562
+ createdAt: new Date().toISOString(),
563
+ };
564
+ m.sig = this.#sign("membership", m, args.root);
565
+ const oid = await this.store.put(m);
566
+ await this.store.setRef(`member:${args.actorId}`, oid);
567
+ await this.registerPublicKey({ keyId: args.actorId, publicKey: args.publicKey, actorId: args.actorId, actorKind: args.actorKind ?? "ai_agent" });
568
+ return oid;
569
+ }
570
+ async membershipOf(actorId) {
571
+ const oid = await this.store.getRef(`member:${actorId}`);
572
+ if (!oid)
573
+ return null;
574
+ const m = await this.store.get(oid);
575
+ return m.revokedAt ? null : m;
576
+ }
577
+ async roleOf(actorId) {
578
+ return (await this.membershipOf(actorId))?.role ?? "reader";
579
+ }
580
+ async hasRole(actorId, min) {
581
+ return _a.ROLE_WEIGHT[await this.roleOf(actorId)] >= _a.ROLE_WEIGHT[min];
582
+ }
583
+ /** deciderId → role weight, for authority-weighted decision precedence (docs/08 §4). */
584
+ async #authorityMap() {
585
+ const m = new Map();
586
+ for (const mem of await this.store.collect("membership")) {
587
+ if (mem.revokedAt)
588
+ continue;
589
+ m.set(mem.actorId, _a.ROLE_WEIGHT[mem.role]);
590
+ }
591
+ return m;
592
+ }
593
+ /** Revoke a membership (admin only): future ops/decisions by this actor lose trust. */
594
+ async revokeMembership(actorId, byAdmin) {
595
+ if (!(await this.hasRole(byAdmin, "admin"))) {
596
+ throw new Error(`revoke requires role admin; ${byAdmin} is ${await this.roleOf(byAdmin)}`);
597
+ }
598
+ const m = await this.membershipOf(actorId);
599
+ if (!m)
600
+ return;
601
+ const revoked = { ...m, revokedAt: new Date().toISOString() };
602
+ delete revoked.oid;
603
+ revoked.sig = undefined;
604
+ const oid = await this.store.put(revoked);
605
+ await this.store.setRef(`member:${actorId}`, oid);
606
+ }
607
+ async setProtection(p) {
608
+ const protection = { type: "protection", ...p, createdAt: new Date().toISOString() };
609
+ const oid = await this.store.put(protection);
610
+ await this.store.setRef(`protection:${p.view}`, oid);
611
+ return oid;
612
+ }
613
+ async getProtection(view) {
614
+ const oid = await this.store.getRef(`protection:${view}`);
615
+ return oid ? this.store.get(oid) : null;
616
+ }
617
+ /** Current protected head (a checkpoint oid) of a view, or null if never finalized. */
618
+ async protectedHead(view) {
619
+ return this.store.getRef(`head:${view}`);
620
+ }
621
+ /**
622
+ * Finalize (= PR merge): advance a view's protected head to `newCheckpoint` via a
623
+ * compare-and-swap on `parentHead`. Rejects a stale (non-fast-forward) finalize
624
+ * even for admins unless allowForcePush — this is the causal-currency guard (docs/08
625
+ * §6/§9): authority never licenses overwriting fresher history.
626
+ */
627
+ async finalize(args) {
628
+ const result = await this.store.withLock(`finalize:${args.view}`, async () => {
629
+ const prot = await this.getProtection(args.view);
630
+ const current = await this.protectedHead(args.view);
631
+ // CAS / non-fast-forward check
632
+ if (current !== args.parentHead && !(prot?.allowForcePush)) {
633
+ return { finalized: false, reason: `head moved: ${current ?? "∅"} ≠ parent ${args.parentHead ?? "∅"} — pull and re-reduce first` };
634
+ }
635
+ // role gate
636
+ const minRole = prot?.finalizeRole ?? "maintainer";
637
+ if (prot && !(await this.hasRole(args.by, minRole))) {
638
+ return { finalized: false, reason: `${args.by} lacks role ${minRole} to finalize ${args.view}` };
639
+ }
640
+ // required checks — unless an active break-glass Override waives them (Phase 12)
641
+ const cp = await this.store.get(args.newCheckpoint);
642
+ const waived = await this.#activeWaivers(args.view);
643
+ for (const k of prot?.requiredChecks ?? []) {
644
+ if (cp.evidence[k] !== "pass" && !waived.has(k)) {
645
+ return { finalized: false, reason: `required check ${k} not pass` };
646
+ }
647
+ }
648
+ // causal-complete gate (docs/08 C-3): never finalize a partially-synced tree —
649
+ // every causalDep behind the checkpoint's frontier must be present locally.
650
+ const missing = await this.#missingCausalDeps(cp.headOps);
651
+ if (missing.length) {
652
+ return { finalized: false, reason: `incomplete causal history: ${missing.length} object(s) missing — pull before finalizing` };
653
+ }
654
+ // required approvals (= PR approvals). A request_changes from any reviewer blocks.
655
+ if (prot && (prot.requiredApprovals > 0 || prot.requireOwnerApproval)) {
656
+ const verdict = await this.#approvalVerdicts(args.newCheckpoint);
657
+ if ([...verdict.values()].includes("request_changes")) {
658
+ return { finalized: false, reason: "changes requested by a reviewer" };
659
+ }
660
+ const approvers = [...verdict].filter(([, v]) => v === "approve").map(([id]) => id);
661
+ if (approvers.length < prot.requiredApprovals) {
662
+ return { finalized: false, reason: `needs ${prot.requiredApprovals} approval(s), have ${approvers.length}` };
663
+ }
664
+ if (prot.requireOwnerApproval) {
665
+ let owner = false;
666
+ for (const id of approvers)
667
+ if (await this.hasRole(id, "maintainer")) {
668
+ owner = true;
669
+ break;
670
+ }
671
+ if (!owner)
672
+ return { finalized: false, reason: "requires an owner (maintainer+) approval" };
673
+ }
674
+ }
675
+ await this.store.setRef(`head:${args.view}`, args.newCheckpoint);
676
+ return { finalized: true, head: args.newCheckpoint };
677
+ });
678
+ if (result.finalized) {
679
+ this.logger.info("finalize.accepted", { view: args.view, head: result.head, parentHead: args.parentHead, by: args.by });
680
+ }
681
+ else {
682
+ this.logger.warn("finalize.rejected", { view: args.view, by: args.by, reason: result.reason });
683
+ }
684
+ return result;
685
+ }
686
+ // ── security (Phase 12) ────────────────────────────────────────────────────
687
+ /**
688
+ * Redact (tombstone) a blob's bytes — for a leaked secret. Admin-only. The oid is
689
+ * preserved so all references and the treeHash stay valid; the plaintext is evicted
690
+ * from this store (and, once a real sync ships, propagated to every replica).
691
+ */
692
+ async redact(blobOid, reason, by, signWith) {
693
+ if (!(await this.hasRole(by, "admin"))) {
694
+ throw new Error(`redact requires role admin; ${by} is ${await this.roleOf(by)}`);
695
+ }
696
+ const blob = await this.store.get(blobOid);
697
+ const original = Buffer.from(blob.data, "base64");
698
+ const redaction = {
699
+ type: "redaction",
700
+ blobOid,
701
+ sha256: sha256hex(original),
702
+ length: original.length,
703
+ reason,
704
+ by,
705
+ createdAt: new Date().toISOString(),
706
+ };
707
+ // Sign so other replicas can verify it's a genuine admin redaction (not a forged
708
+ // DoS). Required when governance is active (see applyRedactions).
709
+ redaction.sig = this.#sign("redaction", redaction, signWith);
710
+ const redactionOid = await this.store.put(redaction);
711
+ // Evict the bytes: overwrite the blob in place with the (deterministic) stub.
712
+ const { redactedStub } = await import("../store/applyRedactions.js");
713
+ await this.store.overwriteAt(blobOid, redactedStub(reason, redactionOid));
714
+ this.#blobCache.delete(blobOid); // bytes changed under a stable oid — evict the cache
715
+ this.logger.warn("redact.applied", { blobOid, redactionOid, by, reason, length: original.length });
716
+ return redactionOid;
717
+ }
718
+ async #activeWaivers(view) {
719
+ const now = new Date().toISOString();
720
+ const out = new Set();
721
+ for (const o of await this.store.collect("override")) {
722
+ if (o.view === view && o.expiresAt > now)
723
+ for (const k of o.waiveChecks)
724
+ out.add(k);
725
+ }
726
+ return out;
727
+ }
728
+ /** Break-glass: a maintainer/admin grants an expiring waiver of required checks. */
729
+ async grantOverride(args) {
730
+ if (!(await this.hasRole(args.by, "maintainer"))) {
731
+ throw new Error(`override requires role >= maintainer; ${args.by} is ${await this.roleOf(args.by)}`);
732
+ }
733
+ const o = {
734
+ type: "override",
735
+ view: args.view,
736
+ waiveChecks: args.waiveChecks,
737
+ reason: args.reason,
738
+ by: args.by,
739
+ expiresAt: new Date(Date.now() + (args.ttlMs ?? 30 * 60_000)).toISOString(),
740
+ createdAt: new Date().toISOString(),
741
+ };
742
+ return this.store.put(o);
743
+ }
744
+ /**
745
+ * Rollback a protected head to an earlier checkpoint — FORWARD-only: it advances the
746
+ * head (a new finalize CAS) to point at a prior state, never rewriting history.
747
+ */
748
+ async rollbackTo(view, checkpointOid, by) {
749
+ return this.finalize({ view, newCheckpoint: checkpointOid, parentHead: await this.protectedHead(view), by });
750
+ }
751
+ /** A reviewer approves (or requests changes on) a checkpoint. = PR approve. */
752
+ async approve(checkpointOid, by, verdict = "approve", opts = {}) {
753
+ if (!(await this.hasRole(by, "reviewer"))) {
754
+ throw new Error(`approve requires role >= reviewer; ${by} is ${await this.roleOf(by)}`);
755
+ }
756
+ const a = { type: "approval", checkpointOid, by, verdict, reason: opts.reason, createdAt: new Date().toISOString() };
757
+ a.sig = this.#sign("approval", a, opts.signWith);
758
+ return this.store.put(a);
759
+ }
760
+ /** Latest verdict per reviewer for a checkpoint (later canonical approval wins). */
761
+ async #approvalVerdicts(checkpointOid) {
762
+ const all = (await this.store.collect("approval"))
763
+ .filter((a) => a.checkpointOid === checkpointOid)
764
+ .sort((x, y) => (x.createdAt < y.createdAt ? -1 : x.createdAt > y.createdAt ? 1 : 0));
765
+ const out = new Map();
766
+ for (const a of all)
767
+ if (await this.hasRole(a.by, "reviewer"))
768
+ out.set(a.by, a.verdict);
769
+ return out;
770
+ }
771
+ /** Objects missing from the causal closure of a frontier (incomplete sync). */
772
+ async #missingCausalDeps(headOps) {
773
+ const seen = new Set();
774
+ const missing = [];
775
+ const stack = [...headOps];
776
+ while (stack.length) {
777
+ const id = stack.pop();
778
+ if (seen.has(id))
779
+ continue;
780
+ seen.add(id);
781
+ if (!(await this.store.has(id))) {
782
+ missing.push(id);
783
+ continue;
784
+ }
785
+ const op = await this.store.get(id);
786
+ for (const d of op.causalDeps)
787
+ if (!seen.has(d))
788
+ stack.push(d);
789
+ }
790
+ return missing;
791
+ }
792
+ // ── sync: object gossip between two stores (Phase 7) ───────────────────────
793
+ /**
794
+ * Pull objects from another repo's store into this one. Objects are append-only and
795
+ * content-addressed, so sync is a conflict-free union of whatever the other side has
796
+ * that we lack. `gate` (optional) lets a hub reject ops not signed by a known member.
797
+ * Returns counts. Refs (governance) are NOT synced — those are hub-authoritative.
798
+ */
799
+ async pull(otherDir, opts = {}) {
800
+ const other = new ObjectStore(otherDir);
801
+ let copied = 0;
802
+ let rejected = 0;
803
+ for await (const obj of other.list()) {
804
+ const oid = obj.oid;
805
+ if (await this.store.has(oid))
806
+ continue;
807
+ // Stash: private ops are local-only — never gossiped (Phase 7 follow-up).
808
+ if (obj.type === "operation" && obj.private)
809
+ continue;
810
+ if (opts.requireSignedMembers && obj.type === "operation") {
811
+ const op = obj;
812
+ const ok = this.keyring.verifyFor(op.actor.id, oid, op.sig) && (await this.hasRole(op.actor.id, "proposer"));
813
+ if (!ok) {
814
+ rejected++;
815
+ continue;
816
+ }
817
+ }
818
+ await this.store.put(obj);
819
+ if (obj.type === "operation")
820
+ for (const k of keysOf(obj))
821
+ await this.store.appendEntityIndex(k, oid);
822
+ copied++;
823
+ }
824
+ // Propagate redactions: evict plaintext for any blob we already had before a peer
825
+ // redacted it (pull skips already-present oids, so the redaction must be applied).
826
+ await this.applyRedactions();
827
+ return { copied, rejected };
828
+ }
829
+ /** Apply all known redaction tombstones locally (evict bytes; oids preserved). */
830
+ async applyRedactions() {
831
+ const { applyRedactions } = await import("../store/applyRedactions.js");
832
+ const n = await applyRedactions(this.store);
833
+ if (n > 0)
834
+ this.#blobCache.clear(); // bytes changed under stable oids — evict the cache
835
+ return n;
836
+ }
837
+ /** Push objects this repo holds that a network hub lacks (M2 / docs/10 WS-B). */
838
+ async pushHub(hubUrl) {
839
+ const { pushToHub } = await import("../hub/hubClient.js");
840
+ return pushToHub(this.dir, hubUrl);
841
+ }
842
+ /** Request a finalize (= PR merge) on a network hub via its CAS endpoint (E6). */
843
+ async finalizeHub(hubUrl, args) {
844
+ const { finalizeOnHub } = await import("../hub/hubClient.js");
845
+ return finalizeOnHub(hubUrl, args);
846
+ }
847
+ /** Pull objects a network hub holds that this repo lacks. */
848
+ async pullHub(hubUrl) {
849
+ const { pullFromHub } = await import("../hub/hubClient.js");
850
+ const r = await pullFromHub(this.dir, hubUrl);
851
+ // pull may have applied redactions (blob bytes overwritten under stable oids) and
852
+ // wrote through a separate ObjectStore; drop the warm blob cache so reads re-hit disk.
853
+ this.#blobCache.clear();
854
+ return r;
855
+ }
856
+ /** Resolve a view's query into the candidate operation set, then reduce. */
857
+ async materialize(viewName = "main") {
858
+ this.metrics.inc("materialize.calls");
859
+ // Compaction (B3): on a cold instance, seed the incremental base from the persisted
860
+ // snapshot so this materialize re-reduces only ops added since it, not all history.
861
+ if (process.env.AVCS_COMPACT === "1" && !this.#incSnap)
862
+ await this.#loadPersistedSnapshot(viewName);
863
+ const view = await this.getView(viewName);
864
+ const q = view.query;
865
+ const exclude = new Set(q.excludeOps ?? []);
866
+ const intentFilter = q.intentOids && q.intentOids.length ? new Set(q.intentOids) : null;
867
+ const sessionFilter = q.sessionOids && q.sessionOids.length ? new Set(q.sessionOids) : null;
868
+ // Lineage (Phase 8): a line materializes its own ops + everything inherited from
869
+ // its fork checkpoint (the base line's frozen frontier). Ops authored on the base
870
+ // line AFTER the fork are excluded, which is what keeps lines divergent.
871
+ const lineName = q.line ?? "main";
872
+ const allOps = await this.#allOpsTailed();
873
+ const inherited = await this.#inheritedOps(lineName, allOps);
874
+ const ops = [];
875
+ for (const op of allOps) {
876
+ const onLine = (op.line ?? "main") === lineName || inherited.has(op.oid);
877
+ if (!onLine)
878
+ continue;
879
+ if (exclude.has(op.oid))
880
+ continue;
881
+ if (intentFilter && !intentFilter.has(op.intentOid))
882
+ continue;
883
+ if (sessionFilter && !sessionFilter.has(op.sessionOid))
884
+ continue;
885
+ ops.push(op);
886
+ }
887
+ // E4 (docs/13): hold back causally-incomplete ops. A push is N independent POSTs,
888
+ // so an op can arrive before its causalDeps (partial/out-of-order sync). Projecting
889
+ // it without its ancestor yields a transient WRONG tree (the reducer would otherwise
890
+ // treat the missing dep as an absent edge and apply the op anyway). We exclude any op
891
+ // a dep of which is absent from the store entirely, transitively. For a complete op
892
+ // set nothing is held back, so determinism for settled history is unchanged.
893
+ const present = new Set(allOps.map((o) => o.oid));
894
+ const { complete, pending } = this.#causallyComplete(ops, present);
895
+ if (pending.length) {
896
+ this.metrics.inc("materialize.causallyPending", pending.length);
897
+ this.logger.info("materialize.pending", { view: viewName, pending: pending.length });
898
+ }
899
+ // Phase 11: in a governed repo, ops authored by non-members (outsiders) are
900
+ // quarantined — excluded from the materialized tree until a reviewer promotes them.
901
+ const { kept, quarantined } = await this.#partitionQuarantine(complete);
902
+ const res = await this.#reduceOpSet(kept, q.includeStatuses, true); // main path: opt-in incremental
903
+ for (const oid of quarantined)
904
+ res.statuses.set(oid, "quarantined");
905
+ return res;
906
+ }
907
+ /**
908
+ * Partition candidate ops into those whose transitive causalDeps are all PRESENT in
909
+ * the store vs those still waiting on a missing dep (E4). A dep absent from the store
910
+ * entirely (`!present.has`) makes its dependents incomplete; incompleteness propagates.
911
+ * A dep that exists in the store but isn't a candidate here (e.g. another line) counts
912
+ * as satisfied — only genuinely-unsynced deps hold an op back, so no false holdback.
913
+ */
914
+ #causallyComplete(candidates, present) {
915
+ const byId = new Map(candidates.map((o) => [o.oid, o]));
916
+ const memo = new Map();
917
+ const ok = (oid) => {
918
+ const cached = memo.get(oid);
919
+ if (cached !== undefined)
920
+ return cached;
921
+ if (!present.has(oid))
922
+ return false; // dep never arrived
923
+ const op = byId.get(oid);
924
+ if (!op)
925
+ return true; // present in the store but not a candidate (other line) — satisfied
926
+ memo.set(oid, true); // cycle guard (an append-only DAG has none)
927
+ for (const d of op.causalDeps)
928
+ if (!ok(d)) {
929
+ memo.set(oid, false);
930
+ return false;
931
+ }
932
+ memo.set(oid, true);
933
+ return true;
934
+ };
935
+ const complete = [];
936
+ const pending = [];
937
+ for (const op of candidates)
938
+ (ok(op.oid) ? complete : pending).push(op);
939
+ return { complete, pending };
940
+ }
941
+ /** Split ops into kept vs quarantined (outsider, not-yet-promoted) for a governed repo. */
942
+ async #partitionQuarantine(ops) {
943
+ const memberships = await this.store.collect("membership");
944
+ if (memberships.length === 0)
945
+ return { kept: ops, quarantined: new Set() }; // governance off
946
+ const members = new Set(memberships.filter((m) => !m.revokedAt).map((m) => m.actorId));
947
+ const promoted = new Set((await this.store.collect("promotion")).flatMap((p) => p.ops));
948
+ const kept = [];
949
+ const quarantined = new Set();
950
+ for (const op of ops) {
951
+ if (!members.has(op.actor.id) && !promoted.has(op.oid))
952
+ quarantined.add(op.oid);
953
+ else
954
+ kept.push(op);
955
+ }
956
+ return { kept, quarantined };
957
+ }
958
+ /** List currently-quarantined ops (outsider contributions awaiting review). */
959
+ async quarantinedOps(line = "main") {
960
+ const res = await this.materialize(line);
961
+ return [...res.statuses].filter(([, s]) => s === "quarantined").map(([oid]) => oid);
962
+ }
963
+ /**
964
+ * Phase 11: a non-member (external contributor) submits an op. It self-signs and
965
+ * lands quarantined. Admission control caps outstanding outsider ops per actor.
966
+ */
967
+ async proposeOutsider(args) {
968
+ const cap = args.maxOutstanding ?? 50;
969
+ const mine = (await this.store.collect("operation")).filter((o) => o.actor.id === args.actor.id);
970
+ if (mine.length >= cap)
971
+ throw new Error(`admission cap (${cap}) reached for outsider ${args.actor.id}`);
972
+ return this.proposeOperation(args);
973
+ }
974
+ /** A reviewer promotes quarantined outsider ops into the normal accepted flow. */
975
+ async promote(opOids, byActor, reason) {
976
+ if (!(await this.hasRole(byActor, "reviewer"))) {
977
+ throw new Error(`promote requires role >= reviewer; ${byActor} is ${await this.roleOf(byActor)}`);
978
+ }
979
+ const p = { type: "promotion", ops: opOids, by: byActor, reason, createdAt: new Date().toISOString() };
980
+ const oid = await this.store.put(p);
981
+ this.logger.info("promote", { promotionOid: oid, ops: opOids.length, by: byActor, reason });
982
+ return oid;
983
+ }
984
+ /**
985
+ * Revert an op: a forward-only inverse. Restores the op's file to its pre-op content
986
+ * (or deletes it if it didn't exist before) as a NEW op with `revertOf` provenance —
987
+ * append-only, recorded, itself revertable. File-granular in the MVP.
988
+ */
989
+ async revert(opOid, actor, line = "main") {
990
+ const target = await this.store.get(opOid);
991
+ const path = target.body.path ?? target.target.entityId.split("#")[0];
992
+ const before = await this.materializeAt(target.causalDeps);
993
+ const prev = (await this.materializedFiles(before)).find((f) => f.path === path);
994
+ const causalDeps = await this.lineFrontier(line);
995
+ const common = {
996
+ sessionOid: target.sessionOid,
997
+ intentOid: target.intentOid,
998
+ actor,
999
+ declaredPurpose: `revert ${opOid.slice(0, 16)}: ${target.declaredPurpose}`,
1000
+ causalDeps,
1001
+ line,
1002
+ revertOf: opOid,
1003
+ };
1004
+ if (prev === undefined) {
1005
+ return this.proposeOperation({ ...common, target: { entityKind: "file", entityId: path }, body: { kind: "delete_file", path } });
1006
+ }
1007
+ return this.proposeOperation({
1008
+ ...common,
1009
+ target: { entityKind: "file", entityId: path },
1010
+ body: { kind: "put_file", path, blobOid: await this.putBlob(prev.content) },
1011
+ });
1012
+ }
1013
+ /**
1014
+ * Reduce an explicit operation set (with the semantic-conflict 2-pass). Shared by
1015
+ * `materialize` (view-selected ops) and `materializeAt` (a frontier's closure).
1016
+ */
1017
+ // M1: cache reduction results keyed on a signature of the inputs. reduce() is a
1018
+ // pure function of (ops, evidence, decisions, policy, materializer), so identical
1019
+ // inputs ⇒ identical result — we skip the grouping/eval/semantic-2-pass/blob-load
1020
+ // cost on repeat calls (the hundreds-of-agents-re-materialize case, and CLI/MCP
1021
+ // repeats). A clone is returned so callers can mutate without corrupting the cache.
1022
+ #reduceCache = new Map();
1023
+ static REDUCE_CACHE_MAX = 64;
1024
+ #cloneResult(r) {
1025
+ return {
1026
+ tree: new Map(r.tree),
1027
+ treeHash: r.treeHash,
1028
+ statuses: new Map(r.statuses),
1029
+ conflicts: r.conflicts.map((c) => ({ ...c })),
1030
+ autoDecisions: r.autoDecisions.map((a) => ({ ...a })),
1031
+ semanticConflicts: r.semanticConflicts.map((s) => ({ ...s })),
1032
+ headOps: [...r.headOps],
1033
+ synthBlobs: new Map(r.synthBlobs),
1034
+ };
1035
+ }
1036
+ /**
1037
+ * Pass-1 reduce (docs/11 A6b). With AVCS_INCREMENTAL=1 and a prior snapshot, re-reduce
1038
+ * only the delta via `reduceIncremental` (falling back to a full `snapshotReduce` if the
1039
+ * preconditions don't hold — e.g. policy changed, or `base` is not an append-superset of
1040
+ * the snapshot). Default (flag off) is the plain full `reduce`, untouched. Only the main
1041
+ * materialize path passes `useInc`, so subset reducers (materializeAt/history/bisect)
1042
+ * never read or pollute the snapshot. AVCS_VERIFY_INCREMENTAL=1 cross-checks each
1043
+ * incremental result against a full reduce and throws on any divergence (CI safety net).
1044
+ */
1045
+ #pass1Reduce(base, useInc) {
1046
+ // Opt-in incremental: AVCS_INCREMENTAL (warm in-process delta), AVCS_COMPACT (cold start
1047
+ // from a persisted snapshot base, B3), or compact() capturing a fresh snapshot.
1048
+ const optIn = useInc && (process.env.AVCS_INCREMENTAL === "1" || process.env.AVCS_COMPACT === "1" || this.#forceSnapshot);
1049
+ if (!optIn)
1050
+ return reduce(base);
1051
+ let snap;
1052
+ if (this.#incSnap) {
1053
+ try {
1054
+ snap = reduceIncremental(this.#incSnap, base);
1055
+ }
1056
+ catch (e) {
1057
+ if (!(e instanceof NonIncrementalError))
1058
+ throw e;
1059
+ snap = snapshotReduce(base);
1060
+ this.metrics.inc("reduce.incremental.fallback");
1061
+ }
1062
+ }
1063
+ else {
1064
+ snap = snapshotReduce(base);
1065
+ }
1066
+ if (process.env.AVCS_VERIFY_INCREMENTAL === "1") {
1067
+ this.#assertReduceEqual(snap.result, snapshotReduce(base).result);
1068
+ }
1069
+ this.#incSnap = snap;
1070
+ return snap.result;
1071
+ }
1072
+ /** Throw if an incremental reduction diverges from the full one (treeHash/statuses/
1073
+ * conflicts/headOps) — incremental reduce must NEVER break the determinism invariant. */
1074
+ #assertReduceEqual(inc, full) {
1075
+ // canonicalize (recursive key-sort) so the compare is key-order-insensitive — a
1076
+ // CBOR-deserialized base (B3) yields sorted-key objects vs freshly-built insertion
1077
+ // order, which are logically identical.
1078
+ const norm = (r) => canonicalize({
1079
+ treeHash: r.treeHash,
1080
+ statuses: [...r.statuses].sort(),
1081
+ conflicts: r.conflicts,
1082
+ autoDecisions: r.autoDecisions,
1083
+ headOps: [...r.headOps].sort(),
1084
+ synth: [...r.synthBlobs.keys()].sort(),
1085
+ });
1086
+ if (norm(inc) !== norm(full)) {
1087
+ throw new Error(`incremental reduce diverged from full reduce (treeHash inc=${inc.treeHash} full=${full.treeHash}) — determinism invariant violated`);
1088
+ }
1089
+ }
1090
+ async #reduceOpSet(ops, includeStatuses, useInc = false) {
1091
+ const evidence = this.#verifiedEvidence(await this.store.collect("evidence"));
1092
+ const decisions = await this.store.collect("decision");
1093
+ // Redactions overwrite blob bytes while keeping the oid, so they don't change op
1094
+ // oids — include them in the signature so a redaction invalidates the cache.
1095
+ const redactions = await this.store.collect("redaction");
1096
+ const sig = sha256hex([
1097
+ ops.map((o) => o.oid).sort().join(","),
1098
+ evidence.map((e) => e.oid).sort().join(","),
1099
+ decisions.map((d) => d.oid).sort().join(","),
1100
+ redactions.map((r) => r.oid).sort().join(","),
1101
+ // memberships affect authority-weighted decisions → invalidate on change
1102
+ (await this.store.collect("membership")).map((m) => m.oid).sort().join(","),
1103
+ (await this.store.getRef("policy")) ?? "default",
1104
+ MATERIALIZER_VERSION,
1105
+ (includeStatuses ?? []).join("+"),
1106
+ ].join("|"));
1107
+ const hit = this.#reduceCache.get(sig);
1108
+ if (hit) {
1109
+ this.metrics.inc("reduce.cache.hit");
1110
+ return this.#cloneResult(hit);
1111
+ }
1112
+ this.metrics.inc("reduce.cache.miss");
1113
+ const result = await this.metrics.time("reduce.ms", () => this.#reduceOpSetUncached(ops, includeStatuses, evidence, decisions, useInc));
1114
+ if (this.#reduceCache.size >= _a.REDUCE_CACHE_MAX) {
1115
+ this.#reduceCache.delete(this.#reduceCache.keys().next().value);
1116
+ }
1117
+ this.#reduceCache.set(sig, result);
1118
+ return this.#cloneResult(result);
1119
+ }
1120
+ async #reduceOpSetUncached(ops, includeStatuses, evidence, decisions, useInc = false) {
1121
+ const intents = new Map();
1122
+ for await (const it of this.store.list("intent"))
1123
+ intents.set(it.oid, it);
1124
+ // Preload blob content needed by content-aware ops (set_symbol reconstructs text).
1125
+ const blobContent = new Map();
1126
+ for (const op of ops) {
1127
+ const oid = op.body.blobOid;
1128
+ if (oid && !blobContent.has(oid))
1129
+ blobContent.set(oid, (await this.readBlob(oid)).toString("utf8"));
1130
+ }
1131
+ const policy = await this.policy();
1132
+ const reliability = computeReliability(ops, evidence, decisions);
1133
+ const authority = await this.#authorityMap();
1134
+ const base = { ops, evidence, decisions, intents, policy, materializeStatuses: includeStatuses, blobContent, reliability, authority };
1135
+ const pass1 = this.#pass1Reduce(base, useInc);
1136
+ // Second-pass conflicts that the text-clean grouping accepted but that must be
1137
+ // held back (re-reduce excluding them so the tree stays safe — base content falls
1138
+ // back in automatically): (a) Phase-4 semantic contract breaks, and (b) the
1139
+ // cross-granularity determinism hole — a whole-file op concurrent with a symbol
1140
+ // edit on the same file (found by the determinism harness).
1141
+ const semantic = detectSemanticConflicts(ops, pass1, evidence, blobContent);
1142
+ const cross = detectCrossGranularity(ops, pass1);
1143
+ if (semantic.length === 0 && cross.length === 0)
1144
+ return pass1;
1145
+ const held = new Set([...semantic.map((s) => s.breakingOp), ...cross.flatMap((c) => c.ops)]);
1146
+ const pass2 = reduce({ ...base, ops: ops.filter((o) => !held.has(o.oid)) });
1147
+ for (const s of semantic) {
1148
+ pass2.statuses.set(s.breakingOp, "needs_decision");
1149
+ for (const d of s.dependentOps)
1150
+ pass2.statuses.set(d, pass2.statuses.get(d) ?? "needs_decision");
1151
+ }
1152
+ for (const oid of cross.flatMap((c) => c.ops))
1153
+ pass2.statuses.set(oid, "needs_decision");
1154
+ pass2.semanticConflicts = semantic;
1155
+ for (const s of semantic) {
1156
+ pass2.conflicts.push({
1157
+ id: `conflict_sem_${(s.symbol.split("#")[1] ?? s.symbol).slice(0, 16)}`,
1158
+ key: `contract:${s.symbol}`,
1159
+ kind: "needs_human",
1160
+ reason: s.reason,
1161
+ recommendedOp: null,
1162
+ options: [s.breakingOp, ...s.dependentOps].map((oid) => ({
1163
+ opOid: oid, actor: "", purpose: oid === s.breakingOp ? "contract change" : "depends on old contract",
1164
+ evidence: [], score: 0, blocked: false, requiresHuman: true,
1165
+ })),
1166
+ });
1167
+ }
1168
+ for (const c of cross) {
1169
+ pass2.conflicts.push({
1170
+ id: conflictIdFor(`file:${c.file}`),
1171
+ key: `file:${c.file}`,
1172
+ kind: "concurrent_write",
1173
+ reason: `whole-file write and symbol edit on ${c.file} are concurrent — can't both apply deterministically`,
1174
+ recommendedOp: null,
1175
+ options: c.ops.map((oid) => ({ opOid: oid, actor: "", purpose: "concurrent whole-file/symbol edit", evidence: [], score: 0, blocked: false, requiresHuman: false })),
1176
+ });
1177
+ }
1178
+ return pass2;
1179
+ }
1180
+ // ── git-like working tree (checkout / commit) ─────────────────────────────
1181
+ /** Read a working directory's files (relative paths → content), skipping .avcs/. */
1182
+ async #readWorkTree(workDir) {
1183
+ const out = new Map();
1184
+ if (!existsSync(workDir))
1185
+ return out;
1186
+ for (const ent of await readdir(workDir, { recursive: true, withFileTypes: true })) {
1187
+ if (!ent.isFile())
1188
+ continue;
1189
+ const rel = join(ent.parentPath ?? ent.path ?? workDir, ent.name).slice(workDir.length + 1);
1190
+ if (rel.startsWith(".avcs") || rel === ".avcs-workspace" || rel.startsWith(".git"))
1191
+ continue;
1192
+ out.set(rel.split("\\").join("/"), await readFile(join(workDir, rel), "utf8"));
1193
+ }
1194
+ return out;
1195
+ }
1196
+ /** Write a view's materialized files into `workDir` (alongside .avcs, like git). */
1197
+ async checkoutInto(workDir, view = "main") {
1198
+ const res = await this.materialize(view);
1199
+ const written = [];
1200
+ for (const [path, blobOid] of res.tree) {
1201
+ const full = join(workDir, path);
1202
+ await mkdir(dirname(full), { recursive: true });
1203
+ const synth = res.synthBlobs.get(blobOid);
1204
+ await writeFile(full, synth !== undefined ? Buffer.from(synth, "utf8") : await this.readBlob(blobOid));
1205
+ written.push(path);
1206
+ }
1207
+ return written.sort();
1208
+ }
1209
+ /**
1210
+ * Commit a working tree: diff `workDir`'s files against the materialized view and
1211
+ * author put_file / delete_file ops for the changes (the git `add`+`commit` step,
1212
+ * which agents do via operation.propose). Causally builds on the current frontier.
1213
+ */
1214
+ async commitWorkingTree(workDir, opts) {
1215
+ const view = opts.line ?? "main";
1216
+ const res = await this.materialize(view);
1217
+ const current = new Map((await this.materializedFiles(res)).map((f) => [f.path, f.content]));
1218
+ const disk = await this.#readWorkTree(workDir);
1219
+ const added = [];
1220
+ const modified = [];
1221
+ const removed = [];
1222
+ for (const [path, content] of disk) {
1223
+ if (!current.has(path))
1224
+ added.push(path);
1225
+ else if (current.get(path) !== content)
1226
+ modified.push(path);
1227
+ }
1228
+ for (const path of current.keys())
1229
+ if (!disk.has(path))
1230
+ removed.push(path);
1231
+ const ops = [];
1232
+ if (!added.length && !modified.length && !removed.length)
1233
+ return { ops, added, modified, removed, intent: "" };
1234
+ const intent = await this.createIntent({ title: opts.message, owner: opts.actor.id });
1235
+ const sess = await this.startSession({ intentOid: intent, actor: opts.actor });
1236
+ const deps = res.headOps;
1237
+ for (const path of [...added, ...modified].sort()) {
1238
+ ops.push(await this.proposeFileWrite({ sessionOid: sess, intentOid: intent, actor: opts.actor, path, content: disk.get(path), declaredPurpose: opts.message, causalDeps: deps, line: opts.line }));
1239
+ }
1240
+ for (const path of removed.sort()) {
1241
+ ops.push(await this.proposeOperation({ sessionOid: sess, intentOid: intent, actor: opts.actor, target: { entityKind: "file", entityId: path }, body: { kind: "delete_file", path }, declaredPurpose: `delete ${path}`, causalDeps: deps, line: opts.line }));
1242
+ }
1243
+ return { ops, added: added.sort(), modified: modified.sort(), removed: removed.sort(), intent };
1244
+ }
1245
+ // ── git bridge (docs/14) ───────────────────────────────────────────────────
1246
+ /** Read `.avcs/config.json` (a torn/absent file is treated as empty). */
1247
+ async #readConfig() {
1248
+ const p = join(this.dir, ".avcs", "config.json");
1249
+ if (!existsSync(p))
1250
+ return {};
1251
+ try {
1252
+ return JSON.parse(await readFile(p, "utf8"));
1253
+ }
1254
+ catch {
1255
+ return {};
1256
+ }
1257
+ }
1258
+ /** Read the repo-local git-bridge mode (default `sidecar` for pre-existing repos). */
1259
+ async getGitMode() {
1260
+ return (await this.#readConfig()).gitMode === "committed" ? "committed" : "sidecar";
1261
+ }
1262
+ /** Whether `git-sync --commit` injects AVCS provenance trailers (default on). */
1263
+ async gitTrailerEnabled() {
1264
+ return (await this.#readConfig()).trailer !== false;
1265
+ }
1266
+ /** Persist the git-bridge mode and (re)write `.avcs/.gitignore` to match it. */
1267
+ async setGitMode(mode) {
1268
+ const cfg = await this.#readConfig();
1269
+ cfg.gitMode = mode;
1270
+ await this.store.writeAux("config.json", JSON.stringify(cfg, null, 2) + "\n");
1271
+ await this.#writeGitignore(mode);
1272
+ this.logger.info("git.mode", { mode });
1273
+ }
1274
+ /**
1275
+ * Build the commit-message trailer block that links a git commit to its AVCS provenance
1276
+ * (the git→avcs half). A reader with the `.avcs/` history can resolve the checkpoint;
1277
+ * for a teammate without AVCS it is a harmless annotation (like `Co-authored-by`).
1278
+ */
1279
+ gitTrailer(info) {
1280
+ const lines = [`AVCS-Checkpoint: ${info.checkpoint}`, `AVCS-TreeHash: ${info.treeHash}`];
1281
+ if (info.intent)
1282
+ lines.push(`AVCS-Intent: ${info.intent}`);
1283
+ return lines.join("\n");
1284
+ }
1285
+ /** Record the git commit ↔ AVCS checkpoint back-link (the avcs→git half of provenance). */
1286
+ async recordGitCommit(sha, checkpointOid) {
1287
+ await this.store.setRef(`git:${sha}`, checkpointOid);
1288
+ this.logger.info("git.link", { sha, checkpoint: checkpointOid });
1289
+ }
1290
+ /** The checkpoint a git commit was synced from, if a back-link was recorded locally. */
1291
+ async gitCheckpoint(sha) {
1292
+ return this.store.getRef(`git:${sha}`);
1293
+ }
1294
+ /**
1295
+ * Resolve the canonical projection (path→content) a checkpoint froze, for provenance
1296
+ * verification. `treeHashOk` re-confirms the checkpoint's recorded treeHash still
1297
+ * reproduces from its frontier (internal integrity); `files` is what git's committed
1298
+ * tree at the linked SHA must match exactly for the commit to be a faithful projection.
1299
+ */
1300
+ async checkpointFiles(checkpointOid) {
1301
+ const cp = await this.store.get(checkpointOid);
1302
+ const res = await this.materializeAt(cp.headOps);
1303
+ return { treeHash: cp.treeHash, treeHashOk: res.treeHash === cp.treeHash, files: await this.materializedFiles(res) };
1304
+ }
1305
+ /** Write `.avcs/.gitignore` for `mode`. Idempotent; safe to call on every sync. */
1306
+ async #writeGitignore(mode) {
1307
+ await this.store.writeAux(".gitignore", mode === "committed" ? GITIGNORE_COMMITTED : GITIGNORE_SIDECAR);
1308
+ // In committed mode, a `.avcs/.gitattributes` (scoped to this dir — never the repo
1309
+ // root) keeps immutable object files out of diffs and off git's text-merge path; they
1310
+ // are content-addressed so distinct oids never collide, and identical oids are
1311
+ // byte-identical (no conflict). Mutable refs CAN still conflict — resolved by the
1312
+ // post-merge reindex+checkout, see docs/14.
1313
+ if (mode === "committed") {
1314
+ await this.store.writeAux(".gitattributes", "objects/** -diff -merge\noplog -diff\nobjlog -diff\n");
1315
+ }
1316
+ }
1317
+ /**
1318
+ * Persist the provenance handoff for the git-hook trio (pre-commit writes it; the
1319
+ * prepare-commit-msg and post-commit hooks consume it). Local working state, git-ignored.
1320
+ */
1321
+ async writeGitPending(info) {
1322
+ await this.store.writeAux(".git-pending", JSON.stringify(info) + "\n");
1323
+ }
1324
+ /** Read the pending provenance handoff, or null if none is staged. */
1325
+ async readGitPending() {
1326
+ const p = join(this.dir, ".avcs", ".git-pending");
1327
+ if (!existsSync(p))
1328
+ return null;
1329
+ try {
1330
+ return JSON.parse(await readFile(p, "utf8"));
1331
+ }
1332
+ catch {
1333
+ return null;
1334
+ }
1335
+ }
1336
+ /** Clear the pending provenance handoff (post-commit, after recording the back-link). */
1337
+ async clearGitPending() {
1338
+ await rm(join(this.dir, ".avcs", ".git-pending"), { force: true });
1339
+ }
1340
+ /**
1341
+ * Rebuild every rebuildable cache from the object store: the entity index AND the
1342
+ * op-log/obj-log. This is the recovery path after objects arrive OUTSIDE the normal
1343
+ * authoring code path — e.g. a `git pull`/`merge` that unions committed-mode
1344
+ * `.avcs/objects` straight onto disk. Those logs are git-ignored, so without this the
1345
+ * op-LOG (which `materialize` reads its op SET from) would stay stale and silently miss
1346
+ * the pulled ops. Rebuilding the op-log is therefore essential, not just cosmetic.
1347
+ * Idempotent.
1348
+ */
1349
+ async reindex() {
1350
+ await rm(join(this.dir, ".avcs", "indexes"), { recursive: true, force: true });
1351
+ let ops = 0;
1352
+ for await (const op of this.store.list("operation")) {
1353
+ const oid = op.oid;
1354
+ for (const k of keysOf(op))
1355
+ await this.store.appendEntityIndex(k, oid);
1356
+ ops++;
1357
+ }
1358
+ // Rebuild the op-log (materialize's op-set source) and refresh the obj-log (hub sync
1359
+ // cursor) so both reflect objects that arrived via git rather than the store's writes.
1360
+ await this.store.rebuildOpLog();
1361
+ this.#opCache.clear();
1362
+ await rm(join(this.dir, ".avcs", "objlog"), { force: true }); // lazily backfilled on next read
1363
+ this.logger.info("reindex", { ops });
1364
+ return { ops };
1365
+ }
1366
+ /**
1367
+ * One-shot "prepare the working tree for `git commit`" (docs/14). The bridge between
1368
+ * AVCS development and a `git add`/`commit`/`push`:
1369
+ * 1. capture any direct working-tree edits as ops (so nothing a human/agent typed is
1370
+ * lost — direct edits and agent-proposed ops converge into one history),
1371
+ * 2. gate: if the view has open (needs-human) conflicts, REFUSE — never let a
1372
+ * conflicted tree be committed; the caller routes the human to `avcs conflicts`,
1373
+ * 3. checkpoint the verified state vector (the git "commit unit"), and
1374
+ * 4. re-project so the working tree is EXACTLY reduce()'s output (folding in any
1375
+ * auto-merged concurrent ops), making git track the deterministic projection.
1376
+ * Git invocation (`git add`) is intentionally left to the caller/CLI so this core stays
1377
+ * git-agnostic; `.avcs/.gitignore` (ensured here) makes a plain `git add -A` mode-correct.
1378
+ */
1379
+ async gitSync(opts) {
1380
+ const view = opts.line ?? "main";
1381
+ const lineOpt = opts.line ? { line: opts.line } : {};
1382
+ // 1. Capture direct working-tree edits as ops before anything else.
1383
+ const cap = await this.commitWorkingTree(this.dir, { message: opts.message, actor: opts.actor, ...lineOpt });
1384
+ const captured = { ops: cap.ops, added: cap.added, modified: cap.modified, removed: cap.removed, intent: cap.intent };
1385
+ // Ensure the gitignore reflects the current mode (pre-existing repos never wrote one).
1386
+ const mode = await this.getGitMode();
1387
+ await this.#writeGitignore(mode);
1388
+ // 2. Conflict gate.
1389
+ const res = await this.materialize(view);
1390
+ if (res.conflicts.length > 0)
1391
+ return { mode, captured, conflicts: res.conflicts };
1392
+ // 3. Checkpoint the verified state. 4. Re-project the working tree.
1393
+ const checkpoint = await this.createCheckpoint(view, opts.message);
1394
+ const written = await this.checkoutInto(this.dir, view);
1395
+ this.logger.info("git.sync", { view, mode, capturedOps: captured.ops.length, checkpoint, treeHash: res.treeHash });
1396
+ return { mode, captured, conflicts: [], checkpoint, treeHash: res.treeHash, reprojected: written.length };
1397
+ }
1398
+ // ── backup / transfer (docs/10 WS-F) ──────────────────────────────────────
1399
+ /** Export the whole repo (all objects + refs) as a portable bundle for backup/transfer. */
1400
+ async exportBundle() {
1401
+ const objects = [];
1402
+ for await (const o of this.store.list())
1403
+ objects.push(o);
1404
+ return { version: 1, objects, refs: Object.fromEntries(await this.store.listRefs()) };
1405
+ }
1406
+ /** Import a bundle into this repo (idempotent, content-addressed). Rebuilds the entity index. */
1407
+ async importBundle(bundle) {
1408
+ for (const o of bundle.objects) {
1409
+ const oid = await this.store.put(o);
1410
+ if (o.type === "operation")
1411
+ for (const k of keysOf(o))
1412
+ await this.store.appendEntityIndex(k, oid);
1413
+ }
1414
+ let refs = 0;
1415
+ for (const [name, oid] of Object.entries(bundle.refs ?? {})) {
1416
+ if (await this.store.has(oid)) {
1417
+ await this.store.setRef(name, oid);
1418
+ refs++;
1419
+ }
1420
+ }
1421
+ return { objects: bundle.objects.length, refs };
1422
+ }
1423
+ /**
1424
+ * Pack loose objects into a packfile (docs/11 B2) — a maintenance op that reduces inode
1425
+ * count and speeds full scans. Reads stay correct throughout (loose-first, then packs);
1426
+ * blobs are intentionally left loose so redaction can always scrub their bytes.
1427
+ */
1428
+ async pack() {
1429
+ const r = await this.store.pack();
1430
+ this.logger.info("pack", { packed: r.packed });
1431
+ return r;
1432
+ }
1433
+ /**
1434
+ * Compaction (docs/11 B3): persist the current reduction of `view` as a durable base
1435
+ * snapshot. A later COLD materialize (with AVCS_COMPACT=1) loads it and `reduceIncremental`s
1436
+ * only the ops added since — folding settled history into the base instead of replaying
1437
+ * it — while the original ops stay on disk (append-only audit preserved). Correctness is
1438
+ * the same invariant as Track A: reduceIncremental(base, current) ≡ full reduce, gated by
1439
+ * the property harness and (with AVCS_VERIFY_INCREMENTAL=1) a per-call self-check.
1440
+ */
1441
+ async compact(view = "main") {
1442
+ this.#forceSnapshot = true;
1443
+ try {
1444
+ await this.materialize(view); // produces & stores #incSnap via #pass1Reduce
1445
+ }
1446
+ finally {
1447
+ this.#forceSnapshot = false;
1448
+ }
1449
+ if (!this.#incSnap)
1450
+ return { baseOps: 0 };
1451
+ // Atomic write (D2): a plain writeFile could leave a torn CBOR snapshot on a crash.
1452
+ // writeAux routes through the store's temp→fsync→rename→fsync-dir path. A torn read
1453
+ // would still fall back to a full reduce (#loadPersistedSnapshot catches decode
1454
+ // errors), but a durable atomic write means the base is never silently corrupt.
1455
+ await this.store.writeAux(join("snapshot", `${view}.cbor`), encodeCbor(serializeSnapshot(this.#incSnap)));
1456
+ const baseOps = this.#incSnap.input.ops.length;
1457
+ this.logger.info("compact", { view, baseOps });
1458
+ return { baseOps };
1459
+ }
1460
+ /** Load a persisted compaction base into the in-memory incremental snapshot (B3). */
1461
+ async #loadPersistedSnapshot(view) {
1462
+ const p = join(this.dir, ".avcs", "snapshot", `${view}.cbor`);
1463
+ if (!existsSync(p))
1464
+ return;
1465
+ try {
1466
+ this.#incSnap = deserializeSnapshot(decodeCbor(await readFile(p)));
1467
+ }
1468
+ catch {
1469
+ this.#incSnap = null; // corrupt/incompatible snapshot → full reduce (always correct)
1470
+ }
1471
+ }
1472
+ /**
1473
+ * Garbage-collect (docs/10 WS-C). Reclaims only objects UNREACHABLE from the
1474
+ * authoritative graph — never the append-only audit history of accepted ops:
1475
+ * - orphan blobs: stored blobs no remaining op references (incl. chunk blobs whose
1476
+ * manifest is gone);
1477
+ * - expired quarantine: outsider ops still quarantined (non-member, never promoted),
1478
+ * past `quarantineTtlMs`, that nothing else builds on — the one place append-only
1479
+ * yields (abandoned/spam contributions, docs/09 G5).
1480
+ * `dryRun` reports without deleting.
1481
+ */
1482
+ async gc(opts = {}) {
1483
+ const ops = await this.store.collect("operation");
1484
+ const ttl = opts.quarantineTtlMs ?? 7 * 24 * 3600_000;
1485
+ const now = Date.now();
1486
+ const memberships = await this.store.collect("membership");
1487
+ const governanceActive = memberships.length > 0;
1488
+ const members = new Set(memberships.filter((m) => !m.revokedAt).map((m) => m.actorId));
1489
+ const promoted = new Set((await this.store.collect("promotion")).flatMap((p) => p.ops));
1490
+ const dependedOn = new Set(ops.flatMap((o) => o.causalDeps));
1491
+ const quarantinedOps = [];
1492
+ const removed = new Set();
1493
+ if (governanceActive) {
1494
+ for (const o of ops) {
1495
+ const oid = o.oid;
1496
+ const quarantined = !members.has(o.actor.id) && !promoted.has(oid);
1497
+ if (!quarantined || dependedOn.has(oid))
1498
+ continue;
1499
+ if (now - Date.parse(o.createdAt) < ttl)
1500
+ continue;
1501
+ quarantinedOps.push(oid);
1502
+ removed.add(oid);
1503
+ }
1504
+ }
1505
+ // Blobs referenced by REMAINING ops (+ chunks of referenced chunked manifests).
1506
+ const referenced = new Set();
1507
+ for (const o of ops) {
1508
+ if (removed.has(o.oid))
1509
+ continue;
1510
+ const b = o.body.blobOid;
1511
+ if (!b)
1512
+ continue;
1513
+ referenced.add(b);
1514
+ const blob = await this.store.get(b).catch(() => null);
1515
+ if (blob?.chunked && blob.chunks)
1516
+ for (const c of blob.chunks)
1517
+ referenced.add(c);
1518
+ }
1519
+ const blobs = (await this.store.collect("blob"))
1520
+ .map((b) => b.oid)
1521
+ .filter((oid) => !referenced.has(oid));
1522
+ if (!opts.dryRun) {
1523
+ for (const oid of quarantinedOps)
1524
+ await this.store.deleteObject(oid);
1525
+ for (const oid of blobs)
1526
+ await this.store.deleteObject(oid);
1527
+ // Objects were deleted from under the warm caches — drop them so the next
1528
+ // materialize re-tails from disk (GC'd op-log entries are then skipped).
1529
+ for (const oid of quarantinedOps)
1530
+ this.#opCache.delete(oid);
1531
+ for (const oid of blobs)
1532
+ this.#blobCache.delete(oid);
1533
+ }
1534
+ this.logger.info("gc", { dryRun: opts.dryRun ?? false, blobs: blobs.length, quarantinedOps: quarantinedOps.length });
1535
+ return { blobs, quarantinedOps };
1536
+ }
1537
+ /**
1538
+ * Materialize the state AT a given frontier: reduce only the causal closure of
1539
+ * `headOps`. The basis for time-travel — history, bisect, and diff-at-point all
1540
+ * reduce over a prefix instead of the whole graph. (Phase 9 / Phase 10)
1541
+ */
1542
+ async materializeAt(headOps, includeStatuses = ["accepted"]) {
1543
+ const allOps = await this.store.collect("operation");
1544
+ const byId = new Map(allOps.map((o) => [o.oid, o]));
1545
+ const closure = new Set();
1546
+ const stack = [...headOps];
1547
+ while (stack.length) {
1548
+ const id = stack.pop();
1549
+ if (closure.has(id))
1550
+ continue;
1551
+ closure.add(id);
1552
+ for (const dep of byId.get(id)?.causalDeps ?? [])
1553
+ if (!closure.has(dep))
1554
+ stack.push(dep);
1555
+ }
1556
+ return this.#reduceOpSet(allOps.filter((o) => closure.has(o.oid)), includeStatuses);
1557
+ }
1558
+ /**
1559
+ * History of one entity (file path or `<path>#<symbol>`) in causal order, via the
1560
+ * entity index — O(ops-on-that-entity), not a full-store scan. The basis for blame
1561
+ * and `log -p`. (Phase 9 / Phase 10)
1562
+ */
1563
+ async historyOf(entityKey) {
1564
+ const oids = await this.store.readEntityIndex(entityKey);
1565
+ const ops = [];
1566
+ for (const o of oids)
1567
+ if (await this.store.has(o))
1568
+ ops.push(await this.store.get(o)); // skip GC'd
1569
+ return ops.sort((a, b) => a.lamport - b.lamport || ((a.oid ?? "") < (b.oid ?? "") ? -1 : 1));
1570
+ }
1571
+ // ── observability (Phase 10) ────────────────────────────────────────────
1572
+ /**
1573
+ * Blame: who currently owns an entity and WHY — the accepted head op on its key,
1574
+ * with actor + intent + purpose. Stronger than git blame: the 'why' is first-class.
1575
+ */
1576
+ async blame(entityKey, line = "main") {
1577
+ const res = await this.materialize(line);
1578
+ const hist = await this.historyOf(entityKey);
1579
+ const owner = [...hist].reverse().find((o) => res.statuses.get(o.oid) === "accepted");
1580
+ if (!owner)
1581
+ return null;
1582
+ const intent = await this.readIntent(owner.intentOid).catch(() => null);
1583
+ return {
1584
+ op: owner.oid,
1585
+ actor: owner.actor,
1586
+ purpose: owner.declaredPurpose,
1587
+ ...(intent ? { intentTitle: intent.title } : {}),
1588
+ at: owner.createdAt,
1589
+ };
1590
+ }
1591
+ /** `log -p` for one entity: each op with its before/after content reconstructed. */
1592
+ async logP(entityKey, filePath) {
1593
+ const hist = await this.historyOf(entityKey);
1594
+ const out = [];
1595
+ const fileOf = async (heads) => (await this.materializedFiles(await this.materializeAt(heads))).find((f) => f.path === filePath)?.content ?? "";
1596
+ for (const o of hist) {
1597
+ out.push({
1598
+ op: o.oid,
1599
+ purpose: o.declaredPurpose,
1600
+ before: await fileOf(o.causalDeps),
1601
+ after: await fileOf([o.oid]),
1602
+ });
1603
+ }
1604
+ return out;
1605
+ }
1606
+ /** Diff two views (or, with materializeAt, two frontiers). */
1607
+ async diff(viewA, viewB) {
1608
+ const { diffTrees } = await import("../query/diff.js");
1609
+ return diffTrees(await this.materialize(viewA), await this.materialize(viewB));
1610
+ }
1611
+ /**
1612
+ * Bisect: find the first operation (between a known-good and known-bad frontier)
1613
+ * that makes `isBad` true. Deterministic — re-reduces at each step with no checkout.
1614
+ */
1615
+ async bisect(goodHeads, badHeads, isBad) {
1616
+ const allOps = await this.store.collect("operation");
1617
+ const byId = new Map(allOps.map((o) => [o.oid, o]));
1618
+ const closure = (heads) => {
1619
+ const seen = new Set();
1620
+ const stack = [...heads];
1621
+ while (stack.length) {
1622
+ const id = stack.pop();
1623
+ if (seen.has(id))
1624
+ continue;
1625
+ seen.add(id);
1626
+ for (const d of byId.get(id)?.causalDeps ?? [])
1627
+ if (!seen.has(d))
1628
+ stack.push(d);
1629
+ }
1630
+ return seen;
1631
+ };
1632
+ const good = closure(goodHeads);
1633
+ const between = [...closure(badHeads)]
1634
+ .filter((id) => !good.has(id))
1635
+ .map((id) => byId.get(id))
1636
+ .sort((a, b) => a.lamport - b.lamport || ((a.oid ?? "") < (b.oid ?? "") ? -1 : 1));
1637
+ // smallest k in [0..n] such that good ∪ first-k-between is bad
1638
+ let lo = 0;
1639
+ let hi = between.length;
1640
+ while (lo < hi) {
1641
+ const mid = (lo + hi) >> 1;
1642
+ const heads = [...goodHeads, ...between.slice(0, mid).map((o) => o.oid)];
1643
+ if (await isBad(await this.materializeAt(heads)))
1644
+ hi = mid;
1645
+ else
1646
+ lo = mid + 1;
1647
+ }
1648
+ return lo > 0 && lo <= between.length ? between[lo - 1].oid : null;
1649
+ }
1650
+ /**
1651
+ * Decision memory: given a conflict key, recall prior human rulings on the same
1652
+ * key — their verdict, reason, and any distilled `futurePolicy`. The next agent
1653
+ * (and the conflict UI) can reuse them instead of re-litigating.
1654
+ */
1655
+ async recallDecisions(conflictKey) {
1656
+ const cid = conflictIdFor(conflictKey);
1657
+ const decisions = await this.store.collect("decision");
1658
+ return decisions
1659
+ .filter((d) => d.conflictId === cid || d.conflictId === conflictKey)
1660
+ .map((d) => ({ reason: d.reason, futurePolicy: d.futurePolicy, decidedBy: d.decidedBy.id }));
1661
+ }
1662
+ /** All distilled `futurePolicy` rules a human has left behind — learned constraints. */
1663
+ async learnedPolicies() {
1664
+ const decisions = await this.store.collect("decision");
1665
+ return [...new Set(decisions.map((d) => d.futurePolicy).filter((p) => !!p))];
1666
+ }
1667
+ /**
1668
+ * Write the materialized tree to a directory. Refuses to clobber an existing
1669
+ * non-empty directory unless it carries our marker, so a stray `--out` can't
1670
+ * `rm -rf` someone's source tree.
1671
+ */
1672
+ async writeWorkspace(result, targetDir) {
1673
+ const marker = join(targetDir, ".avcs-workspace");
1674
+ if (existsSync(targetDir)) {
1675
+ const entries = await readdir(targetDir);
1676
+ const nonEmpty = entries.filter((e) => e !== "." && e !== "..");
1677
+ if (nonEmpty.length > 0 && !existsSync(marker)) {
1678
+ throw new Error(`refusing to overwrite non-empty directory without an .avcs-workspace marker: ${targetDir}`);
1679
+ }
1680
+ await rm(targetDir, { recursive: true, force: true });
1681
+ }
1682
+ await mkdir(targetDir, { recursive: true });
1683
+ await writeFile(marker, `materialized ${result.treeHash}\n`, "utf8");
1684
+ for (const [path, blobOid] of result.tree) {
1685
+ const full = join(targetDir, path);
1686
+ await mkdir(dirname(full), { recursive: true });
1687
+ // Symbol-merged files are synthesized content, not a stored blob.
1688
+ const synth = result.synthBlobs.get(blobOid);
1689
+ await writeFile(full, synth !== undefined ? Buffer.from(synth, "utf8") : await this.readBlob(blobOid));
1690
+ }
1691
+ }
1692
+ async createCheckpoint(viewName, summary) {
1693
+ const view = await this.getView(viewName);
1694
+ const result = await this.materialize(viewName);
1695
+ const evidence = {};
1696
+ // Deterministic aggregation: process evidence in canonical (createdAt, oid) order
1697
+ // so the "last result wins per kind" outcome is replica-independent.
1698
+ const allEvidence = (await this.store.collect("evidence")).sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0) ||
1699
+ ((a.oid ?? "") < (b.oid ?? "") ? -1 : 1));
1700
+ for (const ev of allEvidence) {
1701
+ // Only count trusted evidence for accepted ops.
1702
+ if (ev.producedBy.kind === "ai_agent")
1703
+ continue;
1704
+ if (ev.forOps.some((o) => result.statuses.get(o) === "accepted")) {
1705
+ evidence[ev.kind] = ev.result;
1706
+ }
1707
+ }
1708
+ const cp = {
1709
+ type: "checkpoint",
1710
+ viewOid: view.oid,
1711
+ headOps: result.headOps,
1712
+ treeHash: result.treeHash,
1713
+ policyOid: (await this.store.getRef("policy")),
1714
+ materializerVersion: MATERIALIZER_VERSION,
1715
+ evidence,
1716
+ status: result.conflicts.length === 0 ? "verified" : "draft",
1717
+ summary,
1718
+ createdAt: new Date().toISOString(),
1719
+ };
1720
+ const oid = await this.store.put(cp);
1721
+ await this.store.setRef(`checkpoint:${viewName}:latest`, oid);
1722
+ return oid;
1723
+ }
1724
+ /** Resolve the materialized tree into {path, content} entries. */
1725
+ async materializedFiles(result) {
1726
+ const out = [];
1727
+ for (const [path, blobOid] of result.tree) {
1728
+ const synth = result.synthBlobs.get(blobOid);
1729
+ out.push({ path, content: synth ?? (await this.readBlob(blobOid)).toString("utf8") });
1730
+ }
1731
+ return out;
1732
+ }
1733
+ /**
1734
+ * Phase 6: cut a Release — a verified checkpoint + its evidence + an SBOM of what
1735
+ * shipped + signed-off artifacts. Refuses unless the view is conflict-free (no open
1736
+ * conflicts and no semantic contract breaks): you cannot release an unverified tree.
1737
+ */
1738
+ async cutRelease(viewName, opts = {}) {
1739
+ const result = await this.materialize(viewName);
1740
+ if (result.conflicts.length || result.semanticConflicts.length) {
1741
+ return {
1742
+ released: false,
1743
+ reason: `view has ${result.conflicts.length} open conflict(s) and ${result.semanticConflicts.length} contract break(s); resolve them before releasing`,
1744
+ };
1745
+ }
1746
+ const checkpointOid = await this.createCheckpoint(viewName, opts.summary ?? `release of ${viewName}`);
1747
+ const checkpoint = await this.store.get(checkpointOid);
1748
+ const { generateSbom } = await import("../release/sbom.js");
1749
+ const sbom = generateSbom(await this.materializedFiles(result));
1750
+ const release = {
1751
+ type: "release",
1752
+ checkpointOid,
1753
+ treeHash: result.treeHash,
1754
+ sbom,
1755
+ artifacts: opts.artifacts ?? [],
1756
+ evidence: checkpoint.evidence,
1757
+ signedBy: opts.signedBy ?? (opts.signWith ? [opts.signWith.keyId] : []),
1758
+ status: "released",
1759
+ ...(opts.version ? { version: opts.version } : {}),
1760
+ ...(opts.supportStatus ? { supportStatus: opts.supportStatus } : {}),
1761
+ createdAt: new Date().toISOString(),
1762
+ };
1763
+ release.sig = this.#sign("release", release, opts.signWith);
1764
+ const oid = await this.store.put(release);
1765
+ await this.store.setRef(`release:${viewName}:latest`, oid);
1766
+ if (opts.version)
1767
+ await this.store.setRef(`release:${viewName}:${opts.version}`, oid);
1768
+ return { released: true, releaseOid: oid };
1769
+ }
1770
+ }
1771
+ _a = Repo;
1772
+ //# sourceMappingURL=repo.js.map