← Greenfield Pipeline Design · Existing-system build
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.
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 lines under specification has arrived, and start the loop by policy: a per-customer threshold on the order-entry confidence of every line decides whether the loop starts on its own or waits for one tap. Day one, every customer's threshold is set to ask on each order, so the operator presses Start on each order; a customer is moved to auto-start once its calibration data says the threshold is safe.
- F2. For each line, find the customer's current design for that specification. 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 specification, 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 an answer, 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. Customers on email first. Sari forwards by email or upload until the production WhatsApp number exists (an operator task, ARD-728); the same handlers pick up WhatsApp when it does. 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.
- Specifications, versions and asks are Aradus-owned for every tenant.
Orders, customers and products are owned per tenant through
objects.authority; for IMP that isaradus, since there is no ERP.
4. Concepts, in plain words
Specification. A customer-approved specification for one product, such as "Al-Ain Foods, Caramel 500g" artwork; IMP calls this a brand. One specification has many versions over time.
Specification 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 specification. Changing it is a decision with a record.
Pin. A line on an order points at one specific version of a specification, not at the specification. 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 specification'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 specification it belongs to, so a file cannot be lost while we work out what it is.
Signal. One independent piece of evidence about which specification 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.
Ask and ask item. An ask is one set of questions put to a person or a counterparty, with a policy that says how to chase and when to give up. An ask item is one thing asked about, immutable once written; its current answer is the latest decision recorded against it.
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 gate opens: by an
operator's Start, or on its own when every line clears the customer's
order-entry threshold (F1). The gate is a policies row of
type autonomy on the order, so the same workflow serves a customer who
sends photographs and one who sends clean PDFs, and moving a customer
from the button to auto-start changes a row, not code.
A line confirmation is where the work happens:
stateDiagram-v2
[*] --> unmatched
unmatched --> matched: specification found, version pinned
matched --> sent: pack goes out
sent --> sent: reminder
sent --> confirmed: customer says yes
sent --> deferred: customer rule allows deferral
matched --> superseded: specification got a new version
sent --> superseded: specification got a new version
confirmed --> superseded: specification 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 ask item, 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 specification 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
specifications. 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:
specification name, product, size, variant. Matched against the
specification library, with a confidence. The confidence is the
calibrated one from the greenfield spine (a
fitted_modelsmapping over the proposal's features, see the greenfield page, section 6 "Confidence and calibration") rather than the model's own estimate; on day one it reads the platform mapping, and IMP gets its own once its decisions are enough to fit one. - Geometry. The artwork's trim box against the customer's expected can geometry. A wrap-around label is a rectangle whose width is the can's circumference plus the seam where it overlaps itself, so the page size in the file says which can the design is for, with no model involved. It is deterministic, costs nothing, and is independent of the identity read in a way a second model call would not be.
This replaced a perceptual hash, which measurement rejected. Run over IMP's forty-five library files the hash put two different products six apart on a 256-bit hash while putting one label a hundred and twenty-eight away from a sheet of itself. Neither is a tuning problem: IMP's customers reuse one template across products and countries, so two designs can differ only in a product banner and a country line, while the same design is laid out as a single label on one file and a ten-up imposition sheet on another. A metric measuring overall visual similarity answers both of those correctly and is useless for deciding which specification a file belongs to. Local feature matching fared no better on the same pairs.
Geometry is what separates the cases nothing else can. Every residual ask in that measurement was a pair of designs differing only in which can they wrap, on artwork printing no size at all, where about a hundred and ninety millimetres of trim geometry separates what no text can.
The agreement rule is deterministic code: every signal that voted points to the same specification, 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 specification'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 specification; 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 content hash and the geometry check are 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 (typed rows in their own right, not objects
rows), runs, proposals, decisions, policies, materializations, facts,
evidence_bindings for what a document created or supports,
and order_lines as the order line kind's typed extension.
Order confirmation adds one business aggregate (specifications and their
versions), one fact table (asks and ask items), 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, ask_reply,
sales_order) |
| "Which specification is this file?" | A refers_to proposal, decided by policy or by Sari |
| A new specification version | A specification_versions row materialized from the
decided proposal; the specification's current pointer moves in the same
transaction |
| A line's pin and its answer | A line_specification_pins row ties the order line to
the offered specification_versions row; an
ask_items row,
item_ref_id -> line_specification_pins, carries the
preview sent; its verdict is the latest proposal_decisions
row on the ask item, which moves the pin to confirmed or rejected |
| "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 partner |
| Line text to specification mapping saved from an answer | An alias in the identity spine |
| The supplier PO | An obligation payable to RIC, its
obligation_revisions row in
posting_state = draft (snapshot), 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:
specifications
id PK, organization_id, customer_partner_id -> objects, product_id -> objects,
kind (artwork|label|formula|packaging|quality_standard), name, current_version_id -> specification_versions NULL
UNIQUE (organization_id, customer_partner_id, product_id, kind, name)
specification_versions -- append-only; one row per design file ever received
id PK, organization_id, specification_id -> specifications, 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 (specification_id, version_no)
-- specifications.current_version_id is the single authority for "current"; a deferred trigger asserts the version it
-- points to has status current and no sibling version of the same specification does
line_specification_pins -- which version an order line is confirmed against; no kind of its own
id PK, organization_id, order_line_id -> order_lines, specification_version_id -> specification_versions,
status (matched|sent|confirmed|rejected|superseded), ask_item_id -> ask_items NULL, decided_by_decision_id NULL, superseded_by_id -> line_specification_pins NULL
UNIQUE (order_line_id) WHERE status <> 'superseded'
-- composite: the pinned version's specification must carry this line's product_id and this order's buyer as its customer,
-- enforced by a constraint trigger since no single FK can express that join
asks -- every question this flow puts to a person, whoever they are
id PK, organization_id,
kind (artwork|artwork_identity|line_match|order_start),
audience (user|counterparty),
assignee_user_id NULL, counterparty_partner_id -> objects NULL, contact_role NULL,
subject_type (order|order_line|artifact), subject_id,
policy_id -> policies NULL, -- reminder_interval, escalate_after_reminders, what counts as an answer; NULL where nothing is chased
state (open|complete|abandoned), created_at
ask_items -- one per thing being asked about; immutable once written
id PK, ask_id, organization_id,
item_ref_type (line_specification_pin|order_line|specification_version), item_ref_id,
asked jsonb, -- the preview sent, or the candidates offered
asked_schema_version, dispatch_id -> dispatches NULL -- the message that carried it
UNIQUE (ask_id, item_ref_type, item_ref_id)
Four questions arise in this flow and they take three audiences
between them, which is why the audience and the subject are columns
rather than fixed values. The confirmation pack is artwork
to a counterparty about an order, one item per line. An artwork file
whose signals disagree is artwork_identity to Sari about an
artifact, before any order exists, one item per candidate specification.
A line that matches nothing is line_match to Sari about an
order line. An order whose entry confidence sits below the customer's
threshold is order_start to Sari about the order. Only the
first two carry a chase policy; the others wait without reminding.
The pin is its own row, line_specification_pins, rather
than a field inside asked: it is what lets a confirmed line
be found without reading every ask, and what a
superseded_by_id chain points through when a forwarded
replacement re-pins it. The ask item cites the pin, since the pin is
what the PO conversion and the three-way match read back, rather than
the order line or the ask itself.
Policies for this flow, as rows in policies with
counterparty_id = the customer partner:
review_routing:answer_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_answer,archive_after_days
Authority for IMP: the order, partner and product kinds default to
objects.authority = aradus through the authority policy.
Specifications, versions and asks are Aradus-owned for every tenant and
need no such policy row.
The supplier PO is an obligation (payable, debtor IMP,
creditor RIC) with one obligation_revisions row whose
obligation_lines carry sheet quantities and the working,
rounded to MOQ. The conversion itself reads
bills_of_materials and bom_components for the
recipe (one can is one sheet, one cover and one bottom, with yield and
scrap columns), and units and
product_packagings for the unit the customer ordered in
versus the unit the recipe is written in. 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[ConfirmationWorkflow<br/>one per order, by ask kind and policy]
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, facts<br/>specifications, specification_versions, asks, ask_items, line_specification_pins, objects (order, obligation kinds)<br/>dispatches, policies, aliases, objects.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.
- ConfirmationWorkflow holds one order's waiting: timers for reminders, signals for replies, supersessions and approvals. Parametrised by the ask's kind and policy; artwork is the first kind built.
- ArtworkReplacementWorkflow runs the 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 Confirmation 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: trim geometry against the customer's can formats, origin from caption
W->>P: INSERT pipeline_proposals: the signals and refers_to (specification, 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 specification_versions status current, UPDATE specifications.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: INSERT asks (new round), INSERT ask_items pinned to the new version for each affected line
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 ask_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), INSERT facts
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 signals become
proposals plus a 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.
ConfirmationWorkflow, id
order-confirmation-{order_id}, parametrised by the ask's
kind and policy; artwork is the first kind built.
- 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,specification.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 specification 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 signals fail differently, which is why there are several. Origin fails when the channel hides the sender. Content fails when two variants share their text, or when the artwork prints no size. Geometry fails when two specifications share a can. 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.
Two of them are not independent, and the rule must not pretend otherwise. Reading the identity text and comparing two images are the same model looking at the same picture, so their agreement carries far less evidence than two unrelated signals agreeing. The content hash and the geometry check are the independent ones; the pairwise comparison adjudicates between candidates rather than casting a third vote.
Auto-apply ships disabled, whatever the signals say. IMP's library holds forty-five specifications and forty-five distinct customer-product-brand keys, so it contains no instance of the event this workflow exists to handle: one specification at two versions. There is nothing to set a threshold against, and the measurement had to synthesise its own positive cases for exactly that reason. Until a tenant has accumulated enough resolved replacement decisions to backtest, every replacement opens an ask and a person assents once. Those resolved asks are where the labels come from, and the gate to enable the unattended path is a backtest over held-out pairs reported per pair type rather than pooled. Pooling is what hid the perceptual hash's failure in the first place.
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 specifications 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 specification 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, facts, 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. Orders themselves still come from the existing
pipeline at this step: a customer's purchase order forwarded to IMP's
inbound address is classified and extracted by the existing process
stage (verified on
nexton 10 September: a three-line PO became a linked sales order in about forty seconds, with one field review), and written as anobjectsrow of kindorderfrom this step on, the same write path every other tenant's orders use. The ConfirmationWorkflow wakes on that row through the intake seam and reads its lines from there. - The Command Center reads greenfield decisions alongside the old engine's version reviews. One queue for the operator.
- Specification library readers rewritten (partners
UI, order-line validation, doc-gen asset manager) against
specificationsandspecification_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 facts 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
it looks, 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.