@indigoai-us/hq-cloud 5.30.0 → 5.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +69 -2
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +292 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/share.d.ts +26 -0
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +105 -8
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +210 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +37 -2
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +28 -5
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +137 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/describe-error.d.ts +21 -0
  23. package/dist/lib/describe-error.d.ts.map +1 -0
  24. package/dist/lib/describe-error.js +53 -0
  25. package/dist/lib/describe-error.js.map +1 -0
  26. package/dist/lib/describe-error.test.d.ts +2 -0
  27. package/dist/lib/describe-error.test.d.ts.map +1 -0
  28. package/dist/lib/describe-error.test.js +89 -0
  29. package/dist/lib/describe-error.test.js.map +1 -0
  30. package/dist/personal-vault.d.ts +63 -7
  31. package/dist/personal-vault.d.ts.map +1 -1
  32. package/dist/personal-vault.js +112 -8
  33. package/dist/personal-vault.js.map +1 -1
  34. package/dist/personal-vault.test.d.ts +14 -0
  35. package/dist/personal-vault.test.d.ts.map +1 -0
  36. package/dist/personal-vault.test.js +191 -0
  37. package/dist/personal-vault.test.js.map +1 -0
  38. package/dist/s3.d.ts +12 -1
  39. package/dist/s3.d.ts.map +1 -1
  40. package/dist/s3.js +11 -1
  41. package/dist/s3.js.map +1 -1
  42. package/dist/s3.test.js +24 -0
  43. package/dist/s3.test.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/bin/sync-runner.test.ts +364 -0
  46. package/src/bin/sync-runner.ts +73 -2
  47. package/src/cli/share.test.ts +243 -0
  48. package/src/cli/share.ts +142 -8
  49. package/src/cli/sync.test.ts +152 -0
  50. package/src/cli/sync.ts +68 -5
  51. package/src/index.ts +3 -0
  52. package/src/lib/describe-error.test.ts +100 -0
  53. package/src/lib/describe-error.ts +58 -0
  54. package/src/personal-vault.test.ts +231 -0
  55. package/src/personal-vault.ts +134 -8
  56. package/src/s3.test.ts +30 -0
  57. package/src/s3.ts +12 -2
