@indigoai-us/hq-cloud 6.2.2 → 6.2.4

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 (56) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +8 -0
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/cli/rescue-core.d.ts.map +1 -1
  5. package/dist/cli/rescue-core.js +32 -56
  6. package/dist/cli/rescue-core.js.map +1 -1
  7. package/dist/cli/rescue-journal-reconcile.test.js +100 -49
  8. package/dist/cli/rescue-journal-reconcile.test.js.map +1 -1
  9. package/dist/cli/share.d.ts.map +1 -1
  10. package/dist/cli/share.js +6 -1
  11. package/dist/cli/share.js.map +1 -1
  12. package/dist/cli/sync-scope.test.js +33 -1
  13. package/dist/cli/sync-scope.test.js.map +1 -1
  14. package/dist/cli/sync.d.ts +8 -0
  15. package/dist/cli/sync.d.ts.map +1 -1
  16. package/dist/cli/sync.js +24 -2
  17. package/dist/cli/sync.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/journal.d.ts +1 -1
  23. package/dist/journal.d.ts.map +1 -1
  24. package/dist/journal.js +7 -1
  25. package/dist/journal.js.map +1 -1
  26. package/dist/public-surface.test.js +5 -0
  27. package/dist/public-surface.test.js.map +1 -1
  28. package/dist/remote-pull.d.ts +7 -0
  29. package/dist/remote-pull.d.ts.map +1 -1
  30. package/dist/remote-pull.js +5 -0
  31. package/dist/remote-pull.js.map +1 -1
  32. package/dist/remote-pull.test.js +110 -0
  33. package/dist/remote-pull.test.js.map +1 -1
  34. package/dist/scope-shrink.d.ts +20 -0
  35. package/dist/scope-shrink.d.ts.map +1 -1
  36. package/dist/scope-shrink.js +11 -0
  37. package/dist/scope-shrink.js.map +1 -1
  38. package/dist/scope-shrink.test.js +122 -0
  39. package/dist/scope-shrink.test.js.map +1 -1
  40. package/dist/types.d.ts +12 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +2 -2
  43. package/src/bin/sync-runner.ts +8 -0
  44. package/src/cli/rescue-core.ts +36 -71
  45. package/src/cli/rescue-journal-reconcile.test.ts +113 -54
  46. package/src/cli/share.ts +6 -0
  47. package/src/cli/sync-scope.test.ts +35 -1
  48. package/src/cli/sync.ts +32 -0
  49. package/src/index.ts +7 -0
  50. package/src/journal.ts +7 -0
  51. package/src/public-surface.test.ts +5 -0
  52. package/src/remote-pull.test.ts +118 -0
  53. package/src/remote-pull.ts +12 -0
  54. package/src/scope-shrink.test.ts +128 -0
  55. package/src/scope-shrink.ts +29 -0
  56. package/src/types.ts +12 -0
@@ -594,6 +594,9 @@ describe("pullCompany (engine orchestrator)", () => {
594
594
  size: 8,
595
595
  syncedAt: new Date().toISOString(),
596
596
  direction: "down",
597
+ // Authored by someone else (no matching callerSub passed below), so
598
+ // the authorship guard correctly leaves it eligible for shrink.
599
+ createdBySub: "uploader-sub",
597
600
  },
598
601
  },
