# Customer Satisfaction Desk — decision log

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

## The axis this problem owns, and the one it deliberately does not

Atlassian's bank has a near-duplicate of this problem, `rating-system`: agents get 1-5 ratings, keep
an average, get returned sorted, with top-N. The two are split deliberately along different axes so
meeting the shape twice is useful practice rather than repetition:

- `rating-system` owns **storage and ordering** — incrementally maintained average versus recompute,
  where the ordering policy lives, top-N.
- **This problem owns reporting and format** — month bucketing behind an injected clock, and the
  export-format seam (`Exporter`, three implementations). It deliberately does **not** make top-N or
  running-average storage its centre: `leaderboard()` always returns everyone, and the reference
  recomputes an agent's average from their stored ratings on every call rather than maintaining a
  running sum — the recompute is O(ratings for one agent), the desk is in-memory, and there is
  nothing here to optimise that `rating-system` is not already the place for.

## The measurement, first, because it is the argument

Three requirement changes, each absorbed with the reference's own diff measured by
`server/lib/diff.mjs`:

| Curveball | Where it landed | Measured cost |
|---|---|---|
| a third export format, XML | one new file, one added line in the factory | **1 line** |
| best agents of the month | a new function in `Summaries`, a delegating method in `SatisfactionDesk` | **14 lines** |
| a rolling N-day window | the same shape as the month curveball, plus a cutoff and a guard | **19 lines** |

`RatingRepository`, `InMemoryRatingRepository`, `Rating`, `AgentSummary` and every `Exporter` were
**not opened** by curveballs 2 and 3, and none of the base collaborators except `Summaries` and
`SatisfactionDesk` were opened by any of the three. That is the claim this design makes, and it is
measurable rather than aesthetic.

---

## Seam 1 · `Exporter` — the format seam, and why it is the honest B1 tag

**Anticipates:** a new way to render the same numbers as text. The requirement ladder names this
directly — "export as CSV, JSON or XML" — and the sources behind this corpus's research call out
that a real interview round adds the third format mid-round specifically to see whether it costs a
rewrite.

The interface is one pure method, `export(agentId, months)`, over an internal `MonthlyAverage` —
never over the export text's own shape. `SatisfactionDesk.exportMonthly` does not branch on
`ExportFormat` at all; it looks the chosen `Exporter` up in a map and delegates. That map is built
by `CustomerSatisfactionFactory.standardExporters()`, exposed on its own rather than only assembled,
so registering a new format is adding to the one place that already lists every format there is.

**Why this earns a B1 tag rather than being a dishonest one.** `tic-tac-toe`'s decision log refuses
to tag a seam with only one implementation in sight — "an inflated tag makes the app's 'you have
held every item' claim a lie." `Exporter` starts with **two** real implementations
(`CsvExporter`, `JsonExporter`) and curveball 1 proves a **third** (`XmlExporter`) costs one file and
one line. That is a proven policy seam, not a speculative one.

**`Rounding` is shared on purpose.** All three exporters call `Rounding.twoDecimalPlaces`, so "csv
and json round the same way" — a requirement the base suite asserts directly with the 4.125 example
— is true because there is exactly one rounding implementation, not because three implementations
happen to agree. A design that gave each exporter its own `String.format` call would pass every base
test and drift the day someone "fixed" rounding in one of them.

## Seam 2 · `RatingRepository` — the seam the two later curveballs both measure

**Anticipates:** any new question about which ratings count. This is the same lesson `library`'s
`LoanRepository` teaches, applied a second time on purpose: the tempting shape for "best agents of a
month" or "ratings from the last N days" is a method on the storage interface —
`forMonth(YearMonth)`, `since(Instant)` — and that shape costs an edit to the interface *and* to
every implementation of it, for every new question, forever.

The reference instead exposes `all()`, `forAgent()`, `save()` and `agentIds()` — the four things
three base operations already needed — and every later question is a `Rating::recordedAt` or
`Rating::agentId` filter over `all()`, written in `Summaries`. Curveballs 2 and 3 both land in
exactly that shape, and both measure inside `Summaries` and `SatisfactionDesk`, never inside the
repository. Two data points of the same shape is the evidence, not a single lucky case.

**The one design choice this seam is built on:** `Rating.recordedAt()` keeps the full `Instant`
rather than reducing it to a `YearMonth` at the point of recording. Nothing in the base problem's
reporting needs day-level precision — only the month matters for `exportMonthly`. Throwing the
precision away anyway would have been the natural-looking simplification, and it is exactly the trap
the rolling-window curveball is built to catch: a design that only ever stored a month would need to
change what a `Rating` is — a real, base-suite-breaking migration, not a `reference_diff` a curveball
measures — the moment "the last 30 days" arrived.

## Seam 3 · `Summaries` — one comparator, three reports

**Anticipates:** any new *ranked view* of the same ratings — a month, a window, whatever comes next.

`LEADERBOARD_ORDER` — highest average, more ratings breaks a tie, agent id is the final tie-break —
is defined **once**, package-visible, and every ranked report sorts with the same reference to it:
`leaderboard()`, `leaderboardForMonth()` and `leaderboardWithinDays()` all call `.sorted(LEADERBOARD_ORDER)`
on their own filtered, grouped stream. Curveball 2's tie-break test and curveball 3's tie-break test
are the same assertion applied to a different filter, and both pass without the tie-break rule being
written down a second time. A design that inlined three chained `Comparator.comparing` calls inside
`leaderboard()` itself would need to either duplicate that chain twice more or extract it under time
pressure mid-round — precisely the discriminator this corpus's curveball ladder exists to surface.

