# Trip State Machine — 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.**

Eleven files, none over ~90 lines of code. `Trip` holds the *sequence* of handling an event — ask,
record, react — and not one rule a requirement could reach.

| File | One reason to change |
|---|---|
| `Trip` | the *sequence* of handling an event changes |
| `TripRules` | what a trip is allowed to do changes |
| `TransitionTable` | never — it is an index, not a policy |
| `Edge` | never — it is one row of the table |
| `TripJournal` / `InMemoryJournal` | where the history is kept changes |
| `TripProjection` | a new fact has to be derived from the log |
| `EffectRunner` | the failure policy for side effects changes |
| `RiderNotification` | what the rider is told changes |
| `TripFactory` | what a trip is assembled from changes |
| `Demo` | never — it is the driver, not the design |

---

## Decision 1 · The state is not stored — this is the whole problem

**The plausible wrong answer** is a `TripState state` field, updated at the end of `handle`. It is
what almost everyone writes, it passes most of the base suite, and it is wrong for one reason that
matters more than its convenience: **it makes the same fact true in two places.**

With a field there are two representations of where the trip is — the field and the history — and a
code path whose job is to keep them in step. A bug in that path produces a trip whose `state()` says
`IN_PROGRESS` and whose `history()` ends in a cancellation. Support cannot tell which is right, and
neither can the billing pipeline. Here the question cannot be asked: `state()` is
`TripProjection.stateOf(log)` — the `to` of the last entry, or `REQUESTED` when there are none — so
there is no line of code that has to keep two things in step and therefore no line that can fail to.

Three consequences fall out for free, and each is a feature somebody would otherwise have to build:

- **Replay is not a feature.** Take the events out of one trip's history, hand them to a fresh trip
  with an equivalent clock, and you get an identical history and an identical state — because the
  state was never anywhere but the log. A design with a stored field has to *prove* its replay path
  and its normal path agree, which is two implementations of one rule.
- **`driverId()` cannot go stale.** It is a fold over the log, not a field assigned in the `Matched`
  branch. Curveball 01 charges exactly this: when a driver cancellation must *clear* the driver, the
  fold costs one arm, and a remembered field costs a second place to be wrong.
- **"Record, then react" is enforced by the shape.** Reacting needs an entry to react to, so the
  ordering the contract requires is not a convention anyone has to remember.

**The cost, stated plainly:** `state()` is O(1) but a derived fact like `driverOf` is a walk over the
log. A trip with a million entries would want a projection cached and updated on append. That is a
real change, and the seam for it is `TripProjection` — the fold lives in exactly one place, so
caching it is one file. For a trip, whose log has at most a handful of entries, paying for the cache
now would be inventing a problem.

## Decision 2 · The rules are data, in a list, apart from the thing that indexes them

**Anticipates:** every change to what a trip is allowed to do. A driver-side cancellation, a driver
swap, a no-show timeout, a scheduled trip that must wait before it can be matched — all of them are
rows in `TripRules.standard()` and nothing else.

`TripRules` is the requirement's table transcribed into `Edge` values. `TransitionTable` is handed
those values and indexes them into a `Map<TripState, Map<Class<? extends TripEvent>, TripState>>`; it
does not know what any of them mean. They are separate classes because they change for different
reasons — the index is a mechanism and will not change again, the rows change constantly.

**The plausible wrong answer** is `switch (state)` inside `handle`. Three things go wrong with it, in
increasing order of seriousness:

1. The rules end up inside the method that also timestamps, records and fires effects, so "what can
   a trip do once the driver has arrived?" has no single place to read.
2. Every new state or event grows another arm, kept consistent with the rest by hand.
3. **An absent rule stops being a refusal.** In the table, a missing cell *is* the refusal. Twenty-
   three of the thirty cells are blank, and every rule the requirement states about them is enforced
   by nothing at all: "cancellation is refused once the trip is under way" is the absence of a row,
   and "a duplicate event is an error" is the absence of a single self-loop. In a switch, each of
   those is a branch somebody has to write, and a branch somebody can write wrongly.

The measurements back this up rather than the argument resting on taste. Curveball 02 (a driver swap
— two new legal pairs, no new event type) is **4 lines, all in `TripRules`**, and touches nothing
else in the design.

