@indigoai-us/hq-cloud 6.14.36 → 6.14.39

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 (43) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +20 -3
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +5 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +46 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +1 -9
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/share.d.ts +178 -1
  13. package/dist/cli/share.d.ts.map +1 -1
  14. package/dist/cli/share.js +555 -32
  15. package/dist/cli/share.js.map +1 -1
  16. package/dist/cli/share.test.js +780 -2
  17. package/dist/cli/share.test.js.map +1 -1
  18. package/dist/cli/sync.d.ts +27 -0
  19. package/dist/cli/sync.d.ts.map +1 -1
  20. package/dist/cli/sync.js +113 -13
  21. package/dist/cli/sync.js.map +1 -1
  22. package/dist/cli/sync.test.js +188 -0
  23. package/dist/cli/sync.test.js.map +1 -1
  24. package/dist/lib/readlink-safe.d.ts +11 -0
  25. package/dist/lib/readlink-safe.d.ts.map +1 -0
  26. package/dist/lib/readlink-safe.js +27 -0
  27. package/dist/lib/readlink-safe.js.map +1 -0
  28. package/dist/lib/readlink-safe.test.d.ts +2 -0
  29. package/dist/lib/readlink-safe.test.d.ts.map +1 -0
  30. package/dist/lib/readlink-safe.test.js +34 -0
  31. package/dist/lib/readlink-safe.test.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/bin/sync-runner-company.ts +19 -3
  34. package/src/bin/sync-runner.test.ts +54 -0
  35. package/src/bin/sync-runner.ts +4 -0
  36. package/src/cli/reindex.ts +1 -9
  37. package/src/cli/share.test.ts +974 -2
  38. package/src/cli/share.ts +675 -32
  39. package/src/cli/sync.test.ts +209 -0
  40. package/src/cli/sync.ts +151 -13
  41. package/src/lib/readlink-safe.test.ts +43 -0
  42. package/src/lib/readlink-safe.ts +29 -0
  43. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/src/cli/share.ts CHANGED
@@ -71,6 +71,8 @@ import {
71
71
  import { appendConflictEntry } from "../lib/conflict-index.js";
72
72
  import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
73
73
  import { VaultAuthError } from "../vault-client.js";
74
+ import { describeError } from "../lib/describe-error.js";
75
+ import { readlinkOrNull } from "../lib/readlink-safe.js";
74
76
 
75
77
  /**
76
78
  * Push-side fresh-collision convergence probe.
@@ -258,6 +260,26 @@ export function isMalformedVaultKey(key: string): boolean {
258
260
  return key.includes("\\");
259
261
  }
260
262
 
263
+ /**
264
+ * A remote key that begins with `companies/<slug>/` is legitimate ONLY in a
265
+ * PERSONAL vault, where it is handled by the dedicated `personalMode` branch
266
+ * in `computePullPlan` (companies/* content a peer machine pushed into the
267
+ * personal bucket). A COMPANY-scoped vault is already anchored at its company
268
+ * root, so its keys are bucket-relative — a `companies/...` key there is a
269
+ * doubly-scoped corrupt object. The vault-service refuses to presign such a
270
+ * key on GET/HEAD with `INVALID_KEY_COMPANIES_SCOPED`, so the puller can
271
+ * never materialize it and the whole company sync wedges at `errored` (runner
272
+ * exit 2) on every run. Verified live 2026-06-16: frogbear's
273
+ * `companies/frogbear/drafts/reports/frogbear-signals-report-2026-06-15.html`
274
+ * was uploaded with a doubled key and broke every sync thereafter. The pull
275
+ * and tombstone walkers refuse these keys (skip-excluded-policy), symmetric
276
+ * with the malformed-(backslash)-key filter above; the bogus objects
277
+ * themselves are cleaned server-side.
278
+ */
279
+ export function isForbiddenCompanyVaultKey(key: string, personalMode: boolean): boolean {
280
+ return !personalMode && key.startsWith("companies/");
281
+ }
282
+
261
283
  /**
262
284
  * Test-only export. Kept under a `_testing` namespace so the module's public
263
285
  * surface stays focused on `share()` / `ShareOptions` / `ShareResult` while
@@ -271,6 +293,8 @@ export const _testing = {
271
293
  EPHEMERAL_PATH_PATTERN,
272
294
  wrapFilterWithIgnoreVisibility,
273
295
  collectFiles,
296
+ resolveNamedPath,
297
+ isWithinLexicalOrReal,
274
298
  };
275
299
 
276
300
  /**
@@ -678,6 +702,67 @@ export interface ShareOptions {
678
702
  * the network. See the consult in the Stage-2 classification pass.
679
703
  */
680
704
  fileTombstones?: Map<string, CompanyTombstone>;
705
+ /**
706
+ * What to do when a path the caller EXPLICITLY named cannot be shipped —
707
+ * it does not resolve under any base, or it resolves outside the company
708
+ * folder (see `ShareResult.unreachablePaths`).
709
+ *
710
+ * - `"error"` (DEFAULT): a path that EXISTS on disk but resolves outside the
711
+ * company folder throws {@link UnreachablePushPathsError} BEFORE any upload
712
+ * runs, so the push is an atomic no-op and the CLI exits nonzero. This is
713
+ * ask #2 of feedback_a51cb63d — "error, not warn-skip, when the named file
714
+ * exists locally but is unreachable by the resolver". A user who typed a
715
+ * path and got "✓ Pushed 0 file(s)" had no way to know their content never
716
+ * left the machine. A path that resolves to NOTHING under any base is still
717
+ * only warn-recorded (see `collectFatalUnreachablePaths` for why bulk
718
+ * membership fanout depends on that).
719
+ * - `"warn"`: record it on the result + emit the `not-shipped` event and
720
+ * carry on, never throwing. For callers whose `paths` are INTERNAL walk
721
+ * roots rather than user input — the background sync runner — where a path
722
+ * disappearing mid-run is a benign race (a directory removed between the
723
+ * scan and the push) and must never fail an unattended sync.
724
+ */
725
+ unreachablePathPolicy?: "error" | "warn";
726
+ }
727
+
728
+ /** Why an explicitly-named push path could not be shipped. */
729
+ export type UnreachablePathReason = "missing" | "outside-company" | "unreadable-link";
730
+
731
+ /**
732
+ * Thrown by `share()` when a caller-named path cannot be pushed and
733
+ * `unreachablePathPolicy` is `"error"` (the default). Raised while the plans
734
+ * are still being built, so NOTHING has been uploaded, journaled, or deleted
735
+ * when it surfaces — the failed push leaves no partial state behind.
736
+ */
737
+ export class UnreachablePushPathsError extends Error {
738
+ /** Caller's original spellings, verbatim (see the CollectHooks contract). */
739
+ readonly paths: string[];
740
+ /** Per-path reason, keyed by the same original spelling. */
741
+ readonly reasons: Record<string, UnreachablePathReason>;
742
+
743
+ constructor(unreachable: ReadonlyMap<string, UnreachablePathReason>, syncRoot: string) {
744
+ const paths = [...unreachable.keys()];
745
+ const lines = paths.map((p) => {
746
+ const reason = unreachable.get(p);
747
+ if (reason === "outside-company") {
748
+ return ` · ${p} — resolves outside the company folder (${syncRoot})`;
749
+ }
750
+ if (reason === "unreadable-link") {
751
+ return ` · ${p} — symbolic link target could not be read; it was not dereferenced or uploaded`;
752
+ }
753
+ return ` · ${p} — not found under the hq root, the company folder, or the current directory`;
754
+ });
755
+ super(
756
+ `${paths.length} named path${paths.length === 1 ? "" : "s"} could not be pushed; ` +
757
+ `nothing was uploaded.\n${lines.join("\n")}\n` +
758
+ `A path reached through a symlink is only pushable when it stays inside the HQ tree ` +
759
+ `(e.g. companies/<slug>/knowledge → repos/private/knowledge-<slug>); one that points ` +
760
+ `outside HQ has to sync through whatever owns it, not the vault.`,
761
+ );
762
+ this.name = "UnreachablePushPathsError";
763
+ this.paths = paths;
764
+ this.reasons = Object.fromEntries(unreachable);
765
+ }
681
766
  }
