@indigoai-us/hq-cloud 6.14.46 → 6.14.48

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.
@@ -2826,17 +2826,25 @@ describe("share", () => {
2826
2826
  });
2827
2827
 
2828
2828
  it(
2829
- "personal-vault override: `owned-only` is coerced to `currency-gated` so a direction:'down' " +
2830
- "entry whose local file is gone DOES tombstone in the vault (the May-27 .drift-* leak class)",
2829
+ "vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
2830
+ "even under owned-only, because litter bypasses the policy gate (the May-27 .drift-* leak class)",
2831
2831
  async () => {
2832
- // The personal vault is a single-human-many-machines target. The
2833
- // owned-only "I won't tombstone peer content" rule has no peer to
2834
- // protect against, so it traps legitimate local deletes as permanent
2835
- // vault litter exactly what the May-27 `personal/.obsidian/*.drift-*`
2836
- // pair did on this EC2 (journal direction "down", local rm, push
2837
- // silently swallowed the delete). Coercing to currency-gated keeps the
2838
- // only safety intent that still applies (HEAD-verify etag matches the
2839
- // journal before tombstone) without the multi-user premise.
2832
+ // Provenance: this test was written for a `prs_`-scoped coercion that
2833
+ // rewrote owned-only -> currency-gated. That coercion is gone (6.14.48
2834
+ // it silently defeated the rollback knob; see `createPushRunContext`).
2835
+ // The test still passes, and is retained, because what actually drains
2836
+ // this key is the vault-litter path (6.0.2): `isVaultLitterArtifact`
2837
+ // routes `.drift-<ts>-<pid>` keys to `litterToDelete` BEFORE any policy,
2838
+ // intent, ignore-filter or breaker check runs. That is the durable
2839
+ // guarantee worth pinning the May-27 `personal/.obsidian/*.drift-*`
2840
+ // pair (journal direction "down", local rm, push silently swallowed the
2841
+ // delete) must drain regardless of which delete policy is in force.
2842
+ //
2843
+ // Note the inputs deliberately keep `direction: "down"`, no
2844
+ // `localDeleteIntent`, and an explicit `owned-only` policy: every one of
2845
+ // those would refuse this key on the normal path, so a regression that
2846
+ // removed the litter bypass would fail here rather than silently
2847
+ // re-stranding litter.
2840
2848
  const stateDirPersonal = fs.mkdtempSync(
2841
2849
  path.join(os.tmpdir(), "hq-state-prs-"),
2842
2850
  );
@@ -2886,10 +2894,8 @@ describe("share", () => {
2886
2894
  personalMode: true,
2887
2895
  skipUnchanged: true,
2888
2896
  propagateDeletes: true,
2889
- // Caller pinned owned-only. The override MUST take precedence on a
2890
- // personal-vault entity the whole point of the fix is that owned-
2891
- // only's "don't tombstone peer-uploaded content" rule has no peer
2892
- // to protect against here, so we coerce to currency-gated.
2897
+ // Pinned owned-only the strictest policy. Litter must drain
2898
+ // anyway; that bypass is the property under test.
2893
2899
  propagateDeletePolicy: "owned-only",
2894
2900
  });
2895
2901
 
@@ -2995,15 +3001,14 @@ describe("share", () => {
2995
3001
  });
2996
3002
 
2997
3003
  it(
2998
- "personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
2999
- "refuses to tombstone a direction:'down' entry (multi-user curation intact)",
3004
+ "owned-only on a cmp_ entity refuses to tombstone a direction:'down' entry " +
3005
+ "(multi-user curation intact)",
3000
3006
  async () => {
3001
- // Sibling guard for the previous test: the override is scoped strictly
3002
- // to `prs_` entities. Company vaults preserve owned-only's multi-user
3003
- // semantics a behind machine pulling Alice's upload, then rm'ing it
3004
- // locally, must NOT tombstone Alice's file. Without this guard the
3005
- // override could silently widen its blast radius the next time someone
3006
- // refactors share()'s entity-typing.
3007
+ // owned-only's multi-user semantics: a behind machine that pulled
3008
+ // Alice's upload and then rm'd it locally must NOT tombstone Alice's
3009
+ // file. Paired with the prs_ test below, this pins owned-only as
3010
+ // uniformly strict across BOTH vault kinds the property a rollback
3011
+ // knob has to have to be worth reaching for.
3007
3012
  const companyRoot = path.join(tmpDir, "companies", "acme");
3008
3013
  fs.mkdirSync(companyRoot, { recursive: true });
3009
3014
  // No file on disk under companies/acme/peer-uploaded.md → local missing.
@@ -3044,6 +3049,118 @@ describe("share", () => {
3044
3049
  },
3045
3050
  );
3046
3051
 
3052
+ it(
3053
+ "ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
3054
+ "matching remote etag is REFUSED, not deleted",
3055
+ async () => {
3056
+ // Regression guard for the coercion removed in 6.14.48. A `prs_`-scoped
3057
+ // rewrite of owned-only -> currency-gated used to sit in
3058
+ // `createPushRunContext`. While both policies refused intent-less
3059
+ // deletes it was invisible. Once currency-gated started authorizing on
3060
+ // ETag currency alone, that rewrite meant an operator who set
3061
+ // `HQ_SYNC_DELETE_POLICY=owned-only` — the documented rollback, reached
3062
+ // for precisely BECAUSE deletes were misbehaving — still got intent-less
3063
+ // etag-only deletes on their personal vault. The rollback rolled back
3064
+ // company vaults only.
3065
+ //
3066
+ // The inputs below are exactly the shape that DOES delete under the
3067
+ // default policy (see "currency-gated: propagates a delete with NO
3068
+ // watcher intent…"): local file absent, no `localDeleteIntent`, journal
3069
+ // etag matching live remote. Under owned-only it must refuse. If the
3070
+ // coercion ever returns, `filesDeleted` becomes 1 and this fails.
3071
+ const stateDirPersonal = fs.mkdtempSync(
3072
+ path.join(os.tmpdir(), "hq-state-prs-rollback-"),
3073
+ );
3074
+ process.env.HQ_STATE_DIR = stateDirPersonal;
3075
+ try {
3076
+ const journalPath = path.join(
3077
+ stateDirPersonal,
3078
+ "sync-journal.__hq_personal_vault__.json",
3079
+ );
3080
+ fs.writeFileSync(
3081
+ journalPath,
3082
+ JSON.stringify({
3083
+ version: "1",
3084
+ lastSync: new Date().toISOString(),
3085
+ files: {
3086
+ // direction:"up" — so the refusal cannot be attributed to
3087
+ // owned-only's direction filter. The ONLY thing refusing this
3088
+ // key is the intent requirement the rollback restores.
3089
+ "personal/knowledge/gone.md": {
3090
+ hash: "rollback-hash",
3091
+ size: 42,
3092
+ syncedAt: new Date(Date.now() - 86400000).toISOString(),
3093
+ direction: "up",
3094
+ remoteEtag: "rollback-etag",
3095
+ kind: "file",
3096
+ // NO localDeleteIntent — no watcher was running.
3097
+ },
3098
+ },
3099
+ }),
3100
+ );
3101
+
3102
+ // Live remote still matches the journal — so under currency-gated this
3103
+ // key is a definite `toDelete`. Without this, the beforeEach default
3104
+ // (null = 404) would route a regression to `toTombstone` instead and
3105
+ // the test would catch it only via the weaker journal assertion.
3106
+ //
3107
+ // Deliberately NOT `mockResolvedValueOnce`: on the PASSING path
3108
+ // owned-only refuses before any HEAD is issued, so a queued one-shot
3109
+ // would go unconsumed and leak into the next test (`vi.clearAllMocks`
3110
+ // in afterEach does not drain a `…Once` queue — it clears call
3111
+ // records, not queued implementations). That is the same trap already
3112
+ // documented in the `.drift-*` litter test above. A persistent impl
3113
+ // reset in `finally` cannot leak either way.
3114
+ vi.mocked(headRemoteFile).mockResolvedValue({
3115
+ lastModified: new Date(),
3116
+ etag: '"rollback-etag"',
3117
+ size: 42,
3118
+ });
3119
+
3120
+ const refusals: Array<{ path: string; reason: string }> = [];
3121
+ const result = await share({
3122
+ paths: [tmpDir],
3123
+ entityContext: makeEntityContext({
3124
+ uid: "prs_01HASSAANTESTUSER",
3125
+ slug: "__hq_personal_vault__",
3126
+ bucketName: "hq-vault-prs-01HASSAANTESTUSER",
3127
+ }),
3128
+ hqRoot: tmpDir,
3129
+ personalMode: true,
3130
+ skipUnchanged: true,
3131
+ propagateDeletes: true,
3132
+ propagateDeletePolicy: "owned-only",
3133
+ onEvent: (event) => {
3134
+ if (event.type === "delete-refused-stale-etag") {
3135
+ refusals.push({ path: event.path, reason: event.reason });
3136
+ }
3137
+ },
3138
+ });
3139
+
3140
+ expect(result.filesDeleted).toBe(0);
3141
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
3142
+ // owned-only refuses on the intent gate, which sits BEFORE the HEAD
3143
+ // pass — so no currency check is ever issued. Asserting this also
3144
+ // keeps the mock above honest about never being consumed.
3145
+ expect(headRemoteFile).not.toHaveBeenCalled();
3146
+ // Refused for want of an intent — NOT silently dropped, and not
3147
+ // refused for some incidental reason that would mask a regression.
3148
+ expect(refusals).toEqual([
3149
+ { path: "personal/knowledge/gone.md", reason: "missing-delete-intent" },
3150
+ ]);
3151
+ // Journal entry survives, so a later run (or a watcher-minted intent)
3152
+ // can still authorize the delete properly.
3153
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3154
+ expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
3155
+ } finally {
3156
+ vi.mocked(headRemoteFile).mockReset();
3157
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
3158
+ fs.rmSync(stateDirPersonal, { recursive: true, force: true });
3159
+ delete process.env.HQ_STATE_DIR;
3160
+ }
3161
+ },
3162
+ );
3163
+
3047
3164
  it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
3048
3165
  const companyRoot = path.join(tmpDir, "companies", "acme");
3049
3166
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -3806,7 +3923,11 @@ describe("share", () => {
3806
3923
  expect.objectContaining({
3807
3924
  type: "delete-refused-stale-etag",
3808
3925
  path: "racy.md",
3809
- reason: "intent-changed",
3926
+ // The file came back on disk between planning and applying. That is
3927
+ // now reported as what it is, rather than as an intent-version change:
3928
+ // the re-check is no longer nested inside the intent branch, so it
3929
+ // also covers etag-only candidates that carry no intent version.
3930
+ reason: "recreated-locally",
3810
3931
  }),
3811
3932
  );
3812
3933
  });
@@ -3974,20 +4095,23 @@ describe("share", () => {
3974
4095
 
3975
4096
  expect(result.filesDeleted).toBe(0);
3976
4097
  expect(deleteRemoteFile).not.toHaveBeenCalled();
3977
- // HEAD must NOT be called — the explicit-intent gate rejects legacy
3978
- // entries before any remote operation.
4098
+ // HEAD must NOT be called — an entry with no recorded etag has nothing to
4099
+ // compare against, so currency-gated refuses it before any remote
4100
+ // operation. (Pre-etag-gated-deletes this was refused one gate earlier, for
4101
+ // want of a watcher intent; the outcome — nothing deleted, no remote call —
4102
+ // is deliberately identical.)
3979
4103
  expect(headRemoteFile).not.toHaveBeenCalled();
3980
4104
  const refused = events.find((e) => e.type === "delete-refused-stale-etag");
3981
4105
  expect(refused).toMatchObject({
3982
- journalEtag: "<missing-delete-intent>",
3983
- reason: "missing-delete-intent",
4106
+ journalEtag: "<legacy-no-etag>",
4107
+ reason: "legacy-no-etag",
3984
4108
  });
3985
4109
  expect(result.filesRefusedStale).toBe(1);
3986
4110
  expect(result.pathResults).toContainEqual({
3987
4111
  path: "legacy-no-etag.md",
3988
4112
  status: "refused",
3989
4113
  operation: "delete",
3990
- reason: "missing-delete-intent",
4114
+ reason: "legacy-no-etag",
3991
4115
  });
3992
4116
  });
3993
4117
 
@@ -4047,6 +4171,366 @@ describe("share", () => {
4047
4171
  );
4048
4172
  });
4049
4173
 
4174
+ // ── etag-gated deletes without a watcher ──────────────────────────────────
4175
+ //
4176
+ // A delete intent is minted ONLY by a live watcher at the instant it sees the
4177
+ // unlink. Three of the four ways HQ syncs never construct a watcher (outpost
4178
+ // and agent boxes run a one-shot runner, `hq sync now` is one-shot, and the
4179
+ // desktop drops the whole TreeWatcher when Instant-sync is off), so requiring
4180
+ // an intent made deletes structurally impossible there rather than merely
4181
+ // gated. currency-gated now authorizes on etag currency alone.
4182
+ //
4183
+ // The per-file question is unchanged: "has any peer touched this since we
4184
+ // journaled it". The whole-set question stays with the bulk-asymmetry
4185
+ // breaker, which these candidates still count toward — see the breaker
4186
+ // coverage above and policy `sync-delete-bulk-asymmetry-guard`.
4187
+
4188
+ it("currency-gated: propagates a delete with NO watcher intent when the remote etag still matches", async () => {
4189
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4190
+ fs.mkdirSync(companyRoot, { recursive: true });
4191
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
4192
+ fs.writeFileSync(
4193
+ journalPath,
4194
+ JSON.stringify({
4195
+ version: "1",
4196
+ lastSync: new Date().toISOString(),
4197
+ files: {
4198
+ "notes/gone.md": {
4199
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
4200
+ direction: "down",
4201
+ remoteEtag: "current-etag",
4202
+ kind: "file",
4203
+ // NO localDeleteIntent — deleted while no watcher was running.
4204
+ },
4205
+ },
4206
+ }),
4207
+ );
4208
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
4209
+ lastModified: new Date(),
4210
+ etag: '"current-etag"',
4211
+ size: 12,
4212
+ });
4213
+
4214
+ const result = await share({
4215
+ paths: [companyRoot],
4216
+ company: "acme",
4217
+ vaultConfig: mockConfig,
4218
+ hqRoot: tmpDir,
4219
+ skipUnchanged: true,
4220
+ propagateDeletes: true,
4221
+ propagateDeletePolicy: "currency-gated",
4222
+ });
4223
+
4224
+ expect(result.filesDeleted).toBe(1);
4225
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "notes/gone.md");
4226
+ });
4227
+
4228
+ it("currency-gated: refuses an intent-less delete when a peer has touched the remote", async () => {
4229
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4230
+ fs.mkdirSync(companyRoot, { recursive: true });
4231
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
4232
+ fs.writeFileSync(
4233
+ journalPath,
4234
+ JSON.stringify({
4235
+ version: "1",
4236
+ lastSync: new Date().toISOString(),
4237
+ files: {
4238
+ "notes/peer-edited.md": {
4239
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
4240
+ direction: "down",
4241
+ remoteEtag: "etag-we-journaled",
4242
+ kind: "file",
4243
+ },
4244
+ },
4245
+ }),
4246
+ );
4247
+ // A peer overwrote it since we journaled — their work must survive.
4248
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
4249
+ lastModified: new Date(),
4250
+ etag: '"a-different-etag"',
4251
+ size: 99,
4252
+ });
4253
+
4254
+ const events: Array<{ type: string; reason?: string }> = [];
4255
+ const result = await share({
4256
+ paths: [companyRoot],
4257
+ company: "acme",
4258
+ vaultConfig: mockConfig,
4259
+ hqRoot: tmpDir,
4260
+ skipUnchanged: true,
4261
+ propagateDeletes: true,
4262
+ propagateDeletePolicy: "currency-gated",
4263
+ onEvent: (e) => events.push(e as { type: string }),
4264
+ });
4265
+
4266
+ expect(result.filesDeleted).toBe(0);
4267
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
4268
+ expect(events).toContainEqual(
4269
+ expect.objectContaining({ type: "delete-refused-stale-etag", reason: "stale-etag" }),
4270
+ );
4271
+ });
4272
+
4273
+ it("currency-gated: abandons an etag-only delete when the file reappears between plan and apply", async () => {
4274
+ // Guards the apply-time absence re-check in `executeDeletes`. That check
4275
+ // used to live INSIDE the intent-version branch, so it was skipped for any
4276
+ // item carrying `intentVersion: null`. That was harmless while null meant
4277
+ // only litter and decommission — but etag-only candidates now travel with
4278
+ // `intentVersion: null` too, and without the restructure a file recreated
4279
+ // during the sync would be deleted from the remote while present on disk.
4280
+ //
4281
+ // The recreate is staged from inside the delete-plan HEAD, which is the
4282
+ // real TOCTOU window: planning has already decided the file is absent, and
4283
+ // the apply loop has not yet run. The push plan is computed BEFORE the
4284
+ // delete plan, so the new file is not uploaded by this run — it is purely
4285
+ // a late arrival the delete loop must notice.
4286
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4287
+ fs.mkdirSync(companyRoot, { recursive: true });
4288
+ const recreatedPath = path.join(companyRoot, "notes", "gone.md");
4289
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
4290
+ fs.writeFileSync(
4291
+ journalPath,
4292
+ JSON.stringify({
4293
+ version: "1",
4294
+ lastSync: new Date().toISOString(),
4295
+ files: {
4296
+ "notes/gone.md": {
4297
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
4298
+ direction: "down",
4299
+ remoteEtag: "current-etag",
4300
+ kind: "file",
4301
+ // NO localDeleteIntent — this is an etag-only candidate.
4302
+ },
4303
+ },
4304
+ }),
4305
+ );
4306
+ vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
4307
+ // A pull leg (or the user) puts the file back, mid-sync.
4308
+ fs.mkdirSync(path.dirname(recreatedPath), { recursive: true });
4309
+ fs.writeFileSync(recreatedPath, "back again");
4310
+ return { lastModified: new Date(), etag: '"current-etag"', size: 12 };
4311
+ });
4312
+
4313
+ const events: Array<{ type: string; reason?: string }> = [];
4314
+ const result = await share({
4315
+ paths: [companyRoot],
4316
+ company: "acme",
4317
+ vaultConfig: mockConfig,
4318
+ hqRoot: tmpDir,
4319
+ skipUnchanged: true,
4320
+ propagateDeletes: true,
4321
+ propagateDeletePolicy: "currency-gated",
4322
+ onEvent: (e) => events.push(e as { type: string }),
4323
+ });
4324
+
4325
+ expect(result.filesDeleted).toBe(0);
4326
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
4327
+ expect(events).toContainEqual(
4328
+ expect.objectContaining({
4329
+ type: "delete-refused-stale-etag",
4330
+ reason: "recreated-locally",
4331
+ }),
4332
+ );
4333
+ // The file that came back is still on disk — the whole point.
4334
+ expect(fs.existsSync(recreatedPath)).toBe(true);
4335
+ });
4336
+
4337
+ it("currency-gated: refuses an intent-less delete when the journal has no recorded etag", async () => {
4338
+ // Legacy journal entries (written before etag tracking) carry no
4339
+ // `remoteEtag`. Currency is the ONLY authorization an intent-less entry
4340
+ // has, so with nothing to compare there is nothing to authorize — refuse,
4341
+ // and keep the entry so a later sync that records an etag can re-evaluate.
4342
+ // No HEAD is issued: there is no etag to check it against.
4343
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4344
+ fs.mkdirSync(companyRoot, { recursive: true });
4345
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
4346
+ fs.writeFileSync(
4347
+ journalPath,
4348
+ JSON.stringify({
4349
+ version: "1",
4350
+ lastSync: new Date().toISOString(),
4351
+ files: {
4352
+ "notes/legacy.md": {
4353
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
4354
+ direction: "down",
4355
+ kind: "file",
4356
+ // No remoteEtag, no localDeleteIntent.
4357
+ },
4358
+ },
4359
+ }),
4360
+ );
4361
+
4362
+ const events: Array<{ type: string; reason?: string }> = [];
4363
+ const result = await share({
4364
+ paths: [companyRoot],
4365
+ company: "acme",
4366
+ vaultConfig: mockConfig,
4367
+ hqRoot: tmpDir,
4368
+ skipUnchanged: true,
4369
+ propagateDeletes: true,
4370
+ propagateDeletePolicy: "currency-gated",
4371
+ onEvent: (e) => events.push(e as { type: string }),
4372
+ });
4373
+
4374
+ expect(result.filesDeleted).toBe(0);
4375
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
4376
+ expect(headRemoteFile).not.toHaveBeenCalled();
4377
+ expect(events).toContainEqual(
4378
+ expect.objectContaining({
4379
+ type: "delete-refused-stale-etag",
4380
+ reason: "legacy-no-etag",
4381
+ }),
4382
+ );
4383
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
4384
+ expect(journal.files["notes/legacy.md"]).toBeDefined();
4385
+ });
4386
+
4387
+ it("owned-only still requires an intent — it is the documented rollback knob", async () => {
4388
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4389
+ fs.mkdirSync(companyRoot, { recursive: true });
4390
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
4391
+ fs.writeFileSync(
4392
+ journalPath,
4393
+ JSON.stringify({
4394
+ version: "1",
4395
+ lastSync: new Date().toISOString(),
4396
+ files: {
4397
+ "notes/gone.md": {
4398
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
4399
+ direction: "up",
4400
+ remoteEtag: "current-etag",
4401
+ kind: "file",
4402
+ },
4403
+ },
4404
+ }),
4405
+ );
4406
+
4407
+ const result = await share({
4408
+ paths: [companyRoot],
4409
+ company: "acme",
4410
+ vaultConfig: mockConfig,
4411
+ hqRoot: tmpDir,
4412
+ skipUnchanged: true,
4413
+ propagateDeletes: true,
4414
+ propagateDeletePolicy: "owned-only",
4415
+ });
4416
+
4417
+ expect(result.filesDeleted).toBe(0);
4418
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
4419
+ expect(headRemoteFile).not.toHaveBeenCalled();
4420
+ });
4421
+
4422
+ // THE load-bearing safety test for this change. An etag match cannot tell
4423
+ // "the user deleted this" from "the local mirror vanished" — in the steady
4424
+ // state nobody has overwritten anything, so a moved hqRoot / unmounted volume
4425
+ // / fresh clone presents mass-ENOENT with perfectly matching etags. That is
4426
+ // exactly how the 2026-05-25 indigo vault mass-delete destroyed 559 objects.
4427
+ // Relaxing the per-file gate must NOT weaken the whole-set guard.
4428
+ it("bulk-asymmetry breaker still refuses a mass intent-less disappearance", async () => {
4429
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4430
+ fs.mkdirSync(companyRoot, { recursive: true });
4431
+ const files: Record<string, unknown> = {};
4432
+ // 40 of 100 entries missing locally = 40%, far past the 10% threshold.
4433
+ for (let i = 0; i < 100; i++) {
4434
+ const present = i >= 40;
4435
+ const rel = `docs/f${i}.md`;
4436
+ if (present) {
4437
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
4438
+ fs.writeFileSync(path.join(companyRoot, rel), "x");
4439
+ }
4440
+ files[rel] = {
4441
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
4442
+ direction: "down",
4443
+ remoteEtag: `etag-${i}`,
4444
+ kind: "file",
4445
+ };
4446
+ }
4447
+ fs.writeFileSync(
4448
+ path.join(stateDir, "sync-journal.acme.json"),
4449
+ JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files }),
4450
+ );
4451
+
4452
+ const result = await share({
4453
+ paths: [companyRoot],
4454
+ company: "acme",
4455
+ vaultConfig: mockConfig,
4456
+ hqRoot: tmpDir,
4457
+ skipUnchanged: true,
4458
+ propagateDeletes: true,
4459
+ propagateDeletePolicy: "currency-gated",
4460
+ });
4461
+
4462
+ expect(result.filesDeleted).toBe(0);
4463
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
4464
+ // All 40 absent entries are refused as one set. (No assertion on
4465
+ // headRemoteFile here: the push leg HEADs the 60 files that ARE present,
4466
+ // so a global call-count says nothing about the delete plan.)
4467
+ expect(result.filesRefusedStale).toBe(40);
4468
+ });
4469
+
4470
+ // Regression: moving a company vault from syncMode 'all' to 'shared'.
4471
+ // `hq sync narrow --apply` / scope-shrink calls `tombstoneEntry`, which
4472
+ // deliberately KEEPS hash, direction and remoteEtag so a recovery flow can
4473
+ // see what was pruned — and it removes the file locally. Every such entry
4474
+ // therefore looks exactly like an etag-current intent-less delete candidate.
4475
+ // Propagating those would delete OTHER members' files out of the shared
4476
+ // company vault because one member narrowed their own scope.
4477
+ //
4478
+ // Deliberately asserted with NO prefixSet passed: the scope filter used to be
4479
+ // the only thing preventing this, and that made an implicit dependency
4480
+ // load-bearing for a destructive operation.
4481
+ it("all -> shared: scope-shrink tombstones are never propagated as remote deletes", async () => {
4482
+ const companyRoot = path.join(tmpDir, "companies", "acme");
4483
+ fs.mkdirSync(companyRoot, { recursive: true });
4484
+ const now = new Date().toISOString();
4485
+ const shrunk = (reason: string) => ({
4486
+ hash: "h", size: 3, syncedAt: now,
4487
+ direction: "down",
4488
+ remoteEtag: "still-current",
4489
+ kind: "file",
4490
+ removedAt: now,
4491
+ removedReason: reason,
4492
+ });
4493
+ fs.writeFileSync(
4494
+ path.join(stateDir, "sync-journal.acme.json"),
4495
+ JSON.stringify({
4496
+ version: "2",
4497
+ lastSync: now,
4498
+ files: {
4499
+ "teammate/a.md": shrunk("scope_shrink"),
4500
+ "teammate/b.md": shrunk("narrow_apply"),
4501
+ "teammate/c.md": shrunk("manual"),
4502
+ },
4503
+ pulls: [],
4504
+ }),
4505
+ );
4506
+
4507
+ // The remote is ALIVE and still exactly what the journal recorded. Without
4508
+ // this the keys would 404 into `toTombstone` and the test would pass for
4509
+ // the wrong reason — verified by disabling the guard and watching it still
4510
+ // go green.
4511
+ vi.mocked(headRemoteFile).mockResolvedValue({
4512
+ lastModified: new Date(),
4513
+ etag: '"still-current"',
4514
+ size: 3,
4515
+ });
4516
+
4517
+ const result = await share({
4518
+ paths: [companyRoot],
4519
+ company: "acme",
4520
+ vaultConfig: mockConfig,
4521
+ hqRoot: tmpDir,
4522
+ skipUnchanged: true,
4523
+ propagateDeletes: true,
4524
+ propagateDeletePolicy: "currency-gated",
4525
+ });
4526
+
4527
+ expect(result.filesDeleted).toBe(0);
4528
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
4529
+ // Not refusals either — they are simply not delete candidates at all, so
4530
+ // they never enter the plan and never count toward the breaker ratio.
4531
+ expect(result.filesRefusedStale).toBe(0);
4532
+ });
4533
+
4050
4534
  // ── Conflict-mirror exclusion (ephemeral pattern) ──────────────────────────
