# Phase 5 — reference solution Code in this directory: `p5_state_machine.py`. Verified by `verify_phase_5.py` (11 tests). The rest of this phase is design work, and the reference is an argument rather than a module. --- ## 5.1 The state machine → `p5_state_machine.py` The same machine as `../phase-1/p1_return.py`, written as data: `TRANSITIONS` maps `(state, event)` to a state, and every absent pair is illegal. **Why data and not methods.** `render_table()` prints an artifact a product manager can review without reading Python. The three well-formedness properties — no unreachable state, no non-terminal dead end, no exit from a terminal state — become tests over the table rather than a careful reading. **What the table exposes that the lab hides.** - `AWAITING_LABEL` is a real state. The lab leaves returns in `APPROVED` with no tracking number and nothing that ever retries. Naming it, and giving it a `LABEL_FAILED` self-transition plus a `LABEL_BOUGHT` exit, is what turns a stranded customer into a retry queue. - `CANCEL` is legal from every non-terminal state and from none of the terminal ones. That is a rule nobody had written down. - The review case has its own three-state machine, and approving the return must close it. The lab's `approve` does not, which is why the demo ends with three open cases for returns that are already approved. **Forces.** A transition table refuses things the current system allows, including things support does manually today. Find out what those are before you ship it. **Consequence accepted.** The reference has no admin override. A machine so strict that operations route around it is worse than a loose one, so a real deployment needs a `force_transition(state, reason, actor)` that writes an audit row — a documented escape hatch, not an undocumented `UPDATE`. --- ## 5.2 Invariants at the consistency boundary Four invariants, four different placements: | Invariant | Where it is enforced | Why | |---|---|---| | A held return has exactly one open review case. | One transaction, one unit of work. | They are meaningless apart; a crash between them leaves a return nobody is looking at. | | A refunded return has exactly one payment reference. | Same transaction as the idempotency record. | Otherwise the key and the effect can disagree, and you have moved the race rather than closed it. | | No more than 50 review cases per day. | Not transactionally. A counter, a metric and an alert. | It is a promise to the support team, not a correctness rule. | | A refund never exceeds the order line's value. | A check at submission, not an invariant. | The fact belongs to another team's system and can change after we read it. | `UnitOfWork` in `../phase-2/p2_events.py` is the mechanism: begin, write, commit, *then* publish events. **The one worth arguing about.** Enforcing the 50/day cap exactly costs a lock on a hot path for a rule whose breach costs one extra case in an agent's queue. The reference enforces it approximately and alerts, because 52 cases on a bad day is a conversation and a lock on every submission is an outage waiting for traffic. Say which you chose and check that the support lead agrees. **The architectural consequence.** Every invariant you enforce transactionally shrinks what you can distribute later, and the first two above are exactly why returns and review cases stay in one deployment unit in exercise 5.6. The invariants determine the component boundaries, not the other way round. --- ## 5.3 Deep modules **The reference split: three components, not five.** | Component | Interface | Behind it | |---|---|---| | Return resolution | `submit`, `approve`, `deny`, `cancel`, `record_refund`, `attach_label` | the aggregate, the state machine, the unit of work, the event bus | | Risk | `assess(order, customer_history) -> Decision` | eligibility rules, refund strategies, fraud signals, the review-queue policy | | Fulfilment | `buy_label`, `issue_refund` | the carrier adapter, the payment adapter, retry, idempotency | Notifications is not a component; it is a subscriber. That is the difference events make. **The argument against the split.** Three components for six people means three sets of tests, three interfaces to version, and a change that spans two of them needs coordination that a single module would not. The counter-argument is exercise 1.5: carrier changes, fraud changes and workflow changes arrive from three different people on different weeks and currently collide in one 220-line file. **`agent_review` is the honest test case.** Four functions, no rules, and its only invariant is shared with the Return. The reference folds it into return resolution. Argue the other way if your support team owns the queue's policy — then it has its own reasons to change and deserves its own boundary. **Ousterhout's ratio.** Risk has one method and several hundred lines behind it: deep. A component with five methods and forty lines is a pass-through, and the reference has none. --- ## 5.4 The two contracts **Internal** (`../phase-1/p1_ports.py` plus docstrings): what each port promises, what it requires, what it raises, whether it reads the clock, whether it is safe to call twice. Renaming a method here is a refactor. **External** (`tests/test_consumer_contract_api.py` is its executable form): field by field, required or optional, the vocabulary of each enum, the envelope version, and what a consumer must tolerate. Renaming a field here is a quarter of coordination. **Two decisions worth making explicitly.** - *Is the external status the internal enum serialized, or a separate vocabulary with a mapping?* The reference takes the second. It is more work and it lets `AWAITING_LABEL` exist internally without a client release. - *What does "breaking" mean, and what is the deprecation window?* "Two releases, minimum six weeks" is a policy. "We will give notice" is not. **The cautionary field.** `display_status` was never designed — it came from the generated helper — and the support console now renders it. Under-specified contracts get discovered by consumers through experiment, and then you are bound by behaviour you never intended to promise. --- ## 5.5 Domain model to data model Designed from the five access patterns, not from the objects: | Pattern | Frequency | Shape it needs | |---|---|---| | fetch one return by id | every page load | primary key | | a customer's returns | support console | index on `customer_id` | | count in a 90-day window | every submit | composite index `(customer_id, created_at)`, excluding denials | | review queue by age | polled by agents | partial index on open cases | | finance's nightly aggregate | once a day | a view, not the base table | **Changes from the lab's schema.** Money in minor units with currency; status constrained to the enum's values; `review_reason` split into `denial_code` and `hold_reason`; a unique key on notifications so a retry cannot silently rewrite a delivery record; a correlation id on the audit table. **The aggregate boundary is not the table boundary.** A `Return` and its transition history is one row plus an event table, not five tables. Normalising to match the object graph is the most common way a good domain model produces a slow database. **Finance gets a view.** They are a reporting consumer; forcing them through an aggregate-shaped repository produces N+1 queries and an unhappy analyst. A declared read model is also what makes the next migration a conversation rather than an archaeology exercise. **No read store, and why.** A few thousand returns a day and five access patterns do not justify a separate read model, a projection, or eventual consistency. Name the volume at which they would: roughly when the fraud count query stops fitting in the request budget, which is two orders of magnitude away. --- ## 5.6 The architecture style **The drivers, then the choice.** | Driver | Pull | |---|---| | median resolution under 4h | nothing — well inside any style's reach | | review queue under 50/day | toward strong consistency between return and case | | the order system is external and can be down | toward an anti-corruption layer and a tolerant reader | | three consumer teams on their own release schedules | toward a versioned API, not toward separate services | | one team of six | toward a modular monolith | | pilot on 30 September | toward the fewest moving parts | **Decision: a modular monolith, with the notification worker as a second process.** The worker is the one component with a real case for its own deployment: a different failure mode (a gateway outage should not touch the API), a different scaling profile, and it must survive an API restart. Nothing else has a driver that pays for a deployable boundary. "Scalability" is not a driver; "the fraud model needs a GPU and a weekly release cadence" would be, and it is not true yet. **The composition root** (`../phase-2/p2_composition_root.py`) is the one file that knows every concrete type. It is allowed to be long and boring. **What would change the answer.** A second team owning risk, or a fraud model with its own release cadence and infrastructure. Both are organisational changes, which is the usual reason architecture styles change. --- ## 5.7 Deployment and release **The four things the lab makes impossible today.** 1. *Import is start.* Importing `returns_lab.api` mutates a global route table and constructs four vendor clients. `Application` (phase 2) replaces both with an explicit `build()`. 2. *There is no health check.* One that returns 200 unconditionally is decoration. Assert what must be true to receive traffic: the database answers, the worker thread is alive, and the schema version matches the one this build expects — that last one is what stops a step-3 build talking to a step-1 database. 3. *There is no drain.* On shutdown: stop accepting, finish the queue with a deadline, exit. Whatever is left must be recoverable from the outbox table, which is why the in-memory queue had to go in exercise 3.3. 4. *Migrations and code ship together.* The rule that makes zero-downtime possible: every migration is backward compatible for one release, and a schema change never ships in the same deploy as the code that requires it. **Rollback, per migration step.** Steps 1–3: deploy the previous build. Step 4: restore from backup. Write down which step is the point of no return, and put the date of step 4 somewhere a human will see it. **Forces.** Zero-downtime deployment costs a drain path, a readiness check, a compatibility rule, and the discipline to keep them. It buys shipping on a Tuesday afternoon. **A second valid solution — when you would choose it.** A two-minute window at 04:00. For a pilot with 50 cases a day and no overnight traffic, it is genuinely cheaper and it is the honest answer. Name the volume or the SLA at which it stops being true — here, roughly when returns arrive from a second timezone, at which point there is no quiet hour to take.