@indigoai-us/hq-cloud 6.14.47 → 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.
package/src/cli/share.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  hashSymlinkTarget,
33
33
  updateEntry,
34
34
  removeEntry,
35
+ isTombstone,
35
36
  normalizeEtag,
36
37
  PERSONAL_VAULT_JOURNAL_SLUG,
37
38
  migratePersonalVaultJournal,
@@ -300,6 +301,7 @@ export const _testing = {
300
301
  collectFiles,
301
302
  resolveNamedPath,
302
303
  isWithinLexicalOrReal,
304
+ defaultConsoleLogger,
303
305
  };
304
306
 
305
307
  /**
@@ -646,8 +648,10 @@ export interface ShareOptions {
646
648
  * belong in THIS bucket regardless of local state" and uses the same
647
649
  * DeleteObject + journal-removal path as `propagateDeletes`.
648
650
  *
649
- * Honors `propagateDeletePolicy` — `"owned-only"` (default) only
650
- * decommissions journal entries with `direction === "up"`, so a
651
+ * Honors `propagateDeletePolicy`. Under `"owned-only"` (this function's
652
+ * own fallback when the caller passes nothing; every live caller passes
653
+ * `resolveDeletePolicy()`, which defaults to `"currency-gated"`) only
654
+ * journal entries with `direction === "up"` are decommissioned, so a
651
655
  * misconfigured caller never erases content pulled from elsewhere.
652
656
  *
653
657
  * Independent of `propagateDeletes`: callers can opt into decommission
@@ -893,6 +897,7 @@ export type ShareDeleteRefusalReason =
893
897
  | "divergent-local"
894
898
  | "missing-delete-intent"
895
899
  | "intent-changed"
900
+ | "recreated-locally"
896
901
  | "transfer-error";
897
902
 
898
903
  export interface SharePathResult {
@@ -1093,7 +1098,7 @@ const REFUSED_STALE_PATH_CAP = 50;
1093
1098
 
1094
1099
  async function createPushRunContext(options: ShareOptions): Promise<PushRunContext> {
1095
1100
  const { paths, company, message, onConflict, vaultConfig, entityContext, hqRoot, skipUnchanged, propagateDeletes } = options;
1096
- let propagateDeletePolicy: DeletePolicy =
1101
+ const propagateDeletePolicy: DeletePolicy =
1097
1102
  options.propagateDeletePolicy ?? "owned-only";
1098
1103
  const baseEmit = options.onEvent ?? defaultConsoleLogger;
1099
1104
 
@@ -1123,9 +1128,30 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
1123
1128
  ? entityContext
1124
1129
  : await resolveEntityContext(companyRef, vaultConfig!);
1125
1130
 
1126
- if (ctx.uid.startsWith("prs_") && propagateDeletePolicy === "owned-only") {
1127
- propagateDeletePolicy = "currency-gated";
1128
- }
1131
+ // NOTE (removed 6.14.48): a `prs_`-scoped coercion used to silently rewrite
1132
+ // `owned-only` -> `currency-gated` here (366721b). It was correct for the
1133
+ // problem it was written against and is now actively harmful, for three
1134
+ // reasons that only became true later:
1135
+ //
1136
+ // 1. Its ONLY reachable effect in production was to override an EXPLICIT
1137
+ // `owned-only`. Every live caller passes a policy
1138
+ // (`sync-runner-company.ts` -> `resolveDeletePolicy()`), and that
1139
+ // resolver already defaults to `currency-gated`. So the coercion never
1140
+ // fired on a default run — it fired exactly when an operator had set
1141
+ // `HQ_SYNC_DELETE_POLICY=owned-only`, i.e. the documented rollback.
1142
+ // 2. Once delete authorization moved to ETag currency (no watcher-minted
1143
+ // intent required), that override stopped being cosmetic: an operator
1144
+ // reaching for the rollback because deletes were misbehaving would keep
1145
+ // getting intent-less etag-only deletes on their personal vault. A
1146
+ // rollback knob that silently exempts a vault is not a rollback knob.
1147
+ // 3. Its motivating bug — the May-27 `personal/.obsidian/*.drift-*` leak —
1148
+ // is fixed twice over by mechanisms that postdate it: the vault-litter
1149
+ // drain (6.0.2) drains `.drift-*` markers ahead of every policy gate,
1150
+ // and etag-gated deletes propagate an intent-less `direction:"down"`
1151
+ // removal under the default policy without needing any coercion.
1152
+ //
1153
+ // So `owned-only` now means owned-only on every vault kind. It is the strict
1154
+ // rollback path and must stay strictly stricter than the default everywhere.
1129
1155
 
1130
1156
  // Mirror push progress into the shared cross-process snapshot (see sync.ts).
1131
1157
  // Record the friendly company slug, or null for the personal vault so the
@@ -1553,10 +1579,12 @@ async function executeUploads(
1553
1579
  localHash,
1554
1580
  lstat.size,
1555
1581
  "up",
1556
- remoteMeta.etag,
1557
- lstat.mtimeMs,
1558
- undefined,
1559
- item.kind,
1582
+ absolutePath,
1583
+ {
1584
+ remoteEtag: remoteMeta.etag,
1585
+ mtimeMs: lstat.mtimeMs,
1586
+ kind: item.kind,
1587
+ },
1560
1588
  );
1561
1589
  run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1562
1590
  counters.filesSkipped++;
@@ -1638,10 +1666,8 @@ async function executeUploads(
1638
1666
  localHash,
1639
1667
  size,
1640
1668
  "up",
1641
- etag,
1642
- mtimeMs,
1643
- undefined,
1644
- item.kind,
1669
+ absolutePath,
1670
+ { remoteEtag: etag, mtimeMs, kind: item.kind },
1645
1671
  );
1646
1672
  if (run.message) {
1647
1673
  run.journal.files[relativePath] = {
@@ -1807,21 +1833,34 @@ async function executeDeletes(
1807
1833
  }
1808
1834
  try {
1809
1835
  const entry = run.journal.files[relativePath];
1810
- if (
1836
+ // Last-second absence re-check. Applies to EVERY propagated delete,
1837
+ // intent-backed or etag-only: a pull leg or the user can recreate the
1838
+ // file between planning and applying, and deleting it remotely then
1839
+ // destroys a file that exists locally right now. This used to sit inside
1840
+ // the intent-version check, which meant it was skipped for any item
1841
+ // carrying `intentVersion: null` — harmless while that only covered
1842
+ // litter and decommission, but not once etag-only candidates travel that
1843
+ // way too.
1844
+ //
1845
+ // Codec boundary: journal keys are canonical; the probe must use the
1846
+ // ENCODED local path or a win32 colon key always looks absent (a raw `:`
1847
+ // name can never exist on disk) and the remote delete proceeds while the
1848
+ // encoded local file is still present.
1849
+ const recreatedLocally =
1850
+ !item.decommission &&
1851
+ !isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath));
1852
+ const intentChanged =
1811
1853
  !item.decommission &&
1812
1854
  item.intentVersion !== null &&
1813
1855
  (!hasCurrentLocalDeleteIntent(entry) ||
1814
- entry!.localDeleteIntent!.version !== item.intentVersion ||
1815
- // Codec boundary: journal keys are canonical; the last-second
1816
- // absence re-check must probe the ENCODED local path or a win32
1817
- // colon key always looks absent (a raw `:` name can never exist
1818
- // on disk) and the remote delete proceeds while the encoded local
1819
- // file is still present.
1820
- !isLocallyAbsent(localPathForVaultKey(run.syncRoot, relativePath)))
1821
- ) {
1856
+ entry!.localDeleteIntent!.version !== item.intentVersion);
1857
+ if (recreatedLocally || intentChanged) {
1822
1858
  if (entry?.localDeleteIntent) {
1823
1859
  delete entry.localDeleteIntent;
1824
1860
  }
1861
+ const refusalReason = recreatedLocally
1862
+ ? ("recreated-locally" as const)
1863
+ : ("intent-changed" as const);
1825
1864
  counters.filesRefusedStale++;
1826
1865
  if (filesRefusedStalePaths.length < REFUSED_STALE_PATH_CAP) {
1827
1866
  filesRefusedStalePaths.push(relativePath);
@@ -1831,13 +1870,13 @@ async function executeDeletes(
1831
1870
  path: relativePath,
1832
1871
  journalEtag: entry?.remoteEtag ?? "<missing-delete-intent>",
1833
1872
  remoteEtag: "<not-checked>",
1834
- reason: "intent-changed",
1873
+ reason: refusalReason,
1835
1874
  });
1836
1875
  pathResults.push({
1837
1876
  path: relativePath,
1838
1877
  status: "refused",
1839
1878
  operation: "delete",
1840
- reason: "intent-changed",
1879
+ reason: refusalReason,
1841
1880
  });
1842
1881
  continue;
1843
1882
  }
@@ -2183,6 +2222,32 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
2183
2222
  // Per-key lines stay compact so a 100-candidate refusal doesn't bury
2184
2223
  // the summary banner.
2185
2224
  console.error(` ⚠ bulk-asymmetry refusal, kept on remote: ${event.path}`);
2225
+ } else if (event.reason === "missing-delete-intent") {
2226
+ // NEITHER etag is meaningful here: no HEAD was issued, so `remoteEtag`
2227
+ // is the `<not-checked>` sentinel, and `journalEtag` is the entry's
2228
+ // real recorded etag. Printing them under the `stale-etag` wording
2229
+ // (which this branch used to fall through to) reads as an etag
2230
+ // conflict and has repeatedly sent operators chasing a currency bug
2231
+ // that does not exist — the actual cause is that no watcher observed
2232
+ // the removal, so no delete authorization was ever recorded.
2233
+ console.error(
2234
+ ` ⚠ no-delete-authorization, kept on remote: ${event.path} ` +
2235
+ `(the sync watcher did not observe this removal, so the delete was never authorized)`,
2236
+ );
2237
+ } else if (event.reason === "recreated-locally") {
2238
+ console.error(
2239
+ ` ⚠ recreated-locally, kept on remote: ${event.path} ` +
2240
+ `(the file exists locally again, so the delete was abandoned)`,
2241
+ );
2242
+ } else if (event.reason === "divergent-local") {
2243
+ // Same trap as above — `remoteEtag` is `<not-checked>`. The entry's
2244
+ // recorded etag was stamped only to silence a re-fired conflict; the
2245
+ // local copy never matched it, so propagating the delete would destroy
2246
+ // divergent remote work.
2247
+ console.error(
2248
+ ` ⚠ divergent-local, kept on remote: ${event.path} ` +
2249
+ `(the local copy never matched the recorded remote version; pull to reconcile first)`,
2250
+ );
2186
2251
  } else {
2187
2252
  console.error(
2188
2253
  ` ⚠ stale-etag, kept on remote: ${event.path} (journal=${event.journalEtag}, remote=${event.remoteEtag})`,
@@ -2911,7 +2976,8 @@ type RefusedStaleReason =
2911
2976
  | "bulk-asymmetry"
2912
2977
  | "divergent-local"
2913
2978
  | "missing-delete-intent"
2914
- | "intent-changed";
2979
+ | "intent-changed"
2980
+ | "recreated-locally";
2915
2981
 
2916
2982
  /**
2917
2983
  * Bulk-asymmetry circuit-breaker — refuses to convert a suspiciously-large
@@ -3134,7 +3200,7 @@ async function computeDeletePlan(
3134
3200
  // all), or queue it for HEAD (currency-gated). Keeping this synchronous
3135
3201
  // means the HEAD pass below sees a single, deduplicated candidate list
3136
3202
  // and the journal-mutation buckets are already settled before any I/O.
3137
- type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 };
3203
+ type HeadCandidate = { key: string; journalEtag: string; intentVersion: 1 | null };
3138
3204
  const headCandidates: HeadCandidate[] = [];
3139
3205
  // Litter drain bucket — kept separate from `plan.toDelete` so the
3140
3206
  // bulk-asymmetry breaker (which moves toDelete + headCandidates into
@@ -3177,6 +3243,41 @@ async function computeDeletePlan(
3177
3243
  relativeKey.startsWith(`${root}/`),
3178
3244
  );
3179
3245
  if (!inScope) continue;
3246
+
3247
+ // A tombstoned entry has ALREADY had its disposition decided — by a scope
3248
+ // shrink, `hq sync narrow --apply`, a manual prune, or a pull-side
3249
+ // intentional-delete record. It must never be re-evaluated as a fresh
3250
+ // delete candidate.
3251
+ //
3252
+ // This matters most for `all` -> `shared`: `tombstoneEntry` deliberately
3253
+ // KEEPS `hash`, `direction` and `remoteEtag` (so a recovery flow can see
3254
+ // what was there), and the shrink removes the file locally. Every one of
3255
+ // those entries therefore presents as locally-absent, intent-less and
3256
+ // etag-current — which, now that authorization is etag-only, is an
3257
+ // instruction to delete another member's files out of the shared company
3258
+ // vault, for the crime of having narrowed one's own sync scope.
3259
+ //
3260
+ // Until now the ONLY thing standing in the way was `shouldSync` happening
3261
+ // to carry a `prefixSet` (see `createPushRunContext`); any run that
3262
+ // resolved no prefix set — a degraded scope lookup, a caller that does not
3263
+ // set one — would have walked straight through. That is far too load-
3264
+ // bearing for an implicit dependency, so the invariant is stated here
3265
+ // directly.
3266
+ //
3267
+ // `local-delete` is the ONE reason that must pass: the pull leg stamps it
3268
+ // as a deliberate producer hand-off ("the user removed this; push leg,
3269
+ // finish the job"), and skipping it would strand exactly the deletes this
3270
+ // change exists to enable. Every other reason — scope_shrink,
3271
+ // narrow_apply, manual, or an absent/unrecognised one — means "stop
3272
+ // tracking, do NOT touch the remote", so the test is written fail-closed:
3273
+ // only the explicit hand-off is let through.
3274
+ //
3275
+ // Skipped BEFORE `inScopeJournalEntries++` so these inflate neither the
3276
+ // numerator nor the denominator of the bulk-asymmetry ratio. Excluding
3277
+ // them from the denominator can only make the breaker MORE likely to trip,
3278
+ // which is the safe direction.
3279
+ if (isTombstone(entry) && entry.removedReason !== "local-delete") continue;
3280
+
3180
3281
  inScopeJournalEntries++;
3181
3282
  const localPath = localPathForVaultKey(syncRoot, relativeKey);
3182
3283
 
@@ -3256,13 +3357,78 @@ async function computeDeletePlan(
3256
3357
  }
3257
3358
 
3258
3359
  if (!hasCurrentLocalDeleteIntent(entry)) {
3259
- if (
3260
- policy === "currency-gated" ||
3261
- (policy === "owned-only" && entry.direction === "up")
3262
- ) {
3360
+ // A delete intent is minted ONLY by a live watcher, at the instant it
3361
+ // observes the unlink. Three of the four ways HQ syncs never construct a
3362
+ // watcher at all — outpost/agent boxes run a one-shot runner, `hq sync
3363
+ // now` is one-shot, and the desktop app drops `--event-push` (and with it
3364
+ // the whole TreeWatcher) whenever Instant-sync is off. On those surfaces
3365
+ // an intent can never exist, so requiring one made deletes structurally
3366
+ // impossible rather than merely gated. Files deleted while no watcher was
3367
+ // running also stranded permanently: the file is already gone, so no
3368
+ // watcher can ever retroactively witness it.
3369
+ //
3370
+ // `currency-gated` therefore authorizes on ETag currency alone. The
3371
+ // per-file question it answers is unchanged — "is the remote object still
3372
+ // exactly what this journal recorded, i.e. has no peer touched it since"
3373
+ // — and a mismatch still refuses (`stale-etag`) so a peer's newer work is
3374
+ // never destroyed.
3375
+ //
3376
+ // The whole-set question ("does my local mirror look catastrophically
3377
+ // empty") is NOT answered by the ETag, and must not be conflated with it:
3378
+ // in the steady state nobody has overwritten anything, so a moved hqRoot,
3379
+ // unmounted volume, fresh clone or partial restore presents mass-ENOENT
3380
+ // with perfectly matching ETags. That is exactly how the 2026-05-25
3381
+ // indigo vault mass-delete destroyed 559 objects. The bulk-asymmetry
3382
+ // breaker below is the guard for that class and is deliberately left
3383
+ // intact — these candidates are counted into `bulkCandidatePicks` on the
3384
+ // SAME line as intent-backed ones, so relaxing the per-file gate cannot
3385
+ // weaken the whole-set one. See policy `sync-delete-bulk-asymmetry-guard`
3386
+ // (hard) and workspace/reports/indigo-vault-mass-delete-debug.md.
3387
+ //
3388
+ // `owned-only` keeps requiring an intent: it is the documented rollback
3389
+ // knob, and an operator who selects it is asking for the stricter path.
3390
+ if (policy === "owned-only") {
3391
+ // Still counted into the breaker exactly as before: an owned-only run
3392
+ // whose mirror has gone missing must trip the whole-set guard even
3393
+ // though each individual key is refused for want of an intent.
3394
+ if (entry.direction === "up") {
3395
+ bulkCandidatePicks++;
3396
+ intentlessKeys.add(relativeKey);
3397
+ }
3398
+ plan.refusedStale.push({
3399
+ key: relativeKey,
3400
+ journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
3401
+ remoteEtag: "<not-checked>",
3402
+ reason: "missing-delete-intent",
3403
+ });
3404
+ continue;
3405
+ }
3406
+ if (policy === "currency-gated") {
3263
3407
  bulkCandidatePicks++;
3264
3408
  intentlessKeys.add(relativeKey);
3409
+ // No etag on record ⇒ nothing to compare ⇒ no authorization. Refused
3410
+ // for the same reason an intent-backed legacy entry is (below).
3411
+ if (!entry.remoteEtag) {
3412
+ plan.refusedStale.push({
3413
+ key: relativeKey,
3414
+ journalEtag: "<legacy-no-etag>",
3415
+ remoteEtag: "<unknown>",
3416
+ reason: "legacy-no-etag",
3417
+ });
3418
+ continue;
3419
+ }
3420
+ headCandidates.push({
3421
+ key: relativeKey,
3422
+ journalEtag: entry.remoteEtag,
3423
+ intentVersion: null,
3424
+ });
3425
+ continue;
3265
3426
  }
3427
+ // policy === "all" is deliberately NOT relaxed here. It already skips the
3428
+ // bulk-asymmetry breaker, so letting it skip the intent gate too would
3429
+ // make it a wholly unguarded mass delete — a strictly larger blast radius
3430
+ // than this change is scoped to. It keeps refusing intent-less entries
3431
+ // exactly as before.
3266
3432
  plan.refusedStale.push({
3267
3433
  key: relativeKey,
3268
3434
  journalEtag: entry.remoteEtag ?? "<missing-delete-intent>",
package/src/cli/sync.ts CHANGED
@@ -238,7 +238,8 @@ export type SyncProgressEvent =
238
238
  | "bulk-asymmetry"
239
239
  | "divergent-local"
240
240
  | "missing-delete-intent"
241
- | "intent-changed";
241
+ | "intent-changed"
242
+ | "recreated-locally";
242
243
  }
243
244
  | {
244
245
  /**
@@ -1471,9 +1472,12 @@ async function executeConflictItem(
1471
1472
  item.localHash,
1472
1473
  item.localSize,
1473
1474
  "down",
1474
- remoteFile.etag,
1475
- item.localMtime.getTime(),
1476
- item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1475
+ localPath,
1476
+ {
1477
+ remoteEtag: remoteFile.etag,
1478
+ mtimeMs: item.localMtime.getTime(),
1479
+ kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1480
+ },
1477
1481
  );
1478
1482
  run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
1479
1483
  counters.filesSkipped++;
@@ -1561,9 +1565,12 @@ async function executeConflictItem(
1561
1565
  item.localHash,
1562
1566
  item.localSize,
1563
1567
  "down",
1564
- remoteFile.etag,
1565
- item.localMtime.getTime(),
1566
- item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1568
+ localPath,
1569
+ {
1570
+ remoteEtag: remoteFile.etag,
1571
+ mtimeMs: item.localMtime.getTime(),
1572
+ kind: item.localSnapshot.kind === "symlink" ? "symlink" : "file",
1573
+ },
1567
1574
  );
1568
1575
  // Journal-honesty: we recorded the remote etag so this conflict can't
1569
1576
  // re-fire (#137), but the KEPT local copy diverges from that remote — it
@@ -1694,10 +1701,13 @@ async function downloadOne(
1694
1701
  hash,
1695
1702
  size,
1696
1703
  "down",
1697
- remoteFile.etag,
1698
- localLstat.mtimeMs,
1699
- createdBySub,
1700
- isLocalSymlink ? "symlink" : "file",
1704
+ localPath,
1705
+ {
1706
+ remoteEtag: remoteFile.etag,
1707
+ mtimeMs: localLstat.mtimeMs,
1708
+ ...(createdBySub !== undefined ? { createdBySub } : {}),
1709
+ kind: isLocalSymlink ? "symlink" : "file",
1710
+ },
1701
1711
  );
1702
1712
 
1703
1713
  const priorEntry = getEntry(run.journal, remoteFile.key);
@@ -16,6 +16,7 @@ import {
16
16
  readJournal,
17
17
  writeJournal,
18
18
  updateEntry,
19
+ PrematureJournalEntryError,
19
20
  migrateToV2,
20
21
  normalizeJournalKeys,
21
22
  appendPullRecord,
@@ -208,12 +209,54 @@ describe("journal", () => {
208
209
  describe("updateEntry", () => {
209
210
  it("stamps lastSync and the per-file syncedAt", () => {
210
211
  const j: SyncJournal = { version: "1", lastSync: "", files: {} };
211
- updateEntry(j, "foo.md", "hash", 10, "up");
212
+ const abs = path.join(stateDir, "foo.md");
213
+ fs.writeFileSync(abs, "x");
214
+ updateEntry(j, "foo.md", "hash", 10, "up", abs);
212
215
  expect(j.files["foo.md"]?.hash).toBe("hash");
213
216
  expect(j.files["foo.md"]?.direction).toBe("up");
214
217
  expect(j.lastSync).not.toBe("");
215
218
  expect(j.files["foo.md"]?.syncedAt).not.toBe("");
216
219
  });
220
+
221
+ // Load-bearing once delete propagation authorizes on ETag currency alone:
222
+ // an entry whose file was never written is indistinguishable from "the
223
+ // user deleted this", and the next push would issue a remote DeleteObject
224
+ // for what was only premature bookkeeping.
225
+ it("refuses to journal a path that is not on disk", () => {
226
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
227
+ const missing = path.join(stateDir, "never-written.md");
228
+ expect(() => updateEntry(j, "never-written.md", "hash", 10, "down", missing)).toThrow(
229
+ PrematureJournalEntryError,
230
+ );
231
+ expect(j.files).not.toHaveProperty("never-written.md");
232
+ expect(j.lastSync).toBe("");
233
+ });
234
+
235
+ it("accepts a dangling symlink — the link itself is the synced object", () => {
236
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
237
+ const link = path.join(stateDir, "dangling-link");
238
+ fs.symlinkSync(path.join(stateDir, "no-such-target"), link);
239
+ expect(() =>
240
+ updateEntry(j, "dangling-link", "hash", 0, "down", link, { kind: "symlink" }),
241
+ ).not.toThrow();
242
+ expect(j.files["dangling-link"]?.kind).toBe("symlink");
243
+ });
244
+
245
+ // Regression: `kind` and `createdBySub` were adjacent trailing optionals
246
+ // and two call sites passed kind into the createdBySub slot, leaving
247
+ // entry.kind unset. entry.kind gates delete-intent minting, so those
248
+ // entries could never be deleted. Named options make it unrepresentable.
249
+ it("records kind and createdBySub into their own fields", () => {
250
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
251
+ const abs = path.join(stateDir, "typed.md");
252
+ fs.writeFileSync(abs, "x");
253
+ updateEntry(j, "typed.md", "hash", 1, "down", abs, {
254
+ kind: "file",
255
+ createdBySub: "sub-123",
256
+ });
257
+ expect(j.files["typed.md"]?.kind).toBe("file");
258
+ expect(j.files["typed.md"]?.createdBySub).toBe("sub-123");
259
+ });
217
260
  });
218
261
 
219
262
  // ── Journal v2 (US-005): pulls, tombstones, GC, migration ─────────────────
package/src/journal.ts CHANGED
@@ -416,17 +416,82 @@ export function hashSymlinkTarget(target: string): string {
416
416
  * via `hq files get` are pulled — `direction:"down"` — but ride the pin union
417
417
  * in the caller's inclusion prefixSet, so they are likewise never pruned.)
418
418
  */
