@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.
@@ -2305,16 +2305,24 @@ describe("share", () => {
2305
2305
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2306
2306
  expect(journal.files["dangling-link.md"]).toBeDefined();
2307
2307
  });
2308
- it("personal-vault override: `owned-only` is coerced to `currency-gated` so a direction:'down' " +
2309
- "entry whose local file is gone DOES tombstone in the vault (the May-27 .drift-* leak class)", async () => {
2310
- // The personal vault is a single-human-many-machines target. The
2311
- // owned-only "I won't tombstone peer content" rule has no peer to
2312
- // protect against, so it traps legitimate local deletes as permanent
2313
- // vault litter exactly what the May-27 `personal/.obsidian/*.drift-*`
2314
- // pair did on this EC2 (journal direction "down", local rm, push
2315
- // silently swallowed the delete). Coercing to currency-gated keeps the
2316
- // only safety intent that still applies (HEAD-verify etag matches the
2317
- // journal before tombstone) without the multi-user premise.
2308
+ it("vault-litter drain: a `.drift-*` marker with direction:'down' and NO delete intent tombstones " +
2309
+ "even under owned-only, because litter bypasses the policy gate (the May-27 .drift-* leak class)", async () => {
2310
+ // Provenance: this test was written for a `prs_`-scoped coercion that
2311
+ // rewrote owned-only -> currency-gated. That coercion is gone (6.14.48
2312
+ // it silently defeated the rollback knob; see `createPushRunContext`).
2313
+ // The test still passes, and is retained, because what actually drains
2314
+ // this key is the vault-litter path (6.0.2): `isVaultLitterArtifact`
2315
+ // routes `.drift-<ts>-<pid>` keys to `litterToDelete` BEFORE any policy,
2316
+ // intent, ignore-filter or breaker check runs. That is the durable
2317
+ // guarantee worth pinning the May-27 `personal/.obsidian/*.drift-*`
2318
+ // pair (journal direction "down", local rm, push silently swallowed the
2319
+ // delete) must drain regardless of which delete policy is in force.
2320
+ //
2321
+ // Note the inputs deliberately keep `direction: "down"`, no
2322
+ // `localDeleteIntent`, and an explicit `owned-only` policy: every one of
2323
+ // those would refuse this key on the normal path, so a regression that
2324
+ // removed the litter bypass would fail here rather than silently
2325
+ // re-stranding litter.
2318
2326
  const stateDirPersonal = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-prs-"));
2319
2327
  process.env.HQ_STATE_DIR = stateDirPersonal;
2320
2328
  try {
@@ -2353,10 +2361,8 @@ describe("share", () => {
2353
2361
  personalMode: true,
2354
2362
  skipUnchanged: true,
2355
2363
  propagateDeletes: true,
2356
- // Caller pinned owned-only. The override MUST take precedence on a
2357
- // personal-vault entity the whole point of the fix is that owned-
2358
- // only's "don't tombstone peer-uploaded content" rule has no peer
2359
- // to protect against here, so we coerce to currency-gated.
2364
+ // Pinned owned-only the strictest policy. Litter must drain
2365
+ // anyway; that bypass is the property under test.
2360
2366
  propagateDeletePolicy: "owned-only",
2361
2367
  });
2362
2368
  expect(result.filesDeleted).toBe(1);
@@ -2423,14 +2429,13 @@ describe("share", () => {
2423
2429
  "core/policies/shipped.md",
2424
2430
  ]);
2425
2431
  });
2426
- it("personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
2427
- "refuses to tombstone a direction:'down' entry (multi-user curation intact)", async () => {
2428
- // Sibling guard for the previous test: the override is scoped strictly
2429
- // to `prs_` entities. Company vaults preserve owned-only's multi-user
2430
- // semantics a behind machine pulling Alice's upload, then rm'ing it
2431
- // locally, must NOT tombstone Alice's file. Without this guard the
2432
- // override could silently widen its blast radius the next time someone
2433
- // refactors share()'s entity-typing.
2432
+ it("owned-only on a cmp_ entity refuses to tombstone a direction:'down' entry " +
2433
+ "(multi-user curation intact)", async () => {
2434
+ // owned-only's multi-user semantics: a behind machine that pulled
2435
+ // Alice's upload and then rm'd it locally must NOT tombstone Alice's
2436
+ // file. Paired with the prs_ test below, this pins owned-only as
2437
+ // uniformly strict across BOTH vault kinds the property a rollback
2438
+ // knob has to have to be worth reaching for.
2434
2439
  const companyRoot = path.join(tmpDir, "companies", "acme");
2435
2440
  fs.mkdirSync(companyRoot, { recursive: true });
2436
2441
  // No file on disk under companies/acme/peer-uploaded.md → local missing.
@@ -2463,6 +2468,104 @@ describe("share", () => {
2463
2468
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2464
2469
  expect(journal.files["peer-uploaded.md"]).toBeDefined();
2465
2470
  });
2471
+ it("ROLLBACK KNOB: owned-only is honored on a prs_ personal vault — an intent-less entry with a " +
2472
+ "matching remote etag is REFUSED, not deleted", async () => {
2473
+ // Regression guard for the coercion removed in 6.14.48. A `prs_`-scoped
2474
+ // rewrite of owned-only -> currency-gated used to sit in
2475
+ // `createPushRunContext`. While both policies refused intent-less
2476
+ // deletes it was invisible. Once currency-gated started authorizing on
2477
+ // ETag currency alone, that rewrite meant an operator who set
2478
+ // `HQ_SYNC_DELETE_POLICY=owned-only` — the documented rollback, reached
2479
+ // for precisely BECAUSE deletes were misbehaving — still got intent-less
2480
+ // etag-only deletes on their personal vault. The rollback rolled back
2481
+ // company vaults only.
2482
+ //
2483
+ // The inputs below are exactly the shape that DOES delete under the
2484
+ // default policy (see "currency-gated: propagates a delete with NO
2485
+ // watcher intent…"): local file absent, no `localDeleteIntent`, journal
2486
+ // etag matching live remote. Under owned-only it must refuse. If the
2487
+ // coercion ever returns, `filesDeleted` becomes 1 and this fails.
2488
+ const stateDirPersonal = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-prs-rollback-"));
2489
+ process.env.HQ_STATE_DIR = stateDirPersonal;
2490
+ try {
2491
+ const journalPath = path.join(stateDirPersonal, "sync-journal.__hq_personal_vault__.json");
2492
+ fs.writeFileSync(journalPath, JSON.stringify({
2493
+ version: "1",
2494
+ lastSync: new Date().toISOString(),
2495
+ files: {
2496
+ // direction:"up" — so the refusal cannot be attributed to
2497
+ // owned-only's direction filter. The ONLY thing refusing this
2498
+ // key is the intent requirement the rollback restores.
2499
+ "personal/knowledge/gone.md": {
2500
+ hash: "rollback-hash",
2501
+ size: 42,
2502
+ syncedAt: new Date(Date.now() - 86400000).toISOString(),
2503
+ direction: "up",
2504
+ remoteEtag: "rollback-etag",
2505
+ kind: "file",
2506
+ // NO localDeleteIntent — no watcher was running.
2507
+ },
2508
+ },
2509
+ }));
2510
+ // Live remote still matches the journal — so under currency-gated this
2511
+ // key is a definite `toDelete`. Without this, the beforeEach default
2512
+ // (null = 404) would route a regression to `toTombstone` instead and
2513
+ // the test would catch it only via the weaker journal assertion.
2514
+ //
2515
+ // Deliberately NOT `mockResolvedValueOnce`: on the PASSING path
2516
+ // owned-only refuses before any HEAD is issued, so a queued one-shot
2517
+ // would go unconsumed and leak into the next test (`vi.clearAllMocks`
2518
+ // in afterEach does not drain a `…Once` queue — it clears call
2519
+ // records, not queued implementations). That is the same trap already
2520
+ // documented in the `.drift-*` litter test above. A persistent impl
2521
+ // reset in `finally` cannot leak either way.
2522
+ vi.mocked(headRemoteFile).mockResolvedValue({
2523
+ lastModified: new Date(),
2524
+ etag: '"rollback-etag"',
2525
+ size: 42,
2526
+ });
2527
+ const refusals = [];
2528
+ const result = await share({
2529
+ paths: [tmpDir],
2530
+ entityContext: makeEntityContext({
2531
+ uid: "prs_01HASSAANTESTUSER",
2532
+ slug: "__hq_personal_vault__",
2533
+ bucketName: "hq-vault-prs-01HASSAANTESTUSER",
2534
+ }),
2535
+ hqRoot: tmpDir,
2536
+ personalMode: true,
2537
+ skipUnchanged: true,
2538
+ propagateDeletes: true,
2539
+ propagateDeletePolicy: "owned-only",
2540
+ onEvent: (event) => {
2541
+ if (event.type === "delete-refused-stale-etag") {
2542
+ refusals.push({ path: event.path, reason: event.reason });
2543
+ }
2544
+ },
2545
+ });
2546
+ expect(result.filesDeleted).toBe(0);
2547
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
2548
+ // owned-only refuses on the intent gate, which sits BEFORE the HEAD
2549
+ // pass — so no currency check is ever issued. Asserting this also
2550
+ // keeps the mock above honest about never being consumed.
2551
+ expect(headRemoteFile).not.toHaveBeenCalled();
2552
+ // Refused for want of an intent — NOT silently dropped, and not
2553
+ // refused for some incidental reason that would mask a regression.
2554
+ expect(refusals).toEqual([
2555
+ { path: "personal/knowledge/gone.md", reason: "missing-delete-intent" },
2556
+ ]);
2557
+ // Journal entry survives, so a later run (or a watcher-minted intent)
2558
+ // can still authorize the delete properly.
2559
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2560
+ expect(journal.files["personal/knowledge/gone.md"]).toBeDefined();
2561
+ }
2562
+ finally {
2563
+ vi.mocked(headRemoteFile).mockReset();
2564
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
2565
+ fs.rmSync(stateDirPersonal, { recursive: true, force: true });
2566
+ delete process.env.HQ_STATE_DIR;
2567
+ }
2568
+ });
2466
2569
  it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
2467
2570
  const companyRoot = path.join(tmpDir, "companies", "acme");
2468
2571
  fs.mkdirSync(companyRoot, { recursive: true });
@@ -3104,7 +3207,11 @@ describe("share", () => {
3104
3207
  expect(events).toContainEqual(expect.objectContaining({
3105
3208
  type: "delete-refused-stale-etag",
3106
3209
  path: "racy.md",
3107
- reason: "intent-changed",
3210
+ // The file came back on disk between planning and applying. That is
3211
+ // now reported as what it is, rather than as an intent-version change:
3212
+ // the re-check is no longer nested inside the intent branch, so it
3213
+ // also covers etag-only candidates that carry no intent version.
3214
+ reason: "recreated-locally",
3108
3215
  }));
3109
3216
  });
3110
3217
  it("currency-gated: refuses delete for a divergent-local entry (journal-honesty — never delete a remote the local never genuinely matched)", async () => {
@@ -3247,20 +3354,23 @@ describe("share", () => {
3247
3354
  });
3248
3355
  expect(result.filesDeleted).toBe(0);
3249
3356
  expect(deleteRemoteFile).not.toHaveBeenCalled();
3250
- // HEAD must NOT be called — the explicit-intent gate rejects legacy
3251
- // entries before any remote operation.
3357
+ // HEAD must NOT be called — an entry with no recorded etag has nothing to
3358
+ // compare against, so currency-gated refuses it before any remote
3359
+ // operation. (Pre-etag-gated-deletes this was refused one gate earlier, for
3360
+ // want of a watcher intent; the outcome — nothing deleted, no remote call —
3361
+ // is deliberately identical.)
3252
3362
  expect(headRemoteFile).not.toHaveBeenCalled();
3253
3363
  const refused = events.find((e) => e.type === "delete-refused-stale-etag");
3254
3364
  expect(refused).toMatchObject({
3255
- journalEtag: "<missing-delete-intent>",
3256
- reason: "missing-delete-intent",
3365
+ journalEtag: "<legacy-no-etag>",
3366
+ reason: "legacy-no-etag",
3257
3367
  });
3258
3368
  expect(result.filesRefusedStale).toBe(1);
3259
3369
  expect(result.pathResults).toContainEqual({
3260
3370
  path: "legacy-no-etag.md",
3261
3371
  status: "refused",
3262
3372
  operation: "delete",
3263
- reason: "missing-delete-intent",
3373
+ reason: "legacy-no-etag",
3264
3374
  });
3265
3375
  });
3266
3376
  it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
@@ -3309,6 +3419,316 @@ describe("share", () => {
3309
3419
  expect(result.filesDeleted).toBe(1);
3310
3420
  expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), ".claude/commands/retired-command.md");
3311
3421
  });
3422
+ // ── etag-gated deletes without a watcher ──────────────────────────────────
3423
+ //
3424
+ // A delete intent is minted ONLY by a live watcher at the instant it sees the
3425
+ // unlink. Three of the four ways HQ syncs never construct a watcher (outpost
3426
+ // and agent boxes run a one-shot runner, `hq sync now` is one-shot, and the
3427
+ // desktop drops the whole TreeWatcher when Instant-sync is off), so requiring
3428
+ // an intent made deletes structurally impossible there rather than merely
3429
+ // gated. currency-gated now authorizes on etag currency alone.
3430
+ //
3431
+ // The per-file question is unchanged: "has any peer touched this since we
3432
+ // journaled it". The whole-set question stays with the bulk-asymmetry
3433
+ // breaker, which these candidates still count toward — see the breaker
3434
+ // coverage above and policy `sync-delete-bulk-asymmetry-guard`.
3435
+ it("currency-gated: propagates a delete with NO watcher intent when the remote etag still matches", async () => {
3436
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3437
+ fs.mkdirSync(companyRoot, { recursive: true });
3438
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
3439
+ fs.writeFileSync(journalPath, JSON.stringify({
3440
+ version: "1",
3441
+ lastSync: new Date().toISOString(),
3442
+ files: {
3443
+ "notes/gone.md": {
3444
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
3445
+ direction: "down",
3446
+ remoteEtag: "current-etag",
3447
+ kind: "file",
3448
+ // NO localDeleteIntent — deleted while no watcher was running.
3449
+ },
3450
+ },
3451
+ }));
3452
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
3453
+ lastModified: new Date(),
3454
+ etag: '"current-etag"',
3455
+ size: 12,
3456
+ });
3457
+ const result = await share({
3458
+ paths: [companyRoot],
3459
+ company: "acme",
3460
+ vaultConfig: mockConfig,
3461
+ hqRoot: tmpDir,
3462
+ skipUnchanged: true,
3463
+ propagateDeletes: true,
3464
+ propagateDeletePolicy: "currency-gated",
3465
+ });
3466
+ expect(result.filesDeleted).toBe(1);
3467
+ expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "notes/gone.md");
3468
+ });
3469
+ it("currency-gated: refuses an intent-less delete when a peer has touched the remote", async () => {
3470
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3471
+ fs.mkdirSync(companyRoot, { recursive: true });
3472
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
3473
+ fs.writeFileSync(journalPath, JSON.stringify({
3474
+ version: "1",
3475
+ lastSync: new Date().toISOString(),
3476
+ files: {
3477
+ "notes/peer-edited.md": {
3478
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
3479
+ direction: "down",
3480
+ remoteEtag: "etag-we-journaled",
3481
+ kind: "file",
3482
+ },
3483
+ },
3484
+ }));
3485
+ // A peer overwrote it since we journaled — their work must survive.
3486
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
3487
+ lastModified: new Date(),
3488
+ etag: '"a-different-etag"',
3489
+ size: 99,
3490
+ });
3491
+ const events = [];
3492
+ const result = await share({
3493
+ paths: [companyRoot],
3494
+ company: "acme",
3495
+ vaultConfig: mockConfig,
3496
+ hqRoot: tmpDir,
3497
+ skipUnchanged: true,
3498
+ propagateDeletes: true,
3499
+ propagateDeletePolicy: "currency-gated",
3500
+ onEvent: (e) => events.push(e),
3501
+ });
3502
+ expect(result.filesDeleted).toBe(0);
3503
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
3504
+ expect(events).toContainEqual(expect.objectContaining({ type: "delete-refused-stale-etag", reason: "stale-etag" }));
3505
+ });
3506
+ it("currency-gated: abandons an etag-only delete when the file reappears between plan and apply", async () => {
3507
+ // Guards the apply-time absence re-check in `executeDeletes`. That check
3508
+ // used to live INSIDE the intent-version branch, so it was skipped for any
3509
+ // item carrying `intentVersion: null`. That was harmless while null meant
3510
+ // only litter and decommission — but etag-only candidates now travel with
3511
+ // `intentVersion: null` too, and without the restructure a file recreated
3512
+ // during the sync would be deleted from the remote while present on disk.
3513
+ //
3514
+ // The recreate is staged from inside the delete-plan HEAD, which is the
3515
+ // real TOCTOU window: planning has already decided the file is absent, and
3516
+ // the apply loop has not yet run. The push plan is computed BEFORE the
3517
+ // delete plan, so the new file is not uploaded by this run — it is purely
3518
+ // a late arrival the delete loop must notice.
3519
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3520
+ fs.mkdirSync(companyRoot, { recursive: true });
3521
+ const recreatedPath = path.join(companyRoot, "notes", "gone.md");
3522
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
3523
+ fs.writeFileSync(journalPath, JSON.stringify({
3524
+ version: "1",
3525
+ lastSync: new Date().toISOString(),
3526
+ files: {
3527
+ "notes/gone.md": {
3528
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
3529
+ direction: "down",
3530
+ remoteEtag: "current-etag",
3531
+ kind: "file",
3532
+ // NO localDeleteIntent — this is an etag-only candidate.
3533
+ },
3534
+ },
3535
+ }));
3536
+ vi.mocked(headRemoteFile).mockImplementationOnce(async () => {
3537
+ // A pull leg (or the user) puts the file back, mid-sync.
3538
+ fs.mkdirSync(path.dirname(recreatedPath), { recursive: true });
3539
+ fs.writeFileSync(recreatedPath, "back again");
3540
+ return { lastModified: new Date(), etag: '"current-etag"', size: 12 };
3541
+ });
3542
+ const events = [];
3543
+ const result = await share({
3544
+ paths: [companyRoot],
3545
+ company: "acme",
3546
+ vaultConfig: mockConfig,
3547
+ hqRoot: tmpDir,
3548
+ skipUnchanged: true,
3549
+ propagateDeletes: true,
3550
+ propagateDeletePolicy: "currency-gated",
3551
+ onEvent: (e) => events.push(e),
3552
+ });
3553
+ expect(result.filesDeleted).toBe(0);
3554
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
3555
+ expect(events).toContainEqual(expect.objectContaining({
3556
+ type: "delete-refused-stale-etag",
3557
+ reason: "recreated-locally",
3558
+ }));
3559
+ // The file that came back is still on disk — the whole point.
3560
+ expect(fs.existsSync(recreatedPath)).toBe(true);
3561
+ });
3562
+ it("currency-gated: refuses an intent-less delete when the journal has no recorded etag", async () => {
3563
+ // Legacy journal entries (written before etag tracking) carry no
3564
+ // `remoteEtag`. Currency is the ONLY authorization an intent-less entry
3565
+ // has, so with nothing to compare there is nothing to authorize — refuse,
3566
+ // and keep the entry so a later sync that records an etag can re-evaluate.
3567
+ // No HEAD is issued: there is no etag to check it against.
3568
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3569
+ fs.mkdirSync(companyRoot, { recursive: true });
3570
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
3571
+ fs.writeFileSync(journalPath, JSON.stringify({
3572
+ version: "1",
3573
+ lastSync: new Date().toISOString(),
3574
+ files: {
3575
+ "notes/legacy.md": {
3576
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
3577
+ direction: "down",
3578
+ kind: "file",
3579
+ // No remoteEtag, no localDeleteIntent.
3580
+ },
3581
+ },
3582
+ }));
3583
+ const events = [];
3584
+ const result = await share({
3585
+ paths: [companyRoot],
3586
+ company: "acme",
3587
+ vaultConfig: mockConfig,
3588
+ hqRoot: tmpDir,
3589
+ skipUnchanged: true,
3590
+ propagateDeletes: true,
3591
+ propagateDeletePolicy: "currency-gated",
3592
+ onEvent: (e) => events.push(e),
3593
+ });
3594
+ expect(result.filesDeleted).toBe(0);
3595
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
3596
+ expect(headRemoteFile).not.toHaveBeenCalled();
3597
+ expect(events).toContainEqual(expect.objectContaining({
3598
+ type: "delete-refused-stale-etag",
3599
+ reason: "legacy-no-etag",
3600
+ }));
3601
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
3602
+ expect(journal.files["notes/legacy.md"]).toBeDefined();
3603
+ });
3604
+ it("owned-only still requires an intent — it is the documented rollback knob", async () => {
3605
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3606
+ fs.mkdirSync(companyRoot, { recursive: true });
3607
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
3608
+ fs.writeFileSync(journalPath, JSON.stringify({
3609
+ version: "1",
3610
+ lastSync: new Date().toISOString(),
3611
+ files: {
3612
+ "notes/gone.md": {
3613
+ hash: "h", size: 12, syncedAt: new Date().toISOString(),
3614
+ direction: "up",
3615
+ remoteEtag: "current-etag",
3616
+ kind: "file",
3617
+ },
3618
+ },
3619
+ }));
3620
+ const result = await share({
3621
+ paths: [companyRoot],
3622
+ company: "acme",
3623
+ vaultConfig: mockConfig,
3624
+ hqRoot: tmpDir,
3625
+ skipUnchanged: true,
3626
+ propagateDeletes: true,
3627
+ propagateDeletePolicy: "owned-only",
3628
+ });
3629
+ expect(result.filesDeleted).toBe(0);
3630
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
3631
+ expect(headRemoteFile).not.toHaveBeenCalled();
3632
+ });
3633
+ // THE load-bearing safety test for this change. An etag match cannot tell
3634
+ // "the user deleted this" from "the local mirror vanished" — in the steady
3635
+ // state nobody has overwritten anything, so a moved hqRoot / unmounted volume
3636
+ // / fresh clone presents mass-ENOENT with perfectly matching etags. That is
3637
+ // exactly how the 2026-05-25 indigo vault mass-delete destroyed 559 objects.
3638
+ // Relaxing the per-file gate must NOT weaken the whole-set guard.
3639
+ it("bulk-asymmetry breaker still refuses a mass intent-less disappearance", async () => {
3640
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3641
+ fs.mkdirSync(companyRoot, { recursive: true });
3642
+ const files = {};
3643
+ // 40 of 100 entries missing locally = 40%, far past the 10% threshold.
3644
+ for (let i = 0; i < 100; i++) {
3645
+ const present = i >= 40;
3646
+ const rel = `docs/f${i}.md`;
3647
+ if (present) {
3648
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
3649
+ fs.writeFileSync(path.join(companyRoot, rel), "x");
3650
+ }
3651
+ files[rel] = {
3652
+ hash: "h", size: 1, syncedAt: new Date().toISOString(),
3653
+ direction: "down",
3654
+ remoteEtag: `etag-${i}`,
3655
+ kind: "file",
3656
+ };
3657
+ }
3658
+ fs.writeFileSync(path.join(stateDir, "sync-journal.acme.json"), JSON.stringify({ version: "1", lastSync: new Date().toISOString(), files }));
3659
+ const result = await share({
3660
+ paths: [companyRoot],
3661
+ company: "acme",
3662
+ vaultConfig: mockConfig,
3663
+ hqRoot: tmpDir,
3664
+ skipUnchanged: true,
3665
+ propagateDeletes: true,
3666
+ propagateDeletePolicy: "currency-gated",
3667
+ });
3668
+ expect(result.filesDeleted).toBe(0);
3669
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
3670
+ // All 40 absent entries are refused as one set. (No assertion on
3671
+ // headRemoteFile here: the push leg HEADs the 60 files that ARE present,
3672
+ // so a global call-count says nothing about the delete plan.)
3673
+ expect(result.filesRefusedStale).toBe(40);
3674
+ });
3675
+ // Regression: moving a company vault from syncMode 'all' to 'shared'.
3676
+ // `hq sync narrow --apply` / scope-shrink calls `tombstoneEntry`, which
3677
+ // deliberately KEEPS hash, direction and remoteEtag so a recovery flow can
3678
+ // see what was pruned — and it removes the file locally. Every such entry
3679
+ // therefore looks exactly like an etag-current intent-less delete candidate.
3680
+ // Propagating those would delete OTHER members' files out of the shared
3681
+ // company vault because one member narrowed their own scope.
3682
+ //
3683
+ // Deliberately asserted with NO prefixSet passed: the scope filter used to be
3684
+ // the only thing preventing this, and that made an implicit dependency
3685
+ // load-bearing for a destructive operation.
3686
+ it("all -> shared: scope-shrink tombstones are never propagated as remote deletes", async () => {
3687
+ const companyRoot = path.join(tmpDir, "companies", "acme");
3688
+ fs.mkdirSync(companyRoot, { recursive: true });
3689
+ const now = new Date().toISOString();
3690
+ const shrunk = (reason) => ({
3691
+ hash: "h", size: 3, syncedAt: now,
3692
+ direction: "down",
3693
+ remoteEtag: "still-current",
3694
+ kind: "file",
3695
+ removedAt: now,
3696
+ removedReason: reason,
3697
+ });
3698
+ fs.writeFileSync(path.join(stateDir, "sync-journal.acme.json"), JSON.stringify({
3699
+ version: "2",
3700
+ lastSync: now,
3701
+ files: {
3702
+ "teammate/a.md": shrunk("scope_shrink"),
3703
+ "teammate/b.md": shrunk("narrow_apply"),
3704
+ "teammate/c.md": shrunk("manual"),
3705
+ },
3706
+ pulls: [],
3707
+ }));
3708
+ // The remote is ALIVE and still exactly what the journal recorded. Without
3709
+ // this the keys would 404 into `toTombstone` and the test would pass for
3710
+ // the wrong reason — verified by disabling the guard and watching it still
3711
+ // go green.
3712
+ vi.mocked(headRemoteFile).mockResolvedValue({
3713
+ lastModified: new Date(),
3714
+ etag: '"still-current"',
3715
+ size: 3,
3716
+ });
3717
+ const result = await share({
3718
+ paths: [companyRoot],
3719
+ company: "acme",
3720
+ vaultConfig: mockConfig,
3721
+ hqRoot: tmpDir,
3722
+ skipUnchanged: true,
3723
+ propagateDeletes: true,
3724
+ propagateDeletePolicy: "currency-gated",
3725
+ });
3726
+ expect(result.filesDeleted).toBe(0);
3727
+ expect(deleteRemoteFile).not.toHaveBeenCalled();
3728
+ // Not refusals either — they are simply not delete candidates at all, so
3729
+ // they never enter the plan and never count toward the breaker ratio.
3730
+ expect(result.filesRefusedStale).toBe(0);
3731
+ });
3312
3732
  // ── Conflict-mirror exclusion (ephemeral pattern) ──────────────────────────