682
767
 
683
768
  export interface ShareResult {
@@ -757,6 +842,34 @@ export interface ShareResult {
757
842
  * once if this is > 0).
758
843
  */
759
844
  filesExcludedByIgnore: number;
845
+ /**
846
+ * Paths the caller EXPLICITLY named for push that exist locally but the
847
+ * resolver could not place under the company folder (or could not find under
848
+ * any base). Empty in the common case (internal walk roots are always the
849
+ * reachable company folder). A non-empty list means the push did NOT ship
850
+ * something the caller asked for.
851
+ *
852
+ * Only ever non-empty under `unreachablePathPolicy: "warn"` — the DEFAULT
853
+ * `"error"` policy throws {@link UnreachablePushPathsError} instead of
854
+ * returning, so an interactive `hq sync push` fails loudly rather than
855
+ * reporting the pre-fix silent "Pushed 0 file(s)" success. This field is the
856
+ * warn-mode surface for unattended callers (sync runner / watcher).
857
+ *
858
+ * Entries are the caller's ORIGINAL spellings, verbatim — a relative token
859
+ * stays relative, an absolute token stays absolute — so the list is directly
860
+ * comparable to the `paths` input. See the CollectHooks spelling contract.
861
+ * Mirrors the `not-shipped` event with `reason: "unreachable-path"`.
862
+ */
863
+ unreachablePaths: string[];
864
+ /**
865
+ * Company-relative keys of directory symlinks that were recorded as links but
866
+ * NOT descended because their target lives outside the company folder — their
867
+ * contents sync via their own repo, not the vault. Surfaced so files created
868
+ * under such a link (e.g. `companies/{co}/knowledge` → a linked repo) no
869
+ * longer vanish from every push bucket without a trace. Mirrors the
870
+ * `not-shipped` event with `reason: "linked-subtree"`.
871
+ */
872
+ linkedSubtreesNotShipped: string[];
760
873
  /**
761
874
  * Paths (company-relative) that were detected as push conflicts. Mirrors
762
875
  * `SyncResult.conflictPaths` so push and pull surface conflicts the same
@@ -935,6 +1048,18 @@ interface PushRunContext {
935
1048
  scopeExcludedSet: Set<string>;
936
1049
  ignoreExcludedSet: Set<string>;
937
1050
  ignoreExcludedTotal: { value: number };
1051
+ /** Explicitly-named push paths that exist locally but the resolver could not
1052
+ * place under the company folder (or find at all), keyed by the CALLER'S
1053
+ * ORIGINAL spelling (see the CollectHooks spelling contract) and valued by
1054
+ * why it could not be shipped. Non-empty ⇒ the push did NOT ship something
1055
+ * the caller named — surfaced so a "Pushed 0 file(s)" is never a silent
1056
+ * false success, and (under the default `unreachablePathPolicy: "error"`)
1057
+ * raised as a hard failure before any upload runs. */
1058
+ unreachablePaths: Map<string, UnreachablePathReason>;
1059
+ /** Company-relative keys of directory symlinks recorded but not descended
1060
+ * because their target lives outside the company folder (contents sync via
1061
+ * their own repo, not the vault). */
1062
+ linkedSubtreeSet: Set<string>;
938
1063
  }
939
1064
 
940
1065
  interface ShareCounters {
@@ -1040,6 +1165,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
1040
1165
  const onScopeExcluded = (rel: string) => {
1041
1166
  scopeExcludedSet.add(rel);
1042
1167
  };
1168
+ const unreachablePaths = new Map<string, UnreachablePathReason>();
1169
+ const linkedSubtreeSet = new Set<string>();
1043
1170
  const baseFilter = options.personalMode === true
1044
1171
  ? wrapFilterWithPersonalVaultDefaults(recordedIgnoreFilter, syncRoot, onExcluded)
1045
1172
  : recordedIgnoreFilter;
@@ -1073,6 +1200,8 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
1073
1200
  scopeExcludedSet,
1074
1201
  ignoreExcludedSet,
1075
1202
  ignoreExcludedTotal,
1203
+ unreachablePaths,
1204
+ linkedSubtreeSet,
1076
1205
  };
1077
1206
  }
1078
1207
 
