ADR-0007: Order lifecycle state machine¶
- Status: Accepted
- Date: 2026-05-02
- Decider(s): Theo (SA), with rules-layer split owned by Iris (BA)
- Closes: #88
- Coordinates with: Iris's #80 — Order lifecycle requirements
Amended 2026-05-04 — price-edit policy changed from (b) editable until
paid_at, then locked to (a) always editable for the owning stringer; each edit re-emits the receipt; prior receipt versions retained for audit per Stefan's review of OQ-L-1. The structural model (state derivation, validators, audit table, re-string semantics) is unchanged. See Change log below for the affected sections. Iris'sdocs/requirements/order-lifecycle.mdcarries the parallel requirements-side flip via #94.
Context¶
data-model.md declares four nullable lifecycle dates on Order — ordered_at, strung_at, returned_at, paid_at — and notes that "causal order [is] enforced by the app." ADR-0002 locks "receipt finalized at Strung." Issue #64 (M10) commits to the four dates with blank-allowed for self-jobs.
What's missing on the architecture side:
- The transition graph as a state machine, derived from the (date-set) tuple.
- Where validators sit in the SQLAlchemy model layer — and that they are application-level, not DB triggers (PgBouncer constraint).
- The
order_state_audittable shape — append-only, sized for the per-order transition log. - Allowed actors per transition — owning stringer, admin, Rule-#1 grantee (read-only by ADR-0004, so no transitions).
- Date-clear and re-string semantics — what happens when
strung_atis un-set, what triggers receipt re-emit, what a "re-string" is (new Order vs. edited Order). - Receipt re-emit triggers — which edits emit a new PDF + email vs. silent re-render on next request.
Iris's #80 owns the rules layer (who-may-do-what, the edit-permission matrix per state, the re-emit semantics surfaced to users); this ADR owns the structural model (transition graph, validator placement, audit schema, chokepoint integration). The two MRs reference each other.
Stefan's pre-confirmed defaults:
- Price-edit policy: (a) — pricing fields (
labor_chf,strings_chf,total_chf,main_price_chf,cross_price_chf) are always editable for the owning stringer, regardless of state. Each edit triggers a receipt re-emit (see Receipt re-emit policy). Prior receipt versions are retained for audit. Nopaid_atlock. (Amended 2026-05-04 per OQ-L-1; was previously option (b).) - Receipt is finalized at Strung (ADR-0002) and always re-renderable from order data.
Options¶
Modeling the state¶
- (M-1) Derive state from the (date-set) tuple — no
statecolumn. (chosen) The four dates are the state. Single source of truth; can never go out of sync with the dates. The state machine is a derivation functionstate_of(order). - (M-2) Add a
stateenum column synchronized with the dates. Two sources of truth → drift. Rejected. - (M-3) Stringer-managed state column without dates. Loses the date semantics that V1 carried and that the receipts and reporting paths depend on. Rejected.
Where validators live¶
- (V-1) DB triggers / CHECK constraints for transition validity. PgBouncer transaction-pooling makes session-scoped triggers awkward; CHECK constraints can do shape (e.g.
paid_at IS NULL OR strung_at IS NOT NULL) but cannot capture actor-aware rules. Rejected for full transitions. - (V-2) SQLAlchemy
@validates+ abefore_flushevent hook composing model-level invariants. (chosen) Application-level. Composes with the chokepoint hook from ADR-0001. PgBouncer-friendly (no session-scoped state). Single test surface. - (V-3) Transition validators in handlers. Spreads the rules across N handlers. Easy to bypass via a missed handler. Rejected.
Audit table¶
- (A-1) Reuse
share_audit. Cross-cuts the audit purpose (share_auditis FADP-grant-trail; lifecycle transitions are operational). Mixing them clouds both queries. Rejected. - (A-2) Dedicated
order_state_audittable. (chosen) Append-only, one row per state transition. Independent ofshare_audit. Consistent with the "one audit per concern" pattern.
Re-string semantics¶
- (R-1) "Re-string" = a new Order. (chosen) Preserves the receipt chain and pricing snapshot of the prior service. The Person → ClientProfile → Racket relationship lights up the "copy last order" path naturally.
- (R-2) "Re-string" = clear
strung_atand reset. Loses prior pricing snapshot; loses prior receipt; ambiguates the audit trail. Rejected.
Decision¶
State derivation (no state column)¶
The state of an Order is the function:
state_of(order):
if order.paid_at is not None: return PAID
if order.returned_at is not None: return RETURNED
if order.strung_at is not None: return STRUNG
if order.ordered_at is not None: return ORDERED
return DRAFT
| State | Definition | Required dates |
|---|---|---|
| DRAFT | Order row exists; no lifecycle date set yet. Self-jobs may live here forever. | none |
| ORDERED | Client (or Stringer-for-self) has placed the order; not yet strung. | ordered_at |
| STRUNG | Stringing is complete. Receipt is finalized. Email send is triggered. | strung_at (and conventionally ordered_at, but see "self-job exception" below) |
| RETURNED | Racket handed back to client. | returned_at |
| PAID | Payment recorded. Pricing remains editable per the (a) policy (see Price-edit policy below). Terminal in V2. | paid_at |
Self-job exception: for Orders whose client_profile.is_self_for_stringer = TRUE, the validator skips the date-presence check (any subset of dates may be empty — including strung_at for an in-progress self-job). The causal-ordering check still applies: if both strung_at and paid_at are set, then strung_at <= paid_at. The state is still derived by the function above.
Causal-ordering invariant¶
For any Order, when present, the dates must satisfy:
(<= is intentional — same-day same-second ordering is allowed.)
For self-jobs (client_profile.is_self_for_stringer = TRUE), the same <= chain holds for any non-null subset; missing dates do not break the chain.
The validator runs on before_flush (and on @validates for early UI feedback) and refuses any commit that violates the chain.
Transition table¶
| From | Event | To | Actor | Date set | Side effects | Reversible? |
|---|---|---|---|---|---|---|
| (none) | create draft | DRAFT | owning stringer | — | audit created |
yes (delete draft) |
| DRAFT | place order | ORDERED | owning stringer | ordered_at = now() (or stringer-supplied past date) |
audit ordered |
yes (clear ordered_at) |
| DRAFT | string immediately (no prior order) | STRUNG | owning stringer | strung_at = now() |
emit receipt + email | yes (clear strung_at) — with re-emit on next set |
| ORDERED | string | STRUNG | owning stringer | strung_at |
emit receipt + email | yes |
| STRUNG | return to client | RETURNED | owning stringer | returned_at |
audit returned |
yes (clear returned_at) |
| STRUNG | record payment | PAID | owning stringer | paid_at |
audit paid |
yes (clear paid_at) |
| RETURNED | record payment | PAID | owning stringer | paid_at |
audit paid |
yes |
| PAID | clear payment for correction | RETURNED (or STRUNG) | owning stringer | clear paid_at (and downstream dates if needed) |
audit state_reverted |
yes (re-set paid_at) |
| any | re-string | new Order (DRAFT) | owning stringer | — | new Order created with copy-last-order prefill | n/a — original Order untouched |
| any | bypass-edit | (any) | admin only with bypass_tenant=True |
as specified | audit admin_override with reason |
per-edit |
Forbidden transitions (validator refuses):
- ORDERED → DRAFT with audit suppression. Clearing
ordered_atis allowed (it's a date-reset, audited asstate_reverted), but the audit row is mandatory. - Skipping STRUNG → setting
paid_atwithoutstrung_at. The validator refuses (no receipt was finalized; payment lacks a final price snapshot). - Setting any date in the future beyond a small clock-skew tolerance (default: 5 minutes ahead). Backdating to the past is allowed (Stefan often records dates after the fact).
Allowed actors per transition¶
Sourced from the chokepoint set in ADR-0004:
| Actor class | Read | Write transitions | Notes |
|---|---|---|---|
Owning stringer (order.stringer_id = current_stringer_id) |
full | full per the table above | Default V2 actor. |
Admin (Stringer.role = 'admin') |
full + bypass_tenant for catalogue |
bypass-edit on any field across any stringer's order, with audit reason | Stefan's role; one admin in V2. The owning stringer already has full edit rights in any state per the (a) policy below — admin's special power is cross-tenant reach, not unlocking. |
Rule-#1 grantee (cross-stringer per-job share, granter_kind = stringer) |
redacted per ADR-0004 | none — read-only | The "I'm on holiday" use-case wants visibility, not write. Per auth-and-tenancy.md: "shared-in rows are read-only forever." |
| Rule-#2 / Rule-#3 grantee (client-initiated shares) | full or redacted per ADR-0004 | none — read-only | Same. |
| V3 client (Person-bound session) | own orders across stringers | none (V2). V3 may add narrow self-issued events (e.g. "request a re-string" → creates a draft Order owned by the stringer). | V3-scoped. |
The chokepoint refuses any UPDATE on orders where stringer_id != current_stringer_id — this ADR adds nothing to the read predicate; it constrains which fields the owning stringer may UPDATE per state.
Price-edit policy (Stefan-confirmed — option (a), amended 2026-05-04)¶
| Order state | labor_chf, strings_chf, total_chf, main_price_chf, cross_price_chf |
|---|---|
| DRAFT | freely editable |
| ORDERED | freely editable |
| STRUNG | freely editable; edit triggers receipt re-emit (see below) |
| RETURNED | freely editable; edit triggers receipt re-emit |
| PAID | freely editable; edit triggers receipt re-emit |
The owning stringer can edit pricing in any state. There is no paid_at lock, no admin-override-with-reason gate for pricing edits, no special "unlock" transition. Each pricing edit after Strung emits a fresh receipt PDF + email per the Receipt re-emit policy below; prior receipt versions are retained for audit (see Receipt version retention).
Rationale for (a): at Stefan's V2 scale (~50 orders/year, single stringer, hobby-grade), the operational pain of "I noticed a CHF rounding error after the customer paid and now I need an admin to unlock it" outweighs the audit-tightness of (b). The audit trail is preserved in two places: (i) order_state_audit records every pricing edit via pricing_edited events; (ii) the prior receipt PDFs remain readable in the receipt-version archive. Stefan accepts that "what the customer paid" is reconstructable from the audit trail rather than from a frozen pricing-fields snapshot.
Validator behavior: the before_flush hook does not gate pricing edits by state. It still enforces the causal-ordering invariant on dates and the actor predicate (only the owning stringer or admin may UPDATE the row), but pricing fields themselves are never locked.
If Stefan ever wants to flip back to (b) "locked at paid_at" or pick (c) "locked at Strung", that's a one-line predicate added to the before_flush hook plus a re-numbering of the required tests below.
Edit-permission matrix per state (structural — Iris owns the user-facing wording)¶
| Field set | DRAFT | ORDERED | STRUNG | RETURNED | PAID |
|---|---|---|---|---|---|
| Identity (client, racket) | ✓ | ✓ | admin only | admin only | admin only |
| Technical (string, tension, color, method, DT) | ✓ | ✓ | admin only — re-emit triggered | admin only — re-emit triggered | admin only — re-emit triggered |
| Pricing | ✓ | ✓ | ✓ — re-emit triggered | ✓ — re-emit triggered | ✓ — re-emit triggered |
comments |
✓ | ✓ | ✓ — silent (no re-emit) | ✓ — silent | ✓ — silent |
| Lifecycle dates | per transition table | per transition table | per transition table | per transition table | per transition table |
"Admin only" = the chokepoint refuses the UPDATE for non-admin actors. "Re-emit triggered" = the post-commit hook enqueues a fresh receipt email (see next section).
Receipt re-emit policy¶
Orchestrated by a after_flush SQLAlchemy event hook on Order. Triggers depend on which fields changed:
| Trigger | Re-emit? |
|---|---|
strung_at set (DRAFT/ORDERED → STRUNG) |
yes — first emission. |
Any of {pricing, technical (string/tension/color/method/DT), identity (racket)} changed AND strung_at IS NOT NULL |
yes — receipt is no longer accurate. |
comments changed |
no — receipt does not show comments per ADR-0002 layout. |
Lifecycle date changed (other than strung_at) |
no — receipt shows the strung date, not return/paid dates. |
strung_at cleared (re-string in place) |
no immediate re-emit. Receipt is rendered on demand from current data; the next set of strung_at triggers a fresh emission. The previous receipt is not retracted (FADP — already-issued artifacts are out of band, see ADR-0004). |
paid_at set/cleared |
no — payment status is not on the receipt. |
| Admin bypass-edit | yes if any re-emitting field changed, regardless of state. |
A re-emit produces a new email + a new on-demand-renderable PDF. The PDF carries the current order data (per ADR-0002 R5, "always re-generatable from order data"). The receipt-numbering scheme (ADR-0008 — receipt numbering under PgBouncer) does not mint a new number on re-emit — the re-emitted receipt carries the same receipt_number as the original. (One Order → one receipt number, regardless of how many times it is rendered or emailed.)
Receipt version retention¶
Per the (a) price-edit policy (amended 2026-05-04), any post-Strung re-emit may reflect a different price than the original receipt. To preserve the audit trail of what the customer was actually shown at each emission, the re-emit hook writes a receipt_versions row per emission with a snapshot of the rendered receipt fields (timestamp, total, line items, locale, recipient). The current rendering of the receipt always reflects current order data per ADR-0002 R5; the receipt_versions table is the historical "what we sent on date X" record.
Implementation detail (Pax): receipt_versions is a per-stringer-scoped append-only table keyed by (order_id, emitted_at). The shape mirrors the order_state_audit discipline (append-only, JSONB meta for the rendered field snapshot). One row per emission; the original Strung emission is row 1.
The re-emit dispatch is synchronous in V2 (per integrations.md — single-process, no worker). If Resend fails, the email-send is silently logged on the Order row (receipt_emailed_at left unchanged; receipt_email_last_error set). Per Phase 3 issue #44.
order_state_audit schema¶
Append-only. One row per transition or per significant edit (pricing edit, admin override).
| Field | Notes |
|---|---|
id (PK, monotonic) |
|
order_id (FK NOT NULL) |
The order. |
event_kind |
Enum: created | ordered | strung | returned | paid | state_reverted | admin_override | pricing_edited | receipt_emitted | receipt_re_emitted. |
actor_kind |
Enum: stringer | admin | system. |
actor_id (FK by convention) |
The Stringer who performed the action. system for hooks like receipt_emitted. |
from_state |
Enum: draft | ordered | strung | returned | paid | null (for created). |
to_state |
Same enum. |
request_id (UUID) |
Correlates with structured logs. |
at (timestamptz NOT NULL) |
|
meta (JSONB) |
Optional. For admin_override: {reason, fields_changed[]}. For pricing_edited: {old_total, new_total, fields_changed[]}. For receipt_(re_)emitted: {receipt_number, email_to, send_status, receipt_version_id}. |
Indexes:
order_state_audit(order_id, at DESC)— supports the per-order audit timeline view.order_state_audit(stringer_id-via-join, at DESC)— the per-stringer activity feed (joined throughorders).
Append-only by convention (matches share_audit); a future trigger refusing UPDATE/DELETE is one-line.
Tenancy: order_state_audit rows are reachable only via JOIN to orders, so they inherit the chokepoint predicate via the JOIN. They are not directly per-stringer-scoped (no stringer_id column).
Validator placement (SQLAlchemy)¶
Two layers:
@validatesdecorators on Order's date columns — fast, per-attribute, fires on assignment. Catches simple cases (e.g. settingpaid_at < strung_at) before flush. Returns user-friendly error.before_flushevent hook on the Session — composes cross-attribute invariants (causal chain, state-transition legality, actor predicate). The single source of truth; the@validateslayer is for UX latency only.
after_flush (or after_commit for the email path) hook handles:
- Writing the
order_state_auditrow for the committed transition. - Calling
EmailSender.send_receipt(order, locale)if the re-emit policy says so. - Both run in the same request scope; if the email send fails, the audit row still lands (the audit is the primary record; email is best-effort per
integrations.md).
No DB triggers. PgBouncer's transaction-pooling rules out reliable trigger-based session state; everything stays in the application layer.
Date-clear and re-string¶
Two distinct operations:
- Clear a date (e.g. typo correction): the owning stringer sets
strung_at = NULL. The validator allows it (the state simply reverts to ORDERED). Anorder_state_auditrow withevent_kind = state_revertedis written. Pricing remains editable. The previously-emitted receipt is not re-emitted now — it is re-emitted only on the nextstrung_atset, with the samereceipt_number. - Re-string (the racket comes back for a fresh stringing months later): a new Order is created via the "copy last order" prefill (M9 from v2-scope). The old Order is untouched — its dates, pricing, receipt remain the historical record. The new Order has its own
receipt_numberper stringer-year sequence.
Notification hooks (architectural slot)¶
The transitions strung, returned, paid are natural notification triggers. Per ADR-0005 (in-app notification model — being authored as a sibling ADR), the after_flush hook enqueues an in-app notification for the owning stringer (and, in V3, the client) on each transition. The mechanism:
- The
after_flushhook writes both theorder_state_auditrow and (per ADR-0005) anotificationsrow in the same transaction. - No worker; the writes are synchronous with the order commit.
This ADR commits the trigger points; ADR-0005 commits the notification table and dispatch model.
Required tests (this ADR mandates them)¶
Tests 4 and 5 below were re-shaped on 2026-05-04 when the price-edit policy flipped from (b) to (a). The original "PAID-pricing-lock" and "admin-unlock" tests are superseded by the "PAID-pricing-edit-allowed" + "receipt-version-retention" tests below; the count remains 10.
- Causal-ordering test. For every legal subset of (
ordered_at,strung_at,returned_at,paid_at), the validator accepts; for every illegal ordering, it refuses. - Self-job date-skip test. A self-job Order with only
strung_atset is valid; a self-job withpaid_at < strung_atis refused. - Per-state edit-permission test. For each (state × field-set × actor) tuple in the matrix, assert the validator accepts or refuses correctly.
- PAID-pricing-edit-allowed test. Setting
paid_at, then editing pricing as the owning stringer → accepted; onepricing_editedaudit row written; onereceipt_re_emittedrow written;Order.receipt_numberunchanged. (No admin override required — owning stringer has full edit rights per the (a) policy.) - Receipt-version-retention test. Strung emission produces
receipt_versionsrow 1 with the original total. A subsequent post-Strung pricing edit producesreceipt_versionsrow 2 with the new total. Both rows persist; row 1 is not mutated. Row 1's snapshot equals what was originally emailed. - Receipt re-emit test. Set
strung_at→ email sent (one row in audit,receipt_emitted). Edittotal_chf→ email re-sent (receipt_re_emitted); samereceipt_number. Editcomments→ no re-send. - Re-string test. Existing Order untouched; new Order created with
racket_idandclient_profile_idcopied; new Order has its ownreceipt_numberminted from the per-stringer-per-year sequence (ADR-0008). - Read-only grantee test. A Rule-#1 grantee attempting any UPDATE on a shared-in Order (date, pricing, anything) is refused at the chokepoint. (Already in ADR-0004; restated for completeness.)
- Audit completeness test. Every accepted transition produces exactly one
order_state_auditrow with the rightfrom_state,to_state,actor_kind,actor_id,request_id. No missed transitions. - Future-date refusal test. Setting any date > now() + 5 minutes is refused; backdating to any past date is accepted.
Consequences¶
Good¶
- State has one source of truth (the dates). No drift between a
statecolumn and the dates. Reporting queries (mean turnaround =AVG(strung_at - ordered_at)) are direct. - Validators are application-level → PgBouncer-compatible and composable with the chokepoint.
- Single audit table for lifecycle (
order_state_audit); independent ofshare_audit. Each table has one shape, one query. - Re-string preserves history because it is a new Order; the receipt chain stays intact; the "copy last order" UX path naturally creates the new draft.
- Price-edit policy is a one-line gate in the validator. Stefan picked (a) on 2026-05-04 (always editable for owning stringer); (b) and (c) remain one-line predicate flips with no schema change.
- Notification trigger points are named for ADR-0005 to hang the in-app notification model on, with no extra wiring.
- Causal-ordering check is straightforward at the model layer; the same
<=chain works for self-jobs by tolerating null gaps. - Receipt re-emit reuses the
receipt_number— clients see the same number on the corrected receipt; they understand it's the same job, corrected.
Costs we accept¶
- Date-derived state is implicit — readers of the codebase have to know the derivation function. Mitigated by exposing
Order.stateas a SQLAlchemy@hybrid_property, so it's queryable and visible in serialized output. - No DB-level enforcement of the causal chain. A direct SQL update (bypassing the ORM) could write a violating row. Mitigated by ADR-0001's code-review rule on raw
session.execute(text(...)). order_state_auditwrites one row per transition + per significant edit — at our scale (single-stringer V2, dozens of orders/month) the volume is fine. If V3 client-initiated re-strings inflate it, sample.- Admin override is a free-text reason — disciplined-only. Acceptable at one-admin scale; structured reason codes can be added later if needed.
- Re-emit fires a fresh email synchronously — same Resend-budget exposure as the initial emission. At V2 volume (~50 receipts/year) this is negligible. The fail-soft path from #44 carries.
- Self-job date-skip exception complicates the validator slightly — one branch on
client_profile.is_self_for_stringer. Acceptable.
Open questions (Stefan-confirm — most resolved 2026-05-04)¶
- Price-edit policy — resolved (a) per Stefan's review (was defaulted to (b)). Owning stringer can edit pricing in any state; each post-Strung edit re-emits the receipt; prior receipt versions retained. See Change log.
- Re-string = new Order — confirmed yes, per (R-1). If Stefan ever prefers in-place re-string, the audit gains a
re_strungevent_kind and the receipt-number-per-job invariant changes. Iris's #80 transition table inherits this default. - Future-date tolerance — confirmed 5 minutes clock skew. Could be 0 (strict) or 1 hour (lenient). 5 min covers both the laptop-on-WiFi case and protects against accidentally-typing-2027.
- Date-clear audit threshold — confirmed always audit any date clear. Louder is better.
- Notification on every state transition — confirmed yes (one in-app row per transition). ADR-0005 specs the table accordingly.
Change log¶
| Date | Change | Reason |
|---|---|---|
| 2026-05-02 | Initial accepted version (option (b) price-edit policy). | — |
| 2026-05-04 | Amendment — price-edit policy flipped (b) → (a). Updated: top-of-file amendment note, Stefan's pre-confirmed defaults, transition table (PAID-revert no longer admin-only; pricing-lock side effect dropped), forbidden-transitions list, allowed-actors table (admin description), price-edit policy section (full rewrite), edit-permission matrix (PAID-pricing now ✓), receipt re-emit policy (added Receipt-version-retention subsection), order_state_audit event_kind enum (pricing_edited replaces pricing_edited_post_strung; unlocked_by_admin removed), required tests (tests 4–5 re-shaped). Structural model (state derivation, validators, audit table shape, re-string semantics) unchanged. |
Stefan's resolution of OQ-L-1; tracked in #94. Iris's docs/requirements/order-lifecycle.md carries the parallel requirements-side flip. |
Cross-references¶
- Iris's #80 — companion requirements doc; defines the user-facing rules layer.
- ADR-0001 — chokepoint mechanism this ADR extends.
- ADR-0002 — receipt-finalization and re-renderability rules this ADR enforces.
- ADR-0004 — three-grant model defining "Rule-#1 grantee = read-only."
- ADR-0005 — in-app notification model (sibling ADR — the trigger points named here are consumed there).
- ADR-0008 — receipt numbering under PgBouncer (sibling ADR — the receipt-number-per-Order invariant lives there).
docs/architecture/data-model.md— Order field definitions.docs/architecture/auth-and-tenancy.md— write-protection rules this ADR sits on top of.- Pax's implementation issue (validators +
before_flush+ audit + re-emit hook) — to be opened after this ADR + Iris's requirements doc both merge.