`summarize(agentId, ratings)` is the other function every report reduces to: count and exact average
from a non-empty list of ratings. `leaderboard()`, `leaderboardForMonth()`, `leaderboardWithinDays()`
and `monthly()` all end in a call to it, so "how an average is computed" has exactly one definition
in the whole codebase.

## The injected clock — `TimeSource`, and why it is unambiguously C3 here

Month bucketing is not testable without an injected clock: a rating recorded "now" has to land in a
specific, assertable month, and the only way to see one land in February without an injected clock
is to wait for February. `SatisfactionDesk.rate()` reads `clock.now()` exactly once, at the moment a
rating is recorded, and nothing else in the class ever reads it — `leaderboard()` and
`exportMonthly()` read only ratings already on file, because a report about the past has no business
depending on when it happens to be asked for. `leaderboardWithinDays()` is the one place besides
`rate()` that reads the clock, and it reads it once, at the top, exactly the way `rate()` does.

## The tie-break rule chosen, and why

Three candidates were on the table, and the contract states the choice rather than leaving it
implicit:

1. **Chosen: more ratings first, then agent id.** More ratings is more evidence behind the same
   score — an agent averaging 4.0 from 200 ratings has earned that number in a way one averaging 4.0
   from 2 has not — and agent id is a total, meaningless tie-break that only exists to make the order
   deterministic.
2. **Rejected: most-recent-rating wins.** Defensible, but it rewards recency over volume of evidence,
   and it is a different, equally arbitrary story — the contract names it explicitly as the
   alternative not taken, so a candidate who reasonably picked it can see why the suite disagrees.
3. **Rejected: registration or arrival order.** Not stated as a serious option; it carries no
   information about either agent's performance and would be indistinguishable from an unspecified
   order, the exact failure mode `library`'s promotion-stacking curveball is built to punish.

## Every other ambiguity pinned, and where

- **The 1-5 scale is inclusive; 0, negative or 6+ throws `IllegalArgumentException`** — stated on
  `rate(String, int)` and enforced twice: once by the desk's contract and once more by `Rating`'s own
  compact constructor, so an invalid `Rating` cannot exist even if a future caller inside the design
  tried to construct one directly.
- **An agent with no ratings never appears in any listing** — not because unrated agents sort last,
  but because the design rules the case out entirely: there is no roster and no "register an agent"
  call, so the question of where a zero-rating agent sorts cannot arise. Stated on the interface's
  class javadoc as a deliberate answer, not a gap.
- **The month boundary is UTC, always** — `YearMonth.from(rating.recordedAt().atZone(ZoneOffset.UTC))`,
  spelled out in the javadoc with the exact one-second boundary example (`23:59:59Z` vs one second
  later) so nobody has to guess which side of midnight a rating falls on.
- **Averages are exact for ranking, rounded only for display** — `AgentSummary.averageRating()` never
  rounds; `exportMonthly`'s half-up, two-decimal-place rule is the only rounding in the entire
  problem, demonstrated with a base test built around 4.125 specifically because it is the boundary
  case where half-up and half-even disagree.
- **The exact CSV, JSON (and later XML) shapes** are pinned byte for byte in the interface javadoc —
  delimiter, line ending, decimal places, field order, no trailing newline — because a caller has to
  be able to compare the output exactly, and "close enough" is not a testable contract.
- **The rating-window's edge is inclusive** — a rating recorded exactly `days` days before now is
  inside the window, stated in curveball 3's contract delta and asserted by a dedicated test.

## What was deliberately not built

- **No running-average field, no incremental maintenance.** `AgentSummary` is recomputed from
  `RatingRepository.forAgent()` on every call. That decision belongs to `rating-system`, which owns
  the storage-and-ordering axis; building it here would be duplicating the other problem's centre
  rather than exercising this one's.
- **No top-N.** `leaderboard()` always returns every rated agent. Truncating to a top-N is a
  presentation decision a caller can make over the list this desk already hands out, and it is
  `rating-system`'s stated seam, not this problem's.
- **No agent roster, no "register an agent" call.** Considered and rejected in favour of ruling the
  unrated-agent question out entirely — see "every other ambiguity pinned" above.
- **No sealed hierarchy for `Exporter`.** The set of export formats is exactly the kind of thing new
  requirements keep adding to (three curveball sources all mention a fourth format eventually
  arriving), so sealing it would make every new format an edit to a `permits` clause — the exact cost
  `Exporter` being an open interface avoids.

## An honest note on the weakest tag

**D4 (iteration, pagination and bulk-operation shape)** is the tag most worth being skeptical of.
There is no pagination and no bulk operation anywhere in this problem — `leaderboard()` always
returns the whole list, and `rate()` records one rating at a time. The justification is narrower
than `shopping-cart`'s (which has real paging and a real all-or-nothing batch): here it rests on the
**report shape** decision — one row per agent in `AgentSummary`, one row per month in
`MonthlyAverage`, and the explicit choice that a "bulk" report (the leaderboard) returns everything
rather than a windowed page of it, stated and defended in `problem.json`'s "reinforces D1, F1"
assignment rather than claimed as this problem's centre. A grader auditing this corpus should weigh
this tag lightly; the two seams this problem actually stakes its claim on are **B1** (`Exporter`,
proven by three implementations) and **C3** (the injected clock, unambiguously required for month
bucketing to be testable at all).
