@indigoai-us/hq-cloud 6.2.2 → 6.2.3

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 (44) 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 +70 -54
  6. package/dist/cli/rescue-core.js.map +1 -1
  7. package/dist/cli/rescue-journal-reconcile.test.js +70 -48
  8. package/dist/cli/rescue-journal-reconcile.test.js.map +1 -1
  9. package/dist/cli/sync-scope.test.js +33 -1
  10. package/dist/cli/sync-scope.test.js.map +1 -1
  11. package/dist/cli/sync.d.ts +8 -0
  12. package/dist/cli/sync.d.ts.map +1 -1
  13. package/dist/cli/sync.js +16 -1
  14. package/dist/cli/sync.js.map +1 -1
  15. package/dist/journal.d.ts +1 -1
  16. package/dist/journal.d.ts.map +1 -1
  17. package/dist/journal.js +7 -1
  18. package/dist/journal.js.map +1 -1
  19. package/dist/remote-pull.d.ts +7 -0
  20. package/dist/remote-pull.d.ts.map +1 -1
  21. package/dist/remote-pull.js +5 -0
  22. package/dist/remote-pull.js.map +1 -1
  23. package/dist/remote-pull.test.js +110 -0
  24. package/dist/remote-pull.test.js.map +1 -1
  25. package/dist/scope-shrink.d.ts +20 -0
  26. package/dist/scope-shrink.d.ts.map +1 -1
  27. package/dist/scope-shrink.js +11 -0
  28. package/dist/scope-shrink.js.map +1 -1
  29. package/dist/scope-shrink.test.js +122 -0
  30. package/dist/scope-shrink.test.js.map +1 -1
  31. package/dist/types.d.ts +12 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/bin/sync-runner.ts +8 -0
  35. package/src/cli/rescue-core.ts +72 -63
  36. package/src/cli/rescue-journal-reconcile.test.ts +76 -53
  37. package/src/cli/sync-scope.test.ts +35 -1
  38. package/src/cli/sync.ts +24 -0
  39. package/src/journal.ts +7 -0
  40. package/src/remote-pull.test.ts +118 -0
  41. package/src/remote-pull.ts +12 -0
  42. package/src/scope-shrink.test.ts +128 -0
  43. package/src/scope-shrink.ts +29 -0
  44. package/src/types.ts +12 -0
@@ -1,17 +1,20 @@
1
1
  /**
2
- * Regression for the rescue sync-journal stale-baseline bug.
2
+ * Regression for the rescue -> sync-journal stale-baseline bug (and its
3
+ * follow-ups). The rescue overlay + the post-overlay core.yaml stamp rewrite
4
+ * scaffold files from upstream, but their personal-vault journal entries keep
5
+ * the PRE-rescue hash. The next sync then reads localHash != journal.hash
6
+ * ("local changed") and mints a false `.conflict-*` mirror.
3
7
  *
4
- * Root cause: the rescue overlay rewrites scaffold files from upstream but
5
- * never re-stamps the personal-vault sync journal. The journal keeps the
6
- * PRE-rescue hash, so the next sync reads localHash != journal.hash ("local
7
- * changed") and when the vault also moved mints a false `.conflict-*`
8
- * mirror. `runRescue` now re-stamps the journal baseline for the files the
9
- * overlay actually re-laid (scoped to rsync `-i` itemize output, so a user's
10
- * pending edit is never touched).
8
+ * runRescue now reconciles the baseline by DIFFING the journal against current
9
+ * local hashes (robust the earlier rsync-itemize regex undercounted), running
10
+ * AFTER every in-doRescue mutation (so it also catches the core.yaml stamp), and
11
+ * scoped to NON-preserved roots so a user's pending edit in personal/ is never
12
+ * marked synced.
11
13
  *
12
- * This test seeds a journal whose entry for an overlaid file carries the OLD
13
- * hash, runs a real (non-dry-run) rescue, and asserts the entry was re-stamped
14
- * to the freshly laid-down content i.e. `localChanged` would now be false.
14
+ * This test seeds stale baselines for several overlaid scaffold files + core.yaml
15
+ * and a pending personal/ edit, runs a real rescue, and asserts: every changed
16
+ * scaffold entry is re-stamped to current local (no false conflict), while the
17
+ * personal/ pending edit is left untouched (still uploads).
15
18
  */
