# `saveCooldowns` merge semantics

Scoping deliverable for PRD item **P-bfa3a-scope-cooldown-merge** (Wave 3, PR10
pre-work) of the 2026-05-27 weekly bug-audit plan. Decides the merge strategy
the implementation PR (**P-bfa3b-cooldown-lost-update**) will apply at
`engine/cooldown.js:63-101`. No code is modified by this scoping artefact.

## Context

`engine/cooldown.js:63-101` (`saveCooldowns`) handles only **deletions** across
the debounced in-memory ↔ on-disk boundary. Any key added to `cooldowns.json`
by a concurrent writer between two of our writes is **silently overwritten**
when the in-memory `dispatchCooldowns` Map is flushed via
`Object.fromEntries(dispatchCooldowns)` at line 93. The 1000 ms debounce widens
the window.

`mutateCooldowns` already runs through `mutateJsonFileLocked`, which acquires
an exclusive `withFileLock` for the read-modify-write
(source: `engine/shared.js` `mutateCooldowns` ~L1625 and `mutateJsonFileLocked` ~L1542), so the callback
receives the freshly-read `diskCooldowns` snapshot — but the current code
throws that snapshot away.

## Chosen strategy: union-with-last-writer-wins, in-memory wins on collision

Inside the `mutateCooldowns(diskCooldowns => …)` callback, start from a copy of
`diskCooldowns` (the lock-acquired authoritative snapshot — it already contains
any concurrent writer's added keys), apply the existing deletion-detection step
unchanged, prune expired (>24 h) entries, then **union** every entry from
`dispatchCooldowns` on top. For overlapping keys, the in-memory value wins,
because the in-memory state was mutated within the current debounce window and
is by construction at least as recent as anything on disk. Return that union
object (not `Object.fromEntries(dispatchCooldowns)`) from the callback, and
reset `_lastDiskCooldownKeys` to the keys of the union so the next
deletion-detection cycle is anchored to what we actually persisted. This is the
smallest correct fix given the existing lock and per-entry `timestamp` schema,
requires no schema change, and matches the audit's
"writer A adds X to disk, writer B's snapshot lacks X → X must survive"
acceptance from P-bfa3b verbatim.

## Acceptance bullets for the implementation PR (P-bfa3b)

1. **Union semantics.** Inside the `mutateCooldowns` callback, `saveCooldowns`
   builds the result as `{ …diskCooldowns, …inMemoryEntries }` — every key
   present in the freshly-read `diskCooldowns` but absent from
   `dispatchCooldowns` (and not flagged as a deletion per bullet 2) survives
   the write. The TDD test from P-bfa3b ("writer A persists key X to disk
   while writer B holds a snapshot lacking X — assert X survives the merge")
   MUST pass against this semantic, with `dispatchCooldowns.has(X) === true`
   after the next `loadCooldowns()` on writer B.

2. **Deletion-merge preserved (regression).** The existing
   `_lastDiskCooldownKeys` logic at `engine/cooldown.js:70-74` runs first,
   before the union — any key present in `_lastDiskCooldownKeys` (i.e. we
   wrote it on our previous flush) but absent from the freshly-read
   `diskCooldowns` is treated as a deliberate delete by another writer and is
   removed from BOTH the result object AND `dispatchCooldowns` so it does not
   get re-added by the union step. A regression test asserts
   `clearCooldown(X) → saveCooldowns()` round-trips through disk and the key
   stays gone.

3. **`_lastDiskCooldownKeys` anchored to the persisted union.** After the
   union, the function sets `_lastDiskCooldownKeys = new Set(Object.keys(result))`
   using the **merged** object, not `dispatchCooldowns`, so the next save's
   deletion-detection sees exactly what we last wrote — including the
   concurrent-writer keys that the union absorbed. Expiry-prune (>24 h) and
   `pendingContexts` cap/truncation continue to run on every entry in the
   merged result, not just the in-memory subset.

## Trade-offs for the three options NOT chosen

- **(b) per-key timestamp-tagged merge** — The entry schema already carries
  `timestamp` (source: `engine/cooldown.js:154, 171, 195`), so this would add
  no new field; but it adds per-key branching in the flush hot path while
  converging on the same winner as (a) in every realistic case, because the
  in-memory entry whose mutation triggered the debounce is by construction at
  least as recent as the disk snapshot we just read under the same lock.

- **(c) in-memory canonical** — This is precisely the current bug — calling it
  the design simply documents the lost-update rather than fixing it, and it
  wastes the disk snapshot that `mutateJsonFileLocked` already reads for free
  inside the lock callback.

- **(d) on-disk authoritative** — Re-reading `cooldowns.json` on every
  `setCooldown` / `setCooldownFailure` / `isOnCooldown` call adds one fs read
  to the per-dispatch evaluation hot path, contradicts the module-level
  `dispatchCooldowns` Map cache that exists specifically to avoid that cost
  (source: `engine/cooldown.js:39`), and the lock-protected merge in (a)
  achieves the same correctness without the per-read penalty.

## Implementation notes for the P-bfa3b dispatch (dallas)

- Touch only `engine/cooldown.js:63-101` (`saveCooldowns`) — no API or signature
  changes, no caller-side migration. Expected diff: ~10–15 LoC inside the
  existing `mutateCooldowns` callback.
- The expiry-prune loop and the `pendingContexts` cap/truncate loop must run on
  the **merged result**, not on `dispatchCooldowns` alone, otherwise a stale
  concurrent-writer entry inherited from disk could carry a >24 h timestamp or
  an oversized `pendingContexts` array through the write.
- TDD test harness: drive through real `setCooldown` calls plus a direct
  `fs.writeFileSync(COOLDOWN_PATH, …)` between two flushes (flushing the 1000 ms
  debounce timer via fake timers or by calling the inner write helper directly).
  Extend the existing cooldown test file under `test/unit/cooldown*.test.js`;
  do not add a new top-level file.
- JSDoc paragraph for `saveCooldowns` (P-bfa3b acceptance bullet 4):
  "Merges the in-memory `dispatchCooldowns` Map ON TOP of the lock-acquired
  on-disk snapshot rather than replacing it. Concurrent writers' added keys
  survive; in-memory entries win collisions because they were mutated within
  the current debounce window. Deletions are detected via a
  `_lastDiskCooldownKeys` diff before the union so explicit `clearCooldown`
  calls still persist."

## Source references

- `engine/cooldown.js:63-101` — current lost-update site
- `engine/cooldown.js:38-60` — `loadCooldowns` + `_lastDiskCooldownKeys` baseline
- `engine/shared.js` `mutateCooldowns` (~L1625) — already lock-protected
- `engine/shared.js` `mutateJsonFileLocked` (~L1542) — reads disk inside the
  lock; `skipWriteIfUnchanged` enabled for cooldowns
- `prd/bug-fix-plan-from-weekly-audit-2026-05-27.json` — P-bfa3a (this scoping)
  and P-bfa3b (implementation acceptance criteria)
