Order Confirmation - System Design

The design for IMP's order confirmation flow, built on the greenfield pipeline with self-hosted Temporal. Written so an engineer who has not sat in the IMP calls can follow every step and question every choice. The system we run today is described in the appendix, as the thing this replaces and has to coexist with for a while.

Source of the requirement: the IMP status note of 4 September 2026. Design decisions from the sessions of 8 and 9 September are folded in.

1. The problem

IMP buys printed tin-can sheets from one supplier, RIC in Turkey, and sells the finished cans to its customers. The sheets carry each customer's artwork. So before IMP can order sheets for an order, the customer has to look at the artwork for every line and say "yes, print this."

Today one person does this by hand. For each line they find the current design file, send the set to the customer, chase for a reply, file whatever comes back, work out how many sheets each line needs, and write the supplier purchase order. About two hours per order. It repeats on every order, because customers change artwork between orders.

A wrong design on a print run costs about $10,000. Sari (the decision maker at IMP) has had cases where his team and the customer both signed off on the wrong version. He cannot tell two versions apart by eye. A nutrition panel shifted a few millimetres, a dash missing from a name.

His one hard requirement: when a customer sends him a new design, he forwards it to us and he needs to be certain that this version, and no older one, goes out for confirmation. He does not want to compare files or look at version lists. Ask him only when we are not sure.

IMP has no ERP. WhatsApp and a spreadsheet are the record. That matters for who owns which fact (section 6.2).

2. Requirements

Functional:

Non-functional:

Non-goals for now: inventory, warehouse, tracking after the PO, and any customer-facing UI. The customer only ever sees messages.