@@ -1799,6 +1799,249 @@ describe("share", () => {
1799
1799
  // exclusion list itself is enforced by the runner (sync-runner.ts) by only
1800
1800
  // passing in the allowed top-level directories — share() trusts its
1801
1801
  // `paths` input.
1802
+
1803
+ // ── Decommission prefixes ──────────────────────────────────────────────────
1804
+ //
1805
+ // `decommissionPrefixes` is the "company was promoted to its own team
1806
+ // bucket, sweep its orphaned keys from this bucket" escape hatch. Unlike
1807
+ // `propagateDeletes`, it does NOT require the local file to be missing —
1808
+ // the caller asserts these keys no longer belong here regardless of
1809
+ // local state. Honors the same `propagateDeletePolicy` safety property.
1810
+
1811
+ it("decommissionPrefixes: deletes journal entries under the prefix even when the local file is still present", async () => {
1812
+ // Personal-bucket journal recording a key under companies/foo/. The
1813
+ // local file is intentionally LEFT on disk to prove decommission
1814
+ // doesn't depend on local-absence — the team bucket is now the
1815
+ // canonical home but on-disk content is unchanged.
1816
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1817
+ fs.writeFileSync(path.join(tmpDir, "companies", "foo", "notes.md"), "still here");
1818
+ fs.mkdirSync(path.join(tmpDir, "knowledge"), { recursive: true });
1819
+ fs.writeFileSync(path.join(tmpDir, "knowledge", "x.md"), "knowledge");
1820
+
1821
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1822
+ fs.writeFileSync(
1823
+ personalJournalPath,
1824
+ JSON.stringify({
1825
+ version: "1",
1826
+ lastSync: new Date().toISOString(),
1827
+ files: {
1828
+ "companies/foo/notes.md": {
1829
+ hash: "h", size: 11, syncedAt: new Date().toISOString(),
1830
+ direction: "up",
1831
+ },
1832
+ // Sibling out of decommission scope; must survive untouched.
1833
+ "knowledge/x.md": {
1834
+ hash: "k", size: 9, syncedAt: new Date().toISOString(),
1835
+ direction: "up",
1836
+ },
1837
+ },
1838
+ }),
1839
+ );
1840
+
1841
+ const result = await share({
1842
+ paths: [path.join(tmpDir, "knowledge")],
1843
+ company: "acme",
1844
+ vaultConfig: mockConfig,
1845
+ hqRoot: tmpDir,
1846
+ personalMode: true,
1847
+ journalSlug: "personal",
1848
+ skipUnchanged: true,
1849
+ // Pin owned-only — this test asserts the direction-of-origin gate
1850
+ // applies symmetrically to decommission. Once the default flips to
1851
+ // `currency-gated` (scheduled for 5.25+), the implicit-default form
1852
+ // would force a HEAD call we don't want to mock here.
1853
+ propagateDeletePolicy: "owned-only",
1854
+ decommissionPrefixes: ["companies/foo"],
1855
+ });
1856
+
1857
+ expect(result.filesDeleted).toBe(1);
1858
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "companies/foo/notes.md");
1859
+ expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "knowledge/x.md");
1860
+ // Local file remains on disk — decommission is a remote-only sweep.
1861
+ expect(fs.existsSync(path.join(tmpDir, "companies", "foo", "notes.md"))).toBe(true);
1862
+ // Journal entry pruned in lockstep with the remote delete.
1863
+ const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1864
+ expect(journalAfter.files["companies/foo/notes.md"]).toBeUndefined();
1865
+ expect(journalAfter.files["knowledge/x.md"]).toBeDefined();
1866
+ });
1867
+
1868
+ it("decommissionPrefixes: under owned-only policy, skips direction:'down' entries", async () => {
1869
+ // The orphan was pulled from another machine, never uploaded by this
1870
+ // one. owned-only refuses to remotely erase another machine's upload
1871
+ // even if the prefix list claims it. Mirrors propagateDeletes's
1872
+ // owned-only contract — see test "propagateDeletes: under owned-only".
1873
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1874
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1875
+ fs.writeFileSync(
1876
+ personalJournalPath,
1877
+ JSON.stringify({
1878
+ version: "1",
1879
+ lastSync: new Date().toISOString(),
1880
+ files: {
1881
+ "companies/foo/from-peer.md": {
1882
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1883
+ direction: "down",
1884
+ },
1885
+ },
1886
+ }),
1887
+ );
1888
+
1889
+ const result = await share({
1890
+ paths: [],
1891
+ company: "acme",
1892
+ vaultConfig: mockConfig,
1893
+ hqRoot: tmpDir,
1894
+ personalMode: true,
1895
+ journalSlug: "personal",
1896
+ skipUnchanged: true,
1897
+ propagateDeletePolicy: "owned-only",
1898
+ decommissionPrefixes: ["companies/foo"],
1899
+ });
1900
+
1901
+ expect(result.filesDeleted).toBe(0);
1902
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1903
+ // Journal entry preserved — the pulled-from-peer record stays so a
1904
+ // future pull can still reconcile it correctly.
1905
+ const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
1906
+ expect(journalAfter.files["companies/foo/from-peer.md"]).toBeDefined();
1907
+ });
1908
+
1909
+ it("decommissionPrefixes: dedupes with propagateDeletes plan — a key in both still emits one DeleteObject", async () => {
1910
+ // If the local file is ALSO missing under the prefix, the same key
1911
+ // would be eligible via both propagateDeletes (local gone) and
1912
+ // decommissionPrefixes (prefix match). The dedup guarantee is that
1913
+ // we delete once, not twice.
1914
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1915
+ // Note: NO file at companies/foo/notes.md — local-absence is what
1916
+ // makes propagateDeletes plan it; the prefix is what makes
1917
+ // decommission plan it. The Set-based dedup must collapse them.
1918
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1919
+ fs.writeFileSync(
1920
+ personalJournalPath,
1921
+ JSON.stringify({
1922
+ version: "1",
1923
+ lastSync: new Date().toISOString(),
1924
+ files: {
1925
+ "companies/foo/notes.md": {
1926
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1927
+ direction: "up",
1928
+ },
1929
+ },
1930
+ }),
1931
+ );
1932
+
1933
+ const result = await share({
1934
+ paths: [path.join(tmpDir, "companies", "foo")],
1935
+ company: "acme",
1936
+ vaultConfig: mockConfig,
1937
+ hqRoot: tmpDir,
1938
+ personalMode: true,
1939
+ journalSlug: "personal",
1940
+ skipUnchanged: true,
1941
+ propagateDeletes: true,
1942
+ // Use "all" here so propagateDeletes plans the (already-missing)
1943
+ // key regardless of its direction — the dedup assertion is what
1944
+ // this test exists to pin.
1945
+ propagateDeletePolicy: "all",
1946
+ decommissionPrefixes: ["companies/foo"],
1947
+ });
1948
+
1949
+ expect(result.filesDeleted).toBe(1);
1950
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
1951
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "companies/foo/notes.md");
1952
+ });
1953
+
1954
+ it("decommissionPrefixes: under currency-gated policy, skips direction:'down' entries (same as owned-only)", async () => {
1955
+ // currency-gated gates `propagateDeletes` on per-file HEAD currency
1956
+ // proof. Decommission is unconditional by design — no HEAD check
1957
+ // adds value — but we still want the direction-of-origin safety net
1958
+ // so a peer-written entry isn't blasted away on the peer's behalf.
1959
+ // Asserts `computeDecommissionPlan` collapses currency-gated to
1960
+ // owned-only semantics (the requireOwned branch).
1961
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
1962
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
1963
+ fs.writeFileSync(
1964
+ personalJournalPath,
1965
+ JSON.stringify({
1966
+ version: "1",
1967
+ lastSync: new Date().toISOString(),
1968
+ files: {
1969
+ "companies/foo/from-peer.md": {
1970
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
1971
+ direction: "down",
1972
+ remoteEtag: "abc",
1973
+ },
1974
+ },
1975
+ }),
1976
+ );
1977
+
1978
+ const result = await share({
1979
+ paths: [],
1980
+ company: "acme",
1981
+ vaultConfig: mockConfig,
1982
+ hqRoot: tmpDir,
1983
+ personalMode: true,
1984
+ journalSlug: "personal",
1985
+ skipUnchanged: true,
1986
+ propagateDeletePolicy: "currency-gated",
1987
+ decommissionPrefixes: ["companies/foo"],
1988
+ });
1989
+
1990
+ expect(result.filesDeleted).toBe(0);
1991
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1992
+ });
1993
+
1994
+ it("decommissionPrefixes: yields to toTombstone (key already 404 on remote) — single 'deleted' event, no double-count", async () => {
1995
+ // Regression for an audit finding: a key in BOTH decommissionPlan
1996
+ // AND deletePlan.toTombstone (because the remote was already 404 at
1997
+ // HEAD time) used to double-emit a "deleted" event (one from the
1998
+ // DeleteObject loop, one from the tombstone loop) and increment
1999
+ // both filesDeleted and filesTombstoned. Fix: decommissionPlan
2000
+ // dedups against toTombstone — the tombstone loop handles it
2001
+ // (journal removal, no S3 call), decommission skips it.
2002
+ fs.mkdirSync(path.join(tmpDir, "companies", "foo"), { recursive: true });
2003
+ // No local file → propagateDeletes would normally plan it. With
2004
+ // headRemoteFile mocked to return null (the default in beforeEach),
2005
+ // currency-gated classifies as toTombstone (remote already 404).
2006
+ const personalJournalPath = path.join(stateDir, "sync-journal.personal.json");
2007
+ fs.writeFileSync(
2008
+ personalJournalPath,
2009
+ JSON.stringify({
2010
+ version: "1",
2011
+ lastSync: new Date().toISOString(),
2012
+ files: {
2013
+ "companies/foo/gone.md": {
2014
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
2015
+ direction: "up",
2016
+ remoteEtag: "abc",
2017
+ },
2018
+ },
2019
+ }),
2020
+ );
2021
+
2022
+ const result = await share({
2023
+ paths: [path.join(tmpDir, "companies", "foo")],
2024
+ company: "acme",
2025
+ vaultConfig: mockConfig,
2026
+ hqRoot: tmpDir,
2027
+ personalMode: true,
2028
+ journalSlug: "personal",
2029
+ skipUnchanged: true,
2030
+ propagateDeletes: true,
2031
+ propagateDeletePolicy: "currency-gated",
2032
+ decommissionPrefixes: ["companies/foo"],
2033
+ });
2034
+
2035
+ // No DeleteObject — the tombstone bucket handled it (remote is 404).
2036
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2037
+ // Exactly one journal-removal counted (tombstone), not two.
2038
+ expect(result.filesTombstoned).toBe(1);
2039
+ expect(result.filesDeleted).toBe(0);
2040
+ // Journal entry is gone.
2041
+ const journalAfter = JSON.parse(fs.readFileSync(personalJournalPath, "utf-8"));
2042
+ expect(journalAfter.files["companies/foo/gone.md"]).toBeUndefined();
2043
+ });
2044
+
1802
2045
  describe("personalMode", () => {
1803
2046
  it("personalMode=true keys files hq-root-relative, not companies/{slug}/-relative", async () => {
1804
2047
  fs.mkdirSync(path.join(tmpDir, ".claude", "skills"), { recursive: true });
package/src/cli/share.ts CHANGED
@@ -331,6 +331,32 @@ export interface ShareOptions {
331
331
  * next push to erase it.
332
332
  */
333
333
  propagateDeletePolicy?: "currency-gated" | "owned-only" | "all";
334
+ /**
335
+ * Hq-root-relative key prefixes whose journal entries should be
336
+ * unconditionally decommissioned from the remote bucket and journal,
337
+ * independent of whether the local file is present. Each prefix matches
338
+ * its exact-key form (e.g. "companies/foo") AND any descendant key
339
+ * (e.g. "companies/foo/knowledge/notes.md") — same prefix semantics as
340
+ * `propagateDeletes`'s scope roots.
341
+ *
342
+ * Use case: a company that previously synced to the operator's personal
343
+ * bucket has been promoted to its own team bucket (`/designate-team`).
344
+ * Its keys at `companies/{slug}/...` in the personal bucket are now
345
+ * orphans — the on-disk files still exist (the team bucket is the new
346
+ * canonical home), so the standard `propagateDeletes` gate ("local file
347
+ * missing") never fires. This option asserts "these objects no longer
348
+ * belong in THIS bucket regardless of local state" and uses the same
349
+ * DeleteObject + journal-removal path as `propagateDeletes`.
350
+ *
351
+ * Honors `propagateDeletePolicy` — `"owned-only"` (default) only
352
+ * decommissions journal entries with `direction === "up"`, so a
353
+ * misconfigured caller never erases content pulled from elsewhere.
354
+ *
355
+ * Independent of `propagateDeletes`: callers can opt into decommission
356
+ * without enabling general delete propagation. In practice the runner
357
+ * sets both for the personal slot.
358
+ */
359
+ decommissionPrefixes?: string[];
334
360
  /**
335
361
  * Identity stamped onto each uploaded object's S3 user metadata
336
362
  * (`created-by`, `created-by-sub`, `created-at`). The hq-console vault UI
@@ -548,6 +574,43 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
548
574
  )
549
575
  : { toDelete: [], toTombstone: [], refusedStale: [] };
550
576
 
577
+ // Decommission plan: journal entries under explicit prefixes the caller
578
+ // has asserted no longer belong in this bucket (typically a promoted
579
+ // company's `companies/{slug}/` keys in the personal bucket). Independent
580
+ // of `propagateDeletes` and DOES NOT require the local file to be missing
581
+ // — the caller is making a stronger claim than "local says delete this".
582
+ // Honors the same owned-only safety policy so a misconfigured caller
583
+ // can never erase content the journal records as pulled from elsewhere.
584
+ // Dedupes against `deletePlan` so a key in both plans is only processed
585
+ // once (DeleteObject is idempotent on S3 but the journal-write would
586
+ // race a no-op pass through the loop body).
587
+ const decommissionPlan =
588
+ (options.decommissionPrefixes ?? []).length > 0
589
+ ? computeDecommissionPlan(
590
+ journal,
591
+ options.decommissionPrefixes ?? [],
592
+ propagateDeletePolicy,
593
+ // Dedup against `toDelete` (decommission and propagate-delete
594
+ // would both issue DeleteObject — single call wins) and against
595
+ // `toTombstone` (the remote is already 404; the tombstone loop
596
+ // drops the journal entry without a network call — decommission
597
+ // yields, both for efficiency and to avoid emitting two
598
+ // "deleted" events for the same key).
599
+ //
600
+ // We do NOT dedup against `refusedStale`. A key whose remote
601
+ // ETag drifted (peer wrote a newer version) but which decommission
602
+ // claims should still be removed — the caller has asserted this
603
+ // key doesn't belong in this bucket regardless of peer activity.
604
+ // Under owned-only (default) `computeDecommissionPlan`'s
605
+ // direction:'up' filter already excludes peer-written entries;
606
+ // under policy:'all' the caller has opted out of that safety
607
+ // anyway. The refusedStale loop below filters out keys we're
608
+ // about to decommission to avoid emitting a spurious "kept on
609
+ // remote" event for content we're deleting.
610
+ new Set([...deletePlan.toDelete, ...deletePlan.toTombstone]),
611
+ )
612
+ : [];
613
+
551
614
  emit({
552
615
  type: "plan",
553
616
  // share() is push-only; pull counts are sourced from sync()'s plan event.
@@ -562,8 +625,11 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
562
625
  // Reported count is the deletes we're actually going to issue — does NOT
563
626
  // include tombstones (no S3 call) or refused-stale (no journal change).
564
627
  // Refusals surface as their own event stream so consumers that care can
565
- // render a "kept on remote: N" line separately.
566
- filesToDelete: deletePlan.toDelete.length,
628
+ // render a "kept on remote: N" line separately. `decommissionPlan` adds
629
+ // to this count because every decommission entry IS an issued
630
+ // DeleteObject (different intent than propagate-deletes, same network
631
+ // effect).
632
+ filesToDelete: deletePlan.toDelete.length + decommissionPlan.length,
567
633
  });
568
634
 
569
635
  // Stage 2: execute. Skip items pre-classified as no-ops, then for each
@@ -703,11 +769,16 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
703
769
 
704
770
  // Stage 3: propagate deletes. Three buckets, three actions:
705
771
  //
706
- // 1. `toDelete` write a delete-marker (versioning is enabled on the
707
- // bucket so the delete is soft and prior versions remain recoverable)
708
- // and remove the journal entry so the next sync sees the key as
709
- // truly gone on this machine. A failed DeleteObject leaves both
710
- // the journal entry and remote object intact the next run retries.
772
+ // 1. `toDelete` (PLUS `decommissionPlan` concatenated in) write a
773
+ // delete-marker (versioning is enabled on the bucket so the delete
774
+ // is soft and prior versions remain recoverable) and remove the
775
+ // journal entry so the next sync sees the key as truly gone on
776
+ // this machine. A failed DeleteObject leaves both the journal
777
+ // entry and remote object intact — the next run retries.
778
+ // Decommission keys join this bucket because their effect is
779
+ // identical (DeleteObject + journal removal); the difference is
780
+ // intent only, and the dedupe at plan-computation time ensures we
781
+ // don't double-issue for a key both plans matched.
711
782
  //
712
783
  // 2. `toTombstone` — the remote was 404 at HEAD time (cleaned up out
713
784
  // of band, e.g. someone hand-deleted via console). No DeleteObject
@@ -722,7 +793,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
722
793
  // leg of `sync now` re-pulls naturally via the existing
723
794
  // `hasRemoteChanged` path. Emit a dedicated event so UIs can
724
795
  // surface the refusal without inferring it from absence.
725
- for (const relativePath of deletePlan.toDelete) {
796
+ for (const relativePath of [...deletePlan.toDelete, ...decommissionPlan]) {
726
797
  if (vaultConfig && isExpiringSoon(ctx.expiresAt)) {
727
798
  ctx = await refreshEntityContext(companyRef, vaultConfig);
728
799
  }
@@ -757,7 +828,15 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
757
828
  message: "tombstone (remote already 404)",
758
829
  });
759
830
  }
831
+ // Decommission overrides refusedStale: a key whose peer drifted but
832
+ // which the caller has claimed via `decommissionPrefixes` is processed
833
+ // by the DeleteObject loop above; skip the refusal event here so the
834
+ // event stream doesn't simultaneously claim "we deleted it" and "we
835
+ // kept it on remote" for the same key.
836
+ const decommissionedSet =
837
+ decommissionPlan.length > 0 ? new Set(decommissionPlan) : null;
760
838
  for (const refused of deletePlan.refusedStale) {
839
+ if (decommissionedSet && decommissionedSet.has(refused.key)) continue;
761
840
  filesRefusedStale++;
762
841
  emit({
763
842
  type: "delete-refused-stale-etag",
@@ -1359,3 +1438,58 @@ async function computeDeletePlan(
1359
1438
 
1360
1439
  return plan;
1361
1440
  }
1441
+
1442
+ /**
1443
+ * Walk every journal key that matches one of the supplied `prefixes` and
1444
+ * return the keys eligible for unconditional remote `DeleteObject`. Unlike
1445
+ * `computeDeletePlan` this DOES NOT check whether the local file is missing
1446
+ * — the caller has asserted that these keys no longer belong in this
1447
+ * bucket regardless of local state (typically a company that was promoted
1448
+ * from personal-bucket fallback to its own team bucket).
1449
+ *
1450
+ * An entry is in the plan only when ALL of the following hold:
1451
+ *
1452
+ * 1. Its key matches (or sits beneath) one of the `prefixes`. The match
1453
+ * is exact OR `key.startsWith(prefix + "/")` — same semantics as
1454
+ * `computeDeletePlan`'s scope-root check.
1455
+ * 2. When `policy === "owned-only"`: the journal entry's `direction`
1456
+ * is `"up"` (this machine previously uploaded the file). Mirrors
1457
+ * `computeDeletePlan`'s safety property — a misconfigured prefix
1458
+ * list can never erase content pulled from elsewhere.
1459
+ * 3. The key is NOT already in `alreadyPlanned` (the standard delete
1460
+ * plan), so a single DeleteObject + journal-update pass processes
1461
+ * each key once.
1462
+ *
1463
+ * Empty `prefixes` ⇒ empty plan (caller didn't opt in).
1464
+ */
1465
+ function computeDecommissionPlan(
1466
+ journal: SyncJournal,
1467
+ prefixes: string[],
1468
+ policy: "currency-gated" | "owned-only" | "all",
1469
+ alreadyPlanned: ReadonlySet<string>,
1470
+ ): string[] {
1471
+ if (prefixes.length === 0) return [];
1472
+ // `currency-gated` exists to gate `propagateDeletes` on per-file proof of
1473
+ // currency (HEAD compare). Decommission is unconditional by design — we
1474
+ // don't need a HEAD check to know whether the key belongs here, the
1475
+ // caller has asserted it doesn't. So for the direction-of-origin safety
1476
+ // gate we collapse `currency-gated` to the `owned-only` behavior:
1477
+ // peer-written entries (direction:'down') are skipped. That's the
1478
+ // conservative choice — a peer who wrote into this bucket should
1479
+ // reconcile their own entry, not have us blast it away on their behalf.
1480
+ const requireOwned = policy === "owned-only" || policy === "currency-gated";
1481
+ const out: string[] = [];
1482
+ for (const [relativeKey, entry] of Object.entries(journal.files)) {
1483
+ if (alreadyPlanned.has(relativeKey)) continue;
1484
+ const matches = prefixes.some(
1485
+ (prefix) =>
1486
+ prefix === "" ||
1487
+ relativeKey === prefix ||
1488
+ relativeKey.startsWith(`${prefix}/`),
1489
+ );
1490
+ if (!matches) continue;
1491
+ if (requireOwned && entry.direction !== "up") continue;
1492
+ out.push(relativeKey);
1493
+ }
1494
+ return out;
1495
+ }
@@ -26,6 +26,7 @@ vi.mock("../s3.js", async () => {
26
26
  const dir = innerPath.dirname(localPath);
27
27
  if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
28
28
  innerFs.writeFileSync(localPath, "mock file content");
29
+ return { metadata: {} };
29
30
  }),
30
31
  listRemoteFiles: innerVi.fn().mockResolvedValue(remoteFiles),
31
32
  deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
@@ -325,6 +326,97 @@ describe("sync", () => {
325
326
  expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "docs", "readme.md"))).toBe(false);
326
327
  });
327
328
 
329
+ it("personalMode + includeLocalCompanies: downloads companies/{cloud-false-slug}/... keys when slug NOT in teamSyncedSlugs", async () => {
330
+ // The symmetric flip for the cloud:false → personal-bucket fallback.
331
+ // Machine A pushed `companies/free-co/notes.md` to the personal bucket
332
+ // (because the operator set `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1`
333
+ // AND `company.yaml` declares `cloud: false`). Machine B subscribes:
334
+ // pull must allow these keys through the personalMode filter, otherwise
335
+ // the feature is push-only and the destination machine never sees them.
336
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
337
+ { key: "companies/free-co/notes.md", size: 50, lastModified: new Date(), etag: '"abc"' },
338
+ { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"def"' },
339
+ ]);
340
+
341
+ const result = await sync({
342
+ company: "acme",
343
+ vaultConfig: mockConfig,
344
+ hqRoot: tmpDir,
345
+ personalMode: true,
346
+ includeLocalCompanies: true,
347
+ teamSyncedSlugs: new Set(), // empty → no slug is "orphan"
348
+ });
349
+
350
+ expect(result.filesDownloaded).toBe(2);
351
+ expect(result.filesSkipped).toBe(0);
352
+ expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
353
+ expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
354
+ });
355
+
356
+ it("personalMode + includeLocalCompanies: drops companies/{team-synced-slug}/... keys as orphans (symmetric to push-side decommission)", async () => {
357
+ // The orphan-cleanup half. Once a company has been promoted from
358
+ // cloud:false fallback to its own team bucket, the operator gains
359
+ // an active Membership for it (slug enters teamSyncedSlugs). Any
360
+ // leftover `companies/{that-slug}/...` keys in the personal bucket
361
+ // are stale residue — downloading them would clash with the team-
362
+ // bucket pull at the same disk path. Filter drops them silently.
363
+ // Push-side `decommissionPrefixes` eventually removes them from the
364
+ // bucket; this filter keeps the local tree clean in the meantime
365
+ // (especially important for pull-only mode where decommission never
366
+ // runs).
367
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
368
+ // Orphan: this slug is now team-synced — drop the key.
369
+ { key: "companies/acme/file.md", size: 50, lastModified: new Date(), etag: '"old"' },
370
+ // Legitimate cloud:false content: download.
371
+ { key: "companies/free-co/notes.md", size: 30, lastModified: new Date(), etag: '"new"' },
372
+ ]);
373
+
374
+ const result = await sync({
375
+ company: "acme",
376
+ vaultConfig: mockConfig,
377
+ hqRoot: tmpDir,
378
+ personalMode: true,
379
+ includeLocalCompanies: true,
380
+ teamSyncedSlugs: new Set(["acme"]),
381
+ });
382
+
383
+ expect(result.filesDownloaded).toBe(1);
384
+ expect(result.filesSkipped).toBe(1);
385
+ // acme orphan must NOT land — it would clash with the team-bucket pull.
386
+ expect(fs.existsSync(path.join(tmpDir, "companies", "acme", "file.md"))).toBe(false);
387
+ // free-co legitimate content lands at the hq-root-relative path.
388
+ expect(fs.existsSync(path.join(tmpDir, "companies", "free-co", "notes.md"))).toBe(true);
389
+ });
390
+
391
+ it("personalMode WITHOUT includeLocalCompanies: legacy behavior preserved — ALL companies/... keys dropped", async () => {
392
+ // Regression for the legacy contract. Pre-5.20 (and any operator who
393
+ // hasn't opted into HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1), the
394
+ // personal bucket should never contain `companies/...` keys. If they
395
+ // do exist (stale data, manual S3 console intervention, an old bug),
396
+ // the pull-side filter drops them as a safety net. This test pins
397
+ // that contract under the new option shape: includeLocalCompanies
398
+ // omitted → behaves identically to the original filter, even if
399
+ // teamSyncedSlugs is supplied.
400
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
401
+ { key: "companies/anything/file.md", size: 50, lastModified: new Date(), etag: '"x"' },
402
+ { key: "docs/readme.md", size: 30, lastModified: new Date(), etag: '"y"' },
403
+ ]);
404
+
405
+ const result = await sync({
406
+ company: "acme",
407
+ vaultConfig: mockConfig,
408
+ hqRoot: tmpDir,
409
+ personalMode: true,
410
+ // includeLocalCompanies omitted → defaults to false
411
+ teamSyncedSlugs: new Set(["unrelated"]),
412
+ });
413
+
414
+ expect(result.filesDownloaded).toBe(1);
415
+ expect(result.filesSkipped).toBe(1);
416
+ expect(fs.existsSync(path.join(tmpDir, "companies", "anything", "file.md"))).toBe(false);
417
+ expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
418
+ });
419
+
328
420
  it("overwrites local on --on-conflict overwrite", async () => {
329
421
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
330
422
  fs.mkdirSync(companyDocs, { recursive: true });
@@ -703,6 +795,64 @@ describe("sync", () => {
703
795
  expect(newFilesIdx).toBeGreaterThan(lastProgressIdx);
704
796
  });
705
797
 
798
+ it("stamps progress.author from the downloaded object's created-by metadata", async () => {
799
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
800
+ { key: "docs/authored.md", size: 10, lastModified: new Date(), etag: '"e1"' },
801
+ ]);
802
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
803
+ async (_ctx: unknown, _key: string, localPath: string) => {
804
+ const dir = path.dirname(localPath);
805
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
806
+ fs.writeFileSync(localPath, "authored content");
807
+ return { metadata: { "created-by": "alice@example.com" } };
808
+ },
809
+ );
810
+
811
+ const progressEvents: Array<{ type: string; path: string; author?: string | null }> = [];
812
+ await sync({
813
+ company: "acme",
814
+ vaultConfig: mockConfig,
815
+ hqRoot: tmpDir,
816
+ onEvent: (e) => {
817
+ if (e.type === "progress") progressEvents.push(e);
818
+ },
819
+ });
820
+
821
+ expect(progressEvents).toHaveLength(1);
822
+ expect(progressEvents[0]).toMatchObject({
823
+ type: "progress",
824
+ path: "docs/authored.md",
825
+ author: "alice@example.com",
826
+ });
827
+ });
828
+
829
+ it("omits progress.author when the downloaded object has no created-by metadata", async () => {
830
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
831
+ { key: "docs/anon.md", size: 10, lastModified: new Date(), etag: '"e2"' },
832
+ ]);
833
+ vi.mocked(s3Module.downloadFile).mockImplementationOnce(
834
+ async (_ctx: unknown, _key: string, localPath: string) => {
835
+ const dir = path.dirname(localPath);
836
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
837
+ fs.writeFileSync(localPath, "anon content");
838
+ return { metadata: {} };
839
+ },
840
+ );
841
+
842
+ const progressEvents: Array<{ type: string; path: string; author?: string | null }> = [];
843
+ await sync({
844
+ company: "acme",
845
+ vaultConfig: mockConfig,
846
+ hqRoot: tmpDir,
847
+ onEvent: (e) => {
848
+ if (e.type === "progress") progressEvents.push(e);
849
+ },
850
+ });
851
+
852
+ expect(progressEvents).toHaveLength(1);
853
+ expect(progressEvents[0].author ?? null).toBeNull();
854
+ });
855
+
706
856
  it("plan event counts a 3-way conflict separately from downloads", async () => {
707
857
  // Local edit + journal-tracked + remote ETag drifted → conflict.
708
858
  const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
@@ -775,6 +925,7 @@ describe("sync", () => {
775
925
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
776
926
  // Mirror real downloadFile's symlink branch.
777
927
  fs.symlinkSync(linkTarget, localPath);
928
+ return { metadata: {} };
778
929
  },
779
930
  );
780
931
 
@@ -876,6 +1027,7 @@ describe("sync", () => {
876
1027
  const dir = path.dirname(localPath);
877
1028
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
878
1029
  fs.symlinkSync(linkTarget, localPath);
1030
+ return { metadata: {} };
879
1031
  },
880
1032
  );
881
1033