16
19
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
17
20
  import { execFileSync } from "child_process";
@@ -52,7 +55,9 @@ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
52
55
  return { status, stdout };
53
56
  }
54
57
 
55
- describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-laid files", () => {
58
+ const SCAFFOLD = ["core/a.md", "core/docs/b.md", ".claude/c.sh", "core/core.yaml"];
59
+
60
+ describe.skipIf(!gitAvailable)("rescue reconciles sync-journal baseline (complete + robust)", () => {
56
61
  let workDir: string, upstream: string, hqRoot: string, stateDir: string, floorSha: string;
57
62
  let env: NodeJS.ProcessEnv;
58
63
  let savedStateDir: string | undefined;
@@ -64,52 +69,61 @@ describe.skipIf(!gitAvailable)("rescue re-stamps sync-journal baseline for re-la
64
69
  env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" },
65
70
  }).toString().trim();
66
71
 
72
+ const w = (root: string, rel: string, body: string) => {
73
+ const p = path.join(root, rel);
74
+ fs.mkdirSync(path.dirname(p), { recursive: true });
75
+ fs.writeFileSync(p, body);
76
+ };
77
+
67
78
  beforeAll(() => {
68
79
  workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-journal-"));
69
80
 
70
- // upstream: floor (doc.md=v1), then HEAD advances doc.md -> v2.
81
+ // --- upstream: floor (all scaffold = v1), HEAD advances the 3 docs to v2 ---
71
82
  upstream = path.join(workDir, "upstream");
72
- fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
83
+ fs.mkdirSync(upstream, { recursive: true });
73
84
  git(workDir, "init", "-b", "main", "upstream");
74
- fs.writeFileSync(path.join(upstream, "core/doc.md"), "v1\n");
75
- git(upstream, "add", "-A");
76
- git(upstream, "commit", "-m", "floor");
85
+ w(upstream, "core/a.md", "v1\n");
86
+ w(upstream, "core/docs/b.md", "v1\n");
87
+ w(upstream, ".claude/c.sh", "v1\n");
88
+ w(upstream, "core/core.yaml", "version: 1\n");
89
+ git(upstream, "add", "-A"); git(upstream, "commit", "-m", "floor");
77
90
  floorSha = git(upstream, "rev-parse", "HEAD");
78
- fs.writeFileSync(path.join(upstream, "core/doc.md"), "v2\n");
79
- git(upstream, "add", "-A");
80
- git(upstream, "commit", "-m", "head");
91
+ w(upstream, "core/a.md", "v2\n");
92
+ w(upstream, "core/docs/b.md", "v2\n");
93
+ w(upstream, ".claude/c.sh", "v2\n");
94
+ git(upstream, "add", "-A"); git(upstream, "commit", "-m", "head");
81
95
 
82
- // local HQ root: doc.md == floor (v1) -> rescue overlays it to HEAD (v2).
96
+ // --- local HQ root: scaffold == floor (overlaid to v2); a pending personal/ edit ---
83
97
  hqRoot = path.join(workDir, "hq");
84
- fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
85
- fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
86
98
  fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
87
- const docAbs = path.join(hqRoot, "core/doc.md");
88
- fs.writeFileSync(docAbs, "v1\n");
99
+ w(hqRoot, "core/a.md", "v1\n");
100
+ w(hqRoot, "core/docs/b.md", "v1\n");
101
+ w(hqRoot, ".claude/c.sh", "v1\n");
102
+ w(hqRoot, "core/core.yaml", "version: 1\n");
103
+ w(hqRoot, "personal/edited.md", "USER_LOCAL\n"); // local pending edit
89
104
 
90
- // state dir + seeded personal-vault journal carrying the STALE (v1) hash.
105
+ // --- state dir + seeded journal: stale baselines for scaffold + a divergent personal/ entry ---
91
106
  stateDir = path.join(workDir, "state");
92
107
  fs.mkdirSync(stateDir, { recursive: true });
93
108
  savedStateDir = process.env.HQ_STATE_DIR;
94
- process.env.HQ_STATE_DIR = stateDir; // getStateDir() reads process.env
95
- const staleHash = hashFile(docAbs); // hash of v1
109
+ process.env.HQ_STATE_DIR = stateDir;
110
+ const entry = (rel: string, hash: string) => ({
111
+ hash,
112
+ size: fs.statSync(path.join(hqRoot, rel)).size,
113
+ syncedAt: new Date(0).toISOString(),
114
+ direction: "down" as const,
115
+ remoteEtag: "seed-etag",
116
+ mtimeMs: fs.statSync(path.join(hqRoot, rel)).mtimeMs,
117
+ });
118
+ const files: Record<string, unknown> = {};
119
+ for (const rel of SCAFFOLD) files[rel] = entry(rel, hashFile(path.join(hqRoot, rel))); // hash of v1/version:1
120
+ // personal/ pending edit: journal records a DIFFERENT (already-synced) hash than local.
121
+ files["personal/edited.md"] = { ...entry("personal/edited.md", "remote-side-hash-differs-from-local") };
96
122
  writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
97
- version: "2",
98
- lastSync: new Date(0).toISOString(),
99
- files: {
100
- "core/doc.md": {
101
- hash: staleHash,
102
- size: fs.statSync(docAbs).size,
103
- syncedAt: new Date(0).toISOString(),
104
- direction: "down",
105
- remoteEtag: "seed-etag",
106
- mtimeMs: fs.statSync(docAbs).mtimeMs,
107
- },
108
- },
109
- pulls: [],
123
+ version: "2", lastSync: new Date(0).toISOString(), files, pulls: [],
110
124
  } as never);
111
125
 
112
- // git shim: redirect `git clone <github-url>` to the local fixture.
126
+ // --- git shim: redirect clone to the local fixture ---
113
127
  const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
114
128
  const shimDir = path.join(workDir, "shim");
115
129
  fs.mkdirSync(shimDir, { recursive: true });
@@ -133,24 +147,33 @@ exec ${JSON.stringify(realGit)} "$@"
133
147
  if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
134
148
  });
