Skip to content

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's docs/requirements/order-lifecycle.md carries 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_audit table 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_at is 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. No paid_at lock. (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 state column. (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 function state_of(order).
  • (M-2) Add a state enum 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 + a before_flush event 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_audit is FADP-grant-trail; lifecycle transitions are operational). Mixing them clouds both queries. Rejected.
  • (A-2) Dedicated order_state_audit table. (chosen) Append-only, one row per state transition. Independent of share_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_at and 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:

ordered_at  <=  strung_at  <=  returned_at  <=  paid_at

(<= 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_at is allowed (it's a date-reset, audited as state_reverted), but the audit row is mandatory.
  • Skipping STRUNG → setting paid_at without strung_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 through orders).

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:

  1. @validates decorators on Order's date columns — fast, per-attribute, fires on assignment. Catches simple cases (e.g. setting paid_at < strung_at) before flush. Returns user-friendly error.
  2. before_flush event hook on the Session — composes cross-attribute invariants (causal chain, state-transition legality, actor predicate). The single source of truth; the @validates layer is for UX latency only.

after_flush (or after_commit for the email path) hook handles:

  • Writing the order_state_audit row 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:

  1. Clear a date (e.g. typo correction): the owning stringer sets strung_at = NULL. The validator allows it (the state simply reverts to ORDERED). An order_state_audit row with event_kind = state_reverted is written. Pricing remains editable. The previously-emitted receipt is not re-emitted now — it is re-emitted only on the next strung_at set, with the same receipt_number.
  2. 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_number per 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_flush hook writes both the order_state_audit row and (per ADR-0005) a notifications row 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.

  1. 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.
  2. Self-job date-skip test. A self-job Order with only strung_at set is valid; a self-job with paid_at < strung_at is refused.
  3. Per-state edit-permission test. For each (state × field-set × actor) tuple in the matrix, assert the validator accepts or refuses correctly.
  4. PAID-pricing-edit-allowed test. Setting paid_at, then editing pricing as the owning stringer → accepted; one pricing_edited audit row written; one receipt_re_emitted row written; Order.receipt_number unchanged. (No admin override required — owning stringer has full edit rights per the (a) policy.)
  5. Receipt-version-retention test. Strung emission produces receipt_versions row 1 with the original total. A subsequent post-Strung pricing edit produces receipt_versions row 2 with the new total. Both rows persist; row 1 is not mutated. Row 1's snapshot equals what was originally emailed.
  6. Receipt re-emit test. Set strung_at → email sent (one row in audit, receipt_emitted). Edit total_chf → email re-sent (receipt_re_emitted); same receipt_number. Edit comments → no re-send.
  7. Re-string test. Existing Order untouched; new Order created with racket_id and client_profile_id copied; new Order has its own receipt_number minted from the per-stringer-per-year sequence (ADR-0008).
  8. 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.)
  9. Audit completeness test. Every accepted transition produces exactly one order_state_audit row with the right from_state, to_state, actor_kind, actor_id, request_id. No missed transitions.
  10. 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 state column 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 of share_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.state as 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_audit writes 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)

  1. Price-edit policyresolved (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.
  2. Re-string = new Order — confirmed yes, per (R-1). If Stefan ever prefers in-place re-string, the audit gains a re_strung event_kind and the receipt-number-per-job invariant changes. Iris's #80 transition table inherits this default.
  3. 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.
  4. Date-clear audit threshold — confirmed always audit any date clear. Louder is better.
  5. 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.