# Reference patch — 03-hourly-quota

The answer key for how the reference absorbs this change, and the thing that makes `budget.json`
reproducible. **Never shipped to the candidate.**

The contract does not change for this one. There is no `contract-delta/`.

1. **Add** `HourlyQuota.java` (in this directory) to `reference/src/`. It implements
   `ChannelSelection` by wrapping another one. New file, so `measureChange` charges nothing.
2. **Change one line** in `reference/src/NotificationService.java`, in the convenience constructor
   (mechanised in `edits.json`):

   ```diff
                    new InMemoryDeliveryLog(DUPLICATE_WAIT_LIMIT),
   -                new PreferenceAwareSelection(Set.of(Channel.SMS, Channel.PUSH)),
   +                new HourlyQuota(new PreferenceAwareSelection(Set.of(Channel.SMS, Channel.PUSH)), 3, Duration.ofHours(1)),
                    new RecipientBook(List.of(Channel.EMAIL, Channel.SMS, Channel.PUSH)));
   ```

Measured: `reference_diff = 2` (1 added, 1 removed, one file touched), so
`diff_budget = max(3, 2 + ceil(2/2)) = 3`.

## Why it is one line

**Because a rate limit is not a new kind of rule, it is the rule `ChannelSelection` already
answers.** The seam's own javadoc names it before the curveball arrives: *"Anticipates every rule
about who may be contacted how, and when: quiet hours today, per-recipient rate limits, regulatory
opt-outs, 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 consequences of that shape are what make this cheap, and each one is a rule the interviewer
listed:

- **"Not queued, not an error — the answer we record."** The empty list already means SUPPRESSED, and
  the service's existing empty-candidates branch already gives a terminal receipt with zero attempts,
  a null channel, and the key consumed. There is no `if (rateLimited)` in the reference, because there
  was never an `if (quietHours)` either. **No new status, no new branch, no new exception.**
- **"A notification we never sent does not count."** The decorator asks the inner selection *first*
  and returns early when it is already empty. One early return, and quiet hours and the quota compose
  without either knowing the other exists.
- **"The same notification submitted twice counts once."** Free, and this is the part that makes this
  curveball belong to *this* problem. `channelsFor` is called exactly once per notification, while the
  sender holds the idempotency claim, and never for a duplicate. So the counting inherits exactly-once
  rather than reimplementing it — a candidate whose dedupe guard is not atomic will find this curveball
  double-counting as well as double-sending.
- **"One we tried and failed still counts."** Also free: selection happens before delivery, so the
  slot is spent by the *attempt*, not by the outcome. Nothing had to be written to make FAILED count
  and nothing had to be unwound.
- **Composability.** `HourlyQuota` wraps a `ChannelSelection` and *is* a `ChannelSelection`, so the
  next rule of this shape — a marketing opt-out, a channel down for maintenance — wraps it in turn for
  another one-line change and zero edits to anything that exists.

## The concurrency inside the new file, since this is a concurrency problem

The seam's contract warns that a stateful implementation must look after itself, and the quota is the
first stateful one. Two decisions worth defending:

- A `ConcurrentHashMap` keyed by recipient, so unrelated recipients never contend — the same
  granularity argument as the delivery log.
- One recipient's timestamps in a plain `ArrayDeque` under **its own monitor**, because what must be
  atomic is *expire, count, and maybe record*: three reads and a write, which is exactly what no
  concurrent collection can give you. This is the mirror image of the base reference's
  `ConcurrentHashMap.putIfAbsent` choice, and being able to say why the two are different is the
  point of tagging E4.
- The monitor is a **leaf**: nothing is called while it is held, and this class never sees a gateway,
  so the promise that no lock is held across a provider call survives the change.

## What it costs a design without that seam

The rate limit goes into `send`, between the claim and the delivery:

```java
if (!quota.allows(request.recipientId(), decidedAt)) {
    return Receipts.suppressed(key, decidedAt);       // ...and settle the claim, and fire listeners
}
```

That is more lines than it looks, because everything the empty-candidates branch already did now has
to be repeated in a second place — settling the claim, announcing the outcome, getting `attempts()`
and `channel()` right. Two paths now produce SUPPRESSED and they can drift; the stress suite's
"receipts are never torn" check is exactly what notices when they do. Worse, the ordering rule has to
be written out by hand: does the quota run before or after quiet hours? A design where both are just
"return a shorter list" cannot get that wrong, because the composition *is* the answer. Realistically
12–20 lines across `send` plus a new class, a second suppression path to keep in step, and a rule
about precedence that has to be remembered rather than derived.

**Both suites are green after the patch** — the curveball suite (7 tests) and the base suite (22
tests), with the cap of 3 actually in force. That the base suite survives is worth stating: no base
test sends a fourth *distinct* notification to one recipient through one service, and the one that
sends the same key three times is unaffected because duplicates never reach the selection. This
change alters behaviour rather than merely adding to it, so a green base suite here is evidence that
the requirement was absorbed narrowly and not that it did nothing.