3313
3733
  //
3314
3734
  // Conflict mirrors (`*.conflict-<ISO>-<machineHash>.<ext>`) are local-only
@@ -5785,6 +6205,71 @@ describe("scope-invalid key hardening on push (doubled companies/ tree — incid
5785
6205
  ]);
5786
6206
  expect(events.some((e) => e.type === "error")).toBe(false);
5787
6207
  });
6208
+ it("never plans an upload for macOS Finder Icon\\r files — silent skip, valid files still ship", async () => {
6209
+ // The incident: Finder writes an `Icon\r` file into every directory it
6210
+ // renders a custom icon for, and a folder-level cloud-sync agent spreads
6211
+ // them tree-wide. A CR is a control character, so validateVaultUploadKey
6212
+ // throws INVALID_KEY_CONTROL_CHARS inside executeUploads; that throw lands
6213
+ // in the generic catch and emits type:"error". errors[] non-empty makes
6214
+ // the runner return PARTIAL_SYNC_EXIT, so ONE such file marks EVERY sync
6215
+ // pass failed forever. On the reporting machine there were 100+ per run.
6216
+ //
6217
+ // They are excluded during the walk, like .DS_Store, so they never reach
6218
+ // the planner at all — and, being expected noise, without a per-file
6219
+ // "EXCLUDED from sync" warning.
6220
+ const companyRoot = path.join(tmpDir, "companies", "acme");
6221
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
6222
+ fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
6223
+ fs.writeFileSync(path.join(companyRoot, "Icon\r"), "finder cruft");
6224
+ fs.writeFileSync(path.join(companyRoot, "docs", "Icon\r"), "finder cruft");
6225
+ const events = [];
6226
+ const result = await share({
6227
+ paths: [companyRoot],
6228
+ company: "acme",
6229
+ vaultConfig: mockConfig,
6230
+ hqRoot: tmpDir,
6231
+ onEvent: (e) => events.push(e),
6232
+ });
6233
+ expect(result.aborted).toBe(false);
6234
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
6235
+ expect(uploadedKeys).toEqual(["docs/good.md"]);
6236
+ expect(result.filesUploaded).toBe(1);
6237
+ // The failure class this guards: an error event is what flips the runner
6238
+ // pass to exit 2.
6239
+ expect(events.some((e) => e.type === "error")).toBe(false);
6240
+ // Expected OS noise — excluded without the "review in case this is
6241
+ // unintended" warning that real content would earn.
6242
+ expect(events.some((e) => e.type === "ignore-excluded")).toBe(false);
6243
+ });
6244
+ it("refuses any other control-character key up front — skip event, never an error", async () => {
6245
+ // Belt-and-suspenders for control-char paths that are not the Finder icon
6246
+ // spelling, and for callers that bypass the walker. Symmetric with the
6247
+ // pull leg, which has classified such keys since the same incident.
6248
+ const companyRoot = path.join(tmpDir, "companies", "acme");
6249
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
6250
+ fs.writeFileSync(path.join(companyRoot, "docs", "good.md"), "good");
6251
+ fs.writeFileSync(path.join(companyRoot, "docs", "bad\u0001name.md"), "bad");
6252
+ const events = [];
6253
+ const result = await share({
6254
+ paths: [companyRoot],
6255
+ company: "acme",
6256
+ vaultConfig: mockConfig,
6257
+ hqRoot: tmpDir,
6258
+ onEvent: (e) => events.push(e),
6259
+ });
6260
+ expect(result.aborted).toBe(false);
6261
+ const uploadedKeys = vi.mocked(uploadFile).mock.calls.map((c) => c[2]);
6262
+ expect(uploadedKeys).toEqual(["docs/good.md"]);
6263
+ const skipEvents = events.filter((e) => e.type === "skip-invalid-scoped-key");
6264
+ expect(skipEvents).toEqual([
6265
+ {
6266
+ type: "skip-invalid-scoped-key",
6267
+ path: "docs/bad\u0001name.md",
6268
+ errorCode: "INVALID_KEY_CONTROL_CHARS",
6269
+ },
6270
+ ]);
6271
+ expect(events.some((e) => e.type === "error")).toBe(false);
6272
+ });
5788
6273
  it("tombstones (journal drop, no remote HEAD/DELETE) a companies/ journal key in company mode instead of re-uploading", async () => {
5789
6274
  // Delete-propagation on a poisoned journal entry: HEAD/DeleteObject on a
5790
6275
  // companies/-prefixed key would be rejected by the presign transport's
@@ -5929,4 +6414,86 @@ describe("isForbiddenCompanyVaultKey (company-vault double-scope contract)", ()
5929
6414
  expect(isForbiddenCompanyVaultKey(key, personalMode)).toBe(expected);
5930
6415
  });
5931
6416
  });