4051
4535
  //
4052
4536
  // Conflict mirrors (`*.conflict-<ISO>-<machineHash>.<ext>`) are local-only
@@ -7041,6 +7525,78 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
7041
7525
  expect(events.some((e) => e.type === "error")).toBe(false);
7042
7526
  });
7043
7527
 
7528
+ it("never plans an upload for macOS Finder Icon\\r files — silent skip, valid files still ship", async () => {
7529
+ // The incident: Finder writes an `Icon\r` file into every directory it
7530
+ // renders a custom icon for, and a folder-level cloud-sync agent spreads
7531
+ // them tree-wide. A CR is a control character, so validateVaultUploadKey
7532
+ // throws INVALID_KEY_CONTROL_CHARS inside executeUploads; that throw lands
7533
+ // in the generic catch and emits type:"error". errors[] non-empty makes
7534
+ // the runner return PARTIAL_SYNC_EXIT, so ONE such file marks EVERY sync
7535
+ // pass failed forever. On the reporting machine there were 100+ per run.
7536
+ //
7537
+ // They are excluded during the walk, like .DS_Store, so they never reach
7538
+ // the planner at all — and, being expected noise, without a per-file
7539
+ // "EXCLUDED from sync" warning.
7540
+ const companyRoot = path.join(tmpDir, "companies", "acme");
7541
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
7542
+ fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
7543
+ fs.writeFileSync(path.join(companyRoot, "Icon\r"), "finder cruft");
7544
+ fs.writeFileSync(path.join(companyRoot, "docs", "Icon\r"), "finder cruft");
7545
+
7546
+ const events: Array<{ type?: string; path?: string }> = [];
7547
+ const result = await share({
7548
+ paths: [companyRoot],
7549
+ company: "acme",
7550
+ vaultConfig: mockConfig,
7551
+ hqRoot: tmpDir,
7552
+ onEvent: (e) => events.push(e as { type?: string }),
7553
+ });
7554
+
7555
+ expect(result.aborted).toBe(false);
7556
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
7557
+ expect(uploadedKeys).toEqual(["docs/good.md"]);
7558
+ expect(result.filesUploaded).toBe(1);
7559
+ // The failure class this guards: an error event is what flips the runner
7560
+ // pass to exit 2.
7561
+ expect(events.some((e) => e.type === "error")).toBe(false);
7562
+ // Expected OS noise — excluded without the "review in case this is
7563
+ // unintended" warning that real content would earn.
7564
+ expect(events.some((e) => e.type === "ignore-excluded")).toBe(false);
7565
+ });
7566
+
7567
+ it("refuses any other control-character key up front — skip event, never an error", async () => {
7568
+ // Belt-and-suspenders for control-char paths that are not the Finder icon
7569
+ // spelling, and for callers that bypass the walker. Symmetric with the
7570
+ // pull leg, which has classified such keys since the same incident.
7571
+ const companyRoot = path.join(tmpDir, "companies", "acme");
7572
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
7573
+ fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
7574
+ fs.writeFileSync(path.join(companyRoot, "docs", "bad\u0001name.md"), "bad");
7575
+
7576
+ const events: Array<{ type?: string; path?: string; errorCode?: string }> = [];
7577
+ const result = await share({
7578
+ paths: [companyRoot],
7579
+ company: "acme",
7580
+ vaultConfig: mockConfig,
7581
+ hqRoot: tmpDir,
7582
+ onEvent: (e) => events.push(e as { type?: string }),
7583
+ });
7584
+
7585
+ expect(result.aborted).toBe(false);
7586
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
7587
+ expect(uploadedKeys).toEqual(["docs/good.md"]);
7588
+
7589
+ const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
7590
+ expect(skipEvents).toEqual([
7591
+ {
7592
+ type: "skip-invalid-scoped-key",
7593
+ path: "docs/bad\u0001name.md",
7594
+ errorCode: "INVALID_KEY_CONTROL_CHARS",
7595
+ },
7596
+ ]);
7597
+ expect(events.some((e) => e.type === "error")).toBe(false);
7598
+ });
7599
+
7044
7600
  it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