599
602
  pulls: [
@@ -651,12 +654,14 @@ describe("pullCompany (engine orchestrator)", () => {
651
654
  size: 8,
652
655
  syncedAt: new Date().toISOString(),
653
656
  direction: "down",
657
+ createdBySub: "uploader-sub", // foreign author — eligible for shrink
654
658
  },
655
659
  "companies/indigo/scratch/clean.md": {
656
660
  hash: sha256("clean"),
657
661
  size: 5,
658
662
  syncedAt: new Date().toISOString(),
659
663
  direction: "down",
664
+ createdBySub: "uploader-sub", // foreign author — eligible for shrink
660
665
  },
661
666
  },
662
667
  pulls: [
@@ -711,6 +716,7 @@ describe("pullCompany (engine orchestrator)", () => {
711
716
  size: 5,
712
717
  syncedAt: new Date(Date.now() - 60_000).toISOString(),
713
718
  direction: "down",
719
+ createdBySub: "uploader-sub", // foreign author — eligible for shrink
714
720
  },
715
721
  },
716
722
  };
@@ -741,6 +747,118 @@ describe("pullCompany (engine orchestrator)", () => {
741
747
  expect(journal.version).toBe("2"); // migrated by appendPullRecord
742
748
  });
743
749
 
750
+ it("retains a caller-authored orphan across a scope shrink (own work is never pruned)", async () => {
751
+ // The owner narrowed to `shared`, but `projects/mine.md` is their own work
752
+ // (createdBySub === callerSub). A scope shrink must NOT prune it — mode
753
+ // only governs mirroring OTHER people's files.
754
+ const mineAbs = path.join(hqRoot, "companies/indigo/projects/mine.md");
755
+ fs.mkdirSync(path.dirname(mineAbs), { recursive: true });
756
+ fs.writeFileSync(mineAbs, "mine");
757
+ const past = Date.now() - 60_000;
758
+ fs.utimesSync(mineAbs, past / 1000, past / 1000);
759
+
760
+ const journal: SyncJournal = {
761
+ version: "2",
762
+ lastSync: "",
763
+ files: {
764
+ "companies/indigo/projects/mine.md": {
765
+ hash: sha256("mine"),
766
+ size: 4,
767
+ syncedAt: new Date().toISOString(),
768
+ direction: "down",
769
+ createdBySub: "owner-sub",
770
+ },
771
+ },
772
+ pulls: [
773
+ {
774
+ pullId: "01PREV",
775
+ companyUid: "cmp_indigo",
776
+ startedAt: "2026-05-19T00:00:00.000Z",
777
+ completedAt: "2026-05-19T00:00:05.000Z",
778
+ syncMode: "all",
779
+ prefixSet: ["companies/indigo/"],
780
+ scopeChangeDetected: false,
781
+ orphansRemoved: 0,
782
+ orphansBlocked: 0,
783
+ },
784
+ ],
785
+ };
786
+
787
+ const result = await pullCompany({
788
+ ctx: makeCtx(),
789
+ journal,
790
+ hqRoot,
791
+ callerSub: "owner-sub",
792
+ scope: {
793
+ companyUid: "cmp_indigo",
794
+ syncMode: "shared",
795
+ prefixSet: ["companies/indigo/meetings/"],
796
+ strategy: "vend-fanout",
797
+ },
798
+ listFn: async () => [],
799
+ });
800
+
801
+ expect(result.pullRecord.scopeChangeDetected).toBe(false);
802
+ expect(result.pullRecord.orphansRemoved).toBe(0);
803
+ expect(fs.existsSync(mineAbs)).toBe(true); // own work preserved
804
+ });
805
+
806
+ it("retains an unknown-author orphan on the automatic path (conservative, never auto-deletes)", async () => {
807
+ // A legacy entry with no recorded author. The background pull must not make
808
+ // a destructive guess — retain it (the explicit narrow ritual is the
809
+ // confirmed path that can reclaim it).
810
+ const legacyAbs = path.join(hqRoot, "companies/indigo/projects/legacy.md");
811
+ fs.mkdirSync(path.dirname(legacyAbs), { recursive: true });
812
+ fs.writeFileSync(legacyAbs, "legacy");
813
+ const past = Date.now() - 60_000;
814
+ fs.utimesSync(legacyAbs, past / 1000, past / 1000);
815
+
816
+ const journal: SyncJournal = {
817
+ version: "2",
818
+ lastSync: "",
819
+ files: {
820
+ "companies/indigo/projects/legacy.md": {
821
+ hash: sha256("legacy"),
822
+ size: 6,
823
+ syncedAt: new Date().toISOString(),
824
+ direction: "down",
825
+ // no createdBySub — predates author stamping
826
+ },
827
+ },
828
+ pulls: [
829
+ {
830
+ pullId: "01PREV",
831
+ companyUid: "cmp_indigo",
832
+ startedAt: "2026-05-19T00:00:00.000Z",
833
+ completedAt: "2026-05-19T00:00:05.000Z",
834
+ syncMode: "all",
835
+ prefixSet: ["companies/indigo/"],
836
+ scopeChangeDetected: false,
837
+ orphansRemoved: 0,
838
+ orphansBlocked: 0,
839
+ },
840
+ ],
841
+ };
842
+
843
+ const result = await pullCompany({
844
+ ctx: makeCtx(),
845
+ journal,
846
+ hqRoot,
847
+ callerSub: "owner-sub",
848
+ scope: {
849
+ companyUid: "cmp_indigo",
850
+ syncMode: "shared",
851
+ prefixSet: ["companies/indigo/meetings/"],
852
+ strategy: "vend-fanout",
853
+ },
854
+ listFn: async () => [],
855
+ });
856
+
857
+ expect(result.pullRecord.scopeChangeDetected).toBe(false);
858
+ expect(result.pullRecord.orphansRemoved).toBe(0);
859
+ expect(fs.existsSync(legacyAbs)).toBe(true); // legacy file retained
860
+ });
861
+
744
862
  it("GC's expired tombstones at the start of every leg", async () => {
745
863
  const old = new Date(
746
864
  Date.now() - 31 * 24 * 60 * 60 * 1000,
@@ -368,6 +368,13 @@ export interface PullCompanyInput {
368
368
  conflictKeys?: Set<string>;
369
369
  /** Honor the operator override on dirty orphans (US-005 contract). */
370
370
  forceScopeShrink?: boolean;
371
+ /**
372
+ * The caller's own Cognito `sub` for the scope-shrink authorship guard — a
373
+ * scope shrink never prunes content the caller authored. Defaults to the
374
+ * cached session sub (`resolveCallerSubFromCache()`); pass explicitly when
375
+ * the runner already decoded its idToken claims.
376
+ */
377
+ callerSub?: string;
371
378
  /** Listing override hook — see `ListRemoteForScopeInput.listFn`. */
372
379
  listFn?: ListRemoteForScopeInput["listFn"];
373
380
  vendForBatchFn?: ListRemoteForScopeInput["vendForBatchFn"];
@@ -432,6 +439,11 @@ export async function pullCompany(
432
439
  hqRoot: input.hqRoot,
433
440
  lastPrefixSet,
434
441
  currentPrefixSet: input.scope.prefixSet,
442
+ callerSub: input.callerSub,
443
+ // Background runner pull: protect the caller's own work and don't make a
444
+ // destructive guess about unknown-author (legacy) orphans. The explicit
445
+ // `hq sync narrow` ritual is the confirmed path that opts out of this.
446
+ protectUnknownAuthors: true,
435
447
  });
436
448
 
437
449
  let scopeShrinkApplied: ApplyScopeShrinkResult | null = null;
@@ -239,6 +239,134 @@ describe("buildScopeShrinkPlan", () => {
239
239
  });
240
240
  expect(plan.orphans).toEqual([]);
241
241
  });
242
+
243
+ // ── Authorship guard ──────────────────────────────────────────────────────
244
+ // Sync mode decides whether you mirror OTHER people's files; it must never
245
+ // orphan content the caller authored. Owners hold their whole vault by
246
+ // role-bypass, so without this guard a `shared`/`custom` scope would treat
247
+ // their own un-granted work as "someone else's file" and prune it.
248
+
249
+ it("never orphans a file the caller authored, even out of scope", () => {
250
+ const journal: SyncJournal = {
251
+ ...emptyJournal(),
252
+ files: {
253
+ "companies/indigo/projects/mine.md": {
254
+ hash: "h",
255
+ size: 1,
256
+ syncedAt: "2026-05-01T00:00:00.000Z",
257
+ direction: "down",
258
+ createdBySub: "sub-corey",
259
+ },
260
+ },
261
+ };
262
+ const plan = buildScopeShrinkPlan({
263
+ journal,
264
+ hqRoot,
265
+ lastPrefixSet: ["companies/indigo/"],
266
+ currentPrefixSet: ["companies/indigo/meetings/"],
267
+ callerSub: "sub-corey",
268
+ });
269
+ expect(plan.orphans).toEqual([]);
270
+ expect(plan.scopeChangeDetected).toBe(false);
271
+ });
272
+
273
+ it("orphans a file authored by someone else (mode stops mirroring others' files)", () => {
274
+ const journal: SyncJournal = {
275
+ ...emptyJournal(),
276
+ files: {
277
+ "companies/indigo/projects/theirs.md": {
278
+ hash: "h",
279
+ size: 1,
280
+ syncedAt: "2026-05-01T00:00:00.000Z",
281
+ direction: "down",
282
+ createdBySub: "sub-jacob",
283
+ },
284
+ },
285
+ };
286
+ const plan = buildScopeShrinkPlan({
287
+ journal,
288
+ hqRoot,
289
+ lastPrefixSet: ["companies/indigo/"],
290
+ currentPrefixSet: ["companies/indigo/meetings/"],
291
+ callerSub: "sub-corey",
292
+ protectUnknownAuthors: true,
293
+ });
294
+ expect(plan.orphans.map((o) => o.path)).toEqual([
295
+ "companies/indigo/projects/theirs.md",
296
+ ]);
297
+ });
298
+
299
+ it("retains an unknown-author orphan when protectUnknownAuthors is set (conservative auto path)", () => {
300
+ const journal: SyncJournal = {
301
+ ...emptyJournal(),
302
+ files: {
303
+ "companies/indigo/projects/legacy.md": {
304
+ hash: "h",
305
+ size: 1,
306
+ syncedAt: "2026-05-01T00:00:00.000Z",
307
+ direction: "down",
308
+ // no createdBySub — a legacy entry predating author stamping
309
+ },
310
+ },
311
+ };
312
+ const plan = buildScopeShrinkPlan({
313
+ journal,
314
+ hqRoot,
315
+ lastPrefixSet: ["companies/indigo/"],
316
+ currentPrefixSet: ["companies/indigo/meetings/"],
317
+ callerSub: "sub-corey",
318
+ protectUnknownAuthors: true,
319
+ });
320
+ expect(plan.orphans).toEqual([]);
321
+ });
322
+
323
+ it("prunes an unknown-author orphan when protectUnknownAuthors is off (explicit narrow path)", () => {
324
+ const journal: SyncJournal = {
325
+ ...emptyJournal(),
326
+ files: {
327
+ "companies/indigo/projects/legacy.md": {
328
+ hash: "h",
329
+ size: 1,
330
+ syncedAt: "2026-05-01T00:00:00.000Z",
331
+ direction: "down",
332
+ },
333
+ },
334
+ };
335
+ const plan = buildScopeShrinkPlan({
336
+ journal,
337
+ hqRoot,
338
+ lastPrefixSet: ["companies/indigo/"],
339
+ currentPrefixSet: ["companies/indigo/meetings/"],
340
+ callerSub: "sub-corey",
341
+ });
342
+ expect(plan.orphans.map((o) => o.path)).toEqual([
343
+ "companies/indigo/projects/legacy.md",
344
+ ]);
345
+ });
346
+
347
+ it("ignores authorship when no callerSub is supplied (back-compat)", () => {
348
+ const journal: SyncJournal = {
349
+ ...emptyJournal(),
350
+ files: {
351
+ "companies/indigo/projects/mine.md": {
352
+ hash: "h",
353
+ size: 1,
354
+ syncedAt: "2026-05-01T00:00:00.000Z",
355
+ direction: "down",
356
+ createdBySub: "sub-corey",
357
+ },
358
+ },
359
+ };
360
+ const plan = buildScopeShrinkPlan({
361
+ journal,
362
+ hqRoot,
363
+ lastPrefixSet: ["companies/indigo/"],
364
+ currentPrefixSet: ["companies/indigo/meetings/"],
365
+ });
366
+ expect(plan.orphans.map((o) => o.path)).toEqual([
367
+ "companies/indigo/projects/mine.md",
368
+ ]);
369
+ });
242
370
  });
