@indigoai-us/hq-cloud 6.0.0 → 6.0.2

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.
@@ -1091,6 +1091,140 @@ describe("share", () => {
1091
1091
  expect(journal.files["dangling-link.md"]).toBeDefined();
1092
1092
  });
1093
1093
 
1094
+ it(
1095
+ "personal-vault override: `owned-only` is coerced to `currency-gated` so a direction:'down' " +
1096
+ "entry whose local file is gone DOES tombstone in the vault (the May-27 .drift-* leak class)",
1097
+ async () => {
1098
+ // The personal vault is a single-human-many-machines target. The
1099
+ // owned-only "I won't tombstone peer content" rule has no peer to
1100
+ // protect against, so it traps legitimate local deletes as permanent
1101
+ // vault litter — exactly what the May-27 `personal/.obsidian/*.drift-*`
1102
+ // pair did on this EC2 (journal direction "down", local rm, push
1103
+ // silently swallowed the delete). Coercing to currency-gated keeps the
1104
+ // only safety intent that still applies (HEAD-verify etag matches the
1105
+ // journal before tombstone) without the multi-user premise.
1106
+ const stateDirPersonal = fs.mkdtempSync(
1107
+ path.join(os.tmpdir(), "hq-state-prs-"),
1108
+ );
1109
+ process.env.HQ_STATE_DIR = stateDirPersonal;
1110
+ try {
1111
+ // Personal-vault syncRoot is `hqRoot` itself in personalMode, so the
1112
+ // missing file's absolute path resolves to <hqRoot>/<key>.
1113
+ // No on-disk file = local missing. Journal records a prior pull.
1114
+ const journalPath = path.join(
1115
+ stateDirPersonal,
1116
+ "sync-journal.__hq_personal_vault__.json",
1117
+ );
1118
+ fs.writeFileSync(
1119
+ journalPath,
1120
+ JSON.stringify({
1121
+ version: "1",
1122
+ lastSync: new Date().toISOString(),
1123
+ files: {
1124
+ "personal/scripts/run-project.sh.drift-1779863862-42519": {
1125
+ hash: "personal-vault-drift-hash",
1126
+ size: 1054,
1127
+ syncedAt: new Date(Date.now() - 86400000).toISOString(),
1128
+ direction: "down",
1129
+ remoteEtag: "personal-vault-drift-etag",
1130
+ },
1131
+ },
1132
+ }),
1133
+ );
1134
+
1135
+ // (Pre-6.0.2: currency-gated would have HEADed the candidate; the
1136
+ // mockResolvedValueOnce that lived here is now redundant because the
1137
+ // vault-litter drain bypasses HEAD entirely for `.drift-` markers.
1138
+ // Leaving the mock here would queue an unconsumed return value and
1139
+ // poison the next test's HEAD call — removed so the assertion above
1140
+ // verifies the litter path, not the etag path.)
1141
+
1142
+ const personalCtx = makeEntityContext({
1143
+ uid: "prs_01HASSAANTESTUSER",
1144
+ slug: "__hq_personal_vault__",
1145
+ bucketName: "hq-vault-prs-01HASSAANTESTUSER",
1146
+ });
1147
+
1148
+ const result = await share({
1149
+ paths: [tmpDir],
1150
+ entityContext: personalCtx,
1151
+ hqRoot: tmpDir,
1152
+ personalMode: true,
1153
+ skipUnchanged: true,
1154
+ propagateDeletes: true,
1155
+ // Caller pinned owned-only. The override MUST take precedence on a
1156
+ // personal-vault entity — the whole point of the fix is that owned-
1157
+ // only's "don't tombstone peer-uploaded content" rule has no peer
1158
+ // to protect against here, so we coerce to currency-gated.
1159
+ propagateDeletePolicy: "owned-only",
1160
+ });
1161
+
1162
+ expect(result.filesDeleted).toBe(1);
1163
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
1164
+ expect.anything(),
1165
+ "personal/scripts/run-project.sh.drift-1779863862-42519",
1166
+ );
1167
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1168
+ expect(
1169
+ journal.files["personal/scripts/run-project.sh.drift-1779863862-42519"],
1170
+ ).toBeUndefined();
1171
+ } finally {
1172
+ fs.rmSync(stateDirPersonal, { recursive: true, force: true });
1173
+ delete process.env.HQ_STATE_DIR;
1174
+ }
1175
+ },
1176
+ );
1177
+
1178
+ it(
1179
+ "personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
1180
+ "refuses to tombstone a direction:'down' entry (multi-user curation intact)",
1181
+ async () => {
1182
+ // Sibling guard for the previous test: the override is scoped strictly
1183
+ // to `prs_` entities. Company vaults preserve owned-only's multi-user
1184
+ // semantics — a behind machine pulling Alice's upload, then rm'ing it
1185
+ // locally, must NOT tombstone Alice's file. Without this guard the
1186
+ // override could silently widen its blast radius the next time someone
1187
+ // refactors share()'s entity-typing.
1188
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1189
+ fs.mkdirSync(companyRoot, { recursive: true });
1190
+ // No file on disk under companies/acme/peer-uploaded.md → local missing.
1191
+
1192
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
1193
+ fs.writeFileSync(
1194
+ journalPath,
1195
+ JSON.stringify({
1196
+ version: "1",
1197
+ lastSync: new Date().toISOString(),
1198
+ files: {
1199
+ "peer-uploaded.md": {
1200
+ hash: "alice-hash",
1201
+ size: 100,
1202
+ syncedAt: new Date(Date.now() - 86400000).toISOString(),
1203
+ direction: "down",
1204
+ remoteEtag: "alice-etag",
1205
+ },
1206
+ },
1207
+ }),
1208
+ );
1209
+
1210
+ const result = await share({
1211
+ paths: [companyRoot],
1212
+ company: "acme",
1213
+ vaultConfig: mockConfig,
1214
+ hqRoot: tmpDir,
1215
+ skipUnchanged: true,
1216
+ propagateDeletes: true,
1217
+ // Company-vault entity (cmp_) → owned-only stays in effect.
1218
+ propagateDeletePolicy: "owned-only",
1219
+ });
1220
+
1221
+ expect(result.filesDeleted).toBe(0);
1222
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
1223
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1224
+ expect(journal.files["peer-uploaded.md"]).toBeDefined();
1225
+ },
1226
+ );
1227
+
1094
1228
  it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
