@amalgm/shell 0.1.43 → 0.1.45

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.
@@ -8,7 +8,9 @@ import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
9
  import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
10
10
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
- import { planGroundDetection, reconcileGroundUUIDs, } from "./detection-host.js";
11
+ import { exactTextReplay, planGroundDetection, reconcileGroundUUIDs, } from "./detection/portable.js";
12
+ import { NamedDetectRuntime } from "./detection/runtime.js";
13
+ import { decodeMutationOperation, encodeMutationOperation, } from "./detection/journal-codec.js";
12
14
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
13
15
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
14
16
  import { inspectGitRegistration, } from "./git-registration-host.js";
@@ -22,6 +24,9 @@ const PORTABLE_FIELDS = [
22
24
  const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
23
25
  const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
24
26
  const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
27
+ const DETECT_QUIET_MS = 12;
28
+ const DETECT_MAX_DEFERRAL_MS = 75;
29
+ const DETECT_RETRY_MS = 250;
25
30
  const GROUND_ROW_COLUMNS = [
26
31
  "uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
27
32
  "payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
@@ -36,10 +41,13 @@ export class UserGroundHost {
36
41
  watchHost;
37
42
  activeIdentity = null;
38
43
  rescanTimer = null;
44
+ rescanGenerationStartedAt = null;
39
45
  watchDirty = false;
40
46
  cloudState = null;
41
47
  converged = false;
42
48
  syncing = null;
49
+ namedDetect = null;
50
+ coldOperation = false;
43
51
  closing = false;
44
52
  port;
45
53
  constructor(options) {
@@ -65,6 +73,7 @@ export class UserGroundHost {
65
73
  local,
66
74
  });
67
75
  await this.resumeWorkspaceAdds(this.activeIdentity);
76
+ this.namedDetect ??= new NamedDetectRuntime(this.databasePath(this.activeIdentity), this.cacheDir(this.activeIdentity), this.userRoot(this.activeIdentity));
68
77
  const watchHealth = this.watchHost.health();
69
78
  if (watchHealth.state !== "healthy") {
70
79
  throw new Error(`Watch coverage is degraded: ${watchHealth.reason ?? "unknown failure"}`);
@@ -234,6 +243,11 @@ export class UserGroundHost {
234
243
  if (!identity || !this.converged || !this.cloudState) {
235
244
  throw new Error("Files register requires a converged authenticated machine");
236
245
  }
246
+ this.coldOperation = true;
247
+ if (this.rescanTimer)
248
+ clearTimeout(this.rescanTimer);
249
+ this.rescanTimer = null;
250
+ this.rescanGenerationStartedAt = null;
237
251
  let watch;
238
252
  try {
239
253
  this.ensureWatchers(identity);
@@ -241,11 +255,13 @@ export class UserGroundHost {
241
255
  assertHealthyWatch(watch, workspaceId);
242
256
  }
243
257
  catch (error) {
258
+ this.coldOperation = false;
244
259
  throw stageError("Watch coverage", error);
245
260
  }
246
261
  let rows = readRows(this.databasePath(identity));
247
262
  if (rows.some((row) => row.uuid === workspaceId)
248
263
  && this.cloudState.records.some((record) => record.uuid === workspaceId)) {
264
+ this.coldOperation = false;
249
265
  return {
250
266
  registered: true,
251
267
  entityCount: descendantRows(rows, workspaceId).length,
@@ -253,20 +269,35 @@ export class UserGroundHost {
253
269
  watch,
254
270
  };
255
271
  }
256
- // Reconcile already made this newly declared root suspicious. Consume
257
- // that evidence as-is: registration of one workspace must never widen
258
- // uncertainty to every other root on the machine.
272
+ // Register is a cold declaration, not a filesystem edit. It performs one
273
+ // complete reconciliation and publishes one graph head; it must not mint
274
+ // a parallel stream of Detect records for the same initial contents.
275
+ const observations = this.watchHost.observations();
259
276
  this.watchDirty = false;
260
277
  try {
261
- await this.syncNow();
262
- if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
263
- // A pre-existing scan may have taken its observation before this
264
- // declaration. Preserve the declared root as suspicion and give it
265
- // one scan of its own. Completion is evidence about this workspace,
266
- // never a demand that unrelated live Watch traffic become silent.
267
- this.watchDirty = false;
268
- await this.syncNow();
278
+ if (this.syncing)
279
+ await this.syncing;
280
+ const scanned = await reconcileGround({
281
+ identity,
282
+ userRoot: this.userRoot(identity),
283
+ database: this.databasePath(identity),
284
+ cacheDir: this.cacheDir(identity),
285
+ onScan: this.options.onDetectScan,
286
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
287
+ });
288
+ const retry = scanned.detection.find((plan) => plan.kind === "retry");
289
+ if (retry?.kind === "retry") {
290
+ throw new Error(retry.reasons.join("; "));
269
291
  }
292
+ commitDetectedState(this.databasePath(identity), {
293
+ acceptedMaterializedRows: scanned.acceptedMaterializedRows,
294
+ materializedRemovals: scanned.materializedRemovals,
295
+ acceptedNotebookRows: scanned.acceptedNotebookRows,
296
+ notebookRemovals: scanned.notebookRemovals,
297
+ });
298
+ this.namedDetect?.refreshEnrollmentPolicy();
299
+ await this.publishMaterializedSnapshot(identity);
300
+ this.watchHost.settle(observations);
270
301
  }
271
302
  catch (error) {
272
303
  const failedStage = filesPipelineStage(error);
@@ -275,6 +306,9 @@ export class UserGroundHost {
275
306
  }
276
307
  throw stageError("entity registration or cloud publication", error);
277
308
  }
309
+ finally {
310
+ this.coldOperation = false;
311
+ }
278
312
  try {
279
313
  this.ensureWatchers(identity);
280
314
  watch = this.watchHost.evidence();
@@ -348,7 +382,10 @@ export class UserGroundHost {
348
382
  if (this.rescanTimer)
349
383
  clearTimeout(this.rescanTimer);
350
384
  this.rescanTimer = null;
385
+ this.rescanGenerationStartedAt = null;
351
386
  this.watchHost.close({ catchUp: false });
387
+ this.namedDetect?.close();
388
+ this.namedDetect = null;
352
389
  await this.wire.close();
353
390
  return;
354
391
  }
@@ -360,12 +397,15 @@ export class UserGroundHost {
360
397
  if (this.rescanTimer)
361
398
  clearTimeout(this.rescanTimer);
362
399
  this.rescanTimer = null;
400
+ this.rescanGenerationStartedAt = null;
363
401
  // Full suspicion already contains every callback that could still be
364
402
  // queued. Retire physical coverage before the final observation so native
365
403
  // backends cannot continuously widen a finite shutdown pass. Changes
366
404
  // after this cutoff belong to the next startup blind interval.
367
405
  this.watchHost.close();
368
406
  await this.flushObservedChanges();
407
+ this.namedDetect?.close();
408
+ this.namedDetect = null;
369
409
  await this.wire.close();
370
410
  }
371
411
  userRoot(identity) {
@@ -377,7 +417,7 @@ export class UserGroundHost {
377
417
  cloudPort(identity) {
378
418
  return {
379
419
  lookup: async (resourceId) => {
380
- await this.drainOutboxBeforeLookup(identity, resourceId);
420
+ await this.publishPendingSnapshotsBeforeLookup(identity, resourceId);
381
421
  const frame = await this.wire.request({
382
422
  type: "private.resource.lookup",
383
423
  resource_id: resourceId,
@@ -420,7 +460,21 @@ export class UserGroundHost {
420
460
  return null;
421
461
  if (readRows(database).length === 0)
422
462
  return null;
423
- return localValue(identity, userRoot, database);
463
+ const local = localValue(identity, userRoot, database);
464
+ if (!hasDetectedChanges(database))
465
+ return local;
466
+ if (!this.cloudState) {
467
+ throw new Error("pending local mutations have no accepted cloud baseline");
468
+ }
469
+ // The official local projection remains the accepted cloud graph;
470
+ // Detect's newer materialization is represented exactly once by its
471
+ // durable journal records. Returning that baseline prevents login
472
+ // convergence from overwriting saved-local work before Send accepts it.
473
+ return {
474
+ ...local,
475
+ records: this.cloudState.records,
476
+ pendingMutations: true,
477
+ };
424
478
  },
425
479
  initialize: async () => {
426
480
  createDeclaredHome({
@@ -429,7 +483,7 @@ export class UserGroundHost {
429
483
  declaration: this.options.declaration ?? shippedUserHomeDeclaration,
430
484
  now: this.options.now?.() ?? new Date(),
431
485
  });
432
- await scanAndRegister({
486
+ await reconcileGround({
433
487
  identity, userRoot, database, cacheDir,
434
488
  onScan: this.options.onDetectScan,
435
489
  });
@@ -683,38 +737,85 @@ export class UserGroundHost {
683
737
  }
684
738
  return health;
685
739
  }
686
- scheduleRescan() {
740
+ scheduleRescan(retryDelayMs) {
687
741
  if (this.closing)
688
742
  return;
689
743
  this.watchDirty = true;
744
+ const now = performance.now();
745
+ this.rescanGenerationStartedAt ??= now;
746
+ const remaining = Math.max(0, DETECT_MAX_DEFERRAL_MS - (now - this.rescanGenerationStartedAt));
747
+ const delay = retryDelayMs ?? Math.min(DETECT_QUIET_MS, remaining);
690
748
  if (this.rescanTimer)
691
749
  clearTimeout(this.rescanTimer);
692
750
  this.rescanTimer = setTimeout(() => {
693
751
  this.rescanTimer = null;
752
+ this.rescanGenerationStartedAt = null;
694
753
  const identity = this.activeIdentity;
695
754
  if (!identity || this.closing)
696
755
  return;
756
+ if (this.coldOperation) {
757
+ this.scheduleRescan();
758
+ return;
759
+ }
697
760
  void this.flushObservedChanges().then(() => {
698
- if (!this.closing)
699
- this.ensureWatchers(identity);
761
+ if (this.closing)
762
+ return;
763
+ this.ensureWatchers(identity);
764
+ // This timer may have fired while an older Detect generation was
765
+ // still draining. Awaiting that generation does not consume a newer
766
+ // Watch ring; if suspicion remains, give it its own finite pass.
767
+ if (this.watchDirty || this.watchHost.hasPending)
768
+ this.scheduleRescan();
700
769
  }).catch(() => {
701
- // The durable outbox remains claimable by the next event or resume.
770
+ // Unsettled or unreadable truth keeps its Watch generation pending.
771
+ // Retry without requiring another user edit, but never hot-loop on a
772
+ // persistently unavailable path. A fresh ring replaces this timer
773
+ // with the ordinary low-latency settle window.
774
+ if (!this.closing)
775
+ this.scheduleRescan(DETECT_RETRY_MS);
702
776
  });
703
- }, 150);
777
+ }, delay);
704
778
  this.rescanTimer.unref();
705
779
  }
706
780
  async syncLocalChanges(identity) {
707
- await this.drainOutbox(identity);
708
- const state = this.cloudState;
709
- if (!state)
781
+ if (!this.cloudState)
710
782
  throw new Error("cloud state is unavailable for user-ground Watch");
711
783
  const observations = this.watchHost.observations();
712
- let localRecords;
784
+ if (this.namedDetect) {
785
+ // The named lane is an optimization over the same settled evidence.
786
+ // Any proof failure occurs before its SQLite transaction and widens to
787
+ // reconciliation; it must never poison a Watch generation in a timer
788
+ // rejection that only another filesystem event can revive.
789
+ const named = await this.namedDetect.detect(observations).catch(() => null);
790
+ if (named?.handled) {
791
+ for (const evidence of named.evidence) {
792
+ const observation = observations.find((candidate) => candidate.directory === evidence.directory);
793
+ this.options.onDetectScan?.({
794
+ rootId: observation?.rootId ?? evidence.directory,
795
+ directory: evidence.directory,
796
+ scope: "paths",
797
+ entries: evidence.metadataReads,
798
+ metadataReused: evidence.paths - evidence.contentReads,
799
+ contentReads: evidence.contentReads,
800
+ gitInspections: 0,
801
+ repositoryCaptures: 0,
802
+ durationMs: evidence.durationMs,
803
+ plannedRows: evidence.plannedRows,
804
+ records: evidence.records,
805
+ journalBytes: evidence.journalBytes,
806
+ });
807
+ }
808
+ this.watchHost.settle(observations);
809
+ return;
810
+ }
811
+ }
713
812
  let detection;
813
+ let acceptedMaterializedRows;
814
+ let materializedRemovals;
714
815
  let acceptedNotebookRows;
715
816
  let notebookRemovals;
716
817
  try {
717
- const scanned = await scanAndRegister({
818
+ const scanned = await reconcileGround({
718
819
  identity,
719
820
  userRoot: this.userRoot(identity),
720
821
  database: this.databasePath(identity),
@@ -722,8 +823,9 @@ export class UserGroundHost {
722
823
  onScan: this.options.onDetectScan,
723
824
  suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
724
825
  });
725
- localRecords = [...scanned.records];
726
826
  detection = scanned.detection;
827
+ acceptedMaterializedRows = scanned.acceptedMaterializedRows;
828
+ materializedRemovals = scanned.materializedRemovals;
727
829
  acceptedNotebookRows = scanned.acceptedNotebookRows;
728
830
  notebookRemovals = scanned.notebookRemovals;
729
831
  }
@@ -737,45 +839,24 @@ export class UserGroundHost {
737
839
  const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
738
840
  ? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
739
841
  : []);
740
- // The notebook is durable identity evidence, not evidence scoped to only
741
- // this Watch generation. A later no-op flush must not erase ground that an
742
- // earlier observation proved missing without lifecycle authority.
743
- localRecords = recordsRetainingMissingEvidence(localRecords, readDetectionNotebook(this.databasePath(identity))
744
- .filter((row) => !notebookRemovals.includes(row.uuid))
745
- .map(portableRecord));
746
- const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
747
- const checksum = sha256Hex(stableJson(snapshot));
748
- if (checksum === state.checksum) {
749
- commitDetectedState(this.databasePath(identity), {
750
- acceptedNotebookRows,
751
- notebookRemovals,
752
- });
753
- this.watchHost.settle(observations);
754
- return;
755
- }
756
842
  commitDetectedState(this.databasePath(identity), {
843
+ acceptedMaterializedRows,
844
+ materializedRemovals,
757
845
  acceptedNotebookRows,
758
846
  notebookRemovals,
759
- outbox: {
760
- mutationId: randomUUID(),
761
- resourceId: state.resourceId,
762
- authorityEpoch: state.authorityEpoch,
763
- baseVersion: state.headVersion,
764
- snapshotChecksum: checksum,
765
- snapshotJson: stableJson(snapshot),
766
- detectedRecordsJson: stableJson(detectedRecords),
767
- },
847
+ proposals: detectedRecords,
768
848
  });
769
- await this.drainOutbox(identity);
849
+ this.namedDetect?.refreshEnrollmentPolicy();
770
850
  this.watchHost.settle(observations);
771
851
  }
772
- async drainOutboxBeforeLookup(identity, resourceId) {
773
- const pending = readOutbox(this.databasePath(identity));
852
+ async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
853
+ const pending = readJournal(this.databasePath(identity))
854
+ .filter((entry) => entry.kind === "entity.snapshot.replace");
774
855
  if (pending.length === 0)
775
856
  return;
776
857
  const first = pending[0];
777
858
  if (first.resourceId !== resourceId) {
778
- throw new Error("local cloud outbox belongs to a different user-ground resource");
859
+ throw new Error("local snapshot journal belongs to a different user-ground resource");
779
860
  }
780
861
  this.cloudState = {
781
862
  resourceId: first.resourceId,
@@ -784,10 +865,56 @@ export class UserGroundHost {
784
865
  checksum: "",
785
866
  records: snapshotFromRecords(JSON.parse(first.snapshotJson).records || []).records,
786
867
  };
787
- await this.drainOutbox(identity);
868
+ await this.publishPendingSnapshots(identity);
788
869
  }
789
- async drainOutbox(identity) {
790
- for (const pending of readOutbox(this.databasePath(identity))) {
870
+ /** Register is the deliberate cold graph-publication boundary. Ordinary
871
+ * Watch detection stops at a durable exact record and never rebuilds this
872
+ * resource snapshot. */
873
+ async publishMaterializedSnapshot(identity) {
874
+ const state = this.cloudState;
875
+ if (!state)
876
+ throw new Error("cloud state is unavailable for graph publication");
877
+ const notebook = readDetectionNotebook(this.databasePath(identity));
878
+ const localRecords = recordsRetainingMissingEvidence(portableRecords(this.databasePath(identity)), notebook.map(portableRecord));
879
+ const merged = mergeMaterializedRoots(state.records, localRecords);
880
+ const traveling = travelingRecords(merged);
881
+ const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
882
+ && record.payloadVersion?.startsWith("git:"));
883
+ if (impossibleGitLeaf) {
884
+ const parent = merged.find((record) => record.uuid === impossibleGitLeaf.parentUUID);
885
+ throw new Error(`repository-owned leaf escaped its rail: ${impossibleGitLeaf.name} parent=${parent?.type ?? "missing"}`);
886
+ }
887
+ const snapshot = snapshotFromRecords(traveling);
888
+ const snapshotJson = stableJson(snapshot);
889
+ const checksum = sha256Hex(snapshotJson);
890
+ if (checksum === state.checksum)
891
+ return;
892
+ const pending = readJournal(this.databasePath(identity))
893
+ .filter((entry) => entry.kind === "entity.snapshot.replace");
894
+ const identical = pending.find((entry) => entry.resourceId === state.resourceId
895
+ && entry.snapshotChecksum === checksum);
896
+ if (identical) {
897
+ await this.publishPendingSnapshots(identity);
898
+ return;
899
+ }
900
+ if (pending.length > 0) {
901
+ throw new Error("a different graph snapshot is already pending publication");
902
+ }
903
+ appendSnapshotJournal(this.databasePath(identity), {
904
+ kind: "entity.snapshot.replace",
905
+ mutationId: randomUUID(),
906
+ resourceId: state.resourceId,
907
+ authorityEpoch: state.authorityEpoch,
908
+ baseVersion: state.headVersion,
909
+ snapshotChecksum: checksum,
910
+ snapshotJson,
911
+ });
912
+ await this.publishPendingSnapshots(identity);
913
+ }
914
+ async publishPendingSnapshots(identity) {
915
+ for (const pending of readJournal(this.databasePath(identity))) {
916
+ if (pending.kind !== "entity.snapshot.replace")
917
+ continue;
791
918
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
792
919
  const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
793
920
  .map((row) => row.uuid));
@@ -824,7 +951,7 @@ export class UserGroundHost {
824
951
  checksum: pending.snapshotChecksum,
825
952
  records: snapshot.records,
826
953
  };
827
- deleteOutbox(this.databasePath(identity), pending.mutationId);
954
+ deleteJournalEntry(this.databasePath(identity), pending.mutationId);
828
955
  }
829
956
  }
830
957
  }
@@ -925,15 +1052,7 @@ function initializeDatabase(file) {
925
1052
  ensurePrivateDir(dirname(file));
926
1053
  const database = new Database(file);
927
1054
  database.pragma("journal_mode = WAL");
928
- database.pragma("synchronous = NORMAL");
929
- const entityColumns = database.prepare("PRAGMA table_info(entities)").all();
930
- if (entityColumns.length > 0
931
- && (!entityColumns.some(({ name }) => name === "resource_id")
932
- || !entityColumns.some(({ name }) => name === "root_uuid"))) {
933
- // Entity rows are a derived local projection. The pre-multi-root table
934
- // cannot represent the new invariant and is rebuilt from cloud/ground.
935
- database.exec("DROP TABLE entities");
936
- }
1055
+ database.pragma("synchronous = FULL");
937
1056
  database.exec(`
938
1057
  CREATE TABLE IF NOT EXISTS ground_identity (
939
1058
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
@@ -984,15 +1103,12 @@ function initializeDatabase(file) {
984
1103
  filesystem_mode INTEGER,
985
1104
  content_verified_at_ms REAL
986
1105
  );
987
- CREATE TABLE IF NOT EXISTS cloud_outbox (
1106
+ CREATE TABLE IF NOT EXISTS mutation_journal (
988
1107
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
989
1108
  mutation_id TEXT NOT NULL UNIQUE,
990
1109
  resource_id TEXT NOT NULL,
991
- authority_epoch INTEGER NOT NULL,
992
- base_version INTEGER NOT NULL,
993
- snapshot_checksum TEXT NOT NULL,
994
- snapshot_json TEXT NOT NULL,
995
- detected_records_json TEXT NOT NULL DEFAULT '[]',
1110
+ operation_kind TEXT NOT NULL,
1111
+ operation_json TEXT NOT NULL,
996
1112
  created_at TEXT NOT NULL
997
1113
  );
998
1114
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
@@ -1004,34 +1120,18 @@ function initializeDatabase(file) {
1004
1120
  ON entities(absolute_path);
1005
1121
  CREATE INDEX IF NOT EXISTS entities_by_physical_identity
1006
1122
  ON entities(device_number, inode);
1123
+ CREATE INDEX IF NOT EXISTS entities_by_parent_uuid
1124
+ ON entities(parent_uuid);
1125
+ CREATE INDEX IF NOT EXISTS entities_by_root_path
1126
+ ON entities(root_uuid, relative_path);
1007
1127
  CREATE INDEX IF NOT EXISTS detection_notebook_by_root_path
1008
- ON detection_notebook(resource_id, root_uuid, relative_path);
1128
+ ON detection_notebook(root_uuid, relative_path);
1129
+ CREATE INDEX IF NOT EXISTS detection_notebook_by_absolute_path
1130
+ ON detection_notebook(absolute_path);
1009
1131
  CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
1010
1132
  ON detection_notebook(device_number, inode);
1011
- `);
1012
- const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
1013
- .map(({ name }) => name));
1014
- const fingerprintColumns = [
1015
- ["byte_size", "INTEGER"],
1016
- ["modified_time_ms", "REAL"],
1017
- ["changed_time_ms", "REAL"],
1018
- ["filesystem_mode", "INTEGER"],
1019
- ["content_verified_at_ms", "REAL"],
1020
- ];
1021
- for (const [name, type] of fingerprintColumns) {
1022
- if (!currentEntityColumns.has(name))
1023
- database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
1024
- }
1025
- const outboxColumns = new Set(database.prepare("PRAGMA table_info(cloud_outbox)").all()
1026
- .map(({ name }) => name));
1027
- if (!outboxColumns.has("detected_records_json")) {
1028
- database.exec("ALTER TABLE cloud_outbox ADD COLUMN detected_records_json TEXT NOT NULL DEFAULT '[]'");
1029
- }
1030
- const notebookCount = database.prepare("SELECT COUNT(*) AS count FROM detection_notebook").get().count;
1031
- if (notebookCount === 0)
1032
- database.exec(`
1033
- INSERT INTO detection_notebook(${GROUND_ROW_COLUMNS})
1034
- SELECT ${GROUND_ROW_COLUMNS} FROM entities
1133
+ CREATE INDEX IF NOT EXISTS mutation_journal_by_kind
1134
+ ON mutation_journal(operation_kind);
1035
1135
  `);
1036
1136
  return database;
1037
1137
  }
@@ -1189,18 +1289,52 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
1189
1289
  database.close();
1190
1290
  }
1191
1291
  }
1192
- function readOutbox(file) {
1292
+ function readJournal(file) {
1193
1293
  if (!existsSync(file))
1194
1294
  return [];
1195
1295
  const database = initializeDatabase(file);
1196
1296
  try {
1197
1297
  return database.prepare(`
1198
1298
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
1199
- authority_epoch AS authorityEpoch, base_version AS baseVersion,
1200
- snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson,
1201
- detected_records_json AS detectedRecordsJson
1202
- FROM cloud_outbox ORDER BY sequence
1203
- `).all();
1299
+ operation_kind AS kind, operation_json AS operationJson
1300
+ FROM mutation_journal ORDER BY sequence
1301
+ `).all().map((row) => {
1302
+ const operation = decodeMutationOperation(row.operationJson);
1303
+ if (row.kind === "entity.snapshot.replace") {
1304
+ return {
1305
+ kind: row.kind,
1306
+ mutationId: row.mutationId,
1307
+ resourceId: row.resourceId,
1308
+ authorityEpoch: Number(operation.authorityEpoch),
1309
+ baseVersion: Number(operation.baseVersion),
1310
+ snapshotChecksum: String(operation.snapshotChecksum),
1311
+ snapshotJson: String(operation.snapshotJson),
1312
+ };
1313
+ }
1314
+ if (row.kind === "detected.change") {
1315
+ return {
1316
+ kind: row.kind,
1317
+ mutationId: row.mutationId,
1318
+ resourceId: row.resourceId,
1319
+ proposal: operation.proposal,
1320
+ };
1321
+ }
1322
+ throw new Error(`unknown mutation journal operation: ${String(row.kind)}`);
1323
+ });
1324
+ }
1325
+ finally {
1326
+ database.close();
1327
+ }
1328
+ }
1329
+ function hasDetectedChanges(file) {
1330
+ if (!existsSync(file))
1331
+ return false;
1332
+ const database = initializeDatabase(file);
1333
+ try {
1334
+ return database.prepare(`
1335
+ SELECT 1 AS present FROM mutation_journal
1336
+ WHERE operation_kind = 'detected.change' LIMIT 1
1337
+ `).get() !== undefined;
1204
1338
  }
1205
1339
  finally {
1206
1340
  database.close();
@@ -1208,20 +1342,28 @@ function readOutbox(file) {
1208
1342
  }
1209
1343
  /** Record is the SQLite transaction that accepts Detect's exact proposals and
1210
1344
  * advances the last-verified notebook. Watch may settle only after this
1211
- * function returns. The existing outbox remains the one journal and queue. */
1345
+ * function returns. The mutation journal is the one durable queue. */
1212
1346
  function commitDetectedState(file, input) {
1213
1347
  const database = initializeDatabase(file);
1214
1348
  try {
1215
1349
  database.transaction(() => {
1216
- if (input.outbox) {
1217
- database.prepare(`
1218
- INSERT INTO cloud_outbox(
1219
- mutation_id, resource_id, authority_epoch, base_version,
1220
- snapshot_checksum, snapshot_json, detected_records_json, created_at
1221
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1222
- `).run(input.outbox.mutationId, input.outbox.resourceId, input.outbox.authorityEpoch, input.outbox.baseVersion, input.outbox.snapshotChecksum, input.outbox.snapshotJson, input.outbox.detectedRecordsJson, new Date().toISOString());
1350
+ const append = database.prepare(`
1351
+ INSERT INTO mutation_journal(
1352
+ mutation_id, resource_id, operation_kind, operation_json, created_at
1353
+ ) VALUES (?, ?, ?, ?, ?)
1354
+ `);
1355
+ for (const entry of input.proposals ?? []) {
1356
+ const proposal = entry.proposal;
1357
+ append.run(entry.mutationId, String(proposal.resourceId ?? ""), "detected.change", encodeMutationOperation({ proposal: entry.proposal }), new Date().toISOString());
1223
1358
  }
1224
1359
  const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
1360
+ const removeMaterialized = database.prepare("DELETE FROM entities WHERE uuid = ?");
1361
+ for (const uuid of input.materializedRemovals ?? [])
1362
+ removeMaterialized.run(uuid);
1363
+ const upsertMaterialized = prepareGroundUpsert(database, "entities");
1364
+ for (const row of input.acceptedMaterializedRows ?? []) {
1365
+ upsertMaterialized.run(...groundRowValues(row));
1366
+ }
1225
1367
  for (const uuid of input.notebookRemovals)
1226
1368
  remove.run(uuid);
1227
1369
  const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
@@ -1233,10 +1375,28 @@ function commitDetectedState(file, input) {
1233
1375
  database.close();
1234
1376
  }
1235
1377
  }
1236
- function deleteOutbox(file, mutationId) {
1378
+ function appendSnapshotJournal(file, entry) {
1379
+ const database = initializeDatabase(file);
1380
+ try {
1381
+ database.prepare(`
1382
+ INSERT INTO mutation_journal(
1383
+ mutation_id, resource_id, operation_kind, operation_json, created_at
1384
+ ) VALUES (?, ?, ?, ?, ?)
1385
+ `).run(entry.mutationId, entry.resourceId, entry.kind, stableJson({
1386
+ authorityEpoch: entry.authorityEpoch,
1387
+ baseVersion: entry.baseVersion,
1388
+ snapshotChecksum: entry.snapshotChecksum,
1389
+ snapshotJson: entry.snapshotJson,
1390
+ }), new Date().toISOString());
1391
+ }
1392
+ finally {
1393
+ database.close();
1394
+ }
1395
+ }
1396
+ function deleteJournalEntry(file, mutationId) {
1237
1397
  const database = initializeDatabase(file);
1238
1398
  try {
1239
- database.prepare("DELETE FROM cloud_outbox WHERE mutation_id = ?").run(mutationId);
1399
+ database.prepare("DELETE FROM mutation_journal WHERE mutation_id = ?").run(mutationId);
1240
1400
  }
1241
1401
  finally {
1242
1402
  database.close();
@@ -1530,10 +1690,12 @@ function materializedWorkspaceBindings(bindingDir) {
1530
1690
  }
1531
1691
  return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
1532
1692
  }
1533
- async function scanAndRegister(input) {
1693
+ async function reconcileGround(input) {
1534
1694
  const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
1535
1695
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
1536
1696
  const materialized = readRows(database);
1697
+ const materializedByUuid = new Map(materialized.map((row) => [row.uuid, row]));
1698
+ const materializedUUIDs = new Set(materializedByUuid.keys());
1537
1699
  const notebook = readDetectionNotebook(database);
1538
1700
  const existing = suspicions ? notebook : materialized;
1539
1701
  const notebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
@@ -1562,6 +1724,8 @@ async function scanAndRegister(input) {
1562
1724
  const missingPortableReference = [...boundRoots.keys()]
1563
1725
  .some((workspaceId) => !existingReferenceIds.has(workspaceId));
1564
1726
  const acceptedRecords = [];
1727
+ const acceptedMaterializedRows = [];
1728
+ const materializedRemovals = new Set();
1565
1729
  const acceptedNotebookRows = [];
1566
1730
  const notebookRemovals = new Set();
1567
1731
  const detection = [];
@@ -1574,23 +1738,33 @@ async function scanAndRegister(input) {
1574
1738
  repositoryCaptures: 0,
1575
1739
  };
1576
1740
  const started = performance.now();
1577
- const result = await scanRoot({ ...parameters, evidence: counters });
1741
+ const result = await reconcileRoot({ ...parameters, evidence: counters, materializedUUIDs });
1578
1742
  if (result.detection)
1579
1743
  detection.push(result.detection);
1580
1744
  for (const uuid of result.notebookRemovals) {
1581
1745
  notebookRemovals.add(uuid);
1582
1746
  notebookByUuid.delete(uuid);
1583
1747
  }
1584
- if (result.transferable) {
1585
- acceptedRecords.push(...result.records);
1586
- for (const row of result.rows) {
1587
- const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
1748
+ const currentIds = new Set(result.rows.map((row) => row.record.uuid));
1749
+ for (const prior of materialized.filter((row) => row.resourceId === parameters.resourceId && row.rootUUID === parameters.rootUUID)) {
1750
+ if (!currentIds.has(prior.uuid))
1751
+ materializedRemovals.add(prior.uuid);
1752
+ }
1753
+ for (const row of result.rows) {
1754
+ const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
1755
+ if (!sameGroundRow(captured, materializedByUuid.get(captured.uuid))) {
1756
+ acceptedMaterializedRows.push(captured);
1757
+ }
1758
+ if (result.transferable) {
1588
1759
  if (sameGroundRow(captured, notebookByUuid.get(captured.uuid)))
1589
1760
  continue;
1590
1761
  acceptedNotebookRows.push(captured);
1591
1762
  notebookByUuid.set(captured.uuid, captured);
1592
1763
  }
1593
1764
  }
1765
+ if (result.transferable)
1766
+ acceptedRecords.push(...result.records);
1767
+ const proposals = result.detection?.kind === "ready" ? result.detection.proposals : [];
1594
1768
  onScan?.({
1595
1769
  rootId: parameters.rootUUID,
1596
1770
  directory: parameters.rootPath,
@@ -1599,6 +1773,9 @@ async function scanAndRegister(input) {
1599
1773
  : parameters.suspects.length === 0 ? "none" : "paths",
1600
1774
  ...counters,
1601
1775
  durationMs: performance.now() - started,
1776
+ plannedRows: proposals.length,
1777
+ records: proposals.length,
1778
+ journalBytes: proposals.reduce((bytes, proposal) => bytes + Buffer.byteLength(encodeMutationOperation({ proposal })), 0),
1602
1779
  });
1603
1780
  // Each root commits its local projection before Detect yields. Native
1604
1781
  // callbacks can therefore preserve new suspicion between large roots.
@@ -1648,12 +1825,21 @@ async function scanAndRegister(input) {
1648
1825
  return {
1649
1826
  records: acceptedRecords,
1650
1827
  detection,
1828
+ acceptedMaterializedRows,
1829
+ materializedRemovals: [...materializedRemovals],
1651
1830
  acceptedNotebookRows,
1652
1831
  notebookRemovals: [...notebookRemovals],
1653
1832
  };
1654
1833
  }
1655
- async function scanRoot(input) {
1834
+ async function reconcileRoot(input) {
1656
1835
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
1836
+ const scanSuspects = suspects !== null && rootType === "repo.git"
1837
+ ? [...new Set(suspects.map((path) => {
1838
+ const segments = path.split("/");
1839
+ const marker = segments.indexOf(".git");
1840
+ return marker < 0 ? path : segments.slice(0, marker).join("/");
1841
+ }))].sort()
1842
+ : suspects;
1657
1843
  const existingRows = input.existingRows
1658
1844
  ?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1659
1845
  const existingByPath = new Map(existingRows.flatMap((row) => {
@@ -1703,6 +1889,32 @@ async function scanRoot(input) {
1703
1889
  const priorRootType = existingByPath.get("")?.type;
1704
1890
  const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
1705
1891
  evidence && (evidence.entries += 1);
1892
+ if (scanSuspects !== null && rootType === "repo.git") {
1893
+ for (const row of existingRows) {
1894
+ if (row.relativePath === "")
1895
+ continue;
1896
+ if (scanSuspects.some((path) => path !== ""
1897
+ && (row.relativePath === path || row.relativePath.startsWith(`${path}/`))))
1898
+ continue;
1899
+ entries.push({
1900
+ uuid: row.uuid,
1901
+ type: row.type,
1902
+ parentUUID: row.parentUUID,
1903
+ name: row.name,
1904
+ relativePath: row.relativePath,
1905
+ absolutePath: row.absolutePath,
1906
+ payloadVersion: row.payloadVersion,
1907
+ transportVersion: row.transportVersion,
1908
+ deviceNumber: row.deviceNumber,
1909
+ inode: row.inode,
1910
+ byteSize: row.byteSize,
1911
+ modifiedTimeMs: row.modifiedTimeMs,
1912
+ changedTimeMs: row.changedTimeMs,
1913
+ filesystemMode: row.filesystemMode,
1914
+ contentVerifiedAtMs: row.contentVerifiedAtMs,
1915
+ });
1916
+ }
1917
+ }
1706
1918
  entries.push({
1707
1919
  uuid: rootUUID,
1708
1920
  fixedUUID: rootUUID,
@@ -1716,15 +1928,15 @@ async function scanRoot(input) {
1716
1928
  contentVerifiedAtMs: null,
1717
1929
  ...statFingerprint(rootStats),
1718
1930
  });
1719
- const touchesSuspicion = (path) => suspects === null
1720
- || pathIsSuspect(path, suspects)
1721
- || suspects.some((suspect) => suspect.startsWith(`${path}/`));
1931
+ const touchesSuspicion = (path) => scanSuspects === null
1932
+ || pathIsSuspect(path, scanSuspects)
1933
+ || scanSuspects.some((suspect) => suspect.startsWith(`${path}/`));
1722
1934
  const repositoryEvidencePaths = (repository) => {
1723
- if (suspects === null)
1935
+ if (scanSuspects === null)
1724
1936
  return null;
1725
1937
  const prefix = slashPath(relative(rootPath, repository));
1726
1938
  const paths = [];
1727
- for (const suspect of suspects) {
1939
+ for (const suspect of scanSuspects) {
1728
1940
  if (suspect === prefix || (prefix && prefix.startsWith(`${suspect}/`)))
1729
1941
  return null;
1730
1942
  const local = prefix
@@ -1749,6 +1961,9 @@ async function scanRoot(input) {
1749
1961
  if (isRepositoryMetadataEntry(parentType, child.name))
1750
1962
  continue;
1751
1963
  const relativePath = base ? `${base}/${child.name}` : child.name;
1964
+ if (scanSuspects !== null && rootType === "repo.git"
1965
+ && !forceObserve && !touchesSuspicion(relativePath))
1966
+ continue;
1752
1967
  if (!policy(relativePath))
1753
1968
  continue;
1754
1969
  const absolutePath = join(directory, child.name);
@@ -1762,12 +1977,12 @@ async function scanRoot(input) {
1762
1977
  throw new Error(`registered boundary ${absolutePath} conflicts with entity ${existing.uuid}`);
1763
1978
  }
1764
1979
  const uuid = registeredBoundaryId || existing?.uuid || randomUUID();
1765
- const stableDuringCompleteCatchUp = suspects === null
1980
+ const stableDuringCompleteCatchUp = scanSuspects === null
1766
1981
  && existing !== undefined
1767
1982
  && reusableDuringCompleteCatchUp(existing, stats);
1768
1983
  const observe = forceObserve || existing === undefined
1769
- || (suspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
1770
- if (!observe && suspects === null)
1984
+ || (scanSuspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
1985
+ if (!observe && scanSuspects === null)
1771
1986
  evidence && (evidence.metadataReused += 1);
1772
1987
  const repositoryPath = activeRepository
1773
1988
  ? slashPath(relative(activeRepository.root, absolutePath))
@@ -1878,6 +2093,7 @@ async function scanRoot(input) {
1878
2093
  relativePath: row.relativePath,
1879
2094
  deviceNumber: row.deviceNumber,
1880
2095
  inode: row.inode,
2096
+ physicalContinuity: input.materializedUUIDs?.has(row.uuid) ?? true,
1881
2097
  })), entries.map((entry) => ({
1882
2098
  key: entry.relativePath,
1883
2099
  type: entry.type,
@@ -1940,12 +2156,15 @@ async function scanRoot(input) {
1940
2156
  });
1941
2157
  const persistCurrentRows = () => {
1942
2158
  const rows = rowsFromEntries();
1943
- persistRows(database, identity, resourceId, rootUUID, rows, existingRows, {
1944
- updateNotebook: input.suspicion === undefined,
1945
- });
2159
+ if (input.suspicion === undefined) {
2160
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
2161
+ }
1946
2162
  return rows;
1947
2163
  };
1948
- const repositories = entries.filter((entry) => entry.type === "repo.git");
2164
+ // Nested repository state is an input to its enclosing identity map. Seal
2165
+ // children first so one capture pass is already the fixed point.
2166
+ const repositories = entries.filter((entry) => entry.type === "repo.git")
2167
+ .sort((left, right) => right.relativePath.length - left.relativePath.length);
1949
2168
  const replayByUuid = new Map();
1950
2169
  const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1951
2170
  const repositoryOwner = (entry) => repositories
@@ -2044,7 +2263,7 @@ async function scanRoot(input) {
2044
2263
  catch {
2045
2264
  // Type and identity are filesystem facts, so they settle locally even
2046
2265
  // when the selected repository rail cannot yet capture its artifact.
2047
- // Cloud publication remains commit-last because scanAndRegister omits the
2266
+ // Cloud publication remains commit-last because reconciliation omits the
2048
2267
  // complete materialized root until every repository parcel is capturable.
2049
2268
  // Git unavailability is local to this root; Watch and unrelated roots stay
2050
2269
  // live and the next suspicion or resume retries the same ordinary scan.
@@ -2058,15 +2277,58 @@ async function scanRoot(input) {
2058
2277
  detection: null,
2059
2278
  };
2060
2279
  }
2061
- const rows = persistCurrentRows();
2280
+ let rows = persistCurrentRows();
2062
2281
  const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
2282
+ const retainedMissingRows = input.suspicion === undefined ? [] : existingRows
2283
+ .filter((row) => !currentUUIDs.has(row.uuid) && !existsSync(row.absolutePath));
2284
+ if (retainedMissingRows.length > 0) {
2285
+ // Missing ground changes this machine's materialization evidence, not the
2286
+ // portable entity graph. Keep those identities in the logical membership
2287
+ // used by Record while still removing their materialized SQLite rows.
2288
+ const logicalRecords = [
2289
+ ...rows.map((row) => row.record),
2290
+ ...retainedMissingRows.map(portableRecord),
2291
+ ];
2292
+ const logicalChildren = new Map();
2293
+ for (const record of logicalRecords) {
2294
+ if (record.parentUUID === null || record.status !== "active")
2295
+ continue;
2296
+ const group = logicalChildren.get(record.parentUUID) ?? [];
2297
+ group.push({ uuid: record.uuid, name: record.name });
2298
+ logicalChildren.set(record.parentUUID, group);
2299
+ }
2300
+ rows = rows.map((row) => {
2301
+ if (row.record.type !== "workspace" && row.record.type !== "folder")
2302
+ return row;
2303
+ const version = canonicalVersion({
2304
+ type: row.record.type,
2305
+ parentUUID: row.record.parentUUID,
2306
+ name: row.record.name,
2307
+ status: row.record.status,
2308
+ payloadVersion: membershipHash(logicalChildren.get(row.record.uuid) ?? [], sha256Hex),
2309
+ }, sha256Hex);
2310
+ return version === row.record.version ? row : {
2311
+ ...row,
2312
+ record: { ...row.record, version },
2313
+ };
2314
+ });
2315
+ }
2063
2316
  const notebookRemovals = input.suspicion === undefined ? [] : existingRows
2064
2317
  .filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
2065
2318
  .map((row) => row.uuid);
2066
2319
  const before = existingRows.map(portableRecord);
2067
2320
  const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
2321
+ const entryByUuid = new Map(entries.map((entry) => [entry.uuid, entry]));
2068
2322
  const detected = rows.map((row) => {
2069
2323
  const base = beforeByUuid.get(row.record.uuid) ?? null;
2324
+ const entry = entryByUuid.get(row.record.uuid);
2325
+ if (row.record.type !== "repo.git" && entry && repositoryOwner(entry)) {
2326
+ return {
2327
+ record: row.record,
2328
+ relativePath: row.relativePath,
2329
+ replay: { kind: "structure" },
2330
+ };
2331
+ }
2070
2332
  const contentChanged = base === null
2071
2333
  || base.type !== row.record.type
2072
2334
  || base.payloadVersion !== row.record.payloadVersion
@@ -2077,15 +2339,25 @@ async function scanRoot(input) {
2077
2339
  case "file.text":
2078
2340
  if (!row.record.payloadVersion)
2079
2341
  throw new Error("detected text file has no content head");
2080
- replay = {
2081
- kind: "file.text",
2082
- mode: "snapshot",
2083
- basePayloadVersion: base?.payloadVersion && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2084
- ? base.payloadVersion
2085
- : null,
2086
- resultPayloadVersion: row.record.payloadVersion,
2087
- delta: null,
2088
- };
2342
+ {
2343
+ const resultBytes = readFileSync(contentCacheFile(cacheDir, row.record.payloadVersion));
2344
+ const baseBytes = base?.payloadVersion
2345
+ && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2346
+ && existsSync(contentCacheFile(cacheDir, base.payloadVersion))
2347
+ ? readFileSync(contentCacheFile(cacheDir, base.payloadVersion))
2348
+ : null;
2349
+ replay = exactTextReplay({
2350
+ entityId: row.record.uuid,
2351
+ basePayloadVersion: base?.payloadVersion
2352
+ && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2353
+ ? base.payloadVersion
2354
+ : null,
2355
+ baseBytes,
2356
+ resultPayloadVersion: row.record.payloadVersion,
2357
+ resultBytes,
2358
+ sha256Hex,
2359
+ });
2360
+ }
2089
2361
  break;
2090
2362
  case "file.binary": {
2091
2363
  if (!row.record.payloadVersion)
@@ -2125,9 +2397,19 @@ async function scanRoot(input) {
2125
2397
  }
2126
2398
  }
2127
2399
  return { record: row.record, relativePath: row.relativePath, replay };
2128
- });
2400
+ }).concat(retainedMissingRows.map((row) => ({
2401
+ record: portableRecord(row),
2402
+ relativePath: row.relativePath,
2403
+ replay: { kind: "structure" },
2404
+ })));
2405
+ const plannedBefore = rootType === "repo.git"
2406
+ ? before.filter((record) => record.type === "repo.git")
2407
+ : before;
2408
+ const plannedAfter = rootType === "repo.git"
2409
+ ? detected.filter(({ record }) => record.type === "repo.git")
2410
+ : detected;
2129
2411
  return {
2130
- records: rows.map((row) => row.record),
2412
+ records: [...rows.map((row) => row.record), ...retainedMissingRows.map(portableRecord)],
2131
2413
  rows,
2132
2414
  referenceWorkspaceIds: [...referenceWorkspaceIds],
2133
2415
  transferable: true,
@@ -2135,8 +2417,8 @@ async function scanRoot(input) {
2135
2417
  detection: input.suspicion ? planGroundDetection({
2136
2418
  rootId: rootUUID,
2137
2419
  suspicion: input.suspicion,
2138
- before,
2139
- after: detected,
2420
+ before: plannedBefore,
2421
+ after: plannedAfter,
2140
2422
  }) : null,
2141
2423
  };
2142
2424
  }