135
149
 
136
- it("re-stamps the journal so the overlaid file is no longer seen as locally changed", () => {
137
- const docAbs = path.join(hqRoot, "core/doc.md");
138
- const staleHash = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"].hash;
139
-
150
+ it("re-stamps EVERY changed scaffold file (incl. core.yaml), and never touches the personal/ pending edit", () => {
140
151
  const r = runRescueCapture(
141
152
  ["--hq-root", hqRoot, "--source", "test/repo", "--ref", "main", "--floor-sha", floorSha, "--yes", "--no-backup"],
142
153
  env,
143
154
  );
144
155
  expect(r.status, r.stdout).toBe(0);
145
156
 
146
- // overlay actually re-laid the file (v1 -> v2)
147
- expect(fs.readFileSync(docAbs, "utf-8")).toBe("v2\n");
157
+ // the 3 docs were overlaid v1 -> v2
158
+ for (const rel of ["core/a.md", "core/docs/b.md", ".claude/c.sh"]) {
159
+ expect(fs.readFileSync(path.join(hqRoot, rel), "utf-8")).toBe("v2\n");
160
+ }
161
+
162
+ const j = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files;
163
+
164
+ // INVARIANT: after the reconcile, every scaffold entry's baseline == current
165
+ // local hash -> localChanged is false -> no false conflict on the next sync.
166
+ // core.yaml is included BECAUSE the reconcile runs AFTER its yq/py stamp.
167
+ for (const rel of SCAFFOLD) {
168
+ expect(j[rel].hash, `${rel} not reconciled`).toBe(hashFile(path.join(hqRoot, rel)));
169
+ expect(j[rel].remoteEtag, `${rel} remote side touched`).toBe("seed-etag"); // clean push/converge, not conflict
170
+ }
171
+
172
+ // SAFETY: the personal/ pending edit is preserved (NOT marked synced) so it
173
+ // still uploads — its baseline stays the divergent seeded hash.
174
+ expect(j["personal/edited.md"].hash).toBe("remote-side-hash-differs-from-local");
175
+ expect(j["personal/edited.md"].hash).not.toBe(hashFile(path.join(hqRoot, "personal/edited.md")));
148
176
 
149
- // journal entry was re-stamped to the NEW content's hash
150
- const entry = readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files["core/doc.md"];
151
- expect(entry.hash).toBe(hashFile(docAbs)); // == hash(v2): localChanged would be FALSE
152
- expect(entry.hash).not.toBe(staleHash); // proves it changed from the stale v1 baseline
153
- expect(entry.remoteEtag).toBe("seed-etag"); // remote side untouched -> clean push/converge, not conflict
154
177
  expect(r.stdout).toContain("Reconciled sync-journal baseline");
155
178
  });
156
179
  });