3. Decisions taken

  1. Build on the greenfield pipeline, with self-hosted Temporal as the runtime. Control flow is code; everything a tenant varies is policy data. No operator-editable graph builder.
  2. Design the full desk from the demo, and phase the build. The data model covers all ten steps on day one so later phases add code, not migrations.
  3. Channel-agnostic core. Sari on WhatsApp and customers on email first. Customer WhatsApp needs the customer's opt-in and Meta-approved templates, so it is added per customer later.
  4. A forwarded file replaces the current version when independent signals agree, and the mode is a per-customer policy: auto when signals agree (IMP's ask), require approval on every version, or ask only when the artwork visibly changed.
  5. The delivery is the unit of reading. A WhatsApp text, a photo of an order, a forwarded PDF and a reply are all deliveries; each file inside, body included, is a document under it.
  6. Headless and conversational are one path. A step that is confident materializes; a step that is not becomes a question on the person's channel, and the reply is the decision. Conversations are Temporal workflows.
  7. Brands, versions and sign-offs are Aradus-owned for every tenant. Orders, customers and products are owned per tenant through entity_authority; for IMP that is Aradus, since there is no ERP.

4. Concepts, in plain words

Brand. A customer's design for one product, such as "Al-Ain Foods, Caramel 500g." One brand has many versions over time.

Brand version. One design file, frozen. A new file is a new version. Versions are not edited after creation.

Current version. The version we would print today. Exactly one per brand. Changing it is a decision with a record.

Pin. A line on an order points at one specific version of a brand, not at the brand. The pack we sent, the yes we received and the sheets on the PO all cite that same version. That pointer is the pin, and it is how we can say with certainty what the customer approved.

Supersede. When a brand's current version changes, every open line pinned to the old version is marked superseded, re-pinned to the new version, and only those lines go back to the customer.

Candidate. A forwarded file is saved right away as a candidate version, before anyone decides which brand it belongs to, so a file cannot be lost while we work out what it is.

Signal. One independent piece of evidence about which brand a candidate belongs to. We use three, and a candidate is promoted without asking only when they agree and the customer's policy allows it.

Delivery and dispatch. A delivery is anything that arrives: a message, a file, a reply. A dispatch is anything we send: a pack, a reminder, a question, a receipt, a PO. Both are rows; together they are the transcript.

Proposal and decision. The machine proposes, with a confidence and the evidence it used. A policy or a person decides. Only the decision becomes a fact. Every proposal and decision is kept.

Observation. A statement a source made about an existing object: the customer said line 3 is confirmed, the supplier said the lead time slipped a week. Observations are appended, and a per-attribute reducer computes the current value from them.

Policy. A per-tenant, per-customer rule read by the workflow at each step: thresholds, contacts, intervals, modes. The customer rulebook is the operator's view of the policies for that customer.

Conversation workflow. One long-lived Temporal workflow per (tenant, channel, contact). Every message that person sends goes to it first; it decides whether the message answers an open question, instructs a change, or asks something.

Durable timer. A timer that survives restarts and deploys. "Remind in two days" fires in two days whatever happened to the service in between.

5. The core

5.1 Two state machines

An order confirmation moves through receivedmatchingwith_customerconfirmedpo_draftedpo_sent, with paused reachable from any state. Nothing runs until the operator presses Start.

A line confirmation is where the work happens:

stateDiagram-v2
    [*] --> unmatched
    unmatched --> matched: brand found, version pinned
    matched --> sent: pack goes out
    sent --> sent: reminder
    sent --> confirmed: customer says yes
    sent --> deferred: customer rule allows deferral
    matched --> superseded: brand got a new version
    sent --> superseded: brand got a new version
    confirmed --> superseded: brand got a new version
    superseded --> matched: re-pinned to the new version, re-send queued
    confirmed --> [*]
    deferred --> [*]

An order is confirmed when every line is confirmed, or deferred under a customer rule that permits it. Supersession of a line on an order in po_drafted blocks the PO until the line is re-confirmed. On an order in po_sent it raises an operator alert, because the print may be running.

In the greenfield these states are derived: a line's state is the latest decision on its sign-off, and the order's state is a function of its lines. The workflow holds the waiting; Postgres holds the facts.

5.2 The certainty rule

A forwarded file lands as a candidate. Three signals then vote on which brand it belongs to:

  1. Origin. Who did the forward come from? A forwarded email keeps the original sender in the quoted headers, which maps to a customer contact and narrows the candidates to that customer's brands. The WhatsApp Business API exposes a Forwarded flag and withholds the original sender, so on WhatsApp this signal abstains unless Sari's caption mentions the customer.
  2. Content. Text read out of the artwork: brand name, product, size, variant. Matched against the brand library, with a confidence.
  3. Visual. A perceptual hash of the image (a short fingerprint that stays similar when the image changes slightly) compared with the current version of each candidate brand.

The agreement rule is deterministic code: every signal that voted points to the same brand, at least two voted, and content confidence is above the customer's threshold. What happens when it holds is policy, per customer:

Whatever the mode, a promotion runs the supersession cascade in the same transaction, and a candidate that is rejected stays in history as rejected.

5.3 The invariant

No line in state confirmed may pin a version that is not its brand's current version. The same transaction that moves the current pointer supersedes the lines, so the invariant cannot be broken between two writes.

5.4 The model calls

Three, all activities returning structured results with confidence: match a line's text to a brand; read identity text out of an artwork image; interpret a reply per line as confirmed, rejected, deferred or unclear, quoting the words that support each verdict. A fourth phrases a question in the customer's language when a step needs to ask. The perceptual hash is plain code. Any failed or low-confidence call becomes a question for a person (N2).

6. The design

6.1 How it sits on the greenfield spine

The greenfield pipeline already has deliveries, artifacts, born-typed documents, runs, proposals, decisions, policies, materializations, observations and evidence bindings. Order confirmation adds one business aggregate (brands and their versions), one fact table (line sign-offs), and three Temporal workflows. Everything else is the spine doing what it does for invoices.

Concept in this flow What it is in the greenfield
A forwarded design file, a reply, a photo of an order A delivery with delivery_artifacts; each artifact becomes a born-typed document (artwork, signoff_reply, sales_order)
"Which brand is this file?" A refers_to proposal, decided by policy or by Sari
A new brand version A brand_versions row materialized from the decided proposal; the brand's current pointer moves in the same transaction
A line's pin and state A line_signoffs row; state is the latest proposal_decision on it
"Line 3 confirmed" in a customer's reply An assertion proposal with a text-span source_ref, decided with actor_type = counterparty, materialized as an observation
The pack, a reminder, a question, a receipt, the PO dispatches
The customer rulebook policies rows scoped to the customer party
Line text to brand mapping saved from an answer An alias in the identity spine
The supplier PO An obligation payable to RIC in posting_state = draft, posted by a decision
Reminders, waits, escalations Durable timers and signals in the order workflow
Sari's messages The conversation workflow keyed on his number

6.2 Data model

New tables, all with organization_id and row-level security like the rest of the spine:

brands
  id PK, organization_id, customer_party_id -> parties, product_id -> products,
  name, current_version_id -> brand_versions NULL
  UNIQUE (organization_id, customer_party_id, product_id, name)

brand_versions                        -- append-only; one row per design file ever received
  id PK, organization_id, brand_id -> brands, version_no,
  artifact_id -> artifacts,           -- the file, content-addressed
  status (candidate|current|superseded|rejected),
  created_from_proposal_id -> pipeline_proposals UNIQUE,   -- the decided refers_to proposal; a retry cannot create a duplicate
  received_via_delivery_id -> deliveries, promoted_by_decision_id NULL, created_at
  UNIQUE (brand_id, version_no)
  UNIQUE (brand_id) WHERE status = 'current'

line_signoffs                         -- one row per order line per pinned version
  id PK, organization_id, order_line_id, brand_version_id -> brand_versions,
  dispatch_id -> dispatches NULL,     -- the pack that carried it
  state (matched|sent|confirmed|deferred|rejected|superseded),
  decided_by_decision_id NULL, superseded_by_id -> line_signoffs NULL, created_at
  UNIQUE (order_line_id) WHERE state NOT IN ('superseded')

Policies for this flow, as rows in policies with counterparty_id = the customer:

Authority for IMP, in entity_authority: orders, customers, products = aradus. Brands, versions and sign-offs are Aradus-owned for every tenant and need no row.

The supplier PO is an obligation (payable, debtor IMP, creditor RIC) with one revision whose lines carry sheet quantities and the working, rounded to MOQ. Approval is a decision on its materialization; posting and sending follow.

6.3 Architecture

flowchart TB
    SW[Sari on WhatsApp] --> IN
    CE[Customer on email] --> IN
    RIC[RIC on email] --> IN
    UI[Rulebook and review queue<br/>read models over decisions and dispatches] --> W1
    IN[Edge adapters<br/>existing Twilio, Outlook and Resend handlers<br/>write deliveries and artifacts, emit delivery.arrived] --> CW[ConversationWorkflow<br/>one per tenant, channel, contact<br/>SignalWithStart on every inbound]
    CW -- answers an open question --> W1[OrderConfirmationWorkflow<br/>one per order: timers and signals]
    CW -- a forwarded design file --> W2[ArtworkReplacementWorkflow<br/>one per file, keyed on its hash]
    W2 -. line_superseded signal .-> W1
    W1 --> AC[Activities<br/>idempotent on business keys]
    W2 --> AC
    CW --> AC
    AC -- model calls --> GW[Python gateway<br/>classify, read artwork, interpret reply, phrase question]
    AC -- every fact --> PG[(Postgres, the only business truth<br/>deliveries, artifacts, documents, runs<br/>proposals, decisions, materializations, observations<br/>brands, brand_versions, line_signoffs, orders, obligations<br/>dispatches, policies, aliases, entity_authority)]
    AC -- send dispatch --> OUT[Sari, customers, RIC]
    TP[Temporal, self-hosted on the cluster<br/>execution history only] -.- W1

Reading the boxes:

6.4 Data flow

sequenceDiagram
    autonumber
    participant S as Sari (WhatsApp)
    participant C as Customer (email)
    participant CW as ConversationWorkflow
    participant W as Order and Artwork workflows
    participant G as Python gateway
    participant P as Postgres
    Note over S,P: A forwarded design file
    S->>CW: forward arrives: INSERT deliveries, delivery_artifacts, artifacts, then SignalWithStart conversation-{tenant}-whatsapp-{sari}
    CW->>G: interpret: an answer, an instruction, a question, or a file to file?
    G-->>CW: a design file with no open question, caption "Al-Ain"
    CW->>W: SignalWithStart artwork-{artifact hash}
    W->>G: classify
    G-->>W: document_type artwork
    W->>P: INSERT proposal_decisions (policy), documents born typed, run
    W->>G: read artwork identity
    W->>W: perceptual hash against each candidate's current version, origin from caption
    W->>P: INSERT pipeline_proposals: three signals and refers_to (brand, confidence)
    alt policy auto_when_signals_agree and the rule holds
        W->>P: INSERT proposal_decisions, actor policy
    else policy says ask, or the signals disagree
        W->>P: INSERT dispatches (question, asks_proposal_ids)
        W->>S: one-tap question via the channel sender
        S-->>CW: reply arrives as a delivery
        CW->>W: signal decision_recorded (routed because the dispatch asked this proposal)
        W->>P: INSERT proposal_decisions, actor user
    end
    W->>P: INSERT brand_versions status current, UPDATE brands.current_version_id, UPDATE prior version superseded, INSERT materializations (one transaction)
    W->>W: SignalWithStart line_superseded to every order-confirmation-{order} pinning the old version
    W->>S: receipt dispatch: replaced v3 with v4
    Note over C,P: The order workflow reacts
    W->>P: UPDATE line_signoffs superseded, INSERT line_signoffs pinned to the new version, state matched
    W->>P: INSERT dispatches (pack, affected lines only)
    W->>C: pack with the changed design, previews and one link per line
    loop durable timer at the customer's reminder_interval
        W->>C: reminder dispatch, escalation contact added after the policy's count
    end
    C-->>W: reply arrives as a delivery, routed by the dispatch it answers
    W->>G: classify signoff_reply, interpret per line
    W->>P: INSERT pipeline_proposals (assertion per line, text-span source_ref)
    W->>P: INSERT proposal_decisions (policy for clear verdicts with actor counterparty, operator for unclear), UPDATE line_signoffs confirmed, INSERT observations
    Note over S,P: Every line confirmed
    W->>P: INSERT obligations (payable to RIC, draft), revision and lines with sheets, working, MOQ, INSERT materializations
    W->>S: PO approval question as a dispatch
    S-->>CW: approve
    CW->>W: signal po_decision
    W->>P: INSERT proposal_decisions, UPDATE obligations posting_state posted
    W->>P: INSERT dispatches (PO to RIC from IMP's mailbox)

Walking it once. Sari's forward is a delivery, and it goes to his conversation workflow first, which sees a design file with no question pending and hands it to the artwork workflow, keyed on the file's hash so a duplicate forward joins the existing run. The three signals become three proposals and a fourth, refers_to, which is the linkage. Policy decides, or asks Sari; his answer comes back through the same conversation workflow, which knows which dispatch asked which proposal and routes it as a decision. The promotion, the supersession of the old version and the materialization record are one transaction. The artwork workflow then signals every order workflow that pinned the old version.

The order workflow re-pins and re-sends only the affected lines. The customer's reply is a delivery too, routed to the order workflow by the dispatch it answers, read per line as assertions with the quoted words as evidence, and decided with the customer as actor. When every line is confirmed the workflow converts the BOM into a draft obligation, asks Sari to approve it, and sends the PO.

6.5 The three workflows

ConversationWorkflow, id conversation-{tenant}-{channel}-{contact}. Started by SignalWithStart on the first message from that contact; lives indefinitely.

OrderConfirmationWorkflow, id order-confirmation-{order_id}.

  1. Wait for start.
  2. match_lines activity: proposals, policy, returns the lines needing a person. Wait on decision_recorded until none remain.
  3. One dispatch per recipient. Per dispatch: durable timer at reminder_interval, send_reminder activity (template or free-form chosen from the conversation's last inbound time), escalation contact after the policy's count. Signals: reply_processed, remind_now, pause, resume, line_superseded.
  4. On line_superseded: match_lines for those lines, re-send dispatch, continue.
  5. When every line is confirmed or permissibly deferred: convert_bom writes the draft obligation; wait on po_decision; send_po; wait for the receipt delivery or a timeout that alerts.
  6. ContinueAsNew after each pack cycle.

ArtworkReplacementWorkflow, id artwork-{artifact_hash}.

  1. Activities for the three signal proposals and refers_to.
  2. Read the customer's replacement_mode; apply the agreement rule; decide or ask (question dispatch, wait on decision_recorded with a reminder timer).
  3. materialize_version: version row, current pointer, prior version superseded, materialization record, brand.version_replaced event, then SignalWithStart line_superseded on every order workflow pinning the old version (a Postgres query). An order already at po_sent gets a short alert workflow rather than nothing.
  4. send_receipt.

Rules for all three: model calls only in activities; workflows carry ids rather than customer data; every activity idempotent on a business key (dispatch_id, created_from_proposal_id, the materialization digest); worker versioning decided before the first live run, because a deploy that changes a running workflow's logic without it fails on replay.

6.6 Deep dive: why replacement is designed this way

The naive design asks Sari which brand a file belongs to on each forward. He said no. The other naive design trusts one model call. That fails N2 the day the model is confidently wrong about a file that differs by one dash.

The three signals fail differently. Origin fails when the channel hides the sender. Content fails when two variants share their text. Visual fails when a re-send of the same file looks identical to the current version. Requiring agreement covers one signal's blind spot with another's vote, and abstaining is allowed so a missing signal does not force a guess.

The candidate step matters as much as the rule. Saving first means a wrong decision is reversed by promoting a different candidate. Nothing is lost while we ask.

And the mode is policy because IMP's answer is one customer's answer. A customer with a compliance team wants to approve every version. A customer with fifty brands and a designer who re-sends the current file to "make sure" wants the similarity ceiling. Same workflow, one policy row.

On WhatsApp the origin signal is usually absent, so content and visual have to agree on their own, and Sari will be asked more often than he would like. A short caption habit ("Al-Ain") fixes it. So does a WhatsApp group per customer, which the rulebook can record as that customer's channel.

6.7 Failure modes

What breaks What the system does
Model call fails, times out, or returns low confidence The activity retries on transient errors; a permanent failure or low confidence becomes a review task. The line stays where it was.
Worker restarts mid-chase The workflow resumes from history. Timers fire on schedule. Nothing is re-sent, because dispatch activities are idempotent on dispatch_id.
Reply arrives after the request expired The conversation workflow routes it as a late answer; the order workflow records it and raises a review task rather than confirming.
Reply from an unknown number or address A conversation workflow is created for it; with no open questions and no resolvable object, the message becomes a review task.
Two forwards of the same file Same content hash, same workflow id: the second SignalWithStart joins the first run.
Near-identical file to the current version Above the similarity ceiling the question becomes "new version or the same file?" regardless of mode.
New version lands while the PO is drafted The order workflow blocks po_decision until the affected lines are re-confirmed.
New version lands after the PO was sent Alert workflow. Nothing automatic can help.
Sari has several questions open at once Each dispatch carries asks_proposal_ids; his reply is matched to the question it answers, and an ambiguous reply is asked about.
A deploy changes workflow code Worker versioning keeps old runs on old code; new runs take the new code. Without it, replay fails loudly rather than diverging silently.
Temporal is unavailable No new signals are processed and no timers fire until it returns; deliveries keep landing in Postgres through the edge adapters, so nothing is lost, only delayed.

6.8 Testing

Temporal's time-skipping test environment runs a three-week chase in milliseconds: every reminder, escalation and supersession path is a unit test. Replay tests over recorded histories catch a non-deterministic change before deploy. A golden set of Sari's production brand files, including near-identical pairs, runs through the three signals and each policy mode with expected outcomes. Replayed production replies, including phone relays and marked-up screenshots, run through the interpreter. A property test asserts the invariant in 5.3 after any sequence of events, and a database constraint enforces it.

6.9 Phasing

7. What this means for the existing system

The greenfield is not a second building. Four things move out of the current system so both can share them, in this order, each with a parallel run before the old path is switched off:

  1. Shared dispatch. The existing SendMessage and message_deliveries become the dispatches service with an idempotency key. Tenant-visible change: none.
  2. Shared intake. The Twilio and inbox handlers write a delivery and its artifacts in the same transaction as the old rows, and announce arrivals. Old readers are untouched until each is moved and compared. A failure to write the new row cannot fail the old one.
  3. Greenfield core plus this flow. IMP goes live. No other tenant is on it.
  4. The Command Center reads greenfield decisions alongside the old engine's version reviews. One queue for the operator.
  5. Brand library readers rewritten (partners UI, order-line validation, doc-gen asset manager) against brands and brand_versions; the old brandings and order_brandings tables are dropped.

For ITCC, whose invoice versioning, tracking and document pipeline all read the inbox tables directly: nothing changes until step 2, and step 2 only adds a write. Their readers move one at a time, each with the old row still present as the fallback. What they gain once step 2 is done is the thing they asked for: email bodies read as observations rather than only attachments.

8. Open questions

Appendix A. What exists today

The current system (next branch) has more of this than the status note suggests, and none of it is the runtime this design uses.

This design was first drafted against Engine B and the scheduler. It was moved to the greenfield because the two things Engine B bundles, hand-built durability and an operator-editable graph, are the two things the greenfield replaces with Temporal and policy data, and because a chase that runs for weeks sits badly on a seven-day sweep.