@aiwg/cli 2026.8.26 → 2026.8.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,11 @@
11
11
  * @implements #544
12
12
  */
13
13
  import { OpsRegistry } from './registry.js';
14
+ import { appendFile, chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
15
+ import { dirname, resolve } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { parse as parseYaml } from 'yaml';
18
+ import { prepareEvidenceForSink, } from '../governance/index.js';
14
19
  /**
15
20
  * Main CLI entry point for `aiwg ops <subcommand> [args]`
16
21
  */
@@ -53,6 +58,9 @@ export async function main(args) {
53
58
  case 'adopt':
54
59
  await handleAdopt(registry, subArgs);
55
60
  break;
61
+ case 'evidence':
62
+ await handleEvidence(subArgs);
63
+ break;
56
64
  default:
57
65
  printUsage();
58
66
  if (subcommand) {
@@ -61,6 +69,91 @@ export async function main(args) {
61
69
  break;
62
70
  }
63
71
  }
72
+ function flagValue(args, name) {
73
+ const index = args.indexOf(name);
74
+ if (index === -1)
75
+ return undefined;
76
+ const value = args[index + 1];
77
+ if (!value || value.startsWith('--'))
78
+ throw new Error(`${name} requires a value`);
79
+ return value;
80
+ }
81
+ function parseData(source, label) {
82
+ try {
83
+ return JSON.parse(source);
84
+ }
85
+ catch {
86
+ try {
87
+ return parseYaml(source);
88
+ }
89
+ catch {
90
+ throw new Error(`${label} must contain valid JSON or YAML`);
91
+ }
92
+ }
93
+ }
94
+ async function readStdin(maxBytes = 16 * 1024 * 1024) {
95
+ const chunks = [];
96
+ let bytes = 0;
97
+ for await (const chunk of process.stdin) {
98
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
99
+ bytes += value.length;
100
+ if (bytes > maxBytes)
101
+ throw new Error('evidence input exceeds 16 MiB');
102
+ chunks.push(value);
103
+ }
104
+ return Buffer.concat(chunks).toString('utf8');
105
+ }
106
+ async function readInput(args) {
107
+ const inputPath = flagValue(args, '--input');
108
+ return inputPath ? readFile(resolve(inputPath), 'utf8') : readStdin();
109
+ }
110
+ async function appendBoundaryAudit(cwd, value, configuredPath) {
111
+ const path = resolve(configuredPath ?? `${cwd}/.aiwg/ops/audit/governance-boundary.jsonl`);
112
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
113
+ await appendFile(path, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600 });
114
+ await chmod(path, 0o600);
115
+ }
116
+ async function writePreparedOutput(pathValue, value) {
117
+ const path = resolve(pathValue);
118
+ const temporary = `${path}.tmp-${randomUUID()}`;
119
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
120
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
121
+ await rename(temporary, path);
122
+ await chmod(path, 0o600);
123
+ }
124
+ async function handleEvidence(args) {
125
+ const action = args[0];
126
+ if (action !== 'prepare') {
127
+ throw new Error('Usage: aiwg ops evidence prepare [--input <json-or-yaml>] [--policy <json-or-yaml>] [--output <path>] [--audit <path>]');
128
+ }
129
+ const envelope = parseData(await readInput(args), 'evidence input');
130
+ if (!envelope || typeof envelope !== 'object' || !envelope.artifact || typeof envelope.sinkId !== 'string') {
131
+ throw new Error('evidence input requires artifact and sinkId');
132
+ }
133
+ const policyPath = flagValue(args, '--policy');
134
+ const policy = policyPath
135
+ ? parseData(await readFile(resolve(policyPath), 'utf8'), 'governance policy')
136
+ : undefined;
137
+ const result = prepareEvidenceForSink({
138
+ ...envelope,
139
+ policy,
140
+ });
141
+ await appendBoundaryAudit(process.cwd(), result.audit, flagValue(args, '--audit'));
142
+ const response = result.allowed && result.prepared
143
+ ? { schemaVersion: 'ops-prepared-evidence.aiwg.io/v1', prepared: result.prepared, audit: result.audit, auditRecorded: true }
144
+ : { schemaVersion: 'ops-prepared-evidence.aiwg.io/v1', denied: true, audit: result.audit, auditRecorded: true };
145
+ const outputPath = flagValue(args, '--output');
146
+ if (outputPath) {
147
+ if (!result.allowed)
148
+ throw new Error(`evidence publication denied: ${result.audit.reasonCodes.join(', ')}`);
149
+ await writePreparedOutput(outputPath, response);
150
+ }
151
+ else {
152
+ process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
153
+ }
154
+ if (!result.allowed)
155
+ process.exitCode = 2;
156
+ }
64
157
  async function handleInit(registry, args) {
65
158
  // Parse flags
66
159
  let silent = false;
@@ -225,6 +318,7 @@ Subcommands:
225
318
  push [--workspace <n>] Push workspace repos to remote
226
319
  discover [root...] Scan filesystem for orphaned ops-workspace clones
227
320
  adopt <path> Register an existing local clone as a repo entry
321
+ evidence prepare Sanitize and policy-gate evidence before publication
228
322
 
229
323
  Init options:
230
324
  --silent Skip interactive prompts
@@ -251,6 +345,7 @@ Examples:
251
345
  aiwg ops push --workspace personal
252
346
  aiwg ops discover ~/projects ~/work --max-depth 4
253
347
  aiwg ops discover --register --workspace home
348
+ aiwg ops evidence prepare --input request.yaml --policy .aiwg/ops/governance-policy.yaml
254
349
 
255
350
  Discover options:
256
351
  --max-depth <n> Max walk depth from each root (default: 3)
@@ -85,7 +85,7 @@ async function dispatchV2(executor, payload, opts) {
85
85
  // discovery dependency to the compatibility path.
86
86
  if (clientOpts.protocolPolicy === '0.3') {
87
87
  clientOpts.selectedInterface = {
88
- url: `${executor.transportEndpoints.rest.replace(/\/+$/, '')}/agents/${encodeURIComponent(a2aInstanceId)}`,
88
+ url: `${executor.transportEndpoints.rest.replace(/\/+$/, '')}/agents/${encodeURIComponent(a2aInstanceId)}/v1`,
89
89
  protocolBinding: 'REST',
90
90
  protocolVersion: '0.3',
91
91
  preference: 0,
@@ -1,21 +1,23 @@
1
- import { createRequire } from 'node:module';
1
+ import { requireFeaturePackage } from '../features/runtime.js';
2
2
  import { CandidateReviewReceiptSchema, DeletionReceiptSchema, IntelligenceCandidateSchema, PromotionDependencyDecisionSchema, PromotionReceiptSchema, SessionContractError, sha256, } from './contracts.js';
3
3
  import { coverageFromBatchRun, } from './batch-contracts.js';
4
4
  import { redactSessionText, sanitizeNativeExtensions } from './policy.js';
5
5
  import { deriveSessionAnalytics, } from './analytics.js';
6
- const require = createRequire(import.meta.url);
7
6
  const POLICY_PROVIDER_MIGRATION = 'policy-provider-identity:v2';
8
7
  const EVENT_ORIGIN_INTENT_MIGRATION = 'event-origin-intent:v1';
9
8
  export class SessionRepository {
10
9
  db;
11
10
  constructor(path = ':memory:') {
11
+ let Database;
12
12
  try {
13
- const Database = require('better-sqlite3');
14
- this.db = new Database(path);
13
+ Database = requireFeaturePackage('better-sqlite3');
15
14
  }
16
- catch {
17
- throw new Error('session SQLite repository requires optional peer dependency better-sqlite3');
15
+ catch (cause) {
16
+ throw new Error('session SQLite catalog is unavailable because better-sqlite3 could not be loaded; ' +
17
+ 'run `aiwg features install sqlite`, then retry. If the native build fails, install ' +
18
+ 'Python 3, make, and a C/C++ compiler supported by node-gyp.', { cause });
18
19
  }
20
+ this.db = new Database(path);
19
21
  this.db.pragma('foreign_keys = ON');
20
22
  this.db.pragma('journal_mode = WAL');
21
23
  this.db.pragma('busy_timeout = 5000');
@@ -387,7 +387,7 @@ async function createPool(options) {
387
387
  const Pool = (pg.Pool ?? defaultExport?.Pool);
388
388
  if (!Pool)
389
389
  throw new PostgresBackendError('AIWG_POSTGRES_DRIVER_INVALID', 'optional pg package does not export Pool');
390
- return new Pool({
390
+ const pool = new Pool({
391
391
  connectionString,
392
392
  max: bounded(options.maxConnections, DEFAULT_POOL_MAX, 1, 100, 'maxConnections'),
393
393
  connectionTimeoutMillis: bounded(options.connectionTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'connectionTimeoutMs'),
@@ -395,6 +395,11 @@ async function createPool(options) {
395
395
  application_name: options.applicationName ?? 'aiwg',
396
396
  ssl: options.ssl === 'disable' ? false : { rejectUnauthorized: options.ssl === 'verify-full' },
397
397
  });
398
+ // pg emits idle-client failures at pool scope. Registering a listener keeps
399
+ // server restarts from becoming uncaught process exceptions; failed clients
400
+ // are evicted by pg and the next backend operation reconnects normally.
401
+ pool.on?.('error', () => undefined);
402
+ return pool;
398
403
  }
399
404
  function isLoopbackPostgresUrl(connectionString) {
400
405
  try {
@@ -22,7 +22,7 @@ export { ObsidianAdapter } from './backends/obsidian.js';
22
22
  export { LogseqAdapter } from './backends/logseq.js';
23
23
  export { FortemiAdapter } from './backends/fortemi.js';
24
24
  export { STORAGE_BACKEND_CONTRACT, STORAGE_BACKEND_MATRIX, StorageCapabilityError, negotiateStorageCapabilities, } from './backend-contract.js';
25
- export { STORAGE_MIGRATION_PROTOCOL, MigrationProtocolError, StorageMigrationCoordinator, approvalDigest, digestRecords, validateManifest, } from './migration-protocol.js';
25
+ export { STORAGE_MIGRATION_PROTOCOL, MigrationProtocolError, StorageMigrationCoordinator, approvalDigest, digestRecordChunks, digestRecords, validateManifest, } from './migration-protocol.js';
26
26
  export { PostgresStorageBackend, PostgresBackendError, } from './backends/postgres.js';
27
27
  export { POSTGRES_SCHEMA_VERSION, POSTGRES_SCHEMA_V1_SQL, inspectPostgresSchema, upgradePostgresSchemaV1, rollbackPostgresSchemaV1, postgresLeastPrivilegeSql, } from './backends/postgres-schema.js';
28
28
  export { PostgrestStorageBackend, PostgrestBackendError, } from './backends/postgrest.js';
@@ -37,10 +37,28 @@ export class StorageMigrationCoordinator {
37
37
  this.rollbackWindowMs = boundedInteger(options.rollbackWindowMs, 86_400_000, 1, 31_536_000_000, 'rollbackWindowMs');
38
38
  this.now = options.now ?? (() => new Date());
39
39
  this.random = options.random ?? Math.random;
40
+ if (options.mode === 'online' && !options.compareSourceRevisions) {
41
+ throw new MigrationProtocolError('AIWG_MIGRATION_REVISION_ORDER_REQUIRED', 'online migration requires a source-revision comparator');
42
+ }
40
43
  }
41
44
  async preview() {
42
45
  this.throwIfAborted();
43
- const snapshot = await this.source.snapshot(this.options.signal);
46
+ const boundary = await this.options.safety.prepare(this.options.mode, this.source.identity, this.options.signal);
47
+ try {
48
+ assertBoundaryState(boundary, this.options.mode === 'offline' ? 'quiesced' : 'tracking', 'initial');
49
+ }
50
+ catch (error) {
51
+ await this.options.safety.release(boundary, 'failed');
52
+ throw error;
53
+ }
54
+ let snapshot;
55
+ try {
56
+ snapshot = await this.source.snapshot(this.options.signal);
57
+ }
58
+ catch (error) {
59
+ await this.options.safety.release(boundary, 'failed');
60
+ throw error;
61
+ }
44
62
  const createdAt = this.now().toISOString();
45
63
  const sourceDigest = digestRecords(snapshot.records);
46
64
  const manifest = {
@@ -67,57 +85,69 @@ export class StorageMigrationCoordinator {
67
85
  },
68
86
  digests: { source: sourceDigest },
69
87
  records: [],
88
+ boundaries: { initial: boundary },
70
89
  };
71
- await this.store.save(manifest);
90
+ try {
91
+ await this.store.save(manifest);
92
+ }
93
+ catch (error) {
94
+ await this.options.safety.release(boundary, 'failed');
95
+ throw error;
96
+ }
72
97
  return manifest;
73
98
  }
74
99
  async apply(manifest) {
75
100
  validateManifest(manifest, this.source.identity, this.destination.identity);
76
- const snapshot = await this.source.snapshot(this.options.signal);
77
- if (snapshot.id !== manifest.snapshot.id || digestRecords(snapshot.records) !== manifest.digests.source) {
78
- throw new MigrationProtocolError('AIWG_MIGRATION_SNAPSHOT_CHANGED', 'source snapshot no longer matches the preview; create a new preview');
79
- }
80
- manifest.state = 'copying';
81
- await this.persist(manifest);
82
- await this.copyRecords(snapshot.records, manifest);
83
- if (manifest.mode === 'online') {
84
- if (!this.source.changes) {
85
- throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
101
+ try {
102
+ const snapshot = await this.source.snapshot(this.options.signal);
103
+ if (digestRecords(snapshot.records) !== manifest.digests.source) {
104
+ throw new MigrationProtocolError('AIWG_MIGRATION_SNAPSHOT_CHANGED', 'source snapshot no longer matches the preview; create a new preview');
86
105
  }
87
- manifest.state = 'replaying';
106
+ manifest.state = 'copying';
88
107
  await this.persist(manifest);
89
- let cursor = manifest.snapshot.cursor;
90
- for (;;) {
91
- this.throwIfAborted();
92
- const page = await this.source.changes(cursor, this.options.signal);
93
- await this.copyRecords(page.records, manifest);
94
- cursor = page.nextCursor;
95
- manifest.checkpoint = {
96
- ...manifest.checkpoint,
97
- ...(cursor === undefined ? {} : { cursor }),
98
- highWaterMark: page.highWaterMark,
99
- drained: page.records.length === 0 || cursor === undefined,
100
- };
108
+ await this.copyRecords(snapshot.records, manifest);
109
+ if (manifest.mode === 'online') {
110
+ if (!this.source.changes) {
111
+ throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
112
+ }
113
+ manifest.state = 'replaying';
101
114
  await this.persist(manifest);
102
- if (page.records.length === 0 || cursor === undefined)
103
- break;
115
+ await this.replayChanges(manifest);
104
116
  }
117
+ const finalBoundary = await this.options.safety.freeze(manifest, this.options.signal);
118
+ manifest.boundaries.final = finalBoundary;
119
+ assertBoundaryState(finalBoundary, 'quiesced', 'final');
120
+ await this.persist(manifest);
121
+ if (manifest.mode === 'online')
122
+ await this.replayChanges(manifest);
123
+ manifest.state = 'verifying';
124
+ await this.persist(manifest);
125
+ const verification = await this.verify(manifest);
126
+ if (!verification.valid) {
127
+ throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', summarizeVerificationFailure(verification));
128
+ }
129
+ manifest.digests.destination = verification.destinationDigest;
130
+ manifest.verification = verification;
131
+ manifest.digests.verification = sha256(stableStringify(verification));
132
+ manifest.state = 'awaiting-approval';
133
+ manifest.updatedAt = this.now().toISOString();
134
+ manifest.digests.approval = approvalDigest(manifest);
135
+ await this.save(manifest);
136
+ return manifest;
105
137
  }
106
- manifest.state = 'verifying';
107
- await this.persist(manifest);
108
- const verification = await this.verify(manifest);
109
- if (!verification.valid) {
138
+ catch (error) {
139
+ const stage = manifest.state;
110
140
  manifest.state = 'failed';
111
- manifest.failure = { stage: 'verify', message: summarizeVerificationFailure(verification) };
112
- await this.persist(manifest);
113
- throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', manifest.failure.message);
141
+ manifest.failure = { stage, message: errorMessage(error) };
142
+ try {
143
+ await this.persist(manifest);
144
+ }
145
+ catch {
146
+ // Preserve the triggering failure; the manifest store error is already observable at its source.
147
+ }
148
+ await this.options.safety.release(manifest.boundaries.final ?? manifest.boundaries.initial, 'failed');
149
+ throw error;
114
150
  }
115
- manifest.digests.destination = verification.destinationDigest;
116
- manifest.state = 'awaiting-approval';
117
- manifest.updatedAt = this.now().toISOString();
118
- manifest.digests.approval = approvalDigest(manifest);
119
- await this.save(manifest);
120
- return manifest;
121
151
  }
122
152
  async verify(manifest) {
123
153
  const [sourceRecords, destinationRecords] = await Promise.all([
@@ -131,16 +161,35 @@ export class StorageMigrationCoordinator {
131
161
  const corrupt = [...sourceMap.entries()]
132
162
  .filter(([key, record]) => {
133
163
  const destination = destinationMap.get(key);
134
- return destination !== undefined && (destination.digest !== record.digest ||
164
+ return destination !== undefined && (destination.sourceRevision !== record.sourceRevision ||
165
+ destination.digest !== record.digest ||
135
166
  Boolean(destination.tombstone) !== Boolean(record.tombstone));
136
167
  })
137
168
  .map(([key]) => key)
138
169
  .sort();
139
- const lag = manifest.mode === 'online' ? Number(!manifest.checkpoint.drained) : 0;
170
+ const boundaryReady = manifest.boundaries.final?.state === 'quiesced';
171
+ const lag = Number(!boundaryReady || (manifest.mode === 'online' && !manifest.checkpoint.drained));
140
172
  const sourceDigest = digestRecords(sourceRecords);
141
173
  const destinationDigest = digestRecords(destinationRecords);
174
+ const sourceChunks = digestRecordChunks(sourceRecords, this.batchSize);
175
+ const destinationChunks = digestRecordChunks(destinationRecords, this.batchSize);
176
+ const chunks = {
177
+ valid: stableStringify(sourceChunks) === stableStringify(destinationChunks),
178
+ source: sourceChunks,
179
+ destination: destinationChunks,
180
+ };
181
+ const semantic = await this.options.verifier.verify({
182
+ manifest,
183
+ source: this.source,
184
+ destination: this.destination,
185
+ sourceRecords,
186
+ destinationRecords,
187
+ signal: this.options.signal,
188
+ });
189
+ validateSemanticVerification(semantic);
142
190
  return {
143
- valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0 && lag === 0,
191
+ valid: missing.length === 0 && unexpected.length === 0 && corrupt.length === 0 && lag === 0 &&
192
+ sourceDigest === destinationDigest && chunks.valid && semanticIsValid(semantic),
144
193
  sourceCount: sourceRecords.length,
145
194
  destinationCount: destinationRecords.length,
146
195
  sourceDigest,
@@ -149,6 +198,8 @@ export class StorageMigrationCoordinator {
149
198
  unexpected,
150
199
  corrupt,
151
200
  lag,
201
+ chunks,
202
+ semantic,
152
203
  };
153
204
  }
154
205
  async cutover(manifest, approval) {
@@ -164,14 +215,22 @@ export class StorageMigrationCoordinator {
164
215
  if (!verification.valid) {
165
216
  throw new MigrationProtocolError('AIWG_MIGRATION_PARITY_FAILED', summarizeVerificationFailure(verification));
166
217
  }
218
+ if (!manifest.digests.verification || sha256(stableStringify(verification)) !== manifest.digests.verification) {
219
+ throw new MigrationProtocolError('AIWG_MIGRATION_VERIFICATION_CHANGED', 'migration verification changed after approval; verify and approve a new manifest');
220
+ }
221
+ const finalBoundary = manifest.boundaries.final;
222
+ if (!finalBoundary) {
223
+ throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_MISSING', 'migration has no final quiesced source boundary');
224
+ }
167
225
  manifest.state = 'cutover';
168
226
  await this.persist(manifest);
169
227
  try {
170
- await this.routing.switchReads(manifest);
171
- await this.routing.switchWrites(manifest);
228
+ manifest.routingSwitch = await this.routing.switchAtomically(manifest);
229
+ await this.options.safety.release(finalBoundary, 'cutover');
172
230
  }
173
231
  catch (error) {
174
232
  await this.routing.restoreSource(manifest);
233
+ await this.options.safety.release(finalBoundary, 'failed');
175
234
  manifest.state = 'failed';
176
235
  manifest.failure = { stage: 'cutover', message: errorMessage(error) };
177
236
  await this.persist(manifest);
@@ -195,13 +254,47 @@ export class StorageMigrationCoordinator {
195
254
  throw new MigrationProtocolError('AIWG_MIGRATION_NOT_ROLLBACKABLE', `cannot roll back state ${manifest.state}`);
196
255
  }
197
256
  await this.routing.restoreSource(manifest);
257
+ await this.options.safety.release(manifest.boundaries.final ?? manifest.boundaries.initial, 'rollback');
198
258
  manifest.state = 'rolled-back';
199
259
  await this.persist(manifest);
200
260
  return manifest;
201
261
  }
202
262
  async copyRecords(records, manifest) {
203
263
  const completed = new Set(manifest.records.map(receiptKey));
204
- const pending = records.filter(record => !completed.has(recordKey(record)));
264
+ const latest = new Map();
265
+ for (const receipt of manifest.records)
266
+ latest.set(identityKey(receipt.identity), receipt);
267
+ const selected = new Map();
268
+ for (const record of records) {
269
+ if (completed.has(recordKey(record)))
270
+ continue;
271
+ const key = identityKey(record.identity);
272
+ const receipt = latest.get(key);
273
+ if (receipt && this.options.compareSourceRevisions) {
274
+ const order = compareRevisions(this.options.compareSourceRevisions, record.sourceRevision, receipt.sourceRevision);
275
+ if (order < 0)
276
+ continue;
277
+ if (order === 0) {
278
+ if (record.digest !== receipt.contentDigest)
279
+ throw revisionConflict(key, record.sourceRevision);
280
+ continue;
281
+ }
282
+ }
283
+ const prior = selected.get(key);
284
+ if (!prior) {
285
+ selected.set(key, record);
286
+ continue;
287
+ }
288
+ if (!this.options.compareSourceRevisions) {
289
+ throw new MigrationProtocolError('AIWG_MIGRATION_DUPLICATE_IDENTITY', `snapshot contains multiple records for ${key}`);
290
+ }
291
+ const order = compareRevisions(this.options.compareSourceRevisions, record.sourceRevision, prior.sourceRevision);
292
+ if (order > 0)
293
+ selected.set(key, record);
294
+ else if (order === 0 && record.digest !== prior.digest)
295
+ throw revisionConflict(key, record.sourceRevision);
296
+ }
297
+ const pending = [...selected.values()].sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
205
298
  const batches = chunk(pending, this.batchSize);
206
299
  let next = 0;
207
300
  const workers = Array.from({ length: Math.min(this.concurrency, batches.length) }, async () => {
@@ -210,7 +303,7 @@ export class StorageMigrationCoordinator {
210
303
  if (index >= batches.length)
211
304
  return;
212
305
  const batch = batches[index];
213
- const mutations = batch.map(record => mutationFor(manifest.migrationId, record));
306
+ const mutations = batch.map(record => mutationFor(manifest.migrationId, record, latest.get(identityKey(record.identity))?.destinationRevision));
214
307
  const receipt = await this.retry(() => this.destination.commitBatch(mutations, this.options.signal), `batch ${index}`);
215
308
  if (!receipt.committed || receipt.recordReceipts.length !== batch.length) {
216
309
  throw new MigrationProtocolError('AIWG_MIGRATION_INVALID_RECEIPT', `destination returned an invalid receipt for batch ${index}`);
@@ -242,6 +335,27 @@ export class StorageMigrationCoordinator {
242
335
  });
243
336
  await Promise.all(workers);
244
337
  }
338
+ async replayChanges(manifest) {
339
+ if (!this.source.changes) {
340
+ throw new MigrationProtocolError('AIWG_MIGRATION_CURSOR_UNAVAILABLE', 'online migration requires a source change cursor');
341
+ }
342
+ let cursor = manifest.checkpoint.cursor ?? manifest.snapshot.cursor;
343
+ for (;;) {
344
+ this.throwIfAborted();
345
+ const page = await this.source.changes(cursor, this.options.signal);
346
+ await this.copyRecords(page.records, manifest);
347
+ cursor = page.nextCursor ?? page.highWaterMark;
348
+ manifest.checkpoint = {
349
+ ...manifest.checkpoint,
350
+ cursor,
351
+ highWaterMark: page.highWaterMark,
352
+ drained: page.records.length === 0 || page.nextCursor === undefined,
353
+ };
354
+ await this.persist(manifest);
355
+ if (manifest.checkpoint.drained)
356
+ break;
357
+ }
358
+ }
245
359
  async retry(operation, label) {
246
360
  let attempt = 0;
247
361
  for (;;) {
@@ -250,7 +364,7 @@ export class StorageMigrationCoordinator {
250
364
  return await operation();
251
365
  }
252
366
  catch (error) {
253
- const retryable = error instanceof MigrationProtocolError && error.retryable;
367
+ const retryable = isRetryableError(error);
254
368
  if (!retryable)
255
369
  throw error;
256
370
  if (attempt >= this.maxRetries) {
@@ -290,6 +404,10 @@ export function digestRecords(records) {
290
404
  .sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
291
405
  return sha256(stableStringify(normalized));
292
406
  }
407
+ export function digestRecordChunks(records, chunkSize) {
408
+ const sorted = [...records].sort((left, right) => identityKey(left.identity).localeCompare(identityKey(right.identity)));
409
+ return chunk(sorted, chunkSize).map(recordsInChunk => digestRecords(recordsInChunk));
410
+ }
293
411
  export function approvalDigest(manifest) {
294
412
  const copy = structuredClone(manifest);
295
413
  delete copy.digests.approval;
@@ -302,6 +420,10 @@ export function validateManifest(manifest, source, destination) {
302
420
  if (!manifest.migrationId || !manifest.snapshot?.id || !manifest.digests?.source) {
303
421
  throw new MigrationProtocolError('AIWG_MIGRATION_MANIFEST_CORRUPT', 'migration manifest is missing required identity or digest fields');
304
422
  }
423
+ if (!manifest.boundaries?.initial) {
424
+ throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_MISSING', 'migration manifest has no durable source boundary receipt');
425
+ }
426
+ assertBoundaryState(manifest.boundaries.initial, manifest.mode === 'offline' ? 'quiesced' : 'tracking', 'initial');
305
427
  if (source && stableStringify(manifest.source) !== stableStringify(source)) {
306
428
  throw new MigrationProtocolError('AIWG_MIGRATION_SOURCE_MISMATCH', 'manifest source does not match the active source');
307
429
  }
@@ -309,13 +431,29 @@ export function validateManifest(manifest, source, destination) {
309
431
  throw new MigrationProtocolError('AIWG_MIGRATION_DESTINATION_MISMATCH', 'manifest destination does not match the active destination');
310
432
  }
311
433
  }
312
- function mutationFor(migrationId, record) {
434
+ function assertBoundaryState(boundary, expected, stage) {
435
+ if (!boundary.boundaryId || !boundary.establishedAt || boundary.state !== expected) {
436
+ throw new MigrationProtocolError('AIWG_MIGRATION_BOUNDARY_INVALID', `${stage} source boundary must provide a durable ${expected} receipt`);
437
+ }
438
+ }
439
+ function mutationFor(migrationId, record, expectedRevision) {
313
440
  return {
314
441
  operation: record.tombstone ? 'delete' : 'upsert',
315
442
  record,
316
443
  idempotencyKey: sha256(`${migrationId}\0${recordKey(record)}`),
444
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
317
445
  };
318
446
  }
447
+ function revisionConflict(identity, revision) {
448
+ return new MigrationProtocolError('AIWG_MIGRATION_REVISION_CONFLICT', `source revision ${revision} has conflicting content for ${identity}`);
449
+ }
450
+ function compareRevisions(comparator, left, right) {
451
+ const result = comparator(left, right);
452
+ if (!Number.isFinite(result)) {
453
+ throw new MigrationProtocolError('AIWG_MIGRATION_REVISION_ORDER_INVALID', 'source-revision comparator returned a non-finite result');
454
+ }
455
+ return Math.sign(result);
456
+ }
319
457
  function recordMap(records) {
320
458
  return new Map(records.map(record => [identityKey(record.identity), record]));
321
459
  }
@@ -357,11 +495,51 @@ function boundedInteger(raw, fallback, min, max, label) {
357
495
  return value;
358
496
  }
359
497
  function summarizeVerificationFailure(verification) {
360
- return `migration parity failed: missing=${verification.missing.length} unexpected=${verification.unexpected.length} corrupt=${verification.corrupt.length} lag=${verification.lag}`;
498
+ const semanticFailures = Object.values(verification.semantic).filter(check => !check.valid).length;
499
+ return `migration parity failed: missing=${verification.missing.length} unexpected=${verification.unexpected.length} corrupt=${verification.corrupt.length} chunks=${verification.chunks.valid ? 'valid' : 'invalid'} semantic=${semanticFailures} lag=${verification.lag}`;
500
+ }
501
+ function validateSemanticVerification(verification) {
502
+ for (const dimension of [
503
+ 'schema', 'constraints', 'countsByType', 'edgeIntegrity', 'queryParity', 'traversalParity',
504
+ ]) {
505
+ const check = verification?.[dimension];
506
+ if (!check || typeof check.valid !== 'boolean' || !Number.isInteger(check.declared) || check.declared < 0 ||
507
+ !Number.isInteger(check.checked) || check.checked < 0 || check.checked !== check.declared || !Array.isArray(check.failures)) {
508
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification is incomplete for ${dimension}`);
509
+ }
510
+ if (check.valid !== (check.failures.length === 0)) {
511
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification validity disagrees with failures for ${dimension}`);
512
+ }
513
+ if (check.evidenceDigest !== undefined && !/^[0-9A-Za-z._:-]+$/.test(check.evidenceDigest)) {
514
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification evidence digest is invalid for ${dimension}`);
515
+ }
516
+ if (['schema', 'constraints', 'queryParity'].includes(dimension) && check.declared < 1) {
517
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', `semantic verification must exercise at least one ${dimension} check`);
518
+ }
519
+ if (dimension === 'countsByType') {
520
+ if (!isCountMap(check.sourceCounts) || !isCountMap(check.destinationCounts)) {
521
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', 'countsByType must include non-negative integer source and destination maps');
522
+ }
523
+ const declaredTypes = new Set([...Object.keys(check.sourceCounts), ...Object.keys(check.destinationCounts)]).size;
524
+ if (check.declared !== declaredTypes || (check.valid && stableStringify(check.sourceCounts) !== stableStringify(check.destinationCounts))) {
525
+ throw new MigrationProtocolError('AIWG_MIGRATION_SEMANTIC_VERIFICATION_INVALID', 'countsByType scope or validity does not match its declared maps');
526
+ }
527
+ }
528
+ }
529
+ }
530
+ function semanticIsValid(verification) {
531
+ return Object.values(verification).every(check => check.valid);
532
+ }
533
+ function isCountMap(value) {
534
+ return typeof value === 'object' && value !== null && !Array.isArray(value) &&
535
+ Object.values(value).every(count => Number.isInteger(count) && count >= 0);
361
536
  }
362
537
  function errorMessage(error) {
363
538
  return error instanceof Error ? error.message : String(error);
364
539
  }
540
+ function isRetryableError(error) {
541
+ return typeof error === 'object' && error !== null && 'retryable' in error && error.retryable === true;
542
+ }
365
543
  async function abortableDelay(ms, signal) {
366
544
  if (!signal) {
367
545
  await new Promise(resolve => setTimeout(resolve, ms));