243
371
 
244
372
  describe("applyScopeShrink", () => {
@@ -62,6 +62,26 @@ export interface BuildScopeShrinkPlanInput {
62
62
  lastPrefixSet: string[];
63
63
  /** Coalesced prefixes the CURRENT pull will use. */
64
64
  currentPrefixSet: string[];
65
+ /**
66
+ * The caller's own Cognito `sub`. When set, a file the caller authored
67
+ * (`entry.createdBySub === callerSub`) is NEVER orphaned by a scope shrink —
68
+ * regardless of mode. This is the core of the authorship contract: sync mode
69
+ * governs whether you mirror *other people's* files; it must never disown
70
+ * your own work. Owners hold their whole vault by role-bypass, so without
71
+ * this guard a `shared`/`custom` scope would treat their own un-granted
72
+ * content as "someone else's file I happen to see" and prune it.
73
+ */
74
+ callerSub?: string;
75
+ /**
76
+ * When `true`, an orphan whose authorship is unknown (`createdBySub`
77
+ * undefined — a legacy entry predating author stamping, or an object
78
+ * uploaded without author metadata) is also retained rather than pruned.
79
+ * The automatic background pull sets this so a routine sync never makes a
80
+ * destructive guess about pre-stamp content; the explicit `hq sync narrow`
81
+ * ritual (which carries its own confirmation + dirty gate) leaves it off so
82
+ * a deliberately-confirmed narrow can still reclaim legacy files.
83
+ */
84
+ protectUnknownAuthors?: boolean;
65
85
  }
66
86
 
67
87
  /**
@@ -84,6 +104,7 @@ export function buildScopeShrinkPlan(
84
104
  input: BuildScopeShrinkPlanInput,
85
105
  ): ScopeShrinkPlan {
86
106
  const { journal, hqRoot, lastPrefixSet, currentPrefixSet } = input;
107
+ const { callerSub, protectUnknownAuthors } = input;
87
108
  const orphans: OrphanClassification[] = [];
88
109
 
89
110
  for (const [relPath, entry] of Object.entries(journal.files)) {
@@ -91,6 +112,14 @@ export function buildScopeShrinkPlan(
91
112
  if (entry.direction !== "down") continue;
92
113
  if (!isCoveredByAny(relPath, lastPrefixSet)) continue;
93
114
  if (isCoveredByAny(relPath, currentPrefixSet)) continue;
115
+ // Authorship guard: sync mode decides whether you mirror OTHER people's
116
+ // files — it must never disown your own. A file the caller authored is
117
+ // sacred and never orphaned, even out of the current prefix scope. When
118
+ // `protectUnknownAuthors` is set (the automatic pull path), a legacy
119
+ // entry with no recorded author is also retained — a routine background
120
+ // sync should never make a destructive guess about pre-stamp content.
121
+ if (callerSub && entry.createdBySub === callerSub) continue;
122
+ if (protectUnknownAuthors && entry.createdBySub === undefined) continue;
94
123
  orphans.push(classifyOrphan(relPath, entry, hqRoot));
95
124
  }
96
125
 
package/src/types.ts CHANGED
@@ -26,6 +26,18 @@ export interface JournalEntry {
26
26
  size: number;
27
27
  syncedAt: string;
28
28
  direction: "up" | "down";
29
+ /**
30
+ * Cognito `sub` of the file's author, captured from the object's
31
+ * `created-by-sub` S3 user-metadata at download time (zero extra network —
32
+ * the GET response already carries it). Powers the scope-shrink authorship
33
+ * guard: a scope shrink must never orphan content the caller authored, so
34
+ * sync mode only ever governs whether you ALSO mirror *other people's*
35
+ * files. Optional for backwards compatibility — entries written before this
36
+ * field existed (or uploaded without author metadata) leave it `undefined`,
37
+ * which the automatic prune path treats conservatively (never auto-delete an
38
+ * unknown-author orphan).
39
+ */
40
+ createdBySub?: string;
29
41
  /**
30
42
  * S3 ETag of the remote object as of last successful sync, normalized (no
31
43
  * surrounding quotes). Optional for backwards compatibility: entries