@indigoai-us/hq-cloud 6.12.5 → 6.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -68,7 +68,7 @@ export interface JournalEntry {
68
68
  * the same paths as orphans, then garbage-collected.
69
69
  */
70
70
  removedAt?: string;
71
- removedReason?: "scope_shrink" | "narrow_apply" | "manual";
71
+ removedReason?: "scope_shrink" | "narrow_apply" | "manual" | "local-delete";
72
72
  /**
73
73
  * Durable automatic-pull retention marker. Set when a scope shrink keeps an
74
74
  * out-of-scope caller-authored or unknown-author entry on disk instead of
@@ -77,6 +77,16 @@ export interface JournalEntry {
77
77
  * the entry becomes in-scope again.
78
78
  */
79
79
  outOfScopeProtected?: boolean;
80
+ /**
81
+ * Journal-honesty marker. Set when a conflict was resolved by KEEPing a local
82
+ * copy that diverges from the remote: the entry still records the remote
83
+ * `remoteEtag` (so the conflict can't re-fire — the #137 invariant), but the
84
+ * local bytes never matched that remote. The currency-gated delete planner
85
+ * refuses to propagate a delete for such an entry — its currency would
86
+ * falsely "match" on HEAD, and deleting would destroy the divergent remote
87
+ * version. Cleared by any genuine download (which makes local == remote).
88
+ */
89
+ localDiverges?: boolean;
80
90
  }