@@ -1094,7 +1223,30 @@ async function buildSharePlans(run: PushRunContext): Promise<SharePlans> {
1094
1223
  run.hqRoot,
1095
1224
  run.syncRoot,
1096
1225
  run.shouldSync,
1226
+ {
1227
+ onUnreachablePath: (namedPath, reason) => {
1228
+ // First reason wins: a path is named once, and re-adding would only
1229
+ // churn the map ordering the error message and event sample rely on.
1230
+ if (!run.unreachablePaths.has(namedPath)) {
1231
+ run.unreachablePaths.set(namedPath, reason);
1232
+ }
1233
+ },
1234
+ onLinkedSubtree: (rel) => run.linkedSubtreeSet.add(rel),
1235
+ },
1097
1236
  );
1237
+ // Ask #2 of feedback_a51cb63d: "error, not warn-skip, when the named file
1238
+ // exists locally but is unreachable by the resolver". This is the throw that
1239
+ // makes it true end-to-end — the CLI's push handler already turns a thrown
1240
+ // error into "✗ Push failed: <message>" + exit 1, so the pre-fix silent
1241
+ // "✓ Pushed 0 file(s)" success can no longer happen for a file that is
1242
+ // sitting right there on disk. It fires HERE, before executeUploads, so a
1243
+ // failed push is also an ATOMIC no-op: nothing uploaded, no journal entry
1244
+ // written, no delete propagated.
1245
+ const fatal = collectFatalUnreachablePaths(run);
1246
+ if (fatal.size > 0) {
1247
+ emitUnreachablePathEvent(run);
1248
+ throw new UnreachablePushPathsError(fatal, run.syncRoot);
1249
+ }
1098
1250
  // Scope-invalid key filter (incident 2026-07-11). In company mode the sync
1099
1251
  // root IS the company folder, so a local entry whose vault key starts with
1100
1252
  // `companies/` can only come from a stale doubled local tree
@@ -1480,8 +1632,7 @@ async function executeUploads(
1480
1632
  run.emit({
1481
1633
  type: "error",
1482
1634
  path: relativePath,
1483
- message:
1484
- retryErr instanceof Error ? retryErr.message : String(retryErr),
1635
+ message: describeError(retryErr),
1485
1636
  });
1486
1637
  }
1487
1638
  return;
@@ -1501,7 +1652,7 @@ async function executeUploads(
1501
1652
  run.emit({
1502
1653
  type: "error",
1503
1654
  path: relativePath,
1504
- message: err instanceof Error ? err.message : String(err),
1655
+ message: describeError(err),
1505
1656
  });
1506
1657
  }
1507
1658
  };
@@ -1572,9 +1723,7 @@ async function writePushConflictMirror(
1572
1723
  run.emit({
1573
1724
  type: "error",
1574
1725
  path: item.relativePath,
1575
- message:
1576
- "conflict mirror write failed: " +
1577
- (mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)),
1726
+ message: "conflict mirror write failed: " + describeError(mirrorErr),
1578
1727
  });
1579
1728
  }
1580
1729
  }
