@amalgm/shell 0.1.87 → 0.1.89

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.
@@ -3,7 +3,7 @@ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlin
3
3
  import { open } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
6
- import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
6
+ import { CHUNK_BYTES, CONTENT_CONTRACT, INLINE_CARGO_MAX_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, repositoryIdentityHash, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
7
7
  import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir, ensureUserDir } from "./filesystem.js";
9
9
  import { emitFilesStage, } from "./files-observability.js";
@@ -18,7 +18,7 @@ import { AcceptedInboxResultIndex } from "./detection/accepted-result.js";
18
18
  import { NamedDetectRuntime } from "./detection/runtime.js";
19
19
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
20
20
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, readCachedRepositoryIdentity, } from "./git-repository-host.js";
21
- import { inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
21
+ import { gitEvidenceFingerprint, inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
22
22
  import { projectMaterializedGraph } from "./materialized-graph.js";
23
23
  import { NodeWatchHost, } from "./watching/index.js";
24
24
  import { GroundCoordinator } from "./ground-coordination.js";
@@ -227,6 +227,7 @@ export class UserGroundHost {
227
227
  findKnownWorkspaceId: ({ absolutePath, deviceNumber, inode }) => findKnownEntityId(this.databasePath(identity), absolutePath, deviceNumber, inode),
228
228
  });
229
229
  const add = {
230
+ sha256Hex,
230
231
  lookupRegistry: async () => {
231
232
  const started = performance.now();
232
233
  this.stage({
@@ -1666,7 +1667,8 @@ export class UserGroundHost {
1666
1667
  }
1667
1668
  }
1668
1669
  async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
1669
- const pending = readSnapshotPublications(this.databasePath(identity));
1670
+ const pending = readSnapshotPublications(this.databasePath(identity))
1671
+ .filter((entry) => entry.status === "pending");
1670
1672
  if (pending.length === 0)
1671
1673
  return;
1672
1674
  const first = pending[0];
@@ -1680,12 +1682,36 @@ export class UserGroundHost {
1680
1682
  checksum: "",
1681
1683
  records: snapshotFromRecords(JSON.parse(first.snapshotJson).records || []).records,
1682
1684
  };
1683
- await this.publishPendingSnapshots(identity);
1685
+ try {
1686
+ await this.publishPendingSnapshots(identity);
1687
+ }
1688
+ catch (error) {
1689
+ // A conclusive refusal settles the intent as rejected: the lookup that
1690
+ // follows fetches authoritative state and a later register supersedes
1691
+ // the settled row, so a refused publication never wedges startup. An
1692
+ // unknown outcome must keep blocking — the mutation may have landed.
1693
+ if (!conclusivelyRefused(error))
1694
+ throw error;
1695
+ this.cloudState = null;
1696
+ }
1684
1697
  }
1685
1698
  /** Register is the deliberate cold graph-publication boundary. Ordinary
1686
1699
  * Watch detection stops at a durable exact record and never rebuilds this
1687
1700
  * resource snapshot. */
1688
1701
  async publishMaterializedSnapshot(identity, publicationRootIds, trace) {
1702
+ // Any durable pending intent resolves before a new snapshot derives: an
1703
+ // acknowledged publication advances the head through its original
1704
+ // mutation id (the authority answers a known mutation id idempotently),
1705
+ // and a conclusive refusal marks the row rejected so the snapshot
1706
+ // derived below may supersede it. A transport failure keeps the row
1707
+ // pending — its outcome is unknown, so nothing may replace it.
1708
+ try {
1709
+ await this.publishPendingSnapshots(identity, trace);
1710
+ }
1711
+ catch (error) {
1712
+ if (!conclusivelyRefused(error))
1713
+ throw error;
1714
+ }
1689
1715
  const state = this.cloudState;
1690
1716
  if (!state)
1691
1717
  throw new Error("cloud state is unavailable for graph publication");
@@ -1693,7 +1719,7 @@ export class UserGroundHost {
1693
1719
  .map(portableRecord), readGroundRowsForRoots(this.databasePath(identity), "detection_notebook", publicationRootIds)
1694
1720
  .map(portableRecord));
1695
1721
  const merged = mergeMaterializedRoots(state.records, localRecords);
1696
- const traveling = travelingRecords(merged);
1722
+ const traveling = travelingRecords(merged, sha256Hex);
1697
1723
  const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
1698
1724
  && record.payloadVersion?.startsWith("git:"));
1699
1725
  if (impossibleGitLeaf) {
@@ -1705,16 +1731,6 @@ export class UserGroundHost {
1705
1731
  const checksum = sha256Hex(snapshotJson);
1706
1732
  if (checksum === state.checksum)
1707
1733
  return;
1708
- const pending = readSnapshotPublications(this.databasePath(identity));
1709
- const identical = pending.find((entry) => entry.resourceId === state.resourceId
1710
- && entry.snapshotChecksum === checksum);
1711
- if (identical) {
1712
- await this.publishPendingSnapshots(identity, trace);
1713
- return;
1714
- }
1715
- if (pending.length > 0) {
1716
- throw new Error("a different graph snapshot is already pending publication");
1717
- }
1718
1734
  const localIds = new Set(localRecords.map((record) => record.uuid));
1719
1735
  const replacementRootIds = localRecords
1720
1736
  .filter((record) => record.parentUUID === null)
@@ -1736,7 +1752,7 @@ export class UserGroundHost {
1736
1752
  const artifact = artifactForRecord(record);
1737
1753
  return artifact !== null && !publishedArtifacts.has(artifact.contentHash);
1738
1754
  });
1739
- appendSnapshotJournal(this.databasePath(identity), {
1755
+ supersedeSnapshotJournal(this.databasePath(identity), {
1740
1756
  mutationId: randomUUID(),
1741
1757
  resourceId: state.resourceId,
1742
1758
  authorityEpoch: state.authorityEpoch,
@@ -1746,11 +1762,17 @@ export class UserGroundHost {
1746
1762
  uploadRecords,
1747
1763
  replacementRootIds: replacement.rootIds,
1748
1764
  replacementRecords: replacement.records,
1765
+ status: "pending",
1749
1766
  });
1750
1767
  await this.publishPendingSnapshots(identity, trace);
1751
1768
  }
1752
1769
  async publishPendingSnapshots(identity, trace) {
1753
1770
  for (const pending of readSnapshotPublications(this.databasePath(identity))) {
1771
+ // A rejected row is a settled fact, not an intent: the authority
1772
+ // conclusively refused this exact mutation, and only a newer complete
1773
+ // snapshot superseding it can publish this resource again.
1774
+ if (pending.status === "rejected")
1775
+ continue;
1754
1776
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
1755
1777
  const replacement = rootReplacementFromRecords(pending.replacementRootIds, pending.replacementRecords);
1756
1778
  try {
@@ -1796,6 +1818,13 @@ export class UserGroundHost {
1796
1818
  durationMs: performance.now() - graphCommitStarted,
1797
1819
  });
1798
1820
  }
1821
+ // A non-retryable error frame is the authority's answer: this exact
1822
+ // mutation is refused forever. Settle the row so it is never
1823
+ // replayed and a newer snapshot may supersede it. Every other
1824
+ // failure leaves the outcome unknown and the row pending.
1825
+ if (error instanceof WireRequestError && error.retryable === false) {
1826
+ markSnapshotJournalRejected(this.databasePath(identity), pending.mutationId);
1827
+ }
1799
1828
  throw pipelineStageError("cloud graph commit", error);
1800
1829
  }
1801
1830
  if (trace?.journey === "register") {
@@ -2006,13 +2035,21 @@ function initializeDatabase(file) {
2006
2035
  mutation_id TEXT NOT NULL UNIQUE,
2007
2036
  resource_id TEXT NOT NULL,
2008
2037
  publication_json TEXT NOT NULL,
2009
- created_at INTEGER NOT NULL
2038
+ created_at INTEGER NOT NULL,
2039
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))
2010
2040
  );
2011
2041
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
2012
2042
  workspace_uuid TEXT PRIMARY KEY,
2013
2043
  destination_path TEXT NOT NULL UNIQUE,
2014
2044
  intent_json TEXT NOT NULL
2015
2045
  );
2046
+ CREATE TABLE IF NOT EXISTS repository_git_evidence (
2047
+ uuid TEXT PRIMARY KEY,
2048
+ payload_version TEXT NOT NULL,
2049
+ transport_version TEXT NOT NULL,
2050
+ identity_hash TEXT NOT NULL,
2051
+ fingerprint TEXT NOT NULL
2052
+ );
2016
2053
  CREATE INDEX IF NOT EXISTS entities_by_absolute_path
2017
2054
  ON entities(absolute_path);
2018
2055
  CREATE INDEX IF NOT EXISTS entities_by_physical_identity
@@ -2034,6 +2071,10 @@ function initializeDatabase(file) {
2034
2071
  if (!inboxColumns.has("resolved_at")) {
2035
2072
  database.exec("ALTER TABLE record_inbox ADD COLUMN resolved_at INTEGER");
2036
2073
  }
2074
+ const publicationColumns = new Set(database.prepare("PRAGMA table_info(snapshot_publications)").all().map(({ name }) => name));
2075
+ if (!publicationColumns.has("status")) {
2076
+ database.exec("ALTER TABLE snapshot_publications ADD COLUMN status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'rejected'))");
2077
+ }
2037
2078
  database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
2038
2079
  database.exec("DROP TABLE IF EXISTS mutation_journal");
2039
2080
  migratedDatabases.add(file);
@@ -2407,7 +2448,7 @@ function readSnapshotPublications(file) {
2407
2448
  try {
2408
2449
  return database.prepare(`
2409
2450
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
2410
- publication_json AS publicationJson
2451
+ publication_json AS publicationJson, status
2411
2452
  FROM snapshot_publications ORDER BY sequence
2412
2453
  `).all().map((row) => {
2413
2454
  const publication = JSON.parse(row.publicationJson);
@@ -2428,6 +2469,7 @@ function readSnapshotPublications(file) {
2428
2469
  uploadRecords: snapshotFromRecords(publication.uploadRecords).records,
2429
2470
  replacementRootIds: publication.replacementRootIds.map(String),
2430
2471
  replacementRecords: snapshotFromRecords(publication.replacementRecords).records,
2472
+ status: row.status === "rejected" ? "rejected" : "pending",
2431
2473
  };
2432
2474
  });
2433
2475
  }
@@ -2502,27 +2544,68 @@ function commitDetectedState(file, input) {
2502
2544
  database.close();
2503
2545
  }
2504
2546
  }
2505
- function appendSnapshotJournal(file, entry) {
2547
+ /** Journal the newest snapshot publication for its resource. A journal row
2548
+ * has exactly two meanings: `pending` — the submit outcome is unknown, so
2549
+ * the same mutation id must retry until the authority answers (the mutation
2550
+ * may already be accepted with only the acknowledgement lost); `rejected` —
2551
+ * the authority conclusively refused it, so it is never replayed. Only a
2552
+ * rejected row may be superseded — last write wins — and supersession and
2553
+ * insertion share one transaction, so a crash leaves exactly one durable
2554
+ * intent. Content uploads are content-addressed and idempotent, so a
2555
+ * superseded row loses no uploaded work. */
2556
+ function supersedeSnapshotJournal(file, entry) {
2506
2557
  const database = initializeDatabase(file);
2507
2558
  try {
2508
- database.prepare(`
2509
- INSERT INTO snapshot_publications(
2510
- mutation_id, resource_id, publication_json, created_at
2511
- ) VALUES (?, ?, ?, ?)
2512
- `).run(entry.mutationId, entry.resourceId, stableJson({
2513
- authorityEpoch: entry.authorityEpoch,
2514
- baseVersion: entry.baseVersion,
2515
- snapshotChecksum: entry.snapshotChecksum,
2516
- snapshotJson: entry.snapshotJson,
2517
- uploadRecords: entry.uploadRecords,
2518
- replacementRootIds: entry.replacementRootIds,
2519
- replacementRecords: entry.replacementRecords,
2520
- }), Date.now());
2559
+ database.transaction(() => {
2560
+ const unresolved = database.prepare(`
2561
+ SELECT 1 AS present FROM snapshot_publications
2562
+ WHERE resource_id = ? AND status = 'pending' LIMIT 1
2563
+ `).get(entry.resourceId);
2564
+ if (unresolved) {
2565
+ throw new Error("a pending publication with an unknown outcome must resolve before supersession");
2566
+ }
2567
+ database.prepare("DELETE FROM snapshot_publications WHERE resource_id = ? AND status = 'rejected'")
2568
+ .run(entry.resourceId);
2569
+ database.prepare(`
2570
+ INSERT INTO snapshot_publications(
2571
+ mutation_id, resource_id, publication_json, created_at
2572
+ ) VALUES (?, ?, ?, ?)
2573
+ `).run(entry.mutationId, entry.resourceId, stableJson({
2574
+ authorityEpoch: entry.authorityEpoch,
2575
+ baseVersion: entry.baseVersion,
2576
+ snapshotChecksum: entry.snapshotChecksum,
2577
+ snapshotJson: entry.snapshotJson,
2578
+ uploadRecords: entry.uploadRecords,
2579
+ replacementRootIds: entry.replacementRootIds,
2580
+ replacementRecords: entry.replacementRecords,
2581
+ }), Date.now());
2582
+ })();
2521
2583
  }
2522
2584
  finally {
2523
2585
  database.close();
2524
2586
  }
2525
2587
  }
2588
+ /** Settle a journal row as conclusively refused. It is never replayed again;
2589
+ * a newer complete snapshot supersedes it on the next publication. */
2590
+ function markSnapshotJournalRejected(file, mutationId) {
2591
+ const database = initializeDatabase(file);
2592
+ try {
2593
+ database.prepare("UPDATE snapshot_publications SET status = 'rejected' WHERE mutation_id = ?")
2594
+ .run(mutationId);
2595
+ }
2596
+ finally {
2597
+ database.close();
2598
+ }
2599
+ }
2600
+ /** True when the authority itself answered that this exact mutation is
2601
+ * refused (a non-retryable error frame), as opposed to a transport or
2602
+ * pipeline failure whose outcome is unknown. Stage wrappers carry the wire
2603
+ * error as their cause. */
2604
+ function conclusivelyRefused(error) {
2605
+ if (error instanceof WireRequestError)
2606
+ return error.retryable === false;
2607
+ return error instanceof Error && conclusivelyRefused(error.cause);
2608
+ }
2526
2609
  function deleteJournalEntry(file, mutationId) {
2527
2610
  const database = initializeDatabase(file);
2528
2611
  try {
@@ -2666,6 +2749,45 @@ function runGroundUpsert(database, table, statement, row) {
2666
2749
  + `occupied by ${occupant?.uuid ?? "no retained row"} (${occupant?.absolutePath ?? "unknown"})`, { cause: error });
2667
2750
  }
2668
2751
  }
2752
+ /** The Git-evidence fingerprint proven by the last successful capture, bound
2753
+ * to the exact heads AND the exact identity map it certified. A stored
2754
+ * fingerprint only gates when the stored root row still carries the same
2755
+ * heads and the current rows still derive the same identity map, so neither a
2756
+ * capture whose detection never reached the table nor a raced base rollback
2757
+ * can silence a real change. */
2758
+ function readSealedRepositoryEvidence(file, uuid) {
2759
+ const database = initializeDatabase(file);
2760
+ try {
2761
+ return database.prepare(`
2762
+ SELECT payload_version AS payloadVersion,
2763
+ transport_version AS transportVersion,
2764
+ identity_hash AS identityHash,
2765
+ fingerprint
2766
+ FROM repository_git_evidence WHERE uuid = ?
2767
+ `).get(uuid) ?? null;
2768
+ }
2769
+ finally {
2770
+ database.close();
2771
+ }
2772
+ }
2773
+ function writeSealedRepositoryEvidence(file, uuid, evidence) {
2774
+ const database = initializeDatabase(file);
2775
+ try {
2776
+ database.prepare(`
2777
+ INSERT INTO repository_git_evidence
2778
+ (uuid, payload_version, transport_version, identity_hash, fingerprint)
2779
+ VALUES (?, ?, ?, ?, ?)
2780
+ ON CONFLICT(uuid) DO UPDATE SET
2781
+ payload_version = excluded.payload_version,
2782
+ transport_version = excluded.transport_version,
2783
+ identity_hash = excluded.identity_hash,
2784
+ fingerprint = excluded.fingerprint
2785
+ `).run(uuid, evidence.payloadVersion, evidence.transportVersion, evidence.identityHash, evidence.fingerprint);
2786
+ }
2787
+ finally {
2788
+ database.close();
2789
+ }
2790
+ }
2669
2791
  function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = [], options = {}) {
2670
2792
  const database = initializeDatabase(file);
2671
2793
  try {
@@ -3136,16 +3258,25 @@ async function reconcileRoot(input) {
3136
3258
  let rootRepositoryEvidence = null;
3137
3259
  let rootRepositoryInspectionFailed = false;
3138
3260
  if (rootType === "repo.git") {
3139
- evidence && (evidence.gitInspections += 1);
3140
- try {
3141
- // A Git metadata ring can arrive before the corresponding worktree
3142
- // ring. Git already knows every dirty tracked and untracked path, so
3143
- // those paths join this observation and Card + Checkpoint cannot carry
3144
- // stale identity.
3145
- rootRepositoryEvidence = inspectGitRegistration(rootPath);
3146
- }
3147
- catch {
3148
- rootRepositoryInspectionFailed = true;
3261
+ // On a complete pass whose Git state files are unchanged since the last
3262
+ // sealed capture, the up-front dirty-path evidence would only re-prove
3263
+ // what the stored rows already hold. The walk's lazy inspection still
3264
+ // asks Git the moment any entry is actually observed.
3265
+ const sealed = suspects === null
3266
+ ? readSealedRepositoryEvidence(database, rootUUID)
3267
+ : null;
3268
+ if (sealed === null || sealed.fingerprint !== gitEvidenceFingerprint(rootPath)) {
3269
+ evidence && (evidence.gitInspections += 1);
3270
+ try {
3271
+ // A Git metadata ring can arrive before the corresponding worktree
3272
+ // ring. Git already knows every dirty tracked and untracked path, so
3273
+ // those paths join this observation and Card + Checkpoint cannot carry
3274
+ // stale identity.
3275
+ rootRepositoryEvidence = inspectGitRegistration(rootPath);
3276
+ }
3277
+ catch {
3278
+ rootRepositoryInspectionFailed = true;
3279
+ }
3149
3280
  }
3150
3281
  }
3151
3282
  const normalizedSuspects = suspects === null ? null : suspects.map((path) => {
@@ -3401,7 +3532,25 @@ async function reconcileRoot(input) {
3401
3532
  }
3402
3533
  };
3403
3534
  await visit(rootPath, rootUUID, rootType);
3404
- const resolvedIdentity = reconcileGroundUUIDs(existingRows.map((row) => ({
3535
+ // When every entry sits at its stored address with its stored type and
3536
+ // physical fingerprint (honoring any fixed UUID), path-first claiming makes
3537
+ // the multi-pass reconciliation the identity map — resolve directly. Any
3538
+ // single mismatch falls back to the full algorithm.
3539
+ const stableIdentity = () => {
3540
+ const resolved = new Map();
3541
+ for (const entry of entries) {
3542
+ const row = existingByPath.get(entry.relativePath);
3543
+ if (!row
3544
+ || row.type !== entry.type
3545
+ || row.deviceNumber !== entry.deviceNumber
3546
+ || row.inode !== entry.inode
3547
+ || (entry.fixedUUID !== undefined && entry.fixedUUID !== row.uuid))
3548
+ return null;
3549
+ resolved.set(entry.relativePath, row.uuid);
3550
+ }
3551
+ return resolved;
3552
+ };
3553
+ const resolvedIdentity = stableIdentity() ?? reconcileGroundUUIDs(existingRows.map((row) => ({
3405
3554
  uuid: row.uuid,
3406
3555
  type: row.type,
3407
3556
  relativePath: row.relativePath,
@@ -3435,22 +3584,36 @@ async function reconcileRoot(input) {
3435
3584
  group.push({ uuid: entry.uuid, name: entry.name });
3436
3585
  children.set(entry.parentUUID, group);
3437
3586
  }
3587
+ // canonicalVersion is a pure function of these identity inputs, so a stored
3588
+ // leaf row whose inputs are unchanged already holds the exact version —
3589
+ // recomputing 70k hashes on a stable restart pass proved nothing. Container
3590
+ // versions still derive from live membership, which is never persisted.
3591
+ const existingVersionByUuid = new Map(existingRows.map((row) => [row.uuid, row]));
3438
3592
  const rowsFromEntries = () => entries.map((entry) => {
3439
- const versionPayload = entry.type === "workspace" || entry.type === "folder"
3440
- ? membershipHash(children.get(entry.uuid) ?? [], sha256Hex)
3441
- : entry.payloadVersion;
3593
+ const container = entry.type === "workspace" || entry.type === "folder";
3594
+ const stored = container ? undefined : existingVersionByUuid.get(entry.uuid);
3595
+ const storedVersion = stored !== undefined
3596
+ && stored.type === entry.type
3597
+ && stored.parentUUID === entry.parentUUID
3598
+ && stored.name === entry.name
3599
+ && stored.status === "active"
3600
+ && stored.payloadVersion === entry.payloadVersion
3601
+ ? stored.version
3602
+ : null;
3442
3603
  const record = {
3443
3604
  uuid: entry.uuid,
3444
3605
  type: entry.type,
3445
3606
  parentUUID: entry.parentUUID,
3446
3607
  name: entry.name,
3447
3608
  status: "active",
3448
- version: canonicalVersion({
3609
+ version: storedVersion ?? canonicalVersion({
3449
3610
  type: entry.type,
3450
3611
  parentUUID: entry.parentUUID,
3451
3612
  name: entry.name,
3452
3613
  status: "active",
3453
- payloadVersion: versionPayload,
3614
+ payloadVersion: container
3615
+ ? membershipHash(children.get(entry.uuid) ?? [], sha256Hex)
3616
+ : entry.payloadVersion,
3454
3617
  }, sha256Hex),
3455
3618
  payloadVersion: entry.payloadVersion,
3456
3619
  transportVersion: entry.transportVersion,
@@ -3552,15 +3715,51 @@ async function reconcileRoot(input) {
3552
3715
  }))
3553
3716
  .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
3554
3717
  };
3555
- let repoIdentity = await refreshRepositoryIdentity();
3556
- /* The rows and the transport identity are derived from the same fresh
3557
- * worktree evidence. A later Watch ring for one of these dirty paths is
3558
- * therefore ordinary equality, not a transport-only echo. */
3559
3718
  const priorRow = existingByPath.get(repository.relativePath);
3560
3719
  if (priorRow?.type === "repo.git") {
3561
3720
  repository.payloadVersion = priorRow.payloadVersion;
3562
3721
  repository.transportVersion = priorRow.transportVersion;
3563
3722
  }
3723
+ // A complete pass re-proves a repository through Git unless Git's own
3724
+ // state files still match the fingerprint sealed with the exact heads
3725
+ // the stored row carries AND every owned entry sits unchanged at its
3726
+ // stored physical identity. Both together mean capture would derive the
3727
+ // rows this scan already holds; any single doubt asks Git in full.
3728
+ const unchangedSinceSeal = (entry) => {
3729
+ const row = existingByPath.get(entry.relativePath);
3730
+ return row !== undefined
3731
+ && row.uuid === entry.uuid
3732
+ && row.type === entry.type
3733
+ && row.payloadVersion === entry.payloadVersion
3734
+ && row.deviceNumber === entry.deviceNumber
3735
+ && row.inode === entry.inode
3736
+ && row.byteSize === entry.byteSize
3737
+ && row.modifiedTimeMs === entry.modifiedTimeMs
3738
+ && row.changedTimeMs === entry.changedTimeMs
3739
+ && row.filesystemMode === entry.filesystemMode;
3740
+ };
3741
+ if (scanSuspects === null
3742
+ && priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
3743
+ const sealed = readSealedRepositoryEvidence(database, repository.uuid);
3744
+ if (sealed !== null
3745
+ && sealed.payloadVersion === priorRow.payloadVersion
3746
+ && sealed.transportVersion === priorRow.transportVersion
3747
+ && sealed.fingerprint === gitEvidenceFingerprint(repository.absolutePath)
3748
+ && ownedEntries.every(unchangedSinceSeal)
3749
+ && sealed.identityHash === repositoryIdentityHash(ownedEntries.map((entry) => ({
3750
+ path: repositoryPath(entry),
3751
+ uuid: entry.uuid,
3752
+ type: entry.type,
3753
+ payloadVersion: entry.type === "repo.git" ? null : entry.payloadVersion,
3754
+ })), sha256Hex)) {
3755
+ continue;
3756
+ }
3757
+ }
3758
+ const fingerprintBefore = gitEvidenceFingerprint(repository.absolutePath);
3759
+ let repoIdentity = await refreshRepositoryIdentity();
3760
+ /* The rows and the transport identity are derived from the same fresh
3761
+ * worktree evidence. A later Watch ring for one of these dirty paths is
3762
+ * therefore ordinary equality, not a transport-only echo. */
3564
3763
  let prior = null;
3565
3764
  let priorLayout = null;
3566
3765
  if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
@@ -3637,6 +3836,18 @@ async function reconcileRoot(input) {
3637
3836
  parentTransportVersion: currentLayout.parentTransportVersion,
3638
3837
  transportVersion: captured.transportVersion,
3639
3838
  });
3839
+ // The fingerprint is only sealed when Git's state files did not move
3840
+ // while capture ran; a concurrent ref or index write leaves no seal and
3841
+ // the next complete pass asks Git again.
3842
+ if (fingerprintBefore !== null
3843
+ && fingerprintBefore === gitEvidenceFingerprint(repository.absolutePath)) {
3844
+ writeSealedRepositoryEvidence(database, repository.uuid, {
3845
+ payloadVersion: captured.stateId,
3846
+ transportVersion: captured.transportVersion,
3847
+ identityHash: repositoryIdentityHash(repoIdentity, sha256Hex),
3848
+ fingerprint: fingerprintBefore,
3849
+ });
3850
+ }
3640
3851
  }
3641
3852
  }
3642
3853
  catch {
@@ -4251,7 +4462,7 @@ async function installCloudWorkspace(input) {
4251
4462
  }
4252
4463
  const expectedRecords = pending?.records ?? records;
4253
4464
  const localRecords = existing.map(portableRecord);
4254
- if (!sameRecords(travelingRecords(localRecords), expectedRecords)) {
4465
+ if (!sameRecords(travelingRecords(localRecords, sha256Hex), expectedRecords)) {
4255
4466
  throw new Error(`local workspace ${workspace.uuid} differs from its cloud graph`);
4256
4467
  }
4257
4468
  if (pending && root.absolutePath !== pending.destinationPath) {