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:
- F1. Know when an order with branded lines has arrived and hold it until an operator presses Start.
- F2. For each line, find the customer's current design for that brand. Ask when there is no match.
- F3. Send the set of designs to the right customer contact, on their channel, with previews.
- F4. Remind on a schedule the customer has agreed to, escalate after a set number of reminders, stop when every line is answered.
- F5. Read replies and record per line: confirmed, rejected, deferred, or unclear. Accept relays by phone entered by the operator.
- F6. When a new design file arrives, make it the current version for the right brand, and re-ask any customer whose open order was pinned to the old version.
- F7. Convert confirmed cans into sheets, covers and bottoms through the bill of materials, round up to the supplier's minimum order quantity, and draft the supplier PO with the working shown.
- F8. Let the operator approve the PO and send it from IMP's own mailbox, and log the receipt.
- F9. Keep a per-customer rulebook: who to send to, who to escalate to, how often to remind, what to do with deferred lines, whether a marked-up screenshot counts as sign-off, whether a new file replaces the current version without asking, when to archive.
- F10. Accept orders that arrive as text or photos in odd formats, and finish them with a short back-and-forth when the machine is not sure.
Non-functional:
- N1. A confirmed line must reference exactly the file the customer saw. Anything else is the $10,000 case.
- N2. A model mistake must turn into a question for a person, and cannot turn into a confirmed line.
- N3. Sari works on WhatsApp. His customers may be on email or WhatsApp. The core must be indifferent to channel.
- N4. Everything a person decided must be recorded with who, when, through which channel, and on what evidence.
- N5. Volume is small (tens of orders a month, a handful of lines each). Correctness beats throughput everywhere.
- N6. The first shippable slice must run one live order end to end: pack out, customer confirms, a forwarded file replaces the old one without Sari touching anything.
- N7. Every human intervention offers to become a rule, so the same question is not asked twice.
Non-goals for now: inventory, warehouse, tracking after the PO, and any customer-facing UI. The customer only ever sees messages.
3. Decisions taken
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
received → matching →
with_customer → confirmed →
po_drafted → po_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:
- 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
Forwardedflag and withholds the original sender, so on WhatsApp this signal abstains unless Sari's caption mentions the customer. - Content. Text read out of the artwork: brand name, product, size, variant. Matched against the brand library, with a confidence.
- 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:
auto_when_signals_agree: promote and send Sari a one-line receipt. IMP's choice.require_approval: send the one-tap question on each forward, with the top candidate pre-selected.ask_below_similarity: promote when the new file is near-identical to the current version, ask when the artwork visibly changed. The safer default for a customer who has not said.
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:
review_routing:signoff_contact,escalation_contact,escalate_after_remindersautonomy:line_match_threshold,replacement_mode,content_confidence_floor,similarity_ceilingtemplate:reminder_interval,pack_template_key,question_template_keycheck:deferred_policy,markup_counts_as_signoff,archive_after_days
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] -.- W1Reading the boxes:
- Edge adapters are the existing Twilio and inbox handlers, changed to do one thing: store what arrived as a delivery with its artifacts (fetching WhatsApp media with their credentials, since the media URLs expire) and announce it. They keep no other copy. This is the shared intake from section 7.
- ConversationWorkflow is the front door for anything a person sends. It knows this contact's open questions and recent turns, and routes each message.
- OrderConfirmationWorkflow holds one order's waiting: timers for reminders, signals for replies, supersessions and approvals.
- ArtworkReplacementWorkflow runs the three signals and the policy for one forwarded file, then signals every order pinned to the old version.
- Activities are where the workflows touch the outside: model calls through the gateway, writes to Postgres, dispatches through the channel senders. Each is safe to run twice, keyed on a business id.
- Postgres holds every fact. Temporal holds no fact a query could not rebuild.
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.
- State: the set of open questions for this contact (each with the dispatch that asked and the workflow to signal), and pointers to the last N deliveries and dispatches. No text.
- On each inbound signal: an
interpret_inboundactivity returns answers question Q, instruction about object O, question, or file to file. Answers are signalled to the asking workflow. InstructionsSignalWithStartthe object's workflow. Questions run a read activity and areplydispatch. Files start the artwork workflow. Anything below threshold becomes a review task. ContinueAsNewevery N turns or every week, carrying the open questions and the pointers.
OrderConfirmationWorkflow, id
order-confirmation-{order_id}.
- Wait for
start. match_linesactivity: proposals, policy, returns the lines needing a person. Wait ondecision_recordeduntil none remain.- One dispatch per recipient. Per dispatch: durable timer at
reminder_interval,send_reminderactivity (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. - On
line_superseded:match_linesfor those lines, re-send dispatch, continue. - When every line is confirmed or permissibly deferred:
convert_bomwrites the draft obligation; wait onpo_decision;send_po; wait for the receipt delivery or a timeout that alerts. ContinueAsNewafter each pack cycle.
ArtworkReplacementWorkflow, id
artwork-{artifact_hash}.
- Activities for the three signal proposals and
refers_to. - Read the customer's
replacement_mode; apply the agreement rule; decide or ask (question dispatch, wait ondecision_recordedwith a reminder timer). materialize_version: version row, current pointer, prior version superseded, materialization record,brand.version_replacedevent, thenSignalWithStart line_supersededon every order workflow pinning the old version (a Postgres query). An order already atpo_sentgets a short alert workflow rather than nothing.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
- P0. The spine at minimum, and the shared edges. Deliveries, artifacts, documents for three types, runs, proposals, decisions, policies, materializations, observations, dispatches, the conversation workflow, self-hosted Temporal on the cluster. Existing Twilio and inbox handlers write deliveries alongside what they write today. This is the part with schedule risk.
- P1. The loop. Match, pack, chase, reply, file replacement. Customers on email, Sari on WhatsApp. This is N6.
- P2. Rulebook and queue. The policy rows behind a per-customer rulebook screen; the queue (Needs you, With customer, Supplier PO, Sent) as a read model over decisions and dispatches, rendered in the existing Command Center.
- P3. Convert and PO. BOM conversion into a draft obligation, approval, send, receipt.
- P4. Customer WhatsApp with opt-in and templates.
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:
- Shared dispatch. The existing
SendMessageandmessage_deliveriesbecome thedispatchesservice with an idempotency key. Tenant-visible change: none. - 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.
- Greenfield core plus this flow. IMP goes live. No other tenant is on it.
- The Command Center reads greenfield decisions alongside the old engine's version reviews. One queue for the operator.
- Brand library readers rewritten (partners UI,
order-line validation, doc-gen asset manager) against
brandsandbrand_versions; the oldbrandingsandorder_brandingstables 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
- Customer WhatsApp. Opt-in mechanics and template approval lead time (ten minutes to two days) are known. Whether Sari's customers will opt in is not. Email first keeps this off the critical path.
- Origin on WhatsApp. Weak by API design. Measure how often the agreement rule asks in P1 before changing it.
- Same file or new version. An identical hash is a duplicate delivery and creates no version. A near-identical file above the similarity ceiling asks. The ceiling is a number to tune on Sari's golden set.
- Who runs Temporal. Self-hosting is four services, persistence on the CNPG operator, and someone on call who has run it. Decide the name before the first long-lived workflow depends on it.
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.
brandingsstores immutable versions per (organisation, customer, product, brand name) with afilesarray.order_brandingsprojects an order's branded lines to pending, confirmed or superseded, keyed by order and brand rather than by line.order_approvalshas kindsbranding_confirmationandbranding_file_replacementwith proposal and decision JSON and an AI confidence: structurally the proposal and decision pattern, in one table.- The WhatsApp channel is built on a platform-owned Twilio number:
templates, delivery ladder, phone verification, inbound webhook,
approval_channel_refslinking a reply to one pending approval per phone. Inbound media is counted rather than fetched. No environment has Twilio configured yet. - Engine B is the current workflow system: a visual builder (Automations) whose graphs are executed by a Go interpreter, with about fifty node types (triggers, delay, wait for event, request approval, send email and WhatsApp, classify and extract, create and update domain objects, an LLM step). It parks runs on rows, and a scheduled sweep fails runs parked more than seven days. Engine A, which it replaced, was fixed-kind subscriptions with hard-coded handlers.
- The Go
BrandingResolverport, which would act on a branding decision, returns 501. - The inbox classifier that assigned intents to emails was deliberately retired; messages land as needs-review.
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.