@@ -1654,7 +1803,7 @@ async function executeDeletes(
1654
1803
  run.emit({
1655
1804
  type: "error",
1656
1805
  path: relativePath,
1657
- message: err instanceof Error ? err.message : String(err),
1806
+ message: describeError(err),
1658
1807
  });
1659
1808
  pathResults.push({
1660
1809
  path: relativePath,
@@ -1665,6 +1814,61 @@ async function executeDeletes(
1665
1814
  }
1666
1815
  }
1667
1816
  for (const relativePath of deletePlan.toTombstone) {
1817
+ const localPath = localPathForVaultKey(run.syncRoot, relativePath);
1818
+ try {
1819
+ const lstat = fs.lstatSync(localPath);
1820
+ const entry = run.journal.files[relativePath];
1821
+ if (lstat.isFile()) {
1822
+ const localHash = hashFile(localPath);
1823
+ if (entry?.hash && entry.hash !== localHash) {
1824
+ run.emit({
1825
+ type: "error",
1826
+ path: relativePath,
1827
+ message:
1828
+ "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1829
+ });
1830
+ continue;
1831
+ }
1832
+ fs.unlinkSync(localPath);
1833
+ } else if (lstat.isSymbolicLink()) {
1834
+ const target = readlinkOrNull(localPath);
1835
+ if (target === null) {
1836
+ run.emit({
1837
+ type: "not-shipped",
1838
+ reason: "unreadable-link",
1839
+ count: 1,
1840
+ samplePaths: [relativePath],
1841
+ });
1842
+ continue;
1843
+ }
1844
+ const localHash = hashSymlinkTarget(target);
1845
+ if (entry?.hash && entry.hash !== localHash) {
1846
+ run.emit({
1847
+ type: "error",
1848
+ path: relativePath,
1849
+ message:
1850
+ "scope-invalid tombstone skipped: local doubled-tree copy diverged from journal",
1851
+ });
1852
+ continue;
1853
+ }
1854
+ fs.unlinkSync(localPath);
1855
+ }
1856
+ } catch (err: unknown) {
1857
+ const code =
1858
+ err && typeof err === "object" && "code" in err
1859
+ ? (err as { code?: string }).code
1860
+ : undefined;
1861
+ if (code !== "ENOENT") {
1862
+ run.emit({
1863
+ type: "error",
1864
+ path: relativePath,
1865
+ message: `tombstone unlink failed: ${
1866
+ err instanceof Error ? err.message : String(err)
1867
+ }`,
1868
+ });
1869
+ continue;
1870
+ }
1871
+ }
1668
1872
  removeEntry(run.journal, relativePath);
1669
1873
  counters.filesTombstoned++;
1670
1874
  run.emit({
@@ -1748,6 +1952,92 @@ function finalizeShareJournal(run: PushRunContext): void {
1748
1952
  samplePaths,
1749
1953
  });
1750
1954
  }
1955
+
1956
+ emitUnreachablePathEvent(run);
1957
+
1958
+ if (run.linkedSubtreeSet.size > 0) {
1959
+ run.emit({
1960
+ type: "not-shipped",
1961
+ reason: "linked-subtree",
1962
+ count: run.linkedSubtreeSet.size,
1963
+ samplePaths: sampleSet(run.linkedSubtreeSet),
1964
+ });
1965
+ }
1966
+ }
1967
+
1968
+ /**
1969
+ * Which unreachable named paths are FATAL under the run's policy.
1970
+ *
1971
+ * Under the default `"error"` policy only `"outside-company"` is fatal: the
1972
+ * entry is sitting on disk and the resolver refused to place it under the
1973
+ * company folder, which is precisely the "exists locally but is unreachable by
1974
+ * the resolver" case the report asks to turn into an error, and it cannot
1975
+ * happen for an internal walk root (a company folder is trivially inside
1976
+ * itself).
1977
+ *
1978
+ * `"missing"` stays a warn-skip — recorded on `ShareResult.unreachablePaths`
1979
+ * and surfaced by the `not-shipped` event, but never fatal. Bulk callers plan
1980
+ * one push leg per MEMBERSHIP (`hq sync push --all`), including companies whose
1981
+ * folder was never materialized locally; throwing there would turn "you haven't
1982
+ * pulled that company yet" into a hard failure of an unrelated multi-company
1983
+ * push. Same reasoning for a watcher path deleted between the event and the
1984
+ * push. Those callers get the loud report without the regression.
1985
+ *
1986
+ * `"warn"` makes nothing fatal, for callers whose paths are internal walk roots
1987
+ * end to end (the background sync runner).
1988
+ */
1989
+ function collectFatalUnreachablePaths(
1990
+ run: PushRunContext,
1991
+ ): Map<string, UnreachablePathReason> {
1992
+ const fatal = new Map<string, UnreachablePathReason>();
1993
+ if (run.options.unreachablePathPolicy === "warn") return fatal;
1994
+ for (const [namedPath, reason] of run.unreachablePaths) {
1995
+ if (reason === "outside-company") fatal.set(namedPath, reason);
1996
+ }
1997
+ return fatal;
1998
+ }
1999
+
2000
+ /**
2001
+ * Emit the `not-shipped` / `unreachable-path` event for a run, if any named
2002
+ * path went unshipped. Shared by BOTH policies so the operator sees the same
2003
+ * report either way: the fail-fast path emits it immediately before throwing
2004
+ * (the journal finalizer never runs on a throw), and the `"warn"` path emits it
2005
+ * from the finalizer at the end of a successful run. Exactly one of those two
2006
+ * call sites can fire per run, so the event is never duplicated.
2007
+ */
2008
+ function emitUnreachablePathEvent(run: PushRunContext): void {
2009
+ if (run.unreachablePaths.size === 0) return;
2010
+ const unreadableLinks: string[] = [];
2011
+ const unreachablePaths: string[] = [];
2012
+ for (const [namedPath, reason] of run.unreachablePaths) {
2013
+ (reason === "unreadable-link" ? unreadableLinks : unreachablePaths).push(namedPath);
2014
+ }
2015
+ if (unreachablePaths.length > 0) {
2016
+ run.emit({
2017
+ type: "not-shipped",
2018
+ reason: "unreachable-path",
2019
+ count: unreachablePaths.length,
2020
+ samplePaths: unreachablePaths.slice(0, 10),
2021
+ });
2022
+ }
2023
+ if (unreadableLinks.length > 0) {
2024
+ run.emit({
2025
+ type: "not-shipped",
2026
+ reason: "unreadable-link",
2027
+ count: unreadableLinks.length,
2028
+ samplePaths: unreadableLinks.slice(0, 10),
2029
+ });
2030
+ }
2031
+ }
2032
+
2033
+ /** First up-to-`limit` members of an iterable, for bounded event payloads. */
2034
+ function sampleSet(set: Iterable<string>, limit = 10): string[] {
2035
+ const sample: string[] = [];
2036
+ for (const value of set) {
2037
+ sample.push(value);
2038
+ if (sample.length >= limit) break;
2039
+ }
2040
+ return sample;
1751
2041
  }
1752
2042
 
1753
2043
  function throwUploadWorkerErrors(workerErrors: Error[]): void {
@@ -1784,6 +2074,8 @@ function buildShareResult(
1784
2074
  filesExcludedByPolicy: run.excludedSet.size,
1785
2075
  filesExcludedByScope: run.scopeExcludedSet.size,
1786
2076
  filesExcludedByIgnore: run.ignoreExcludedSet.size,
2077
+ unreachablePaths: [...run.unreachablePaths.keys()],
2078
+ linkedSubtreesNotShipped: [...run.linkedSubtreeSet],
1787
2079
  conflictPaths,
1788
2080
  pathResults,
1789
2081
  aborted,
@@ -1864,6 +2156,28 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
1864
2156
  if (event.count > event.samplePaths.length) {
1865
2157
  console.warn(` ... and ${event.count - event.samplePaths.length} more`);
1866
2158
  }
2159
+ } else if (event.type === "not-shipped") {
2160
+ // The other "not silent" surface: content the walk saw but chose not to
2161
+ // ship. Name it so a "Pushed 0 file(s)" is never a silent no-op.
2162
+ if (event.reason === "unreachable-path") {
2163
+ console.warn(
2164
+ ` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
2165
+ );
2166
+ } else if (event.reason === "unreadable-link") {
2167
+ console.warn(
2168
+ ` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
2169
+ );
2170
+ } else {
2171
+ console.warn(
2172
+ ` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
2173
+ );
2174
+ }
2175
+ for (const p of event.samplePaths) {
2176
+ console.warn(` · ${p}`);
2177
+ }
2178
+ if (event.count > event.samplePaths.length) {
2179
+ console.warn(` ... and ${event.count - event.samplePaths.length} more`);
2180
+ }
1867
2181
  }
1868
2182
  }
1869
2183
 
@@ -1881,6 +2195,284 @@ type CollectedEntry =
1881
2195
  | { kind: "file"; absolutePath: string; relativePath: string }
1882
2196
  | { kind: "symlink"; absolutePath: string; relativePath: string; target: string };
1883
2197
 
2198
+ /**
2199
+ * Optional visibility callbacks for {@link collectFiles} / {@link walkDir}.
2200
+ * They turn two previously-SILENT outcomes into surfaced signals — without
2201
+ * changing WHAT gets uploaded (feedback_258e4a86 / feedback_a51cb63d):
2202
+ *
2203
+ * - `onUnreachablePath`: a path that cannot be shipped. It is normally the
2204
+ * caller's explicit spelling (`"outside-company"` or `"missing"`), and is
2205
+ * the company-relative key for an unreadable link discovered during a
2206
+ * directory walk (`"unreadable-link"`). Pre-fix this was a bare
2207
+ * `console.error` warn-skip that still let the push report "Pushed 0 file(s)"
2208
+ * — a false success. The caller can now turn a non-empty set into a real
2209
+ * error / nonzero exit.
2210
+ *
2211
+ * SPELLING CONTRACT: resolver outcomes use the caller's ORIGINAL token,
2212
+ * verbatim — never the resolved absolute path, and never normalized. A
2213
+ * relative `knowledge/agents/x.md` is reported as `knowledge/agents/x.md`;
2214
+ * an absolute path is reported absolute because that is what was passed.
2215
+ * An unreadable link discovered during a recursive walk instead uses its
2216
+ * company-relative vault key, because it has no separate caller token.
2217
+ * This keeps UI output actionable without exposing host-specific paths.
2218
+ * - `onLinkedSubtree`: a directory symlink recorded as a link but NOT
2219
+ * descended because its target resolves OUTSIDE the company folder (e.g.
2220
+ * `companies/{co}/knowledge` → `repos/private/knowledge-{co}/`). The link's
2221
+ * contents ship via their own repo, not the vault; reporting it stops files
2222
+ * created under such a link from vanishing from every push bucket silently.
2223
+ */
2224
+ interface CollectHooks {
2225
+ onUnreachablePath?: (namedPath: string, reason: UnreachablePathReason) => void;
2226
+ onLinkedSubtree?: (rel: string) => void;
2227
+ }
2228
+
2229
+ /**
2230
+ * Resolve a caller-supplied push path to an absolute path.
2231
+ *
2232
+ * Relative paths were historically resolved against `hqRoot` ONLY, so a
2233
+ * company-relative spelling like `knowledge/agents/x.md` became
2234
+ * `<hqRoot>/knowledge/agents/x.md` and reported "does not exist" no matter the
2235
+ * caller's cwd or the company being pushed (feedback_258e4a86 /
2236
+ * feedback_a51cb63d — "there is no path spelling that reaches the file").
2237
+ *
2238
+ * PRECEDENCE (documented contract, asserted by test): `hqRoot` → `syncRoot`
2239
+ * (the company folder) → `cwd`. hqRoot stays FIRST so this change is purely
2240
+ * ADDITIVE to the legacy behavior: every relative spelling that resolved
2241
+ * pre-fix still resolves to exactly the same file, and the two new bases only
2242
+ * catch spellings that previously resolved to nothing. Probing cwd first would
2243
+ * silently re-point existing callers (a `knowledge/` directory in the shell's
2244
+ * cwd would win over the hq-root one), which is a behavior change no reporter
2245
+ * asked for. Fall back to the hqRoot candidate so a genuine typo still surfaces
2246
+ * the unchanged "does not exist" diagnostic. Absolute paths are returned
2247
+ * verbatim.
2248
+ *
2249
+ * `cwd` is an explicit injected parameter (defaulting to `process.cwd()`)
2250
+ * rather than an ambient read, so resolution is deterministic and testable
2251
+ * without mutating process state.
2252
+ */
2253
+ function resolveNamedPath(
2254
+ p: string,
2255
+ hqRoot: string,
2256
+ syncRoot: string,
2257
+ cwd: string = process.cwd(),
2258
+ ): string {
2259
+ if (path.isAbsolute(p)) return p;
2260
+ const hqRootCandidate = path.resolve(hqRoot, p);
2261
+ const candidates = [
2262
+ hqRootCandidate,
2263
+ path.resolve(syncRoot, p),
2264
+ path.resolve(cwd, p),
2265
+ ];
2266
+ for (const candidate of candidates) {
2267
+ try {
2268
+ fs.lstatSync(candidate);
2269
+ // An hqRoot hit outside the company folder would be rejected by
2270
+ // collectFiles as outside-company; skip it so a valid company-relative
2271
+ // spelling can win (Codex P2 — common `knowledge/` homonym case).
2272
+ if (candidate === hqRootCandidate && !isWithin(syncRoot, candidate)) {
2273
+ continue;
2274
+ }
2275
+ return candidate;
2276
+ } catch {
2277
+ // Base did not resolve to an on-disk entry — try the next one.
2278
+ }
2279
+ }
2280
+ return hqRootCandidate;
2281
+ }
2282
+
2283
+ /**
2284
+ * Containment check for a regular file or directory that tolerates a symlinked
2285
+ * ANCESTOR. `isWithin` canonicalizes the full child via `realpathSync`, so a
2286
+ * path reached through a symlinked ancestor (`companies/{co}/knowledge` →
2287
+ * `repos/private/knowledge-{co}/`) resolves OUTSIDE the company folder and was
2288
+ * rejected as "outside company folder" — even though its logical path is
2289
+ * in-tree and `vaultKeyForLocalPath` (also lexical) derives a correct
2290
+ * company-namespaced key for it. Accept when the LEXICAL path is inside
2291
+ * (honoring the same logical topology the vault key uses) OR the realpath is
2292
+ * inside (preserving `isWithin`'s macOS APFS case-insensitivity tolerance).
2293
+ *
2294
+ * The lexical arm carries TWO bounds, because it is the only place where a
2295
+ * path's bytes and its vault key come from different trees:
2296
+ *
2297
+ * 1. `hqRoot` — the realpath must still land inside the HQ tree, so a
2298
+ * symlinked ancestor pointing at `/etc` cannot upload arbitrary machine
2299
+ * state under a company-namespaced key.
2300
+ * 2. The TENANT — the realpath must not land inside another company's bytes
2301
+ * (`foreignTenantRoots`). The hqRoot bound alone is NOT sufficient and
2302
+ * must never be mistaken for a tenant boundary: hqRoot CONTAINS every
2303
+ * other company, so `companies/acme/knowledge → companies/other/secret`
2304
+ * (or → `repos/private/knowledge-other`, the linked-repo topology) is
2305
+ * lexically inside acme and really inside HQ, and would upload the other
2306
+ * tenant's bytes into acme's bucket under the key `knowledge/…`.
2307
+ *
2308
+ * The motivating topology (`companies/{co}/knowledge` →
2309
+ * `repos/private/knowledge-{co}`) satisfies both, so it is unaffected. A link
2310
+ * that escapes HQ, or one that reaches another tenant, is refused — and under
2311
+ * the default unreachable-path policy, refused LOUDLY rather than warn-skipped.
2312
+ *
2313
+ * Known limit, stated so it is not mistaken for a guarantee: a foreign tenant's
2314
+ * externally-linked subtree can only be recognized while that company's folder
2315
+ * is materialized locally and publishes the link. A machine holding
2316
+ * `repos/private/knowledge-other` with no `companies/other` folder has no
2317
+ * on-disk evidence of the claim, so a link into it is indistinguishable from a
2318
+ * link into any other local repo. Ownership metadata (not path shape) is what
2319
+ * would close that gap.
2320
+ */
2321
+ function isWithinLexicalOrReal(
2322
+ parent: string,
2323
+ child: string,
2324
+ hqRoot: string,
2325
+ tenantRootsCache?: Map<string, string[]>,
2326
+ ): boolean {
2327
+ // Strict arm first: the realpath is genuinely inside the company folder.
2328
+ // This is the overwhelmingly common case, needs no relaxation, and costs no
2329
+ // directory scan.
2330
+ if (isWithin(parent, child)) return true;
2331
+
2332
+ const resolvedChild = path.resolve(child);
2333
+ if (!isPathWithin(path.resolve(parent), resolvedChild)) return false;
2334
+
2335
+ const childReal = realpathSafe(resolvedChild);
2336
+ if (!isWithin(hqRoot, childReal)) return false; // bound 1: escapes HQ
2337
+
2338
+ // NUL-joined: it is the one byte a path cannot contain, so no pair of
2339
+ // (hqRoot, parent) values can collide on the key.
2340
+ const cacheKey = `${hqRoot}\u0000${parent}`;
2341
+ let foreignRoots = tenantRootsCache?.get(cacheKey);
2342
+ if (foreignRoots === undefined) {
2343
+ foreignRoots = foreignTenantRoots(hqRoot, parent);
2344
+ tenantRootsCache?.set(cacheKey, foreignRoots);
2345
+ }
2346
+ for (const foreign of foreignRoots) {
2347
+ if (isPathWithin(foreign, childReal)) return false; // bound 2: other tenant
2348
+ }
2349
+ return true;
2350
+ }
2351
+
2352
+ /**
2353
+ * Depth (in path segments below a company root) at which we look for the
2354
+ * directory symlinks a company publishes into its own folder. HQ's linked
2355
+ * topologies live at depth 1 (`companies/{co}/knowledge`) and depth 2
2356
+ * (`companies/{co}/repos/{name}`); going deeper would turn a containment check
2357
+ * into a full-tree walk for no additional coverage.
2358
+ */
2359
+ const FOREIGN_TENANT_LINK_SCAN_DEPTH = 2;
2360
+
2361
+ /**
2362
+ * Canonicalized roots that belong to a tenant OTHER than the one being pushed.
2363
+ * A lexically-contained path whose realpath lands inside any of these is
2364
+ * another company's data wearing this company's key, and must be refused.
2365
+ *
2366
+ * Two kinds of root are collected per foreign company:
2367
+ * - the company folder itself (`companies/{other}`), and
2368
+ * - the targets of the directory symlinks that folder publishes — HQ's
2369
+ * pattern-2 topology puts a company's knowledge in `repos/private/
2370
+ * knowledge-{other}` and links it in, so the bytes live OUTSIDE every
2371
+ * `companies/` root and a companies-only check would miss them entirely.
2372
+ *
2373
+ * Only consulted on the rare lexical arm (a path whose realpath is not inside
2374
+ * the company folder), never on the ordinary in-tree push, so the directory
2375
+ * scan is not on the hot path. Callers may memoize it for the duration of a
2376
+ * single collect pass; nothing memoizes it for longer, because the sync runner
2377
+ * is long-lived and a stale tenant map fails OPEN — the wrong direction for a
2378
+ * boundary whose whole job is to refuse.
2379
+ */
2380
+ function foreignTenantRoots(hqRoot: string, syncRoot: string): string[] {
2381
+ const companiesDir = path.join(hqRoot, "companies");
2382
+ let entries: fs.Dirent[];
2383
+ try {
2384
+ entries = fs.readdirSync(companiesDir, { withFileTypes: true });
2385
+ } catch {
2386
+ return []; // no companies/ tree here — nothing to be foreign to
2387
+ }
2388
+
2389
+ const activeReal = realpathSafe(syncRoot);
2390
+ const roots: string[] = [];
2391
+ for (const entry of entries) {
2392
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
2393
+ const companyRoot = path.join(companiesDir, entry.name);
2394
+ const companyReal = realpathSafe(companyRoot);
2395
+ if (companyReal === activeReal) continue; // this is the tenant being pushed
2396
+ roots.push(companyReal);
2397
+ collectPublishedLinkTargets(companyRoot, companyReal, FOREIGN_TENANT_LINK_SCAN_DEPTH, roots);
2398
+ }
2399
+ return roots;
2400
+ }
2401
+
2402
+ /**
2403
+ * Append the resolved targets of the directory symlinks published under
2404
+ * `companyRoot` (down to `depth` levels) into `out`. Targets that resolve back
2405
+ * inside the company folder are skipped — they add no reach beyond the root
2406
+ * already recorded. Errors are swallowed per entry on purpose: an unreadable
2407
+ * sibling directory must narrow what we can prove, never abort the push it is
2408
+ * unrelated to.
2409
+ */
2410
+ function collectPublishedLinkTargets(
2411
+ dir: string,
2412
+ companyReal: string,
2413
+ depth: number,
2414
+ out: string[],
2415
+ ): void {
2416
+ if (depth <= 0) return;
2417
+ let entries: fs.Dirent[];
2418
+ try {
2419
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2420
+ } catch {
2421
+ return;
2422
+ }
2423
+ for (const entry of entries) {
2424
+ const child = path.join(dir, entry.name);
2425
+ if (entry.isSymbolicLink()) {
2426
+ let real: string;
2427
+ try {
2428
+ real = fs.realpathSync.native(child);
2429
+ } catch {
2430
+ continue; // dangling link claims nothing
2431
+ }
2432
+ try {
2433
+ if (!fs.statSync(real).isDirectory()) continue;
2434
+ } catch {
2435
+ continue;
2436
+ }
2437
+ if (isPathWithin(companyReal, real)) continue; // no reach beyond the root
2438
+ out.push(real);
2439
+ } else if (entry.isDirectory()) {
2440
+ collectPublishedLinkTargets(child, companyReal, depth - 1, out);
2441
+ }
2442
+ }
2443
+ }
2444
+
2445
+ /**
2446
+ * If a recorded directory symlink's target resolves OUTSIDE `syncRoot`, invoke
2447
+ * `onLinkedSubtree` so the caller can report that the link's contents were not
2448
+ * uploaded to the vault. Fires only for links that (a) resolve to a directory
2449
+ * and (b) point outside the company folder — an in-tree link is descended
2450
+ * elsewhere, and a dangling or file link has nothing behind it to report.
2451
+ */
2452
+ function reportLinkedSubtreeIfExternal(
2453
+ linkPath: string,
2454
+ syncRoot: string,
2455
+ relativePath: string,
2456
+ hooks: CollectHooks,
2457
+ ): void {
2458
+ if (!hooks.onLinkedSubtree) return;
2459
+ let real: string;
2460
+ try {
2461
+ real = fs.realpathSync.native(linkPath); // resolves the link to its target
2462
+ } catch {
2463
+ return; // dangling link — nothing behind it to report
2464
+ }
2465
+ let targetStat: fs.Stats;
2466
+ try {
2467
+ targetStat = fs.statSync(real);
2468
+ } catch {
2469
+ return;
2470
+ }
2471
+ if (!targetStat.isDirectory()) return;
2472
+ if (isWithin(syncRoot, real)) return; // in-tree target: descended elsewhere
2473
+ hooks.onLinkedSubtree(relativePath);
2474
+ }
2475
+
1884
2476
  /**
1885
2477
  * Collect files from paths (expanding directories recursively).
1886
2478
  *
@@ -1898,11 +2490,17 @@ function collectFiles(
1898
2490
  hqRoot: string,
1899
2491
  syncRoot: string,
1900
2492
  filter: (p: string, isDir?: boolean) => boolean,
2493
+ hooks: CollectHooks = {},
1901
2494
  ): CollectedEntry[] {
1902
2495
  const results: CollectedEntry[] = [];
2496
+ // Scoped to THIS collect pass and discarded with it: a watcher batch can
2497
+ // name hundreds of paths, and rescanning the companies/ tree for each one
2498
+ // is wasted I/O. A cache that outlived the pass could fail open on a tenant
2499
+ // boundary, so it deliberately does not.
2500
+ const tenantRoots = new Map<string, string[]>();
1903
2501
 
1904
2502
  for (const p of paths) {
1905
- const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
2503
+ const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
1906
2504
 
1907
2505
  // Ephemeral artifacts (conflict mirrors) — see EPHEMERAL_PATH_PATTERN doc.
1908
2506
  // Caller may pass one explicitly; we still refuse to upload it. Basename
@@ -1918,6 +2516,7 @@ function collectFiles(
1918
2516
  lstat = fs.lstatSync(absolutePath);
1919
2517
  } catch {
1920
2518
  console.error(` Warning: ${p} does not exist, skipping.`);
2519
+ hooks.onUnreachablePath?.(p, "missing");
1921
2520
  continue;
1922
2521
  }
1923
2522
 
@@ -1936,6 +2535,7 @@ function collectFiles(
1936
2535
  if (lstat.isSymbolicLink()) {
1937
2536
  if (!isWithinForLink(syncRoot, absolutePath)) {
1938
2537
  console.error(` Warning: ${p} is outside company folder, skipping.`);
2538
+ hooks.onUnreachablePath?.(p, "outside-company");
1939
2539
  continue;
1940
2540
  }
1941
2541
  const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
@@ -1950,23 +2550,35 @@ function collectFiles(
1950
2550
  // whole branch). The filter is pure path lookup with no I/O,
1951
2551
  // so two calls are free.
1952
2552
  if (!filter(absolutePath, false) && !filter(absolutePath, true)) continue;
2553
+ const target = readlinkOrNull(absolutePath);
2554
+ if (target === null) {
2555
+ console.error(` Warning: ${p} is an unreadable symbolic link, skipping.`);
2556
+ hooks.onUnreachablePath?.(p, "unreadable-link");
2557
+ continue;
2558
+ }
2559
+ // A directory symlink whose target lives outside the company folder is
2560
+ // recorded here but never descended — its contents ship via their own
2561
+ // repo, not the vault. Surface it so files created under such a link
2562
+ // don't vanish from every push bucket silently.
2563
+ reportLinkedSubtreeIfExternal(absolutePath, syncRoot, relativePath, hooks);
1953
2564
  results.push({
1954
2565
  kind: "symlink",
1955
2566
  absolutePath,
1956
2567
  relativePath,
1957
- target: fs.readlinkSync(absolutePath),
2568
+ target,
1958
2569
  });
1959
2570
  continue;
1960
2571
  }
1961
2572
 
1962
- if (!isWithin(syncRoot, absolutePath)) {
2573
+ if (!isWithinLexicalOrReal(syncRoot, absolutePath, hqRoot, tenantRoots)) {
1963
2574
  console.error(` Warning: ${p} is outside company folder, skipping.`);
2575
+ hooks.onUnreachablePath?.(p, "outside-company");
1964
2576
  continue;
1965
2577
  }
1966
2578
 
1967
2579
  if (lstat.isDirectory()) {
1968
2580
  if (!filter(absolutePath, true)) continue;
1969
- walkDir(absolutePath, syncRoot, filter, results);
2581
+ walkDir(absolutePath, syncRoot, filter, results, hooks);
1970
2582
  } else if (lstat.isFile()) {
1971
2583
  const relativePath = vaultKeyForLocalPath(syncRoot, absolutePath);
1972
2584
  if (filter(absolutePath)) {
@@ -1983,6 +2595,7 @@ function walkDir(
1983
2595
  syncRoot: string,
1984
2596
  filter: (p: string, isDir?: boolean) => boolean,
1985
2597
  results: CollectedEntry[],
2598
+ hooks: CollectHooks = {},
1986
2599
  ): void {
1987
2600
  // A frame per open directory preserves the recursive walk's depth-first
1988
2601
  // ordering without turning a completed subtree into one giant call argument
@@ -2028,15 +2641,26 @@ function walkDir(
2028
2641
  // private/knowledge-{co}/), causing per-company knowledge repos
2029
2642
  // to be uploaded into every vault that links them. Recording
2030
2643
  // and not following preserves the link topology while avoiding
2031
- // that duplication. readlinkSync on a Dirent-known link cannot
2032
- // fail under normal conditions; let the throw propagate if it
2033
- // somehow does (race with rm, EPERM) the operator needs to
2034
- // see it rather than us silently dropping the link again.
2644
+ const linkRelative = vaultKeyForLocalPath(syncRoot, absolutePath);
2645
+ // On win32, a Dirent-known link is not sufficient proof that readlink
2646
+ // will succeed (notably for some reparse points). Never fall through to
2647
+ // normal file/directory handling here: doing so would dereference the
2648
+ // link and duplicate its target under this vault key.
2649
+ const target = readlinkOrNull(absolutePath);
2650
+ if (target === null) {
2651
+ console.error(` Warning: ${linkRelative} is an unreadable symbolic link, skipping.`);
2652
+ hooks.onUnreachablePath?.(linkRelative, "unreadable-link");
2653
+ continue;
2654
+ }
2655
+ // The link is recorded but its (external) target is not descended, so
2656
+ // any files under it are NOT uploaded. Report the subtree so a full
2657
+ // `sync now` no longer drops it from every bucket without a trace.
2658
+ reportLinkedSubtreeIfExternal(absolutePath, syncRoot, linkRelative, hooks);
2035
2659
  results.push({
2036
2660
  kind: "symlink",
2037
2661
  absolutePath,
2038
- relativePath: vaultKeyForLocalPath(syncRoot, absolutePath),
2039
- target: fs.readlinkSync(absolutePath),
2662
+ relativePath: linkRelative,
2663
+ target,
2040
2664
  });
2041
2665
  continue;
2042
2666
  }
@@ -2155,6 +2779,23 @@ function isWithinForLink(parent: string, linkPath: string): boolean {
2155
2779
  *
2156
2780
  * Returns `[""]` (whole-tree) when any input path resolves to `syncRoot`
2157
2781
  * itself; this is the bidirectional-runner case.
2782
+ *
2783
+ * Path spellings are resolved with `resolveNamedPath`, the same resolver the
2784
+ * upload leg uses, so `hq sync push knowledge/agents` scopes deletes exactly
2785
+ * like its absolute equivalent instead of silently resolving nowhere and
2786
+ * scoping nothing.
2787
+ *
2788
+ * Containment, however, deliberately stays on strict realpath `isWithin` and
2789
+ * does NOT adopt the upload leg's `isWithinLexicalOrReal` relaxation. The two
2790
+ * legs are asymmetric on purpose: the upload leg ships the files it was handed,
2791
+ * while a delete scope is a PREFIX that authorizes removing every remote object
2792
+ * beneath it. A linked subtree's contents are never walked (`walkDir` does not
2793
+ * descend external directory symlinks), so anchoring a delete scope on one
2794
+ * would compare an empty local walk against a populated remote prefix and sweep
2795
+ * the whole prefix away. Narrow-and-safe beats wide-and-lossy here; the
2796
+ * accepted cost is that deletes inside a linked subtree are not propagated,
2797
+ * which matches the snapshot semantics `hq sync push` already documents for
2798
+ * those paths.
2158
2799
  */
2159
2800
  function resolveDeleteScopeRoots(
2160
2801
  paths: string[],
@@ -2181,7 +2822,7 @@ function resolveDeleteScopeRoots(
2181
2822
  prefixes.add(normalized);
2182
2823
  }
2183
2824
  for (const p of paths) {
2184
- const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
2825
+ const absolutePath = resolveNamedPath(p, hqRoot, syncRoot);
2185
2826
  if (!fs.existsSync(absolutePath)) continue;
2186
2827
  if (!isWithin(syncRoot, absolutePath)) continue;
2187
2828
  const stat = fs.statSync(absolutePath);
@@ -2480,6 +3121,22 @@ async function computeDeletePlan(
2480
3121
  if (!inScope) continue;
2481
3122
  inScopeJournalEntries++;
2482
3123
  const localPath = localPathForVaultKey(syncRoot, relativeKey);
3124
+
3125
+ // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
3126
+ // context, a journal entry at a literal `companies/…` key records a
3127
+ // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
3128
+ // presign transport is rejected by the server validator
3129
+ // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
3130
+ // straight to `toTombstone` (journal drop + local doubled-tree cleanup, no
3131
+ // remote call). Personal-vault pushes (personalMode) carry legitimate
3132
+ // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
3133
+ // Checked before the presentLocally gate: the poison file lives at the
3134
+ // doubled path and must drain even while still on disk.
3135
+ if (companyScoped && relativeKey.startsWith("companies/")) {
3136
+ plan.toTombstone.push(relativeKey);
3137
+ continue;
3138
+ }
3139
+
2483
3140
  let presentLocally = true;
2484
3141
  try {
2485
3142
  fs.lstatSync(localPath);
@@ -2516,20 +3173,6 @@ async function computeDeletePlan(
2516
3173
  continue;
2517
3174
  }
2518
3175
 
2519
- // Scope-invalid journal keys (incident 2026-07-11): in a COMPANY-scoped
2520
- // context, a journal entry at a literal `companies/…` key records a
2521
- // doubled-tree poisoning upload. HEAD/DeleteObject on such a key via the
2522
- // presign transport is rejected by the server validator
2523
- // (INVALID_KEY_COMPANIES_SCOPED) and would error the push, so route it
2524
- // straight to `toTombstone` (journal drop, no remote call) — the local
2525
- // journal entry drains; server-side cleanup of any poisoned object is an
2526
- // operator action. Personal-vault pushes (personalMode) carry legitimate
2527
- // `companies/{slug}/…` keys and are unaffected (companyScoped=false).
2528
- if (companyScoped && relativeKey.startsWith("companies/")) {
2529
- plan.toTombstone.push(relativeKey);
2530
- continue;
2531
- }
2532
-
2533
3176
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
2534
3177
  // Ephemeral artifacts (conflict mirrors) never propagate-delete via the
2535
3178
  // normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op