@@ -48,7 +48,12 @@ vi.mock("../s3.js", async () => {
48
48
  if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
49
49
  // Deterministic per-key body so re-downloads produce a stable hash.
50
50
  innerFs.writeFileSync(localPath, `mock:${key}`);
51
- return { metadata: {} };
51
+ // Real GETs carry author metadata; surface a fixed uploader sub so
52
+ // journal entries are stamped with a KNOWN author. With no `callerSub`
53
+ // passed (the default for these tests), that author is "foreign", so
54
+ // the scope-shrink prune/block paths behave exactly as pre-guard —
55
+ // only the new own-author retention test passes a matching callerSub.
56
+ return { metadata: { "created-by-sub": "uploader-sub" } };
52
57
  }),
53
58
  listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
54
59
  deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
@@ -230,6 +235,35 @@ describe("sync — scope-aware download (US-005)", () => {
230
235
  expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
231
236
  });
232
237
 
238
+ it("scope shrink (all → shared) RETAINS the owner's own out-of-scope file", async () => {
239
+ // Corey's exact scenario: the caller authored everything (the mock stamps
240
+ // `created-by-sub: "uploader-sub"`), so passing the matching `callerSub`
241
+ // means narrowing to `shared` must NOT prune their own work — mode only
242
+ // governs mirroring OTHER people's files.
243
+ const all = await sync({
244
+ company: "acme",
245
+ vaultConfig: mockConfig,
246
+ hqRoot: tmpDir,
247
+ syncMode: "all",
248
+ callerSub: "uploader-sub",
249
+ });
250
+ expect(all.filesDownloaded).toBe(2);
251
+
252
+ const shared = await sync({
253
+ company: "acme",
254
+ vaultConfig: mockConfig,
255
+ hqRoot: tmpDir,
256
+ syncMode: "shared",
257
+ prefixSet: ["knowledge/"],
258
+ callerSub: "uploader-sub",
259
+ });
260
+ // Out of scope, but authored by the caller → retained, not pruned.
261
+ expect(shared.scopeOrphansRemoved).toBe(0);
262
+ expect(shared.scopeOrphansBlocked).toBe(0);
263
+ expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
264
+ expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
265
+ });
266
+
233
267
  it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
234
268
  // Pull both files under all-mode, then narrow to a scope covering neither
235
269
  // → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