81
91
  /**
82
92
  * Per-pull boundary record (Journal v2, US-005).
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.12.5",
3
+ "version": "6.13.0",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -3580,6 +3580,47 @@ describe("resolvePullScope", () => {
3580
3580
  expect(scope.prefixSet).toEqual(["knowledge/", "shared/"]);
3581
3581
  });
3582
3582
 
3583
+ // Regression lock-in (Michal / ridge report): a non-owner member whose
3584
+ // ONLY grant is a COMPANY-WIDE write grant (surfaced by the server as
3585
+ // `source: "open"`) on a projects prefix — and who has NO direct
3586
+ // person/group/email grant — MUST resolve a non-empty shared scope that
3587
+ // COVERS that prefix. The reported bug was the push resolving an empty/
3588
+ // sparse prefixSet here (ignoring company-wide grants), so every file was
3589
+ // scope-excluded (403 SCOPE_EXCEEDS_PARENT on old clients, silent no-op on
3590
+ // 5.54.1). resolvePullScope treats a company-wide `open` grant identically
3591
+ // to a direct grant; this test guards against a future filter that drops
3592
+ // `source: "open"` and re-introduces the silent-drop regression.
3593
+ it("honors a company-wide-only write grant as the shared push scope (no empty-prune)", async () => {
3594
+ const scope = await resolvePullScope(
3595
+ stubClient({
3596
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3597
+ getMembershipSyncConfig: async () => ({
3598
+ membershipId: "mk_a",
3599
+ syncMode: "shared",
3600
+ isDefault: false,
3601
+ }),
3602
+ // Exactly one grant, company-wide WRITE, no direct grant.
3603
+ listMyExplicitGrants: async () =>
3604
+ [
3605
+ {
3606
+ companyUid: "cmp_a",
3607
+ path: "projects/alpha/",
3608
+ permission: "write",
3609
+ source: "open",
3610
+ },
3611
+ ] as never,
3612
+ }),
3613
+ "cmp_a",
3614
+ "acme",
3615
+ );
3616
+ expect(scope.syncMode).toBe("shared");
3617
+ // The granted prefix is present — NOT an empty set (which would scope-
3618
+ // exclude every file and silently push nothing).
3619
+ expect(scope.prefixSet).toEqual(["projects/alpha/"]);
3620
+ expect(scope.prefixSet).not.toEqual([]);
3621
+ expect(scope.prefixSet?.length).toBeGreaterThan(0);
3622
+ });
3623
+
3583
3624
  it("normalizes real-world mixed/glob grant paths into company-relative prefixes", async () => {
3584
3625
  // The exact shapes observed in the live hq-pro vault for `indigo`:
3585
3626
  // bare glob, full-anchored + /*, full-anchored exact file, slug-anchored
@@ -2470,6 +2470,61 @@ describe("share", () => {
2470
2470
  expect(journal.files["core/policies/old.md"]).toBeUndefined();
2471
2471
  });
2472
2472
 
2473
+ it("currency-gated: propagates the S3 delete for a local-delete tombstone (pull-leg producer hand-off)", async () => {
2474
+ // The pull-leg producer stamped a persisting `local-delete` tombstone for a
2475
+ // file the user deleted (no respawn). The cross-leg hand-off: on the next
2476
+ // push the S3 object must actually be DELETED and the entry dropped, so the
2477
+ // deletion propagates to the cloud + every other surface.
2478
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2479
+ fs.mkdirSync(companyRoot, { recursive: true });
2480
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2481
+ fs.writeFileSync(
2482
+ journalPath,
2483
+ JSON.stringify({
2484
+ version: "2",
2485
+ lastSync: new Date().toISOString(),
2486
+ files: {
2487
+ "docs/deleted.md": {
2488
+ hash: "h",
2489
+ size: 100,
2490
+ syncedAt: new Date().toISOString(),
2491
+ direction: "down",
2492
+ remoteEtag: "abc123",
2493
+ removedAt: new Date().toISOString(),
2494
+ removedReason: "local-delete",
2495
+ },
2496
+ },
2497
+ pulls: [],
2498
+ }),
2499
+ );
2500
+
2501
+ // HEAD returns the same etag — this device had the current version, so the
2502
+ // currency-gated delete is safe to propagate.
2503
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
2504
+ lastModified: new Date(),
2505
+ etag: '"abc123"',
2506
+ size: 100,
2507
+ });
2508
+
2509
+ const result = await share({
2510
+ paths: [companyRoot],
2511
+ company: "acme",
2512
+ vaultConfig: mockConfig,
2513
+ hqRoot: tmpDir,
2514
+ skipUnchanged: true,
2515
+ propagateDeletes: true,
2516
+ propagateDeletePolicy: "currency-gated",
2517
+ });
2518
+
2519
+ expect(result.filesDeleted).toBe(1);
2520
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
2521
+ expect.anything(),
2522
+ "docs/deleted.md",
2523
+ );
2524
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2525
+ expect(journal.files["docs/deleted.md"]).toBeUndefined();
2526
+ });
2527
+
2473
2528
  it("currency-gated: refuses delete + emits stale-etag event when remote moved since last sync", async () => {
2474
2529
  const companyRoot = path.join(tmpDir, "companies", "acme");
2475
2530
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -2531,6 +2586,70 @@ describe("share", () => {
2531
2586
  expect(result.filesTombstoned).toBe(0);
2532
2587
  });
2533
2588
 
2589
+ it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
2590
+ const companyRoot = path.join(tmpDir, "companies", "acme");
2591
+ fs.mkdirSync(companyRoot, { recursive: true });
2592
+ // A keep-conflict recorded the REMOTE etag (so the conflict can't re-fire —
2593
+ // the #137 invariant) but the kept LOCAL content DIVERGED from that remote
2594
+ // (`localDiverges:true`). The local copy was then deleted. The currency HEAD
2595
+ // would MATCH the recorded etag — the trap — yet the etag never reflected a
2596
+ // genuine local==remote sync, so propagating the delete would destroy the
2597
+ // (different) remote version. Must be REFUSED, not propagated.
2598
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
2599
+ fs.writeFileSync(
2600
+ journalPath,
2601
+ JSON.stringify({
2602
+ version: "2",
2603
+ lastSync: new Date().toISOString(),
2604
+ files: {
2605
+ "core/policies/kept.md": {
2606
+ hash: "local-divergent-hash",
2607
+ size: 100,
2608
+ syncedAt: new Date().toISOString(),
2609
+ direction: "down",
2610
+ remoteEtag: "abc123",
2611
+ localDiverges: true,
2612
+ },
2613
+ },
2614
+ pulls: [],
2615
+ }),
2616
+ );
2617
+
2618
+ // Deliberately NO HEAD mock: the divergence guard short-circuits BEFORE the
2619
+ // currency HEAD, so `headRemoteFile` is never called for this entry — the
2620
+ // refusal costs no network round-trip (asserted below).
2621
+ const events: Array<{ type: string; path?: string; reason?: string }> = [];
2622
+ const result = await share({
2623
+ paths: [companyRoot],
2624
+ company: "acme",
2625
+ vaultConfig: mockConfig,
2626
+ hqRoot: tmpDir,
2627
+ skipUnchanged: true,
2628
+ propagateDeletes: true,
2629
+ propagateDeletePolicy: "currency-gated",
2630
+ onEvent: (e) => events.push(e as { type: string }),
2631
+ });
2632
+
2633
+ // No remote DELETE — the recorded etag never reflected a genuine sync.
2634
+ expect(result.filesDeleted).toBe(0);
2635
+ expect(deleteRemoteFile).not.toHaveBeenCalledWith(
2636
+ expect.anything(),
2637
+ "core/policies/kept.md",
2638
+ );
2639
+ // Refusal short-circuits before the currency HEAD — no network needed.
2640
+ expect(headRemoteFile).not.toHaveBeenCalled();
2641
+ // Journal entry preserved, not silently dropped.
2642
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2643
+ expect(journal.files["core/policies/kept.md"]).toBeDefined();
2644
+ // Surfaced as a refusal with the divergent-local reason.
2645
+ const refused = events.find((e) => e.type === "delete-refused-stale-etag");
2646
+ expect(refused).toMatchObject({
2647
+ type: "delete-refused-stale-etag",
2648
+ path: "core/policies/kept.md",
2649
+ reason: "divergent-local",
2650
+ });
2651
+ });
2652
+
2534
2653
  it("currency-gated: tombstones journal entry when remote returns 404 (out-of-band cleanup)", async () => {
2535
2654
  const companyRoot = path.join(tmpDir, "companies", "acme");
2536
2655
  fs.mkdirSync(companyRoot, { recursive: true });
package/src/cli/share.ts CHANGED
@@ -1987,7 +1987,11 @@ function resolveDeleteScopeRoots(
1987
1987
  * tracked. `journalEtag` and `remoteEtag` are
1988
1988
  * placeholder sentinels — do not display as ETags.
1989
1989
  */