7045
7601
  // Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
7046
7602
  // companies/-prefixed key would be rejected by the presign transport's
@@ -7199,3 +7755,89 @@ describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", ()
7199
7755
  expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
7200
7756
  });
7201
7757
  });
7758
+
7759
+ // ── delete-refusal rendering ───────────────────────────────────────────────
7760
+ //
7761
+ // Every refusal cause shares ONE event type (`delete-refused-stale-etag`),
7762
+ // with the real cause carried in `reason`. Before this cover, the renderer
7763
+ // only branched on two of the five causes and the rest fell through to the
7764
+ // stale-etag wording — printing a real journal etag next to a
7765
+ // `<not-checked>` remote for a comparison that never happened. Operators
7766
+ // (including a customer bug report on 2026-08-04, and the investigation into
7767
+ // it) read that as an etag/currency defect and chased the wrong root cause.
7768
+ // Pin all five so no cause can silently fall through to etag wording again.
7769
+ describe("defaultConsoleLogger — delete refusal wording per reason", () => {
7770
+ const render = (reason: string): string => {
7771
+ const lines: string[] = [];
7772
+ const spy = vi.spyOn(console, "error").mockImplementation((msg?: unknown) => {
7773
+ lines.push(String(msg));
7774
+ });
7775
+ try {
7776
+ shareTesting.defaultConsoleLogger({
7777
+ type: "delete-refused-stale-etag",
7778
+ path: "knowledge/example.md",
7779
+ // A REAL etag, as the engine emits whenever the entry has one — the
7780
+ // `<missing-delete-intent>` placeholder only appears for entries that
7781
+ // never had an etag, which is the rare case.
7782
+ journalEtag: "0156114498af2c8939c50d7ef0d3a0b1",
7783
+ remoteEtag: "<not-checked>",
7784
+ reason,
7785
+ } as never);
7786
+ } finally {
7787
+ spy.mockRestore();
7788
+ }
7789
+ return lines.join("\n");
7790
+ };
7791
+
7792
+ it("names the missing authorization instead of blaming the etag", () => {
7793
+ const out = render("missing-delete-intent");
7794
+ expect(out).toContain("no-delete-authorization");
7795
+ expect(out).toContain("watcher did not observe this removal");
7796
+ // The specific regression: neither etag is meaningful, so neither may be
7797
+ // shown, and the stale-etag wording must not appear.
7798
+ expect(out).not.toContain("stale-etag");
7799
+ expect(out).not.toContain("<not-checked>");
7800
+ expect(out).not.toContain("0156114498af2c8939c50d7ef0d3a0b1");
7801
+ });
7802
+
7803
+ it("names local divergence instead of blaming the etag", () => {
7804
+ const out = render("divergent-local");
7805
+ expect(out).toContain("divergent-local");
7806
+ expect(out).not.toContain("stale-etag");
7807
+ expect(out).not.toContain("<not-checked>");
7808
+ });
7809
+
7810
+ it("still explains legacy entries that predate etag tracking", () => {
7811
+ const out = render("legacy-no-etag");
7812
+ expect(out).toContain("no-etag-on-record");
7813
+ expect(out).not.toContain("stale-etag");
7814
+ });
7815
+
7816
+ it("still keeps bulk-asymmetry per-key lines compact", () => {
7817
+ const out = render("bulk-asymmetry");
7818
+ expect(out).toContain("bulk-asymmetry refusal");
7819
+ expect(out).not.toContain("stale-etag");
7820
+ });
7821
+
7822
+ it("still reports a genuine etag mismatch with both etags", () => {
7823
+ const lines: string[] = [];
7824
+ const spy = vi.spyOn(console, "error").mockImplementation((msg?: unknown) => {
7825
+ lines.push(String(msg));
7826
+ });
7827
+ try {
7828
+ shareTesting.defaultConsoleLogger({
7829
+ type: "delete-refused-stale-etag",
7830
+ path: "knowledge/example.md",
7831
+ journalEtag: "aaa111",
7832
+ remoteEtag: "bbb222",
7833
+ reason: "stale-etag",
7834
+ } as never);
7835
+ } finally {
7836
+ spy.mockRestore();
7837
+ }
7838
+ const out = lines.join("\n");
7839
+ expect(out).toContain("stale-etag");
7840
+ expect(out).toContain("aaa111");
7841
+ expect(out).toContain("bbb222");
7842
+ });
7843
+ });