package/src/cli/sync.ts CHANGED
@@ -330,6 +330,14 @@ export interface SyncOptions {
330
330
  * tombstoned. Mirrors `hq sync narrow --force`.
331
331
  */
332
332
  forceScopeShrink?: boolean;
333
+ /**
334
+ * The caller's own Cognito `sub`, used by the scope-shrink authorship guard
335
+ * so a scope shrink never prunes content the caller authored. Injected by the
336
+ * entry point — the runner sources it from its decoded idToken claims (the
337
+ * same sub stamped onto uploads as `created-by-sub`). The engine never reads
338
+ * it from disk, so it stays pure/hermetic; undefined degrades safely.
339
+ */
340
+ callerSub?: string;
333
341
  /**
334
342
  * Skip the post-sync `reindex()` refresh (skill wrappers + personal overlay
335
343
  * mirrors + workers registry). By default, when a sync changes on-disk
@@ -536,6 +544,13 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
536
544
  const syncMode: SyncMode = options.syncMode ?? "all";
537
545
  const currentPrefixSet =
538
546
  syncMode === "all" ? [""] : coalescePrefixes(options.prefixSet ?? []);
547
+ // Authorship guard input (scope-shrink): the caller's own Cognito sub,
548
+ // injected by the entry point (the runner sources it from its decoded
549
+ // idToken claims — the same sub stamped onto uploads as `created-by-sub`).
550
+ // Undefined degrades safely: own-author files lose their special shield, but
551
+ // the `protectUnknownAuthors` conservative path below still prevents a
552
+ // routine sync from deleting anything it can't prove is foreign.
553
+ const callerSub = options.callerSub;
539
554
 
540
555
  let filesDownloaded = 0;
541
556
  let bytesDownloaded = 0;
@@ -603,6 +618,11 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
603
618
  hqRoot: companyRoot,
604
619
  lastPrefixSet,
605
620
  currentPrefixSet,
621
+ callerSub,
622
+ // Automatic pull: never auto-prune content the caller authored, and never
623
+ // make a destructive guess about unknown-author (legacy) orphans. The
624
+ // explicit `hq sync narrow` ritual opts out of the unknown-author shield.
625
+ protectUnknownAuthors: true,
606
626
  });
607
627
  if (shrinkPlan.dirty.length > 0 && options.forceScopeShrink !== true) {
608
628
  throw new ScopeShrinkBlockedError(
@@ -988,6 +1008,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
988
1008
  try {
989
1009
  const { metadata } = await downloadFile(ctx, remoteFile.key, localPath);
990
1010
  const author = metadata?.["created-by"] ?? null;
1011
+ // Author sub for the scope-shrink authorship guard — same field the
1012
+ // upload side stamps, read straight off the GET response metadata.
1013
+ const createdBySub = metadata?.["created-by-sub"];
991
1014
 
992
1015
  // Symlink records materialize as real symlinks on disk. lstat
993
1016
  // (does not follow) lets us detect that case so the journal stamp
@@ -1026,6 +1049,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
1026
1049
  "down",
1027
1050
  remoteFile.etag,
1028
1051
  localLstat.mtimeMs,
1052
+ createdBySub,
1029
1053
  );
1030
1054
 
1031
1055
  // Attach message from the prior journal entry if present (set by a
package/src/journal.ts CHANGED
@@ -211,6 +211,7 @@ export function updateEntry(
211
211
  direction: "up" | "down",
212
212
  remoteEtag?: string,
213
213
  mtimeMs?: number,
214
+ createdBySub?: string,
214
215
  ): void {
215
216
  const entry: JournalEntry = {
216
217
  hash,
@@ -224,6 +225,12 @@ export function updateEntry(
224
225
  if (mtimeMs !== undefined) {
225
226
  entry.mtimeMs = mtimeMs;
226
227
  }
228
+ // Authorship (scope-shrink guard input). Stamped from the object's
229
+ // `created-by-sub` S3 metadata on download. Only persisted when present so
230
+ // legacy journals and author-less uploads stay byte-identical.
231
+ if (createdBySub !== undefined && createdBySub !== "") {
232
+ entry.createdBySub = createdBySub;
233
+ }
227
234
  journal.files[relativePath] = entry;
228
235
  journal.lastSync = new Date().toISOString();
229
236
  }
@@ -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", () => {