# Notification Service — decision log

The Phase 5 answer key. Every entry is a question an interviewer actually asks ("why is that a
separate class?", "what exactly makes that atomic?") and the answer that earns the mark: **what
change or failure the decision anticipates, and what it would cost without it.**

Fourteen files, 981 lines, of which rather more than half is javadoc. The two largest are
`NotificationService` (175) and `InMemoryDeliveryLog` (156); nine of the rest are under 50. There is
no rule in `NotificationService` — no retry schedule, no quiet-hours test, no validation of
preferences, no storage decision, and exactly one line that reads the clock.

| File | One reason to change |
|---|---|
| `NotificationService` | the *sequence* — claim, decide, deliver, record, announce — changes |
| `DeliveryLog` / `DeliveryClaim` | where the idempotency record lives, or what a claim can answer |
| `InMemoryDeliveryLog` | the in-process implementation of that record |
| `RetryPolicy` / `ExponentialBackoffRetry` | how hard to try one channel before giving up on it |
| `RetryingDelivery` | the mechanics of walking a candidate list and reacting to failures |
| `ChannelSelection` / `PreferenceAwareSelection` | which channels a notification may use, and when |
| `QuietWindow` | what "quiet hours" means as a span of time |
| `RecipientBook` | how preferences are stored, validated, normalised and defaulted |
| `Receipts` | what shapes a receipt is allowed to take |
| `DeliveryOutcome` | what one run of the delivery loop reports |
| `Demo` | what we show when we run it |

---

## Idempotency first, because that is what the problem is

### Where exactly-once lives: one statement

```java
DeliveryClaim claim = log.claim(key);
if (!claim.owned()) {
    return Receipts.asDuplicate(claim.outcome());
}
```

Everything after those four lines in `send` is ordinary single-threaded code, and it is ordinary
*because* of them: after the claim, this thread is the only thread in the world working on this key,
so the preferences lookup, the channel choice, the retry loop and the recording need no
synchronisation of their own.

> **The decision the whole problem turns on: make the concurrent region one line wide, then write
> the rest as if there were no threads.**

**What it anticipates.** Every rule in the send path is volatile — retry counts, quiet hours,
channel order, a rate limit arriving next quarter — and none of them wants to be written by somebody
also thinking about locks. Confining the concurrency to one statement means the rules can be edited
by somebody who does not know the service is multi-threaded, which is the only way that code stays
correct over a year.

**What it costs without it.** The obvious alternative is `synchronized` on `send`. It is *correct*
and it is useless: the body of `send` contains a network call with retries and backoff in it, so the
lock serialises every notification in the process behind the slowest provider it talks to. One wedged
SMS provider stops the emails.

### Why `claim(key)` and not `contains(key)` then `put(key, receipt)`

Because that pair **is** the bug, and no amount of care in the caller closes it — the caller cannot
make its own two calls atomic. Reading "is this key known?", deciding it is not, delivering, and then
writing the outcome is a check-then-act whose window is as wide as a gateway call plus its retries.

So the atomicity lives inside the interface, in one method, and the interface makes the wrong thing
**unexpressible**: *there is no way to ask whether a key is taken without taking it.* That is the
difference between a seam and a getter.

Measured, not asserted: a variant of this reference whose `claim` is `!byKey.containsKey(key)` is
caught by the stress suite on repetition 1 with `expected: <1> but was: <202>`. See
`problem.json`'s `concurrency.proven_against`.

### Why `DeliveryClaim` is a type and not a boolean

**Because "have we already sent this?" has three answers, not two.**

1. *I own it* — I must do the work, then settle it.
2. *Somebody finished it already* — read the recorded receipt.
3. *Somebody is doing it right now* — **wait for their answer.**

A method returning `boolean seen` collapses the second and third, and collapsing them is exactly the
defect. The thread that arrives mid-flight either decides the key is new and sends a second copy, or
invents a receipt nobody recorded. The contract's rule — "the losers do not race ahead and they do
not fail; each one waits for the winner to settle and then returns the winner's facts" — is
*unimplementable* against a boolean.

**What it costs without it.** The window this closes is the widest one in the problem: between the
first gateway call and the outcome being recorded sits the entire retry schedule. The stress suite
gives that its own scenario (`duplicates arriving while the owner is retrying wait rather than send
again`) precisely because a guard that is merely *nearly* atomic survives the fast path and dies
here.

### Atomicity and visibility are two problems, solved two ways, two lines apart

```java
Settlement existing = byKey.putIfAbsent(idempotencyKey, fresh);   // atomicity
```
```java
receipt = outcome;  failure = cause;  settled.countDown();        // visibility
```

`putIfAbsent` returns `null` to **exactly one** caller per key however many arrive together. That is
all it does, and it is not enough on its own: when the claim is granted the winner's receipt *does
not exist yet*, so there is nothing for the map to have published. The `Settlement` therefore holds a
`volatile` receipt written **before** `countDown()`, and the waiter reads it **after** `await()`.

> Atomicity got the key. Visibility publishes the answer. A candidate who uses `putIfAbsent` and then
> hands the receipt over through a plain field has solved half of this, and not the half that bites.

The wait is **bounded** (10s, from the contract). An unbounded `await()` turns one wedged provider
into a wedged process, and "no method may block for ever" is a requirement.

### Why `putIfAbsent` and not `computeIfAbsent`

`computeIfAbsent` would be three lines shorter and would be a serious mistake: the delivery, retries
and backoff included, would run **inside the mapping function**, holding the map's bin lock across a
network call and blocking every other key that hashes near it. The mapping function is documented as
needing to be short and to not touch the map. So the map is used for the one instant that must be
atomic — installing the settlement — and the work happens outside it.

> **No lock of any kind is held while the gateway is called.** The claim is a map operation that
> returns immediately; the latch a duplicate waits on is not a lock, and it is bounded.

### Why `abandon` exists

If a seam misbehaves and throws something the design did not anticipate, the owner must not leave
duplicates waiting on a claim that will never settle. `abandon` removes the key and wakes the waiters
with the failure. It also keeps the contract honest: no terminal outcome was recorded, so by the
service's own rule *the key was never consumed*, and a later submission may try afresh.

---

## The shared mutable state, named out loud

There are exactly **three** pieces, they need **three different answers**, and defending the
differences is the mark.

| State | Answer | Why that one |
|---|---|---|
| the delivery log | `ConcurrentHashMap` + `putIfAbsent` | what must be atomic is exactly one entry: "put this key if nobody else has". That is the one shape a concurrent map gives for free |
| the recipient book | `ConcurrentHashMap`, nothing else | each operation touches one whole immutable value. There is no multi-step update to make atomic, so there is nothing to add |
| the listener list | `CopyOnWriteArrayList` | append-only, read on every send, written almost never, and iterated at the exact moment somebody appends |

**The listener list is not a theoretical concern.** A plain `ArrayList` here throws
`ConcurrentModificationException` at a delivery that had *already succeeded* — somebody else's
registration becoming your failure. Measured: the unsafe-collections variant is caught by the stress
suite while all three exactly-once checks still pass, which is the suite telling a broken observer
registry apart from a broken idempotency guard.

**Choosing the collection *is* choosing the lock granularity.** The alternative for the log — a plain
`HashMap` behind one lock — works, and would then have to hold that lock across the gateway call to
be useful, which is the serialising design rejected above.

### What is deliberately *not* shared mutable state

`RetryingDelivery`, `PreferenceAwareSelection` and `ExponentialBackoffRetry` are **stateless**:
everything that varies per notification is a local. So one instance of each is shared by every
thread and there is nothing to guard. That matters most for `RetryingDelivery`, because it is where
the slow call happens — it is the class that must not be holding anything anybody else wants.

`QuietWindow` and every record are immutable. `RecipientBook`'s stored preferences are rebuilt as new
immutable values on write, never mutated in place, which is what lets a send read the map once and
keep the preferences it started with for the whole notification.

### Lock ordering — the honest answer

There isn't any, and that is the point rather than an omission: **the reference takes no lock at
all.** No two monitors are ever held together, so there is no acquisition order to get wrong and no
cycle to build. If asked "what is your lock order?", the answer that earns the mark is "there are no
nested locks; the only synchronisation is one `putIfAbsent` and one bounded latch wait, and neither
is held across anything slow."

---

## Why each seam exists

### `DeliveryLog` — the change that turns this exercise into a service

**Anticipates: one process becomes several.** Exactly-once across a fleet is the same two operations
against Redis (`SET key NX`) or a database (a unique index and an insert that may collide) — a
different implementation of this interface and *nothing else changes*. The whole of
`NotificationService` is written against `claim` and `find` and does not know or care that the map is
in this JVM.

**Without it:** the `ConcurrentHashMap` is a field on the service, `putIfAbsent` is inlined into
`send`, and going multi-instance means rewriting the method that contains every other rule in the
problem — while it is the method under the most test pressure.

### `RetryPolicy` — the most volatile rule in any delivery pipeline

"Three tries, 1ms apart" is not a design decision, it is a knob. A real deployment wants jitter so
ten thousand retries do not arrive in the same millisecond, a longer ceiling for email than for push,
and a circuit breaker that stops trying a provider that is plainly down. Every one of those is a
different implementation of two methods and **none is a change to the delivery loop**.

It is handed **a count and nothing else** — not the request, not the channel, not the exception — so
it cannot reach back into the service and cannot become the place routing rules quietly accumulate.
A policy that needs to know which channel it is pacing is a *per-channel policy*, which is a
different object, not a wider interface.

Both numbers are constructor arguments rather than constants, because the only reason this problem's
schedule is 3 attempts and 1ms is that the hidden suites must finish in seconds. A class that
hardcoded them would have to be edited to be believed in production; this one is wired differently.

### `ChannelSelection` — and why the empty list is the whole trick

**Anticipates every rule about who may be contacted how, and when**: quiet hours today, a
per-recipient rate limit, a regulatory opt-out, a channel taken offline for maintenance, marketing
consent. All of those are the same question, and all of them answer it **by returning a shorter
list**.

The empty list carries a real outcome — nothing may be sent, which is `SUPPRESSED`. That is what
stops suppression being a special case: **the delivery loop has no branch for quiet hours, it only
has a branch for "no candidates".** Any future reason to say no is therefore already implemented,
which is what makes this the seam curveball 03's rate limiter *decorates* rather than edits.

It never sees the body, so it cannot become a content filter; never sees the gateway, so it cannot
send anything. That is interface segregation doing work rather than being quoted.

### Which channels quiet hours forbid is *injected*, not written in the filter

`new PreferenceAwareSelection(Set.of(Channel.SMS, Channel.PUSH))`.

"SMS and push are intrusive, email is not" is a product judgement about a specific list of channels,
and **the list of channels grows**. Every new one arrives with its own answer, and a new answer
should not be a new `case` inside the filter. Held as a set, a new channel that must not buzz
somebody at 3am costs **one entry where the service is wired together**, and
`PreferenceAwareSelection` is never opened again. Curveball 01 is exactly that change, and it is one
line.

### `RetryingDelivery` — why the loop is not a method on the service

It is the only part of the design that blocks, the only part that talks to the outside world, and the
only part with a loop in it. Keeping it apart is what lets `send` read as five statements of policy
rather than a nest of `try`/`catch` inside a `while` inside a `for`, and it is what makes the retry
rule testable with no service, no clock and no log.

The three failure cases are **deliberately distinguished**, and each distinction is a rule somebody
asked for:

- `TransientDeliveryException` — another go on this channel, if the policy still allows one.
- `PermanentDeliveryException` — this channel is finished immediately. Retrying a request a provider
  has already rejected is how a sender gets itself blocked.
- **Any other `RuntimeException` — treated as permanent.** A client library throwing something
  undocumented is not evidence that trying again will help, and it is not a reason to abandon the
  recipient either: the next channel is still worth a go. Catching it *here* is what keeps the
  promise that no gateway failure ever escapes `send`.

### `QuietWindow` — a value object because the obvious comparison is wrong

`hour >= start && hour < end` silently covers **nothing at all** for a 22-to-7 window, which is the
window every real recipient wants, and the bug is invisible at the call site. Naming the concept puts
the wrap in one place, gives it a name to test, and means the two integers can never again be
compared by hand.

`start == end` is not an empty window and not a whole day; it is the documented spelling of "no quiet
hours", and that is a rule worth having in exactly one place. The range invariant lives in the
constructor, so an out-of-range window **cannot exist** to be checked for later.

### `RecipientBook` — validating on the way in, not on the way out

Preferences are written rarely and read on **every** notification. Checking the hours and stripping
the repeats once, at `record()`, means the read path — which runs under contention — is a map lookup
and nothing else, and every `RecipientPreferences` that leaves this class is already known to be
sane. `ChannelSelection` can then be a filter with no defensive checks in it, and `QuietWindow` can
be built from stored hours without wondering whether they are in range.

The contract's record deliberately validates nothing, so this is where "no invalid preferences can be
stored" has to become true. It is enforced by checking **everything before writing anything**, which
is what makes the promise "a rejected update leaves what was there before exactly as it was" hold.

The channel list is copied on the way in, so the caller's list is theirs to mutate afterwards — a
base-suite check, and the reason `List.copyOf` appears at both edges.

An unknown recipient is **ordinary, not an error**: they get the default order and no quiet hours, so
a service that has never been configured still works. That is a deliberate refusal to invent an
error case.

### `Receipts` — four factories because a record cannot enforce a relationship

The receipt's invariants are relationships *between* its fields: `SUPPRESSED` implies zero attempts
and a null channel; `DELIVERED` and `FAILED` imply at least one attempt and a non-null channel. A
record cannot enforce a relationship it is handed. Funnelling every receipt through four named
factories means **the wrong combination is never constructed**, which is what the stress suite's
"receipts are never torn" check is really testing.

`asDuplicate` copies everything except the flag, including `decidedAt`, which is deliberately **not**
refreshed: the decision was made then, not now, and a duplicate is being *told about* that decision
rather than receiving a new one.

---

## Ordering decisions inside `send`, each of which is a test

- **Validate before claiming.** A malformed request must not burn an idempotency key, so validation
  happens before `log.claim` is reached.
- **One clock read, at the top.** `decidedAt` is read once, so a retry cannot carry a notification
  across the boundary into quiet hours. Nothing else in the design touches a clock, and nothing
  anywhere calls `Instant.now()` — a service that reads the wall clock cannot be tested at 3am.
- **Settle before announcing.** The outcome is recorded, *then* listeners are called, so a listener
  that turns round and asks `receiptFor` finds it.
- **Swallow a listener's exception.** A listener is a stranger handed in from outside. The
  notification has already been delivered and already been recorded by the time observers run, so
  there is nothing an observer could usefully fail. This is not carelessness; it is the decision that
  **observation must not be able to change an outcome**. Every registered listener is called even if
  an earlier one threw.

## What is deliberately absent

No scheduler, no background thread, no queue, no persistence, no key expiry, no templating, no
batching, no priority. Quiet hours **suppress**; they never defer — which is why there is no timer in
the design and why `SUPPRESSED` had to be a terminal status rather than a pending one. The service
starts no threads of its own: `send` does all its work on the calling thread, which is what makes
"listeners fire on whichever thread called `send`" a statement of fact rather than a promise.