6417
+ // ── delete-refusal rendering ───────────────────────────────────────────────
6418
+ //
6419
+ // Every refusal cause shares ONE event type (`delete-refused-stale-etag`),
6420
+ // with the real cause carried in `reason`. Before this cover, the renderer
6421
+ // only branched on two of the five causes and the rest fell through to the
6422
+ // stale-etag wording — printing a real journal etag next to a
6423
+ // `<not-checked>` remote for a comparison that never happened. Operators
6424
+ // (including a customer bug report on 2026-08-04, and the investigation into
6425
+ // it) read that as an etag/currency defect and chased the wrong root cause.
6426
+ // Pin all five so no cause can silently fall through to etag wording again.
6427
+ describe("defaultConsoleLogger — delete refusal wording per reason", () => {
6428
+ const render = (reason) => {
6429
+ const lines = [];
6430
+ const spy = vi.spyOn(console, "error").mockImplementation((msg) => {
6431
+ lines.push(String(msg));
6432
+ });
6433
+ try {
6434
+ shareTesting.defaultConsoleLogger({
6435
+ type: "delete-refused-stale-etag",
6436
+ path: "knowledge/example.md",
6437
+ // A REAL etag, as the engine emits whenever the entry has one — the
6438
+ // `<missing-delete-intent>` placeholder only appears for entries that
6439
+ // never had an etag, which is the rare case.
6440
+ journalEtag: "0156114498af2c8939c50d7ef0d3a0b1",
6441
+ remoteEtag: "<not-checked>",
6442
+ reason,
6443
+ });
6444
+ }
6445
+ finally {
6446
+ spy.mockRestore();
6447
+ }
6448
+ return lines.join("\n");
6449
+ };
6450
+ it("names the missing authorization instead of blaming the etag", () => {
6451
+ const out = render("missing-delete-intent");
6452
+ expect(out).toContain("no-delete-authorization");
6453
+ expect(out).toContain("watcher did not observe this removal");
6454
+ // The specific regression: neither etag is meaningful, so neither may be
6455
+ // shown, and the stale-etag wording must not appear.
6456
+ expect(out).not.toContain("stale-etag");
6457
+ expect(out).not.toContain("<not-checked>");
6458
+ expect(out).not.toContain("0156114498af2c8939c50d7ef0d3a0b1");
6459
+ });
6460
+ it("names local divergence instead of blaming the etag", () => {
6461
+ const out = render("divergent-local");
6462
+ expect(out).toContain("divergent-local");
6463
+ expect(out).not.toContain("stale-etag");
6464
+ expect(out).not.toContain("<not-checked>");
6465
+ });
6466
+ it("still explains legacy entries that predate etag tracking", () => {
6467
+ const out = render("legacy-no-etag");
6468
+ expect(out).toContain("no-etag-on-record");
6469
+ expect(out).not.toContain("stale-etag");
6470
+ });
6471
+ it("still keeps bulk-asymmetry per-key lines compact", () => {
6472
+ const out = render("bulk-asymmetry");
6473
+ expect(out).toContain("bulk-asymmetry refusal");
6474
+ expect(out).not.toContain("stale-etag");
6475
+ });
6476
+ it("still reports a genuine etag mismatch with both etags", () => {
6477
+ const lines = [];
6478
+ const spy = vi.spyOn(console, "error").mockImplementation((msg) => {
6479
+ lines.push(String(msg));
6480
+ });
6481
+ try {
6482
+ shareTesting.defaultConsoleLogger({
6483
+ type: "delete-refused-stale-etag",
6484
+ path: "knowledge/example.md",
6485
+ journalEtag: "aaa111",
6486
+ remoteEtag: "bbb222",
6487
+ reason: "stale-etag",
6488
+ });
6489
+ }
6490
+ finally {
6491
+ spy.mockRestore();
6492
+ }
6493
+ const out = lines.join("\n");
6494
+ expect(out).toContain("stale-etag");
6495
+ expect(out).toContain("aaa111");
6496
+ expect(out).toContain("bbb222");
6497
+ });
6498
+ });
5932
6499
  //# sourceMappingURL=share.test.js.map