# State and contract review Most integration bugs are unanswered state questions wearing API syntax. Model transitions, ownership, failure, and change before polishing endpoints. Source: lesson 0045 · Design State, Data Ownership, and Contracts. --- ## Blank — state transitions One row per legal transition. If a transition is not in this table, the code should refuse it. | From | Command | To | Actor | Precondition | Invariant protected | Authoritative owner | Fact emitted | Audit | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `[state]` | `[command]` | `[state]` | `[who]` | `[what must be true first]` | `[what must never be false after]` | `[the one module that may write this]` | `[event]` | `[why anyone would need to reconstruct this]` | Then answer, for the machine as a whole: | Question | Answer | | --- | --- | | Race | Two commands arrive for the same entity at once. What is the expected end state? | | Retry | The same command arrives twice with the same intent key. What is returned the second time? | | Stale | A command arrives referring to a state the entity has already left. Reject, ignore, or reconcile? | | Reordering | Facts arrive out of order. Which ones are safe to apply late? | | Compensation | Which external side effects cannot be undone by a code rollback, and what reverses them? | **The ownership rule.** Prefer one authoritative writer per piece of state. Other modules get a contract, an event, or a read model. A shared database does not imply shared ownership — if two modules can both set a state, its invariant lives nowhere. --- ## Blank — boundary contract One per cross-boundary command, event, or query. | Field | One-line guidance | | --- | --- | | Meaning | Is this a command, a fact, or a query? What business promise does success make? | | Identity | What identifies the resource, the caller's intent (idempotency key), and the ordering? | | Validation vs. authorization | Which edge rejects malformed input, and which domain rule refuses valid-but-forbidden behavior? | | Failures | Rejected, conflicted, unavailable, timed out, partial — and the safe caller action for each. | | Evolution | Required fields, tolerant-reader rule, what may be added, deprecation window, migration owner. | | Data | Classification, minimum fields, trust crossing, encryption, retention, deletion, who may read. | | Proof | Producer and consumer contract tests, plus the operational signal that shows it working. | **The contract review test.** Ask the producer and the consumer separately what *success*, *timeout*, *duplicate*, *missing field*, *new field*, and *deleted user* mean. Any different answer is already a production defect; the code has merely not executed it yet. --- ## Completed — returns resolution pilot ### Return aggregate — state transitions | From | Command | To | Actor | Precondition / invariant | Authoritative owner | Fact emitted | | --- | --- | --- | --- | --- | --- | --- | | — | SubmitReturn | requested | customer | No other open return for this order line (I1); the order line exists and belongs to the actor | Return journey | `ReturnRequested` | | requested | AssessEligibility | accepted | policy engine | Exactly one decision per return; window and condition rules pass; no fraud hold | Eligibility | `ReturnAccepted`, `LabelIntentRecorded` | | requested | AssessEligibility | refused | policy engine | A reason code **and** human text are both mandatory — no refusal without both | Eligibility | `ReturnRefused` | | requested | AssessEligibility | referred | policy engine | A judgment case or a fraud hold; the 4-hour agent SLA starts now | Eligibility | `ReturnReferred` | | referred | AgentDecides | accepted / refused | support agent | Agent identity recorded; contradicting a policy rule requires an override reason | Eligibility | `ReturnAccepted` / `ReturnRefused` + `PolicyOverridden` | | refused | Appeal | under_review | customer via support | One appeal per refusal; new evidence required | Eligibility | `AppealOpened` | | accepted | AttachLabel | awaiting_parcel | label worker | Exactly one carrier label per return (I2) — a unique constraint, not a code check | Return journey | `LabelAttached` | | awaiting_parcel | RecordReceipt | received | warehouse clerk (manual for the pilot) | The scanned item must match the return; the receipt event id is deduplicated | Warehouse (external) | `ParcelReceived` | | awaiting_parcel | Expire | expired | reconciliation job (time) | 21 days since acceptance with no receipt; the customer is notified exactly once | Return journey | `ReturnExpired` | | received | RequestRefund | refund_requested | return journey | Never sets settlement; carries the return id as its idempotency key | Return journey | `RefundRequested` | | refund_requested | — | refunded | **Payments only** | No other actor may write this state. "Refund never exceeds the captured amount" is enforced by Payments, not by us | Payments (external) | `RefundSettled` (consumed) | | refund_requested / refunded | DisputeSettlement | settlement_disputed | reconciliation job or agent | The receipt was reversed, or Payments rejected the request | Return journey | `SettlementDisputed` | | requested / accepted | Cancel | cancelled | customer | Only before a parcel is received; any attached label is voided with the carrier | Return journey | `ReturnCancelled` | **Two rows carry most of the design.** Only Payments may write `refunded` — the returns module requests and observes, so the invariant "a refund never exceeds the captured amount" lives where the captured amount lives, and our module physically cannot break it. And the refund row has **no recovery**, written down rather than hidden behind the word "rollback". A code deploy can be reverted; a settled refund cannot. That single admission is what produced the `settlement_disputed` state and the rule that we never auto-compensate. ### Rules, classified — because not everything called an invariant is one | Id | Rule | Kind | Enforced where | On failure | | --- | --- | --- | --- | --- | | I1 | An order line has at most one open return | Hard invariant | Partial unique index on `(order_line_id)` where the state is not terminal, plus a domain guard | Refuse with `return_already_open` and a link to the existing return | | I2 | A return in `awaiting_parcel` has exactly one carrier label | Hard invariant | Unique constraint on `label_intent.return_id`; the carrier call is idempotent on the intent id | Refuse the second write, alert, and reconcile daily against the carrier's own label list | | I3 | A refund never exceeds the captured amount | Hard invariant **owned by another system** | Payments. We cannot enforce it and do not pretend to | Payments rejects; we move to `settlement_disputed` | | I4 | Every accepted return reaches a terminal state within 21 days | **Reconciliation condition**, not an invariant — it is temporarily false by design | Daily sweep, detection within 24 hours, owner Nils, escalation to Dana after two consecutive days | Referred to an agent with the aging reason, and counted against the manual-queue guardrail | | I5 | Only Payments may assert settlement | Ownership rule | No code path in the returns module writes `refunded`; a fitness function fails the build if one appears | Build failure, before it can be a production defect | | — | Only the assignee or an agent with the returns role may decide a referral | **Authorization precondition**, not an invariant | The API edge plus a domain guard, deliberately separate so error codes tell the caller which edge they hit | 403 `not_permitted`, logged with the actor | Classifying these was not pedantry. I4 looks like an invariant and is not one — it is false for up to 21 days by design — so enforcing it as an invariant would have meant refusing legal states. It needs a detector with an owner instead, which is a completely different piece of work. ### Internal contract — eligibility → return journey | Field | Entry | | --- | --- | | Command | `assess(request) -> EligibilityDecision` | | Promise on success | Exactly one decision, complete: outcome (accepted / refused / referred), a reason code and human text whenever the outcome is not accepted, the policy version used, the timestamp, and the deciding actor. Deterministic given the same request and policy version. | | Forbidden | No network or database I/O. No vendor types in the signature or the result. No knowledge of how the decision will be displayed or stored. Enforced by the import rule, not by a comment. | | Failure | **There is no failure mode.** Missing input is a programming error and raises; there is nothing to retry because there is nothing to fail. That is the payoff of keeping I/O out. | | Events | `ReturnAccepted`, `ReturnRefused`, `ReturnReferred`, each carrying return id, decision, reason code, policy version, correlation id — published from the outbox after commit, never inside the transaction. | | Proof | The 200 sampled agent decisions from A1 run as a table-driven suite against `assess`. A policy change that alters a sampled outcome fails the build and must be signed off by Anya. | ### External contract — the returns API, v1 | Question | Designed answer | | --- | --- | | What is the resource? | A **return**. The eligibility decision is a child resource of it, not a mutable field on it. | | What does success promise? | `201` means the return exists durably, a decision has been made, and if accepted, exactly one label intent is recorded. It does **not** promise a label exists — that is `label_state`, and the distinction is the whole point of ADR 0003. | | How are retries deduplicated? | A mandatory `Idempotency-Key` header on every unsafe method. A timeout means *unknown*: retry with the same key, never submit afresh. | | What does a failure mean? | Every error carries `code` (a stable string), `message` (human text), and `retryable` (boolean). `409` means "your intent conflicts with a stored one — do not retry blindly". `503` carries `retry_after`. | | Which edge rejected me? | `400` schema · `403` authorization · `422` a valid request the domain refuses. Collapsing these into `400` is what makes consumers guess. | | What data crosses? | Request: order line id, item condition, reason. Response: decision, reason text, label state, label URL when ready. The carrier receives recipient name and address only — never the return reason. | **Compatibility rules, because "be tolerant" is not a policy** | Change | Breaking? | Rule | | --- | --- | --- | | Adding an optional response field | No | Free. Consumers must ignore unknown fields; asserted by a consumer contract test that sends an unknown field and expects success | | Adding a new `reason_code` value | No, by construction | `reason_code` is an **open** enum. Every code ships with `reason_text`, and consumers render the text for codes they do not recognize — which is why the internal contract refuses to construct a code without text | | Adding a new value to `state` | **Yes** | `state` is a **closed** enum: consumers branch on it. A new state needs a version bump and a migration window. `referred` was added in v1 before any external consumer existed; after that it would not have been free | | Making an optional request field required | Yes | New version. Never quietly enforced | | Changing what an existing field means | **Yes — and the most dangerous kind** | New field, new name, expand/contract. Redefining `label_state` from "we have a label" to "we have asked for one" would pass every schema check and break every consumer | | Removing a field | Yes | Expand, migrate readers, verify with consumer contract tests, then contract. Minimum 90-day deprecation window, with the migration owner named in the notice | **Idempotency keys — one row per unsafe operation** | Operation | Key | TTL | Same key, different body | | --- | --- | --- | --- | | `POST /v1/returns` | Client-supplied `Idempotency-Key`, derived from the order line id plus the form session | 24 hours | `409 idempotency_key_reuse` — silently returning the first result would hide a client bug; silently processing the second would create two returns | | Carrier label creation | `label_intent.id`, sent as the carrier's client reference | Carrier-defined, 7 days | Impossible — the worker never changes an intent | | Refund request to Payments | `return_id` | Permanent | Payments rejects; we move to `settlement_disputed` | | Warehouse receipt consumption | `receipt_event_id`, stored in a processed-events table | 90 days | A second delivery is a no-op | The carrier row is the one that came from measurement rather than from design instinct: the spike showed that a timed-out create retried **without** a client reference produced a second label in 2 of 10 attempts, and with one, in 0 of 10. The client reference is not a nicety; it is the mechanism that makes I2 achievable at all. **Traces to:** the "approved means three things" hotspot in `workflow-and-eventstorming-board.md` is what produced the accepted / received / refunded split; the carrier idempotency rule is ADR 0003; the compatibility policy is what lets driver D4's one-day policy release happen without a coordinated deploy.