1095
1229
  const companyRoot = path.join(tmpDir, "companies", "acme");
1096
1230
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -1834,14 +1968,23 @@ describe("share", () => {
1834
1968
  expect(uploadFile).not.toHaveBeenCalled();
1835
1969
  });
1836
1970
 
1837
- it("conflict-mirror exclusion: journaled mirror with local-missing is NOT swept by delete plan", async () => {
1971
+ it("vault-litter drain (6.0.2): journaled `.conflict-*` mirror with local-missing IS swept by the delete plan", async () => {
1972
+ // Updated semantics (6.0.2): the existing-litter state — a conflict
1973
+ // mirror that leaked into the journal from a prior buggy upload and was
1974
+ // subsequently deleted locally — used to survive every sync because the
1975
+ // `isEphemeralPath` skip in computeDeletePlan was wired to drop it
1976
+ // before tombstoning. The deferred "dedicated reconcile command" the
1977
+ // doc-comment promised never materialized, and the litter accumulated
1978
+ // until users noticed it (e.g. the May-27 `personal/.obsidian/*.drift-*`
1979
+ // pair on the EC2 outpost). The fix: drain unconditionally via the
1980
+ // litter bucket — bypass shouldSync, ephemeral-skip, policy, and the
1981
+ // bulk-asymmetry breaker. By construction litter is not user content
1982
+ // (the EPHEMERAL_PATH_PATTERN / DRIFT_PATH_PATTERN regexes are precise
1983
+ // enough that a false-positive on a real user filename is vanishingly
1984
+ // unlikely), so the absence of those gates is the correct behavior.
1838
1985
  const companyRoot = path.join(tmpDir, "companies", "acme");
1839
1986
  fs.mkdirSync(companyRoot, { recursive: true });
1840
- // Simulate the existing-litter state: a conflict mirror that leaked
1841
- // into the journal in a prior buggy version. Locally missing (user
1842
- // already deleted it). The regular delete plan must NOT issue a
1843
- // DeleteObject — that's the dedicated reconcile command's job, and
1844
- // a sync should not accidentally race a user reviewing the mirror.
1987
+
1845
1988
  const journalPath = path.join(stateDir, "sync-journal.acme.json");
1846
1989
  fs.writeFileSync(
1847
1990
  journalPath,
@@ -1863,7 +2006,10 @@ describe("share", () => {
1863
2006
  }),
1864
2007
  );
1865
2008
 
1866
- // HEAD needed for the non-mirror entry under currency-gated.
2009
+ // HEAD needed for the non-mirror entry under currency-gated. The litter
2010
+ // drain bypasses HEAD by design — the etag check encodes a "remote
2011
+ // hasn't drifted since I last synced" invariant that doesn't apply to
2012
+ // never-should-have-been-there litter.
1867
2013
  vi.mocked(headRemoteFile).mockResolvedValue({
1868
2014
  lastModified: new Date(),
1869
2015
  etag: '"regular-etag"',
@@ -1879,19 +2025,81 @@ describe("share", () => {
1879
2025
  propagateDeletes: true,
1880
2026
  });
1881
2027
 
1882
- expect(result.filesDeleted).toBe(1);
1883
- expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
2028
+ // BOTH the regular file AND the conflict mirror tombstone in one pass.
2029
+ expect(result.filesDeleted).toBe(2);
2030
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(2);
1884
2031
  expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
1885
- expect(deleteRemoteFile).not.toHaveBeenCalledWith(
2032
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
1886
2033
  expect.anything(),
1887
- expect.stringContaining("conflict-"),
2034
+ "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md",
1888
2035
  );
1889
- // Mirror's journal entry survives reconcile command (separate skill)
1890
- // sweeps it once the user explicitly opts in.
2036
+ // Both journal entries are removed by the delete loop's removeEntry call.
1891
2037
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1892
2038
  expect(
1893
2039
  journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"],
1894
- ).toBeDefined();
2040
+ ).toBeUndefined();
2041
+ expect(journal.files["regular.md"]).toBeUndefined();
2042
+ });
2043
+
2044
+ it("vault-litter drain (6.0.2): `.drift-<unixts>-<pid>` rescue markers drain regardless of personal-vault exclusion path (the live obsidian case)", async () => {
2045
+ // The exact failure mode that motivated the fix: a rescue-overlay
2046
+ // `.drift-` marker sitting under a personal-vault default exclusion
2047
+ // (`.obsidian/**`) survives every sync because `shouldSync` rejects
2048
+ // the parent path before the delete plan ever considers the entry.
2049
+ // The litter bypass routes such keys around shouldSync directly into
2050
+ // `litterToDelete`.
2051
+ const stateDirPersonal = fs.mkdtempSync(
2052
+ path.join(os.tmpdir(), "hq-state-prs-drift-"),
2053
+ );
2054
+ process.env.HQ_STATE_DIR = stateDirPersonal;
2055
+ try {
2056
+ const journalPath = path.join(
2057
+ stateDirPersonal,
2058
+ "sync-journal.__hq_personal_vault__.json",
2059
+ );
2060
+ fs.writeFileSync(
2061
+ journalPath,
2062
+ JSON.stringify({
2063
+ version: "1",
2064
+ lastSync: new Date().toISOString(),
2065
+ files: {
2066
+ "personal/.obsidian/graph.json.drift-1779863862-42519": {
2067
+ hash: "h", size: 1054, syncedAt: new Date().toISOString(),
2068
+ direction: "down",
2069
+ remoteEtag: "obsidian-drift-etag",
2070
+ },
2071
+ },
2072
+ }),
2073
+ );
2074
+
2075
+ const personalCtx = makeEntityContext({
2076
+ uid: "prs_01HASSAANTESTUSER",
2077
+ slug: "__hq_personal_vault__",
2078
+ bucketName: "hq-vault-prs-01HASSAANTESTUSER",
2079
+ });
2080
+
2081
+ const result = await share({
2082
+ paths: [tmpDir],
2083
+ entityContext: personalCtx,
2084
+ hqRoot: tmpDir,
2085
+ personalMode: true,
2086
+ skipUnchanged: true,
2087
+ propagateDeletes: true,
2088
+ });
2089
+
2090
+ expect(result.filesDeleted).toBe(1);
2091
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
2092
+ expect.anything(),
2093
+ "personal/.obsidian/graph.json.drift-1779863862-42519",
2094
+ );
2095
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2096
+ expect(
2097
+ journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"],
2098
+ ).toBeUndefined();
2099
+ } finally {
2100
+ fs.rmSync(stateDirPersonal, { recursive: true, force: true });
2101
+ delete process.env.HQ_STATE_DIR;
2102
+ }
1895
2103
  });
1896
2104
 
1897
2105
  // ── personalMode ───────────────────────────────────────────────────────────
package/src/cli/share.ts CHANGED
@@ -100,6 +100,64 @@ export const EPHEMERAL_PATH_PATTERN =
100
100
  * matches anywhere in the string, which is fine: the
101
101
  * `.conflict-<ISO>-<hash>.` token is unambiguous.
102
102
  */
103
+ /**
104
+ * Rescue-overlay drift marker pattern. Written by `replace-rescue.sh`
105
+ * (`rescue_one` / `conflict_one`) when its rescue target already exists:
106
+ * `<orig>.drift-<unix-ts>-<pid>` — a collision suffix so the previous override
107
+ * is never silently overwritten. Distinct from `EPHEMERAL_PATH_PATTERN` —
108
+ * different producer (the rescue script vs the sync conflict-mirror path),
109
+ * different filename grammar (decimal timestamp + decimal pid vs ISO timestamp
110
+ * + hex machine hash). Like conflict mirrors, drift markers should never live
111
+ * in the vault; if a buggy past run uploaded one, the delete plan must be
112
+ * able to drain it regardless of where it sits.
113
+ */
114
+ export const DRIFT_PATH_PATTERN = /\.drift-\d+-\d+$/;
115
+
116
+ /**
117
+ * True iff the key is local-only ephemeral vault litter — a sync conflict
118
+ * mirror (`.conflict-<ISO>-<machine>[.ext]`) OR a rescue drift marker
119
+ * (`.drift-<unixts>-<pid>`).
120
+ *
121
+ * Used by `computeDeletePlan` to UNCONDITIONALLY drain existing vault litter,
122
+ * bypassing every "skip" gate that would otherwise trap it:
123
+ *
124
+ * 1. `shouldSync` — the personal-vault default exclusions (introduced in
125
+ * 5.25) reject paths like `**.obsidian/**`, `**.env`, `**output/**`,
126
+ * `**node_modules/**`, etc. from BOTH the upload walk AND the delete
127
+ * plan. That's correct for fresh content (don't upload), but it also
128
+ * strands any litter already in the vault at those paths — `<orig>.drift`
129
+ * / `<orig>.conflict` files inside an excluded parent get re-pulled
130
+ * every sync and never tombstoned. The live obsidian case here:
131
+ * `personal/.obsidian/graph.json.drift-1779863862-42519` survived every
132
+ * sync for two weeks because `.obsidian` is excluded.
133
+ * 2. `isEphemeralPath` — the existing skip in `computeDeletePlan` was
134
+ * designed to prevent a FRESH local `.conflict-*` mirror (written by the
135
+ * pull leg's "keep" branch as a side-by-side comparison file) from being
136
+ * miscounted as a delete candidate before the user has resolved the
137
+ * conflict. That intent stands, but it accidentally protects EXISTING
138
+ * cloud litter too. The fix: when the local file is already gone AND the
139
+ * key matches the litter pattern, drain it; the "fresh mirror, hasn't
140
+ * been resolved yet" case is impossible because that mirror would still
141
+ * be on disk.
142
+ * 3. The policy gate — `owned-only`'s direction filter and
143
+ * `currency-gated`'s etag check both encode user-content invariants
144
+ * that don't apply to litter (a `.drift-…-PID` file has no meaningful
145
+ * "ownership" or "freshness"; it's a stale local-overlay collision
146
+ * marker, by construction).
147
+ * 4. The bulk-asymmetry circuit breaker — litter cleanup is intentional
148
+ * ratchet-drain, not a "corrupt local mirror, refuse mass-delete"
149
+ * signal. Litter is queued via a separate bucket the breaker never
150
+ * sweeps.
151
+ *
152
+ * Together: a vault key matching either pattern always tombstones on the
153
+ * next push leg, regardless of personal-vault exclusions, ephemeral skip,
154
+ * policy, or breaker. Producer-side exclusions (upload walker + rescue
155
+ * script) close the ratchet on new litter; this drains the legacy buildup.
156
+ */
157
+ export function isVaultLitterArtifact(p: string): boolean {
158
+ return EPHEMERAL_PATH_PATTERN.test(p) || DRIFT_PATH_PATTERN.test(p);
159
+ }
160
+
103
161
  export function isEphemeralPath(p: string): boolean {
104
162
  return EPHEMERAL_PATH_PATTERN.test(p);
105
163
  }
@@ -538,7 +596,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
538
596
  // `propagateDeletePolicy: "currency-gated"` (explicit) or
539
597
  // `HQ_SYNC_DELETE_POLICY=currency-gated` (env, honored by sync-runner).
540
598
  // The default flip to `"currency-gated"` is scheduled for 5.25.0.
541
- const propagateDeletePolicy: "currency-gated" | "owned-only" | "all" =
599
+ let propagateDeletePolicy: "currency-gated" | "owned-only" | "all" =
542
600
  options.propagateDeletePolicy ?? "owned-only";
543
601
  const emit = options.onEvent ?? defaultConsoleLogger;
544
602
 
@@ -581,6 +639,28 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
581
639
  let ctx: EntityContext = entityContext
582
640
  ? entityContext
583
641
  : await resolveEntityContext(companyRef, vaultConfig!);
642
+
643
+ // Personal-vault policy correction (6.0.1). The `owned-only` rule encodes a
644
+ // multi-user curation premise — "don't tombstone peer-uploaded content even
645
+ // if my journal says I pulled it" — which is meaningful when several humans
646
+ // share a company bucket (a behind machine's first sync must not erase
647
+ // recent uploads from peers). On a personal vault that premise collapses:
648
+ // every file is the same human's content, just routed through different
649
+ // machines, and `direction: "down"` only means "uploaded from my laptop,
650
+ // pulled by my EC2" — it never means "uploaded by someone else." With
651
+ // `owned-only` in effect, `rm <file>` followed by `hq sync` silently fails
652
+ // to propagate the delete, leaving permanent vault litter (the May-27
653
+ // `personal/.obsidian/*.drift-*` files were diagnosed exactly this way).
654
+ // The etag-based `currency-gated` policy already captures the only safety
655
+ // intent that survives the single-user case ("don't tombstone if remote
656
+ // drifted since I last synced"); coerce to it here so the policy is right
657
+ // regardless of which caller's default landed. Explicit `"all"` is
658
+ // preserved — it's the emergency-reconcile opt-out and the caller has
659
+ // already asserted intent.
660
+ if (ctx.uid.startsWith("prs_") && propagateDeletePolicy === "owned-only") {
661
+ propagateDeletePolicy = "currency-gated";
662
+ }
663
+
584
664
  // Remote keys are company-relative; the on-disk scoping prefix is
585
665
  // companies/{slug}/. Anything outside this folder gets skipped to avoid
586
666
  // leaking cross-company state into the vault.
@@ -1777,12 +1857,21 @@ async function computeDeletePlan(
1777
1857
  // and the journal-mutation buckets are already settled before any I/O.
1778
1858
  type HeadCandidate = { key: string; journalEtag: string };
1779
1859
  const headCandidates: HeadCandidate[] = [];
1860
+ // Litter drain bucket — kept separate from `plan.toDelete` so the
1861
+ // bulk-asymmetry breaker (which moves toDelete + headCandidates into
1862
+ // `refusedStale` when it trips) can't sweep these out. See
1863
+ // `isVaultLitterArtifact` for the patterns and rationale: by construction
1864
+ // these aren't user content losses, so a high ratio of litter must not
1865
+ // refuse the drain — that's the whole point of the bypass. Merged into
1866
+ // `plan.toDelete` after the breaker check.
1867
+ const litterToDelete: string[] = [];
1780
1868
  // Bulk-asymmetry tracking: count every in-scope journal entry (denominator)
1781
1869
  // and every entry that would have been a delete-candidate before the guard
1782
1870
  // (numerator). Numerator = headCandidates + owned-only/all toDelete picks +
1783
1871
  // legacy-no-etag refusals. We do NOT count "ENOENT but ignore-filtered" or
1784
1872
  // "ENOENT but ephemeral" — those drop out of the plan entirely on their own
1785
- // and don't reflect mirror-loss intent.
1873
+ // and don't reflect mirror-loss intent — and litter drains (see above),
1874
+ // which are intentional ratchet-cleanup, not mass-delete intent.
1786
1875
  let inScopeJournalEntries = 0;
1787
1876
  let bulkCandidatePicks = 0;
1788
1877
 
@@ -1812,9 +1901,28 @@ async function computeDeletePlan(
1812
1901
  }
1813
1902
  }
1814
1903
  if (presentLocally) continue;
1904
+
1905
+ // Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
1906
+ // ALWAYS drain, bypassing `shouldSync` (which would skip them when the
1907
+ // parent path is in personal-vault default exclusions like
1908
+ // `.obsidian/**`), the `isEphemeralPath` skip (which protects FRESH
1909
+ // local conflict mirrors but accidentally also protects existing vault
1910
+ // litter), the policy gate (no meaningful ownership/freshness for
1911
+ // litter), and the bulk-asymmetry breaker (intentional ratchet-drain,
1912
+ // not corrupt-mirror mass-delete intent). See `isVaultLitterArtifact`
1913
+ // for the full rationale and patterns. Queued via the separate
1914
+ // `litterToDelete` bucket so the breaker can't sweep these out.
1915
+ if (isVaultLitterArtifact(relativeKey)) {
1916
+ litterToDelete.push(relativeKey);
1917
+ continue;
1918
+ }
1919
+
1815
1920
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
1816
1921
  // Ephemeral artifacts (conflict mirrors) never propagate-delete via the
1817
- // normal path — see EPHEMERAL_PATH_PATTERN doc.
1922
+ // normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
1923
+ // post-litter-drain above — any key matching EPHEMERAL_PATH_PATTERN was
1924
+ // already routed to `litterToDelete`. Retained as defense-in-depth +
1925
+ // documentation of the policy-side intent.
1818
1926
  if (isEphemeralPath(relativeKey)) continue;
1819
1927
 
1820
1928
  if (policy === "all") {
@@ -1885,6 +1993,12 @@ async function computeDeletePlan(
1885
1993
  ratio: bulkCandidatePicks / inScopeJournalEntries,
1886
1994
  samplePaths,
1887
1995
  };
1996
+ // Litter still drains even when the breaker trips — see `litterToDelete`
1997
+ // declaration. The breaker protects against corrupt-mirror mass-deletes
1998
+ // of user content; litter cleanup is orthogonal and should never be
1999
+ // refused for the same reason new-litter producer-side exclusions
2000
+ // shouldn't block it.
2001
+ plan.toDelete.push(...litterToDelete);
1888
2002
  return plan;
1889
2003
  }
1890
2004
 
@@ -1922,6 +2036,13 @@ async function computeDeletePlan(
1922
2036
  }
1923
2037
  }
1924
2038
 
2039
+ // Litter drains alongside the normal candidates — bypasses every gate but
2040
+ // settles into the same plan.toDelete bucket so the share() executor's
2041
+ // delete loop tombstones each key identically (DeleteObject + remove from
2042
+ // journal). Merged at the end so the bulk-asymmetry breaker above had its
2043
+ // chance to NOT include litter in either the numerator or the sweep.
2044
+ plan.toDelete.push(...litterToDelete);
2045
+
1925
2046
  return plan;
1926
2047
  }
1927
2048