## Decision 3 · `Edge` is keyed on the event's `Class`, and the cost is stated

`TripEvent` is a sealed hierarchy rather than an enum because its variants carry data that differs
per occurrence: `Matched` knows which driver, `RiderCancelled` knows why. An enum constant is a
singleton and can carry neither, and the usual workaround — `enum Kind` plus a nullable `driverId`
plus a nullable `reason` — permits instances that cannot mean anything, like a `TRIP_STARTED`
carrying a driver id. Those are unconstructable here, so nothing has to validate them.

So there is no enum constant to key a row on, and the thing that identifies "what kind of event is
this?" *is* its type. Keying on `Class` is the natural key, not a trick.

**What it costs, and this is the honest part of the answer:** the key is not exhaustiveness-checked.
Nothing forces the table to have a row for a newly added event. That is the right trade here — an
event with no row is *refused*, which is a safe default and usually the correct one — but it must be
said out loud, because the exhaustiveness this design does rely on is enforced somewhere else. See
decision 4.

## Decision 4 · Three exhaustive switches with no `default` — the compiler as the reviewer

`TripProjection.driverOf`, `RiderNotification.message` and `Demo.narrate` all switch over the sealed
`TripEvent` with **no `default` branch**. Add a sixth kind of event and each stops compiling with
*"the switch expression does not cover all possible input values"*, naming the file and the line,
before any test runs.

In `driverOf` most arms return the driver unchanged, which looks like noise a `default` would remove.
It is the opposite: the absence of a default is what forces whoever adds an event to answer *"does
this change who the driver is?"* rather than getting a silent no. Curveball 01 is precisely that
question, and the honest answer there is `Optional.empty()` — a `default -> driver` would have
shipped a trip that reports a driver it does not have.

The same in `message`: a `default -> ""` would have turned a new event into a rider who is silently
told nothing, discovered in production.

**Verified, not assumed.** Compiling `reference/src` against
`curveballs/01-driver-cancels/contract-delta/` fails in all three files. Note that `javac` reports
only the first exhaustiveness error per compilation and stops, so they surface one at a time as each
is fixed — a property of the compiler, not of the design. Every site is still named before any test
runs, which is the whole point.

## Decision 5 · `TripJournal` is an interface with one implementation, and that is defensible

A one-implementation interface is a cost, and pretending otherwise is how designs get bloated. What
it buys here is not the second implementation — it is that **`Trip` cannot hold a state field.** If
the history were an `ArrayList` the trip owned, the trip would also own a state variable beside it
and shadow the log, which is decision 1 undone. Making the log a collaborator the trip *asks* is what
makes the log structurally the only source of truth.

**Anticipates** the change a real deployment makes first: a trip's log is what an audit team, a
billing pipeline and a support agent all read, so in production it is a topic or a table, not an
`ArrayList`. A trip that appends through this interface can be handed a journal that writes through
to storage without the trip knowing.

`InMemoryJournal.transitions()` returns `List.copyOf`. The history is the trip's single source of
truth, so handing a caller a mutable view of it would let them rewrite the trip's state from outside —
and a live view would let the list they are holding change under them. The base suite asserts both.

## Decision 6 · A failing side effect does not roll the transition back

This is the second question the problem is really about, and the answer is owned by `EffectRunner`.
Three reasons, in increasing order of seriousness:

1. **The event already happened.** The driver really did arrive. Refusing to record it because a push
   gateway timed out produces a log that is wrong about the world, which is worse than a rider who
   did not get a notification.
2. **A rollback is a second copy of the logic running backwards.** Undoing a transition means undoing
   the effects that already succeeded before this one failed — un-sending a push, un-writing a
   receipt — and most of them cannot be undone. That code exists only to handle failure, so it is the
   code least often run and most often stale.
3. **There is nothing safer to roll back to.** If the state stayed at `MATCHED` because a
   notification failed, the next `TripStarted` would be refused, and a real trip would be stuck
   behind a push notification.