1990
- type RefusedStaleReason = "stale-etag" | "legacy-no-etag" | "bulk-asymmetry";
1990
+ type RefusedStaleReason =
1991
+ | "stale-etag"
1992
+ | "legacy-no-etag"
1993
+ | "bulk-asymmetry"
1994
+ | "divergent-local";
1991
1995
 
1992
1996
  /**
1993
1997
  * Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
@@ -2241,6 +2245,22 @@ async function computeDeletePlan(
2241
2245
  // documentation of the policy-side intent.
2242
2246
  if (isEphemeralPath(relativeKey)) continue;
2243
2247
 
2248
+ // Journal-honesty guard: an entry flagged `localDiverges` recorded the
2249
+ // remote etag only to silence a re-fired conflict (#137) — the local copy
2250
+ // NEVER matched that remote. Its currency would falsely "match" on HEAD, so
2251
+ // propagating a delete would destroy the divergent remote version. Refuse it
2252
+ // regardless of policy, and do NOT count it toward the bulk-asymmetry ratio
2253
+ // (it isn't a genuine mirror-loss candidate). Cleared by a real download.
2254
+ if (entry.localDiverges) {
2255
+ plan.refusedStale.push({
2256
+ key: relativeKey,
2257
+ journalEtag: entry.remoteEtag ?? "<divergent-local>",
2258
+ remoteEtag: "<not-checked>",
2259
+ reason: "divergent-local",
2260
+ });
2261
+ continue;
2262
+ }
2263
+
2244
2264
  if (policy === "all") {
2245
2265
  // policy:"all" is the explicit-opt-out emergency-reconcile mode; the
2246
2266
  // bulk-asymmetry guard skips this branch (caller asserted intent).
@@ -279,6 +279,76 @@ describe("sync", () => {
279
279
  expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
280
280
  });
281
281
 
282
+ it("conflict keep marks the entry localDiverges (journal-honesty: kept copy never matched the recorded remote etag)", async () => {
283
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
284
+ fs.mkdirSync(companyDocs, { recursive: true });
285
+ fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
286
+ fs.writeFileSync(
287
+ journalPath,
288
+ JSON.stringify({
289
+ version: "1",
290
+ lastSync: new Date().toISOString(),
291
+ files: {
292
+ "docs/handoff.md": {
293
+ hash: "old-hash-from-last-sync",
294
+ size: 20,
295
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
296
+ direction: "down",
297
+ },
298
+ },
299
+ }),
300
+ );
301
+
302
+ const result = await sync({
303
+ company: "acme",
304
+ onConflict: "keep",
305
+ vaultConfig: mockConfig,
306
+ hqRoot: tmpDir,
307
+ });
308
+
309
+ expect(result.conflicts).toBe(1);
310
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
311
+ // remoteEtag still recorded so the conflict can't re-fire (#137 invariant)…
312
+ expect(journal.files["docs/handoff.md"].remoteEtag).toBeTruthy();
313
+ // …but the kept-local copy is flagged divergent so a later currency-gated
314
+ // delete cannot fire on a journal that lies about local==remote.
315
+ expect(journal.files["docs/handoff.md"].localDiverges).toBe(true);
316
+ });
317
+
318
+ it("a genuine download clears a stale localDiverges flag (entry is now truly local==remote)", async () => {
319
+ const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
320
+ fs.mkdirSync(companyDocs, { recursive: true });
321
+ fs.writeFileSync(
322
+ journalPath,
323
+ JSON.stringify({
324
+ version: "2",
325
+ lastSync: new Date().toISOString(),
326
+ files: {
327
+ "docs/handoff.md": {
328
+ hash: "old-divergent-hash",
329
+ size: 20,
330
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
331
+ direction: "down",
332
+ remoteEtag: "old-etag",
333
+ localDiverges: true,
334
+ },
335
+ },
336
+ pulls: [],
337
+ }),
338
+ );
339
+ // Remote advanced to a new etag; local absent → genuine download lands the
340
+ // remote bytes, so local==remote now and the divergence flag must clear.
341
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
342
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"new-etag"' },
343
+ ]);
344
+
345
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
346
+
347
+ expect(fs.existsSync(path.join(companyDocs, "handoff.md"))).toBe(true);
348
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
349
+ expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
350
+ });
351
+
282
352
  it("emits a conflict event with path + resolution on hash mismatch", async () => {
283
353
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
284
354
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -884,6 +954,192 @@ describe("sync", () => {
884
954
  expect(journal.files[trackedKey]).toBeUndefined();
885
955
  });
886
956
 
957
+ it("local journal tombstone suppresses drift-restore: a deleted-but-still-remote key is NOT re-downloaded (no respawn)", async () => {
958
+ // Repro of the intermittent delete-respawn: the user deleted a clean file,
959
+ // its delete has not (yet) cleared the S3 object, and the SERVER
960
+ // FILE_TOMBSTONE fetch is degraded (empty map — the logged failure mode).
961
+ // The only intent record is the LOCAL journal tombstone (`removedAt`).
962
+ // The drift-restore path must honor it and NOT re-download the key.
963
+ const key = "docs/handoff.md";
964
+ const localPath = path.join(tmpDir, "companies", "acme", key);
965
+
966
+ // Remote still lists the object, etag matching the journal, lastModified
967
+ // BEFORE the local delete — a stale resurrection of a deleted key, not a
968
+ // genuine remote re-create.
969
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
970
+ {
971
+ key,
972
+ size: 42,
973
+ lastModified: new Date(Date.now() - 2 * 86_400_000),
974
+ etag: '"abc123"',
975
+ },
976
+ ]);
977
+ // Degraded-fetch case: server FILE_TOMBSTONE map is EMPTY.
978
+ setupFetchMock({ tombstones: [] });
979
+
980
+ // Local file ABSENT (user deleted it). Journal entry is a TOMBSTONE,
981
+ // recent enough to survive TTL GC.
982
+ fs.writeFileSync(
983
+ journalPath,
984
+ JSON.stringify({
985
+ version: "2",
986
+ lastSync: "2026-06-19T00:00:00.000Z",
987
+ files: {
988
+ [key]: {
989
+ hash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
990
+ size: 42,
991
+ syncedAt: "2026-06-19T00:00:00.000Z",
992
+ direction: "down",
993
+ remoteEtag: "abc123",
994
+ removedAt: new Date().toISOString(),
995
+ removedReason: "local-delete",
996
+ },
997
+ },
998
+ pulls: [],
999
+ }),
1000
+ );
1001
+
1002
+ await sync({
1003
+ company: "acme",
1004
+ vaultConfig: mockConfig,
1005
+ hqRoot: tmpDir,
1006
+ onEvent: () => {},
1007
+ });
1008
+
1009
+ // The intentionally-deleted key must NOT be re-materialised.
1010
+ expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
1011
+ expect.anything(),
1012
+ key,
1013
+ expect.anything(),
1014
+ );
1015
+ expect(fs.existsSync(localPath)).toBe(false);
1016
+ });
1017
+
1018
+ it("pull producer: a single clean/current/non-divergent missing file is tombstoned (intentional delete), not drift-restored", async () => {
1019
+ // The user deleted a genuinely-synced file. Its entry is clean (etag matches
1020
+ // the current remote — currency holds), not divergent. The pull leg must
1021
+ // treat the absence as an intentional delete: stamp a persisting
1022
+ // `local-delete` tombstone and SKIP the re-download — not resurrect it.
1023
+ const key = "docs/gone.md";
1024
+ const localPath = path.join(tmpDir, "companies", "acme", key);
1025
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1026
+ { key, size: 42, lastModified: new Date(), etag: '"cur123"' },
1027
+ ]);
1028
+ setupFetchMock({ tombstones: [] });
1029
+ fs.writeFileSync(
1030
+ journalPath,
1031
+ JSON.stringify({
1032
+ version: "2",
1033
+ lastSync: new Date().toISOString(),
1034
+ files: {
1035
+ [key]: {
1036
+ hash: "h",
1037
+ size: 42,
1038
+ syncedAt: new Date().toISOString(),
1039
+ direction: "down",
1040
+ remoteEtag: "cur123",
1041
+ },
1042
+ },
1043
+ pulls: [],
1044
+ }),
1045
+ );
1046
+
1047
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1048
+
1049
+ expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
1050
+ expect.anything(),
1051
+ key,
1052
+ expect.anything(),
1053
+ );
1054
+ expect(fs.existsSync(localPath)).toBe(false);
1055
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1056
+ expect(journal.files[key]?.removedAt).toBeTruthy();
1057
+ expect(journal.files[key]?.removedReason).toBe("local-delete");
1058
+ });
1059
+
1060
+ it("pull producer: a BULK disappearance is drift-restored, NOT tombstoned (ridge-incident guard in the pull leg)", async () => {
1061
+ // 12 clean files vanish at once (corrupt mirror / bulk rm / checkout). The
1062
+ // bulk-asymmetry guard refuses to treat this as intent — every file is
1063
+ // healed (re-downloaded), none tombstoned.
1064
+ const keys = Array.from({ length: 12 }, (_, i) => `docs/f${i}.md`);
1065
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce(
1066
+ keys.map((k) => ({ key: k, size: 10, lastModified: new Date(), etag: `"e-${k}"` })),
1067
+ );
1068
+ setupFetchMock({ tombstones: [] });
1069
+ const files: Record<string, unknown> = {};
1070
+ for (const k of keys) {
1071
+ files[k] = {
1072
+ hash: "h",
1073
+ size: 10,
1074
+ syncedAt: new Date().toISOString(),
1075
+ direction: "down",
1076
+ remoteEtag: `e-${k}`,
1077
+ };
1078
+ }
1079
+ fs.writeFileSync(
1080
+ journalPath,
1081
+ JSON.stringify({
1082
+ version: "2",
1083
+ lastSync: new Date().toISOString(),
1084
+ files,
1085
+ pulls: [],
1086
+ }),
1087
+ );
1088
+
1089
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1090
+
1091
+ // All restored, none tombstoned.
1092
+ expect(s3Module.downloadFile).toHaveBeenCalledTimes(12);
1093
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1094
+ for (const k of keys) {
1095
+ expect(journal.files[k]?.removedAt).toBeUndefined();
1096
+ }
1097
+ });
1098
+
1099
+ it("pull producer: a local-delete tombstone persists across pulls (not consumed/removed), so the delete stays propagatable", async () => {
1100
+ // After the intentional-delete tombstone is stamped, a later pull must NOT
1101
+ // drop the entry (the push leg still needs it to propagate the S3 delete).
1102
+ // It stays skipped + preserved until the push propagates or TTL GC.
1103
+ const key = "docs/gone.md";
1104
+ const localPath = path.join(tmpDir, "companies", "acme", key);
1105
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1106
+ { key, size: 42, lastModified: new Date(Date.now() - 2 * 86_400_000), etag: '"cur123"' },
1107
+ ]);
1108
+ setupFetchMock({ tombstones: [] });
1109
+ fs.writeFileSync(
1110
+ journalPath,
1111
+ JSON.stringify({
1112
+ version: "2",
1113
+ lastSync: new Date().toISOString(),
1114
+ files: {
1115
+ [key]: {
1116
+ hash: "h",
1117
+ size: 42,
1118
+ syncedAt: new Date().toISOString(),
1119
+ direction: "down",
1120
+ remoteEtag: "cur123",
1121
+ removedAt: new Date().toISOString(),
1122
+ removedReason: "local-delete",
1123
+ },
1124
+ },
1125
+ pulls: [],
1126
+ }),
1127
+ );
1128
+
1129
+ await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir });
1130
+
1131
+ expect(s3Module.downloadFile).not.toHaveBeenCalledWith(
1132
+ expect.anything(),
1133
+ key,
1134
+ expect.anything(),
1135
+ );
1136
+ expect(fs.existsSync(localPath)).toBe(false);
1137
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1138
+ // Entry PRESERVED as a tombstone, not removed.
1139
+ expect(journal.files[key]?.removedAt).toBeTruthy();
1140
+ expect(journal.files[key]?.removedReason).toBe("local-delete");
1141
+ });
1142
+
887
1143
  it("aborts on --on-conflict abort", async () => {
888
1144
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
889
1145
  fs.mkdirSync(companyDocs, { recursive: true });