419
+ /**
420
+ * Thrown when a caller tries to journal a path that is not on disk.
421
+ *
422
+ * Deliberately loud rather than a silent skip: a caller reaching this has a
423
+ * real ordering bug, and swallowing it would reintroduce exactly the silent
424
+ * drift this guard exists to prevent.
425
+ */
426
+ export class PrematureJournalEntryError extends Error {
427
+ constructor(readonly relativePath: string, readonly absolutePath: string) {
428
+ super(
429
+ `refusing to journal '${relativePath}': no file at '${absolutePath}'. ` +
430
+ `A journal entry must be written only AFTER its file is on disk.`,
431
+ );
432
+ this.name = "PrematureJournalEntryError";
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Optional fields of a journal entry.
438
+ *
439
+ * These are an OBJECT rather than trailing positional parameters because they
440
+ * were four adjacent optionals, two of them `string`, and callers drifted:
441
+ * two conflict-resolution sites in `cli/sync.ts` passed their `"file" |
442
+ * "symlink"` value into the `createdBySub` slot, so `entry.kind` was silently
443
+ * never set on any conflict-resolved entry. That is not cosmetic — `entry.kind`
444
+ * gates delete-intent minting (`markLocalDeleteIntent` requires it to match,
445
+ * and the watcher skips any entry without one), so a file that had ever been
446
+ * through conflict resolution could never be deleted, even with a live watcher.
447
+ * Named fields make that class of mistake unrepresentable.
448
+ */
449
+ export interface UpdateEntryOptions {
450
+ remoteEtag?: string;
451
+ mtimeMs?: number;
452
+ /** Object's `created-by-sub` S3 metadata. Download path only. */
453
+ createdBySub?: string;
454
+ kind?: "file" | "symlink";
455
+ }
456
+
457
+ /**
458
+ * Record a journal entry for a file that is CONFIRMED present on disk.
459
+ *
460
+ * `verifyAbsolutePath` is required, and the entry is written only after an
461
+ * `lstat` proves the file exists. This is a load-bearing invariant, not a
462
+ * defensive nicety: a journal entry says "this key was synced and the local
463
+ * copy is at this hash". Once delete propagation authorizes on ETag currency
464
+ * alone (no watcher-minted intent), an entry whose file was never actually
465
+ * written becomes indistinguishable from "the user deleted this file" — and
466
+ * the next push issues a remote DeleteObject for a file that was only ever
467
+ * premature bookkeeping.
468
+ *
469
+ * Every existing caller already had a `fs.lstatSync` in hand and passed values
470
+ * derived from it, so this makes an existing convention structural. The point
471
+ * is that a FUTURE caller cannot get it wrong: with the check inside this
472
+ * function there is no longer a way to add a call site that records an entry
473
+ * for a file that is not there.
474
+ *
475
+ * `lstat` (not `stat`) so a dangling symlink still counts as present — the
476
+ * link itself is the synced object, and its target may legitimately be absent.
477
+ *
478
+ * @throws PrematureJournalEntryError when nothing exists at `verifyAbsolutePath`.
479
+ */
419
480
  export function updateEntry(
420
481
  journal: SyncJournal,
421
482
  relativePath: string,
422
483
  hash: string,
423
484
  size: number,
424
485
  direction: "up" | "down",
425
- remoteEtag?: string,
426
- mtimeMs?: number,
427
- createdBySub?: string,
428
- kind?: "file" | "symlink",
486
+ verifyAbsolutePath: string,
487
+ opts: UpdateEntryOptions = {},
429
488
  ): void {
489
+ try {
490
+ fs.lstatSync(verifyAbsolutePath);
491
+ } catch {
492
+ throw new PrematureJournalEntryError(relativePath, verifyAbsolutePath);
493
+ }
494
+ const { remoteEtag, mtimeMs, createdBySub, kind } = opts;
430
495
  const entry: JournalEntry = {
431
496
  hash,
432
497
  size,