What is *not* acceptable is losing the failure, so every failure is recorded by name and reported
through `failedEffects()`. That list is a retry queue's input, and naming it is the whole difference
between "we chose not to roll back" and "we swallowed an exception". Each effect gets its own `try`,
because a broken metrics counter must not cost the rider their notification.

**The line this draws, and it is the most useful thing in the file.** If an effect is a
*precondition* rather than a consequence — a payment authorisation that must succeed before a trip
may start — it does not belong in `EffectRunner` at all. It belongs *before* the transition, in the
same place the table lookup is: asked first, and the transition simply not made if the answer is no.
"May this happen?" and "tell the world it happened" sit on opposite sides of the log append rather
than both being called "an effect". Curveball 03 lands exactly on that line and the design had a
place for it.

## Decision 7 · Ask, record, react — which is why there is no rollback path anywhere

`Trip.handle` is three steps in a fixed order:

1. **Ask.** Is the trip over? Does the table have a cell for this? Both questions are pure —
   `TransitionTable.next` looks a value up and changes nothing — so a refusal happens before the
   clock has been read, before the log has been touched and before the world has been told anything.
2. **Record.** Append the entry. A single append that cannot half-happen.
3. **React.** Run the effects, which by then cannot undo anything.

**There is no rollback path in `Trip` because there is nothing to roll back.** "A refused event
changes nothing at all" is not a guarantee the code maintains; it is a consequence of asking before
touching. That is also why the clock is read exactly once per *accepted* transition and never for a
refusal — the read sits after both questions, so a refused event cannot reach it. `StepClock`
advances on every read, which makes an extra read visible and therefore a defect the suite catches
rather than an invisible sloppiness.

## Decision 8 · Two exception types, and a stated precedence

`IllegalTripTransitionException` and `TripFinishedException` are a deliberate distinction, and the
reason is the caller's real question: **should I try again?** An illegal transition on a live trip may
well be legal later, once something else has happened — retrying is not obviously wrong. A finished
trip will still be finished tomorrow. A retry loop that cannot tell them apart either gives up too
early or hammers a trip that ended last week.

Both can be true at once — sending `Matched` to a cancelled trip is both illegal and too late — so
the precedence is fixed rather than left to whichever check happens to run first: **the terminal
check wins.** `handle` asks `from.terminal()` before it asks the table. Pinning it is the point; an
unpinned ordering here is exactly the arbitrariness a candidate could not have guessed, so it is
stated in the contract javadoc and asserted by the base suite.

`terminal()` is behaviour on the `TripState` enum rather than a lookup somewhere else, because "a
trip that is over is over" is a fact about the state itself and not about any particular trip. Both
exceptions carry the state and the event rather than only prose, because a dispatcher's alerting
wants to count refusals by pair, and computing that twice in two places is how the two drift apart.

## Decision 9 · `TripFactory`, and why `Trip` has no convenience constructor

There is deliberately no `Trip(Clock, List<SideEffect>)` that supplies its own table and journal. It
looks like ceremony until you ask where a requirement lands. Every change to what a trip is assembled
from — a new rule set, a journal that writes through to storage, a guard consulted before the append,
an extra capability wrapped around the whole API — is one line *here*, and none of them opens the
class that decides whether an event is legal and appends to the log. Curveball 03 is the proof: a new
collaborator reached `Trip`'s constructor and `TripFactory.standard`, and `TransitionTable`,
`TripRules`, `EffectRunner`, `TripJournal` and `InMemoryJournal` were not opened at all.

The parts are exposed (`TripFactory.table()`) as well as the whole, so a variation on a standard trip
can reuse what it is not changing rather than copy it.

## Decision 10 · Single-threaded, said out loud rather than guarded speculatively

The contract says the grader never calls two methods at once, so there are no locks. Saying that is
better than adding them, and better than saying nothing.

The shared mutable state, named rather than hidden: the journal's entry list and the runner's failure
list. **If** a trip were reachable from a request thread and a driver-app thread at once, the lock
would go around the whole of `handle` — not around the append — because "look the rule up, then
append" has to be one step or two events can both be accepted from the same state, and the table
lookup being pure is exactly what makes that a lost-update rather than a crash. That is the answer to
"what would you change to make this thread-safe", and it is a one-line change to one method, which is
itself a consequence of decision 7.
