Synthetic data, shaped after a Gulf delivery platform Canon Ops
World clock Fri 23:00:00 GST paused · 10×

Architecture

The full design. Back to the ten-minute version. docs/architecture-full.md

Canon Ops: architecture

Canon on live data at the platform's published scale (350,000+ orders a day, 14,000+ active locations).

This repository is the reference implementation and runs only on a synthetic world (known gaps). Section 8 maps each component to its code or its gap. Platform facts come from the platform's public documentation and site; every other statement about the platform is marked "(assumption)". The repo says "branch" where this document says "store": one brand at one location.

1. On one page

What runs. One region, next to the order feed (5.5), runs a data plane and a control plane. The data plane moves order, POS, menu, store and dispatch events onto a bus, keeps hot state per store, evaluates real-time definitions on the stream and business definitions in batch, and keeps history. The control plane turns detections into incidents, proposes one action for each, checks it against a written policy, calls the platform (4.3), and records every step in an append-only ledger. People approve what the policy reserves for them.

What decides.

  1. Definitions say what is true. A stuck order is what stuck_order version 2 says it is, and every detection carries that version. Each definition names its owner.
  2. The policy says what may be done: tier, conditions, budgets, cooldowns and a never-list. A controller enforces it on every proposal. The COO owns it, and nothing automated can change it.
  3. Rules pick the action where the runbook leaves one option. A model is asked only where there is a real choice among the actions and arguments the policy lists, or where tier 2 is possible and the approver needs a reason. Its answer is a proposal like any other.

What is measured. Precision and recall per detector per day; the share of incidents closed without a person and of actions reverted; time to detect and time to fix; model cost per resolved incident; the age of every input; drift on all of these.

flowchart LR
    subgraph SRC["Sources"]
        ORD["Order feed"]
        POS["POS integration logs"]
        MENU["Menu service"]
        STORE["Store state"]
        DISP["Dispatch"]
        SLOW["Support desk, CRM, billing"]
    end

    subgraph DP["Data plane"]
        BUS[("Event bus: Redpanda")]
        FLINK["Stream jobs: real-time terms"]
        HOT[("Hot state: Redis")]
        COLD[("Cold store: Parquet")]
        BATCH["Batch jobs: business terms"]
    end

    subgraph CP["Control plane"]
        DEFS["Definitions: term, variant, version"]
        INC["Incidents"]
        PROP["Proposer: rules, model only for a choice"]
        CTRL["Controller: policy, budgets"]
        ADPT["Adapters: platform calls"]
        GW["Key gateway: policy verbs only"]
        LEDG[("Ledger: append-only")]
        QUEUE["Approval queue and on-call"]
        MEAS["Measurement"]
    end

    ORD --> BUS
    POS --> BUS
    MENU --> BUS
    STORE --> BUS
    DISP --> BUS
    SLOW --> COLD
    BUS --> FLINK
    BUS --> COLD
    FLINK --> HOT
    COLD --> BATCH
    DEFS -.-> FLINK
    DEFS -.-> BATCH
    FLINK --> INC
    BATCH --> INC
    INC --> PROP
    PROP --> CTRL
    HOT -.->|evidence| CTRL
    CTRL -->|tier 0-1| ADPT
    CTRL -->|tier 2| QUEUE
    QUEUE -->|approved| ADPT
    ADPT --> GW
    GW --> API["Platform APIs"]
    CTRL --> LEDG
    ADPT --> LEDG
    QUEUE --> LEDG
    LEDG --> MEAS
    SLOW -.->|tickets as labels| MEAS
    LEDG --> COLD

The control plane changes the world only through the platform's APIs, behind a gateway that holds the platform key (4.7), and through drafts a person sends.

2. Data plane

2.1 Sources

The order-feed and dispatch rows, gData's menu tables and item-availability log, and store status, working mode (busyUntil) and working hours come from the platform's docs; every other cell, including what the POS logs contain and a channel listing status, is an assumption.

SourceWhat it givesHow it reaches Canon
Order feed (gData)Orders; status history (the gData docs name OrderCreated through OrderCanceled; the full list was not read); KOT history (RECEIVED, STARTED, COMPLETED, CANCELLED, ON_HOLD, RESUMED); uniqueorderid, remotestoreid, deliveryplan. The order-integration docs list a separate webhook lifecycle sent to aggregators: Accepted, Rejected, Started, Prepared, Cancelled, Dispatched, CompletedgData pushes into Canon's own Postgres; CDC on that database puts gdata_order, gdata_order_source, gdata_order_status_history and gdata_kot_update_history on the bus. An event stream from the platform itself would be better (assumption: not offered today)
POS integration logsInjection attempts and errors, token refreshes and expiries, POS version upgrades, print resultsNot in the feed. A log stream or export from the integration service
Menu serviceItems, modifiers, availability and mapping per store and channelgData's menu tables and item-availability log, plus a full reconciliation every 15 minutes
Store managementStore status (online, offline), working mode (busy, busyUntil), working hours, channel listing statusChange events; otherwise a poll every 30 s
DispatchDelivery jobs: DELIVERY_JOB_CREATED, DRIVER_ASSIGNED, DRIVER_ARRIVED, DELIVERY_JOB_STARTED, DELIVERY_JOB_COMPLETED, DELIVERY_JOB_CANCELLEDThe Delivery Manager API's status webhook, or an export
Support deskTickets with category, store, open and close timesEvery 5 minutes into the cold store; also labels
CRMContract signed, onboarding stage, churn, account ownerHourly into the cold store
BillingInvoices, amounts, due and paid datesDaily into the cold store

gData is a push: the platform writes, from four IP addresses of its own, into a Postgres 10+ database the receiver owns, in AWS eu-west-2 (London). CDC (Debezium with pgoutput) runs on Canon's own primary. The feed starts the day it is configured; earlier weeks need a history sync request. No lag is published, only "near-real-time", so measuring it is week one's first job. That one destination can take every customer's feed is an assumption. Without CDC, Canon polls gdata_order_status_history every one to two seconds, re-reading an overlap at least as long as the measured commit lag (60 seconds until known) to catch rows committed out of timestamp order.

2.2 Event bus

Redpanda, three brokers. It speaks the Kafka protocol, so Debezium and Kafka clients work unchanged, and it is one binary per node with no JVM, which suits a three-person team. The community edition is under the Business Source License; if that fails the platform's policy, Apache Kafka in KRaft mode is the drop-in.

Topics are keyed by store id (the docs suggest a v4 UUID), so each store's events stay in order in one partition: 48 partitions per topic, replication factor 3, 7 days' retention, matching the definitions' data window. Market-wide checks, such as no orders on one app in one market, run as a second stage keyed by market and channel.

2.3 Stream jobs and hot state

A keyed consumer service first; Flink only if a number demands it. The hard cases are absences: an order not injected 90 seconds after acceptance, a courier who has not arrived, a channel with no orders. Their timers run on processing time: an event-time timer fires only when later events move the watermark, so it stalls exactly when events stop (an aggregator outage, idle partitions at 03:00). A timer fires at the source timestamp plus the threshold plus a lateness allowance (2.6). Freshness comes from each source's position and heartbeat, not from arrivals (2.5). At 225 to 290 events a second (5.1), a consumer group with per-key state in RocksDB and a timer wheel is enough; gate 1 runs it. Flink replaces it if state outgrows one node or sustained load passes 10,000 events a second (assumption: about three times the burst). The definition is the contract, not the engine.

Detector state is snapshotted with its bus offsets every 30 seconds. A projection goes to Redis, which the controller, the interface and the model's evidence read:

KeyFields
StoreOpen orders by stage, with timestamps; open orders against capacity per 15 minutes; status per channel; working mode and busyUntil
POS integrationInjection latency p50 and p95 over 15 minutes; failures and orders per minute over the last 60 minutes; auth_expires_at and the last auth event; POS version
Store and channelMenu hash; items switched off; last successful menu sync
Every keyas_of and the source position it reflects (LSN or row id, bus offset)

The menu hash covers item ids, availability and modifier mapping but not prices, because a channel price above the till price is the merchant's markup, not drift.

2.4 Cold store

Parquet on object storage, by market, day and topic. Raw events stay 13 months (assumption), and the ledger is copied in hourly. DuckDB runs the batch definitions and replays over it. That is the engine the reference implementation uses, so the definitions' SQL runs over views with the same table names.

2.5 Freshness per fact

FactSourceMaximum age at peakRead by
Order placed, accepted, rejected, cancelledOrder feed5 sstuck_order, failing_integration, menu_drift, duplicate_order, overloaded_branch, acceptance_risk
Orders per market and channelOrder feed60 saggregator_outage
Order injected into the POSPOS logs5 sstuck_order, failing_integration
Injection errorsPOS logs10 sfailing_integration, release_regression
POS version upgradesPOS logs15 minrelease_regression
Token expiry, last auth eventPOS logs5 min (the warning window is 30)pos_token_expiring, stuck_order, pos_link_down, failing_integration, churn_risk (last 7 days)
Print results, printer statusPOS or KDS logs (assumption)30 soffline_printer
Item availability per channelMenu service60 s (the docs set 5 s for an availability push to apply)menu_drift
Full menu hash, menu syncs, POS change logMenu service, POS logs15 minmenu_drift
Store status, working modeStore management30 soverloaded_branch, every store-facing action
Ready, picked up, delivery job statusOrder feed, Dispatch30 scourier_no_show
TicketsSupport desk10 minchurn_risk, labels
Contract, onboarding, churnCRM1 hactive_client (growth), churn_risk
InvoicesBilling1 dayactive_client (finance), overdue_invoice
Daily order aggregates, orders in the last 7 daysOrder feed, batch1 daychurn_risk, overdue_invoice, active_client (ops)

Every hot-state key carries as_of, and in production every definition lists the maximum age of each input. A detector with an input past its bound reports "blind", not "nothing found", and the controller does not act on that evidence.

2.6 Order, lateness, duplicates

ProblemHandling
Event timeDetectors use the source's timestamp, not arrival time, for order and history. Absence timers fire on processing time, after a lateness allowance: the measured p99 lag of the closing input (gData CDC, the POS log stream) plus clock skew, and never below that input's age bound in 2.5 (5 s for pos.injected). Reset from the lag measured in shadow (assumption)
Late eventsStill update state and can close an incident. A late injected must not cause a double order, so before a re-send Canon asks the POS synchronously, through the platform, whether it has the order (assumption: such a query exists), and does not trust its own lagging view. That is still check-then-act: the platform's own relay may deliver the order between the check and the re-send (assumption: the relay retries on its own), so the POS dropping a repeated order id is an assumption to confirm per POS vendor. The repo's repush_order makes the same check against the world
Out of orderStages are monotonic (placed, accepted, injected, ready, picked up, delivered). A late status fills in history and never moves an order back
BacklogsAct horizon: an event that arrives more than 10 minutes after its event time, as after a bus outage, updates history and metrics and triggers no action by itself. When the outage ends, every detector re-reads current state, as the repo's SQL does on each tick, and acts on what is still true: an order still stuck is re-sent on fresh evidence. If the measured feed lag nears the horizon, the source is already past its age bound (2.5): detectors report blind and nothing acts until the lag recovers; the horizon is set well above the measured p99 lag, never moved toward it
KeysEvent: its source position (the feed's LSN or row id, the bus offset, or the webhook's event id; assumption: one is sent, otherwise a hash of the payload with its status and timestamp). Order: uniqueorderid, "unique across systems" per the gData docs; the re-send's idempotency key (assumption). Incident: kind, subject and start time, as in the repo. Action: incident, action, target and attempt number, unique over the ledger rows that start a call (production work, 4.4; the repo's ledger has primary keys only)
RedeliveryAn aggregator retries the Place Order call (createorder, into the platform) three to five times with exponential backoff when it gets no 201, and the platform retries a webhook until it gets a 204, so one delivery can arrive twice. Ingestion drops an event whose source position it has already seen, never an order by its business key, which would hide the double deliveries duplicate_order exists to catch. The Place Order retries happen before acceptance, so they never overlap the stuck-order clock, which starts at acceptance; a repeated status webhook is dropped by its key (above)
Same order, different idsSame store, channel and total seconds apart is usually a double submit upstream (assumption), and belongs to duplicate_order (the repo's version 2 raises it at a 0.4% pair rate per channel)

Exactly-once. Snapshots and bus offsets keep state consistent inside the data plane; nothing is exactly-once against an external API. Canon assumes at-least-once delivery and makes every effect idempotent: deterministic incident ids, a unique action key in the ledger, idempotency keys and read-backs in the adapters. The controller's dedupe window and cooldowns (4.2) stop repeats.

3. Taxonomy: the definitions

3.1 A definition is code

Each term is one YAML file: term, title, unit, description, synonyms, params, a canonical variant or null, variants, and where it applies a default period (on the term or the variant). A variant has a key, owner, department, version, dated change note, history, SQL and tests (row-count bands, uniqueness, not-null, evidence shape). Detector terms also name a subject, and most a data window. Thresholds are parameters outside the SQL, so changing one is a versioned edit with an author and a reason. That is the repo's format. Production adds stream, inputs (each fact read, with its maximum age) and approvers.

3.2 The terms Canon acts on

Every detector in canon/dictionary/definitions/, the grouping the agent adds, and the batch back-office checks. Variant, version, owner and thresholds come from the YAML (owners are the repo's role names, so assumed roles on the platform); action and tier from policy version 3, the action being the rule's usual first pick; age bounds from 2.5 (production; the repo enforces none).

TermForm and triggerVariant, version, ownerOne incident is aboutInputs, maximum ageFirst actionTier
stuck_orderStream, absence: accepted, not in the POS after stuck_seconds (90); high after 600 s or with a dead tokenintegrations v2, Head of IntegrationsOne orderAccepted, cancelled, rejected 5 s; injected 5 s; auth state 5 minrepush_order; refresh_pos_token first if the token expired0
pos_link_downGrouping in canon/agent/linkdown.py, no definition of its own: stuck orders behind one integration with an auth event in the last 60 min, or 3 stuck within 10 minCarries stuck_order v2One store's POS link, with its waiting ordersAs stuck_order; auth events 5 minrefresh_pos_token if still expired, then repush_batch (up to 50 orders per store per hour)0
pos_token_expiringStream, absence: expiry within warning_minutes (30), no refresh since the window openedintegrations v2, Head of IntegrationsOne POS integrationToken expiry and refreshes 5 minrefresh_pos_token0
failing_integrationStream, windowed rate: 3 or more failed orders and over 20% of orders in 60 minops v4, COOOne POS integrationInjection attempts and errors 10 s; accepted, injected 5 s; auth state 5 minrefresh_pos_token if the token expired; else switch the one mis-mapped item off, or pause the channel (tier 1)0
release_regressionBatch, hourly (failing_integration, integrations variant): a release failing 2% or more of orders and 3 times its siblings over 24 h, on 200+ orders spread over 12+ hours, after a rollout or a good dayintegrations v3, Head of IntegrationsOne POS release (vendor and version)Injection results 10 s; version upgrades 15 minopen_partner_escalation to the vendor1, queued: a message to a POS vendor asks first
overloaded_branchStream, comparison: open kitchen orders above capacity per 15 minops v1, COOOne storeOrder stages 5 s; store status, working mode 30 sset_working_mode_busy, at most 15 min0
offline_printerStream, absence: no print since 2 failed tickets, or since the printer reported offline; 3-hour look-backintegrations v2, Head of IntegrationsOne store's main printerPrint results, printer status 30 sreroute_print_backup; without a backup, a store message (tier 1)0
menu_driftStream, comparison: 3 rejections for one item with no menu sync since (1 when the POS change log confirms)ops v4, COOOne store and itemRejections and reasons 5 s; availability per channel 60 s; menu syncs, POS change log 15 minupdate_item_availability off; with no named item, a full menu push (tier 2)0
duplicate_orderStream, windowed rate: same store, channel and total within 60 s, on a channel at a 0.4% pair rate or moreops v2, COOOne order, the later copyPlaced orders 5 sdraft_message to the store1
courier_no_showStream, absence: ready 25 min ago, not picked upops v3, COOOne orderReady, picked up, delivery job 30 sdraft_message: a logistics ticket to the delivery partner1
aggregator_outageStream, windowed rate: under 15% of the expected orders in 20 min, 20 or more expected, other channels at 60% or moreops v1, COOOne market and channelOrders per market and channel 60 sopen_partner_escalation to the delivery app; its stuck orders fold in1
acceptance_riskBatch, hourly: under 98% over 7 days and getting worse, or projected to cross within 2 daysops v2, COOOne storeOrders, rejections and reasons 5 s; the other open incidents that explain rejectionsupdate_item_availability when one item dominates; else open_ticket_draft (tier 1)0
churn_riskBatch, daily at 08:00: orders 40% below last week, 3 of 6 days under 75% of the same weekday, a ticket open 12 h or more, and no POS token expiry or overload burst at the store in the last 7 dayscs v4, Head of CSOne storeDaily order aggregates 1 day; tickets 10 min; token expiries and order stages, last 7 daysflag_churn_risk1
overdue_invoiceBatch, daily at 08:00: a positive invoice unpaid 45 days past duefinance v1, CFOOne merchantInvoices 1 day; orders in the last 7 daysescalate_finance1

Second variants no detector runs, still answered by Ask: acceptance_risk cs v1 (Head of CS), churn_risk finance v1 (CFO), overdue_invoice cs v1 (Head of CS), overloaded_branch cs v1 (Head of CS), menu_drift integrations v3 (Head of Integrations). The other terms (active client, GMV, late order, onboarded merchant, stage gap) answer questions and never trigger an action.

3.3 Compiled twice

  • Business terms and batch checks (active client, GMV, late order, onboarded merchant, stage gap, overdue invoice, churn risk, acceptance risk, release regression) run as batch SQL on the cold store, on the policy's cadence: acceptance and release checks hourly, invoices and churn daily at 08:00.
  • Real-time terms compile to stream jobs from three templates: absence (an opening event with no closing event in time), windowed rate (failures over orders in a sliding window) and comparison (two states that should agree, such as the channel menu and the POS menu).
  • The SQL stays the reference. A parity test replays a recorded day through both forms and requires the same subjects, with start times within 30 seconds (the SQL side's replay step). A stream form that disagrees with its SQL does not ship.

3.4 Ambiguity

Canon never picks a meaning silently. A term with several variants and no canonical one is answered with every variant, owner and number, plus the gaps between them, as the repo's Ask does. Detectors never run an ambiguous term: each names its variant or uses the canonical one. The model is told the definition and version behind the incident and cannot swap it.

3.5 Lineage

Every answer carries the term, variant, version, owner, parameters, period and tables read. Every ledger row carries the definition term and version, policy version, evidence, who decided, the call and the undo. Production adds the source position of each evidence fact (topic, partition and offset, or the feed's row key), with those events kept in the cold store as long as the row.

3.6 Changing a definition

ChangeWho approvesWhat re-runs firstWhat the approver sees
A parameter of a real-time term (stuck_seconds 90 to 75)The variant's owner, and the policy owner, because the agent acts on itTests; old and new version over the last 7 days; the ledger replayed in shadow on the new versionRows added and removed; actions that would have been added and removed; orders and money involved
The SQL or stream formThe variant's owner and a data engineerTests; the parity test; 24 hours beside the old version in shadowThe same, plus the diff
A new variantIts ownerTestsThe new number beside the existing ones; nothing else changes
Making one variant canonicalThe executive who owns the number (assumption: the CEO for active client)Every report that reads the termThe number before and after, and why
The policy: tiers, budgets, runbooksThe policy owner (COO)Last week's ledger replayed in shadow under the new policyActions that would change tier or be blocked

Definitions and policy live in git: a change is a reviewed commit, and CI runs the third column. The repo has the layer underneath: each parameter edit bumps the version of the variants that use it, and each canonical choice is recorded; both go to a history file with author and reason, and what_changes() lists the rows a parameter change adds and drops before it is committed. It has no approval step. In production, rollback switches the acting version back, and the previous version runs in shadow for 24 hours after each change.

3.7 Business term: active client

Finance, Growth and Ops count clients differently. The repo keeps all three at version 1, with canonical: null:

VariantOwnerA merchant counts whenDefault period
financeCFOAt least one invoice for a positive amount was issued in the period. Credit notes alone do not countThis month
growthCGOA contract is signed and not churned at the time asked. Includes merchants still onboardingAs of now
opsCOOIts first order has arrived, and it had at least one order in the period (daily aggregates plus order-level rows)Last 30 days
-- finance (active_client.yaml)
WHERE i.issued_at >= {period_start} AND i.issued_at < {period_end} AND i.amount > 0
-- growth
WHERE m.contract_signed_at <= {now} AND (m.churned_at IS NULL OR m.churned_at > {now})
-- ops: orders from the daily aggregates plus the order-level rows
WHERE m.first_order_at IS NOT NULL AND m.first_order_at < {period_end}
  AND (daily.merchant_id IS NOT NULL OR live.merchant_id IS NOT NULL)

The three are stages of one pipeline, from contract to pilot store to handover. Growth counts from the signature, Ops from the first order, Finance from the first positive invoice; the inactivity fee means a store that stopped trading can still be invoiced. So "how many active clients" gets three numbers and four gaps: signed but not live, billed but not ordering, live but quiet (no order in 30 days, not billed), ordering but not billed. The repo's stage_gap term lists the gaps by merchant (130, 14, 6 and 9 in the seed-23 synthetic world, all generator settings). Making one variant canonical is recorded with author and reason; it bumps no version, and every answer names the variant it used (3.5).

3.8 Real-time term: stuck order

One variant, owned by the Head of Integrations, at version 2 (the change in version 2: the token state is read from the auth event stream as of the time asked). Parameters: stuck_seconds: 90, high_after_seconds: 600. An order is stuck when it was accepted at least 90 seconds ago and is not in the POS, not cancelled or rejected, and not dine-in. Severity is high when its POS token has expired or the refresh failed, or after 600 seconds.

WHERE o.accepted_at >= {period_start} AND o.accepted_at < {period_end}
  AND o.accepted_at <= {now} - INTERVAL ({stuck_seconds}) SECOND
  AND (o.injected_at IS NULL OR o.injected_at > {now})
  AND c.kind <> 'dine_in'
  AND NOT (o.status IN ('cancelled', 'rejected') AND coalesce(x.closed_at, o.placed_at) <= {now})

The evidence row names the order, store, integration, channel, wait, POS vendor and version, token state, failed attempts and last error; the controller checks targets against it. The stream form uses the absence template:

stream:                                   # production addition, not in the repo
  template: absence
  key: pos_integration                    # orders re-keyed from store to its POS integration
  lookup: store_to_integration            # broadcast table: store id -> POS integration id
  opens_on: order.accepted                # processing-time timer at accepted_at + stuck_seconds + allowance
  closes_on: [pos.injected, order.cancelled, order.rejected]
  skip_when: {channel_kind: dine_in}
  state: integration_auth_state           # latest auth event, held under the same key
  raise_severity: [auth_expired, refresh_failed, {age_seconds: high_after_seconds}]
  inputs: {order.accepted: 5s, pos.injected: 5s, integration_auth_state: 5m}

The key is the POS integration, not the order, because keyed state cannot read another key's state. Ninety seconds is a setting, not a platform fact: it equals the manual accept window but measures accepted to injected. The production version must also read deliveryplan and measure a SCHEDULED order from its requested time (assumption: such orders reach the POS nearer that time).

4. Decision model

4.1 From incident to proposal

Alarms with one cause are merged first, so one cause gets one action. In the repo, stuck orders behind one POS integration with a recent auth event, or three stuck within 10 minutes, become one pos_link_down incident re-sent as a batch; alarms on an aggregator that is down in a market fold into the outage; a bad POS release is one incident for all its stores.

Each incident kind has a runbook in the policy: the allowed actions, in order. For a stuck order: refresh the POS token, re-send the order, re-send a batch, escalate to the POS partner, draft a store message. Where the kind's runbook leaves one action at tier 0 or 1, a rule fills it in and no model is called. Stuck orders, a POS link down and an expiring token are always rules.

The model is asked when two or more actions are possible, or when a person may review the decision. It gets the incident, its evidence, the definition and version, and for each allowed action its tier, description, argument names and limits (maximum minutes, minimum error rate, allowed values, when a person must decide). It returns JSON: action, arguments, a short reason, a confidence. If the answer is missing, late or malformed, uses an action or argument not offered, or names an id not in the evidence, the rule's proposal is used instead, and the ledger records which path decided.

4.2 The controller

The controller trusts no proposal, from a rule or a model. It checks in this order and stops at the first failure:

#CheckFails whenResult
1AuthorityNot in the policy, on the never-list, or tier 3Declined. An action the policy does not name never runs
2RunbookNot listed for this incident kindDeclined
3ArgumentsUnknown names, values outside the allowed set, or a target the detector's evidence does not nameDeclined. The model cannot introduce a store
4ConditionsRequired evidence missing or false (a channel pauses only when 20% or more of its orders fail)Declined
5Production adds, before anything is queuedEvidence past its age bound; inside a maintenance window; kill switch onDeclined, so nobody is asked to approve on stale evidence
6Ask-first, tier 2A pause over 30 minutes, a dish back on, a message to a POS vendor, a store offline, a full menu pushQueued for a person
7CooldownSame action on the same target too recentlyDeclined; the repeat folds into the earlier row
8BudgetsA store's hourly budget used up; the platform-wide per-minute limit reachedQueued with the reason; or waits for the next minute
9Production addsBlast radiusOne queue item for the market or brand
TierMeaningActions in policy version 3
0Act, record the evidence, undo is available (a re-send has none at the POS, so a read-back comes first)Refresh a POS token; re-send an order or a batch; print on the backup printer; busy mode for up to 15 minutes; switch one item off
1Act and tell the roles named in the policyPause or resume a channel; draft a store message, a support ticket or a partner escalation; flag a churn risk; hand an overdue invoice to finance
2A person decidesFull menu push; store offline or online; any tier 0 or 1 action caught by an ask-first rule or a used-up budget
3NeverRefunds, menu prices, commission, contracts, credit notes, messages to a restaurant group's management or to guests, deleting data, and anything not listed
LimitPolicy version 3Production
Automatic service actions per store6 per rolling hourSame
Automatic service actions across the platform30 per minutePer market (assumption: 15 per market per minute)
Back-office hand-overs (invoices, churn, acceptance, releases)Own pool of 60 per minute; never uses the service budgetSame
Re-sends3 tries per order; 3 single re-sends per store per hour; up to 50 orders per store per hour in batches after a token refreshPlus 300 orders per market per minute
The same problem on the same subjectHandled once per 15 minutesSame
CooldownsPer action, from 2 minutes to 24 hoursSame
Blast radiusNoneA store-facing action (busy mode, item off, channel pause) on more than 10 stores of one market in a minute, or on more than 5% of one brand's stores in 15 minutes, stops and becomes one queue item for the market or brand (assumption values)

When many stores need the same action at once, the cause is usually upstream (an aggregator, a POS release, the platform itself), so the blast-radius rule hands the batch to a person as one decision.

4.3 Adapters on the platform's calls

ActionWhat Canon callsDocumentedIdempotent byUndo
Re-send an order, or a batchThe platform's relay to the POS (POST /orders to the POS), run again for the order. Not createorder, which is the call an aggregator makes into the platformThe relay, yes (order-integration docs); a re-send Canon can trigger, no (assumption)uniqueorderid as the key (assumption); a synchronous read-back from the POS; the POS dropping a repeated order id (assumption)None at the POS: the ledger records it, and a double order is cancelled at the POS by the store (which is why the read-back comes first)
Busy modePUT /stores/{storeId}/working-mode with busyUntilThe endpoint, yes (Store Management API); its direction, to verifyPUT; it ends at busyUntil (assumption: the platform lapses it)Restore the previous mode
Store offline or onlinePUT /stores/{storeId}/statusThe endpoint, yes; its direction, to verifyPUTRestore the previous status
Switch an item offTriggers the platform's availability push, which calls updateitemavailability on each aggregator; the trigger would be the POS suite's Update item availability call (assumption: Canon may act as a POS app)The push and the POS call, yes; Canon using them, noSetting a valueSwitch it on (tier 2: it may still be sold out)
Full menu pushTriggers the platform's full-menu push, which calls savemenu on each aggregator, a full overwrite; the docs describe the push as manualThe push, yes; an API trigger, no (assumption)Same payload, same resultPush the saved previous menu
Refresh a POS tokenThe repo names POST /integrations/{id}/token/refreshNo (assumption)One refresh in flight per integrationNot needed
Pause or resume a channelThe repo names PUT /stores/{storeId}/channels/{channelId}/statusNo (assumption)PUTResume
Print on the backup printerThe platform's KDS print routing (assumption: not in the public API)No (assumption)Once per incidentRoute printing back to the main printer
Drafts, flagsInternalNot applicableOnce per incidentCancel the draft, withdraw the flag

Where an assumed call does not exist, the action becomes a queue item with steps for a person. The platform documents only generic HTTP codes, so adapters retry only 429, 5xx and timeouts, with bounded backoff, and never retry a POST without a read-back. A circuit breaker per verb raises one platform incident instead of one per store. Deprecated endpoints live three months, so adapters pin an API version and run contract tests nightly and on every change against the developer portal's sandbox, UAT, starter account (a sample brand, menu and location) and Virtual KDS (assumption: they expose these verbs).

4.4 Ledger

Every proposal, decision, call, approval and rollback is a row, and nothing is updated or deleted: a status change is a new row pointing at its parent, approvals have their own table, and repeated declines inside a cooldown go to a side table. A row holds the evidence, definition and policy versions, who decided and at what token cost, the call, the result and the undo. In production it is Postgres, with no UPDATE or DELETE grant for the application, a unique key on incident, action, target and attempt number over the rows that start a call (a partial unique index; status rows point at that row and are not constrained), a hash of the previous row in each row, and an hourly copy to the cold store.

4.5 People

Tier 2 items go to a queue, and during the evening shift each also notifies the on-call in one line (the repo drafts it; production pages). A person can approve, approve a narrower version (offline for 45 minutes instead of 240), reject with a note, or roll back from the recorded before-state; a rejection or rollback is final for that incident. "Approve and remember" also files a policy suggestion (this kind, this action, cases like this, tier 2 to 1) for the policy owner and never changes the policy. A kind with no runbook goes on a process backlog with a runbook drafted from past human decisions; the agent does nothing until a person writes it.

Production adds single sign-on for approvers, a deadline per queue item (an offline proposal for an overloaded kitchen is useless after 20 minutes; expired means "not done"), and drafted tickets tagged with the platform's published support priorities (assumption: P1 for a market-wide outage, P2 for one store's integration, P5 for menu and configuration).

4.6 State machines

An incident. In the repo these states are implicit in ledger rows; production makes them explicit and closes an incident when the detector stops returning it.

stateDiagram-v2
    [*] --> Detected
    Detected --> Dropped: duplicate, or inside the dedupe window
    Detected --> Folded: same cause already open
    Detected --> Proposed: new
    Proposed --> Declined: controller refuses
    Proposed --> Queued: needs a person
    Proposed --> Acting: allowed
    Queued --> Acting: approved
    Queued --> Closed: rejected or expired
    Acting --> Watching: call succeeded
    Acting --> Queued: call failed, follow-up needs a person
    Watching --> Resolved: detector no longer returns it
    Watching --> Proposed: still true after the cooldown
    Watching --> Closed: a person rolled it back
    Declined --> Proposed: still true after the dedupe window
    Declined --> Resolved: detector no longer returns it
    Folded --> Closed: parent incident closes
    Resolved --> Closed
    Dropped --> [*]
    Closed --> [*]

An action. A successful action may start one follow-up (then: re-send the orders behind a refreshed token) and a failed one its fallback (if_it_fails: escalate to the POS partner); either enters as a new proposal. "Expired" is a production addition.

stateDiagram-v2
    [*] --> Proposed
    Proposed --> Declined: unknown, never, off runbook, bad arguments, condition, stale evidence or maintenance or kill switch (production), cooldown
    Proposed --> Waiting: platform rate limit
    Waiting --> Proposed: next minute
    Proposed --> Queued: tier 2, ask-first rule, budget used up, blast radius (production)
    Proposed --> Allowed: tier 0 or 1 within budget
    Queued --> Allowed: approved
    Queued --> Rejected: rejected
    Queued --> Expired: deadline passed
    Allowed --> Shadow: shadow mode
    Allowed --> Calling: live
    Calling --> Done: success, or read-back confirms
    Calling --> Failed: final error
    Done --> RolledBack: a person undoes it
    Done --> [*]
    Failed --> [*]
    Declined --> [*]
    Rejected --> [*]
    Expired --> [*]
    Shadow --> [*]
    RolledBack --> [*]

4.7 Who holds which keys

ProcessHoldsCanCannot
Proposer: rules and the model clientThe model gateway keyRead evidence; write a JSON proposalReach the platform's APIs; write the ledger
Controller, with the adapters inside itThe platform credential, through a scoping gateway (below); the policy, read-onlyDecide; call the platform; write decision rowsEdit the policy or the definitions
Operator interfaceApprover identitiesApprove, reject, roll backEdit the policy or the definitions

The documented X-API-KEY has no scopes, and the OAuth 2.0 pages read describe an app-install flow (assumption: no client-credentials grant or scopes; the install and refresh pages were not read in full). So a gateway holds the key and passes only the policy's verbs for one market: ideally the platform's (assumption: none exists), until then Canon's, as the only process holding the key.

One controller is active, with a standby holding a lease in the ledger database. Decisions are serial, so no race overspends a budget (the repo also decides on one thread). The model holds nothing because it can be wrong, can be misled by free text in its evidence (ticket bodies, error payloads, dish names), and can be replaced next month; none of that widens authority. The controller ignores the reasoning and checks only whether the action is permitted for this incident, on targets the detector named. In the repo the controller is a separate class in the same process and the model has no tools; separate processes and credentials are production work.

5. Scale and latency

5.1 Volume

ValueBasis
Orders per day350,000The platform's published figure
Average rate4 orders/s350,000 / 86,400
Friday dinner peak15-19 orders/sAssumption: 30-40% of a day's 350,000 orders (no Friday uplift) in a three-hour Gulf dinner peak, markets an hour apart, the busiest 15 minutes at 1.5 times that: 350,000 x 0.30-0.40 / 10,800 s x 1.5
Burst200 orders/s for a few minutesAssumption: an aggregator that comes back after an outage releases its queue at once
Events per orderAbout 15Status changes, KOT updates, delivery-job updates, POS log lines (assumption)
Events per second225-290 at peak; about 3,000 in a burst15 x 15-19; 15 x 200
Raw volumeAbout 5 GB a day at 1 KB per event; about 0.6 GB a day as compressed ParquetAssumption: 1 KB per event, about 9x Parquet compression; 4 orders/s x 15 events x 86,400 s
Locations14,000+ activeThe platform's published figure. A store is one brand at one location, so there are at least as many stores

None of this is large; components are sized for availability, retention and bursts.

5.2 Latency budget

For rule-decided stuck orders, the only measured number is the repo's: a 63 s mean from the tick an order behind a dead POS link is first seen stuck to its re-send (README, seed 23; the wait before detection is not counted). It reflects the replay, not processing: the clock moves in ticks (30 s on the Friday evening, 5 minutes on the days before) and nothing happens between them; a store gets one batch per 5 minutes at most (BATCH_EVERY in canon/agent/linkdown.py), so orders stuck in between wait; and when the token is the cause, the first batch waits for the refresh step. Production replaces the first two with timers and a per-store lock; the third stays, hence the refresh row.

StepRepo replayProduction target, p99 (assumption)
Seen stuck after the 90-second markAt the next tick (not counted in the 63 s)Lateness allowance: the measured p99 lag of the slowest closing input (gData CDC, the POS log stream) plus clock skew, and at least that input's age bound (5 s; with a platform event stream the age bound of pos.injected, and so this floor, could be set lower, assumption)
Rule proposal, controller checks, ledger writeWithin the tickUnder 0.1 s
Token refresh, when the token is the causeIn the same step as the batch when it succeeds; when the vendor refused it, the repo escalates and re-sends anyway (the re-send skips orders the POS has)3 s (no published figure)
Batch spacing per storeAt most one batch per 5 minutesNone: one batch in flight per store
Read-back from the POSA query on the world1 s
The platform's POS relay re-sendA write to the world1 s
To the re-send63 s mean from first seen stuck, measured (canon/recal/metrics.py; the wait before detection is not counted)About 7 s from the 90-second mark at a 5 s allowance without a refresh, about 10 s with one; a larger measured lag adds to both

The production column is a target for gate 1 to test, not a measurement. Model-decided kinds stay off this path: the median call took 5.6 seconds (p95 13.0 s) in the seed-23 Friday replay, and the client gives up at 20.

5.3 Hot state size

By Little's law, about 50 minutes from acceptance to delivery (assumption) at 4 orders a second is 12,000 open orders on average, about 57,000 at 19 a second: under 60 MB at 1 KB each. Per-store and per-integration aggregates (assumption: 20,000 stores on 14,000+ locations, about 1.5 integrations each, 60 one-minute buckets) are under 150 MB. Item availability (assumption: 150 items on 4 channels) is about 12 million rows, roughly 500 MB, in the stream job's disk-backed state; Redis holds only the hash per store and channel. It all fits in one machine's memory; the second Redis node is for availability.

5.4 Model calls

ValueBasis
Detections a day, syntheticAbout 110 on average; 435 on the Friday evening aloneSeed 23: 774 in 7 days on 4,700 stores, 435 of them in the four Friday hours at 30 s steps (the other days ran at 5-minute steps and see fewer)
Detections a day at the platform's scaleAbout 470Scaled from 4,700 to 20,000 stores (5.3's assumption); 14,000+ locations alone give at least 330
Model share of decisionsAbout a thirdSeed 23, Friday evening: 95 by rules, 50 by the model
Model calls a dayAbout 1,400Seed 23, Friday evening: 129 gateway calls against 52 ledger rows that asked the model (50 decided by it, 2 invalid answers the rule replaced; the other 77 left no row and their cause is not traced, so sizing uses calls); about 550 at 20,000 stores; that evening taken as 40% of a day's calls (assumption)
Sizing1,400-7,000 a dayUp to five times that for a noisier live feed (assumption, measured in shadow)
Throughput capAbout 21 a minute at the median, 9 at p952 calls in flight (the repo's default) at 5.6 s median and 13.0 s p95 (seed 23); the peak need is about 3 a minute at 1,400 a day and 16 at 7,000 (40% in three hours), so the high end needs 4 in flight

Each call has a 20-second timeout with one retry, fallback models in order, and a 60-second pause after three calls in which every model failed. In the repo a call over the cap waits for a free slot, and a call that times out or fails is recorded as unavailable before the rule decides. In production, stuck orders, a POS link down and an expiring token are rule-decided and never wait for a model (in the repo a tick's decisions wait until that tick's model calls return, which the replay's logical clock hides); for other kinds, a late or down model costs the timeout and one retry per configured model (40 s with a single model) before the rule decides, until the breaker opens.

5.5 One region

The gData docs name only AWS eu-west-2 (London), so the feed lands there for every market (assumption until the platform confirms), and in-country partitions would not keep order data in-country: it leaves before Canon sees it. Canon runs one region there; markets keep their own gateway scopes, budgets, kill switches and queue. A second region pays only once a market's rules require in-country processing and the platform delivers that market's feed in-country (assumption). A failed bus or stream job stops automatic actions with one notice ("no fresh data since 19:42; no automatic actions"); section 7 has the rest.

5.6 Cost per month

ItemRange
Bus: 3 brokers, 4 vCPU and 16 GB each, 500 GB disk$700-1,200
Stream jobs: the keyed consumer service on 3 nodes$200-500
Redis: 2 nodes$200-400
Ledger: Postgres with a standby$300-600
Controller, proposer, adapters, interface$200-500
Object storage and batch jobs$50-200
Monitoring and logs$200-500
One region$1,850-3,900
Flink instead of the keyed consumer: a job manager and 3 task managers$600-1,000 more
A second region, only if a market requires oneThe same again
Model calls: 1,400-7,000 a day at $0.0005-0.01 each, 30 days$21-2,100

Assumptions: on-demand eu-west-2 prices for self-run components, no reserved capacity, 2,500 input and 200 output tokens per model call. Managed Kafka and managed Flink cost roughly 1.5 to 2 times the self-run lines. The team in section 9 costs more than all of it.

6. Measurement and rollout

6.1 Shadow mode

Every tier 0 and tier 1 decision is written as "would do", with its evidence and call, and nothing runs; approvals are recorded, not executed. The repo has the switch (CANON_SHADOW=1). A row ops would have reverted means the tier or the definition is wrong.

6.2 Labels in production

Production has no answer key. Labels come from people, arrive late, and each source has a known bias.

Label sourceWhat it tellsDelayBias
Ops review of a random sample of ledger rows, shadow rows includedPrecision per detector; whether the action was rightSame dayLowest; review blind, labelling from the raw facts before seeing Canon's decision
Approvals and rejections in the queueWhether tier 2 proposals were rightMinutesCovers only tier 2
RevertsWrong actionsMinutes to hoursUndercounts: people revert what they notice
Support tickets matched to incidentsMisses, for recallHours to daysRecall reads high: tickets exist only for problems someone noticed
Planned faults on the developer portal's test setup (sandbox, UAT, starter account, Virtual KDS)Recall and end-to-end latency on the platform's own systemsImmediateOnly kinds that can be staged there

6.3 Metrics

MetricHow
Precision per detector per dayConfirmed rows over reviewed rows, with a 95% interval; at small samples the interval matters more than the point
Recall per detector per weekProblems found by people or by planned faults that Canon also found
Auto rate, revert ratePer action
Time to detect, time to fixProblem start to the first ledger row; first row to the resolving action; per kind
Cost per resolved incidentModel spend plus approver minutes
FreshnessShare of peak minutes each input was within its bound
DriftThe repo's alarms (invalid model answers at 25% or more is red; a 10-point rise in that rate, confidence falling 0.05 per bucket, or 20% of executed actions reverted is amber), plus a detector's daily count leaving its tested band, new values on the stream, and freshness slipping

6.4 Gates

GateScopeModeTo pass (starting values, set with ops)
1UAE: stuck order, token expiring and failing integration as stream forms; the other detectors as their reference SQL, polled on the cold storeShadow, two weeksFeed lag and freshness within bounds in 99% of Friday-peak minutes for the stream forms' inputs (the polled detectors are judged on reviewed precision only, with their inputs' age recorded); parity tests green for the three stream forms; reviewed precision known per detector, with its interval; on-call answered test approvals within 10 minutes at dinner
2UAE, POS token refresh plus re-send of the waiting ordersTier 0, live, two weeksThat kind's reviewed shadow precision at 95% or more; no row with a wrong target; contract tests green; kill switch tested live
3UAE, more kinds one at a time: busy mode, item off, backup printerA week in shadow, then live, per kindThe same, per kind; revert rate under 5%
4KSAIn the same region, gates 1 to 3 again for its marketThe same

Token refresh goes first: rule-decided, invisible to guests, harmless when wrong. The re-send behind it is not (a double order reaches the kitchen), so it ships with the synchronous read-back, only for POS vendors confirmed to drop a repeated order id.

6.5 What the first two weeks need from the platform

  1. gData pointed at Canon's Postgres in eu-west-2, which whitelists the platform's four sending addresses, and a history sync. No access to the platform's own databases.
  2. The facts the feed does not carry: POS injection results, token expiry and auth events, store status and working mode, item availability per channel. An export or read-only API is enough for shadow. Without them a stuck order can only be approximated as "accepted, with no KOT RECEIVED after 90 seconds", which holds only where the platform's own kitchen system receives the ticket (assumption).
  3. No write credentials during shadow. Before gate 2, a write credential (an unscoped X-API-KEY), which the gateway (4.7) limits to one market, the token refresh and the POS relay re-send.
  4. A named on-call per dinner shift who answers queue items, and an ops reviewer for 30 to 60 minutes a day.
  5. A ticket export for labels, the maintenance calendar, and what each aggregator supports for busy mode.

7. Failure modes and safety

FailureHow Canon noticesWhat it does
Bus or stream jobs downHeartbeats and offsets stopNo tier 0 or 1 action; one notice; after recovery the detectors re-read current state and act only on what is still true
Hot state staleas_of older than a definition's boundThe detector reports "blind"; the controller declines actions on that evidence
Model down or slowTimeouts; the breaker opensRules decide; the ledger records the model as unavailable; rule-decided kinds are unaffected, and other kinds lose the timeout and one retry per configured model before the rule acts, until the breaker opens
Platform API downAdapter errorsIn production: the verb's breaker opens; one platform incident; queued items marked as waiting for the API
Credential leakFound out of bandRevoke the key; adapters fail closed; the ledger lists every call made with it; the model held none. An unscoped key makes this the worst case, hence the gateway
Runaway action loopBudgets and cooldowns are hitA used-up store budget goes to a person; the platform-wide limit makes actions wait; tries per order, once per incident and human overrides stop repeats; in production a kill switch turns everything to shadow
Wrong definition deployedA detector's count leaves its tested band (in the repo only the replay-time at: bands were set from seed 7's counts, TUNING.md; the other bands are loose sanity bounds, for example late_order's 10 to 15,000, and would be re-derived from prior-week quantiles in shadow); reverts riseIn production the detector drops to shadow by itself and rollback switches the acting version back; every affected row names its version
Aggregator or platform API changeA contract test fails; an unknown status or field appears on the streamIn production that adapter or detector drops to shadow; integrations is paged
Planned maintenance, 02:00-06:00 UAEIn production, the maintenance calendarNo calls to platform APIs; detectors keep running; release checks after 06:00 (the synthetic world's bad POS release rolled out in that window)
Duplicated or replayed eventsThe source position was seen beforeDropped at ingestion; same incident id; the ledger's unique action key (production)

Automation may reduce its own authority without asking, by dropping a detector, a kind or a market to shadow. It may never increase it; only the policy owner can.

Never automated: money, contracts and messages to clients. These are tier 3 (4.2), and the never-list wins even if one is added to the actions by mistake. Detectors may find such cases (churn risk, an overdue invoice); Canon hands them to the named team with the evidence. A wrong busy mode costs minutes of orders and is undone; a wrong message to a client's finance director cannot be unsent, and a refund moves money. A store offline sits between: guests see it, so it is tier 2.

8. Reference implementation and production

What exists: a Python reference implementation on a synthetic world of 4,700 stores in 7 markets with a week of full orders (about 650,000), 17 definitions, 13 detector specs, policy version 3, the controller, the ledger, the approval queue, shadow mode, accuracy reports and an operator interface. It reads one DuckDB file on a clock tick. Nothing streams and nothing calls a real API. Its accuracy figures are scored against the generator's answer key, with thresholds tuned on seeds 7 and 11 and results reported on seed 23 (canon/dictionary/TUNING.md): over seven replayed days, 249 injected incidents, 774 detections, precision 98%, recall 99%.

ComponentWhere the repo demonstrates itStatusGap to production
Sources and ingestioncanon/world/ generator, canon/world/schema.sqlSynthetic onlygData reader (CDC or poller), integration-log and store-state readers; none written
Event busNone; detectors query tables each tickAbsentRedpanda, topics keyed by store
Real-time evaluationcanon/agent/detectors.py runs each definition's SQL per tick; cadence.py paces hourly and daily checksPolling, not streamingStream templates on the keyed consumer, processing-time absence timers, act horizon, parity tests
Hot stateDuckDB tablesNo separate storeRedis projection with as_of
Cold store and replayscripts/replay_evening.py, canon/agent/replay.py; 7 days of orders, 90 days of daily aggregatesOne fileParquet, retention
Freshnesscanon/world/systems.py (systems, fact sources, refresh cadence; the Map screen)Shown, not enforcedInput age bounds in definitions; "blind"; the controller's age check
Definitions as codecanon/dictionary/definitions/*.yaml, store.py, models.py, testing.pyOwner, version, history, parameters, SQL, testsstream, inputs, approvers
Ambiguity policyDictionary.ambiguous, canon/ask/answer.pyDoneNone
LineageLineage in canon/ask/models.py; ledger definition_term, definition_version, evidenceTo term and versionSource offsets per evidence fact
Changing a definitionDictionary.set_param, set_canonical, _history.jsonl, canon/ask/what_changes.pyHistory and row diff; no approvalReview in git, CI, side-by-side shadow, rollback by version switch
Grouping alarms by causecanon/agent/linkdown.py, canon/agent/outages.py, the release subject in detectors.pyDoneBlast-radius grouping per market and brand
Proposalcanon/agent/rules.py, workers.py, llm.py, parallel.pyDone; the model is optionalThroughput cap from concurrency
Controllercanon/agent/controller.py, policy.py, policy.yamlTiers, runbooks, arguments, targets, conditions, ask-first rules, cooldowns, two budget pools, never-list, unknown actions declinedOwn process and credentials; evidence age, maintenance calendar and a runtime kill switch before the ask-first check; blast radius
Action adapterscanon/agent/tools/, endpoints.py: change the synthetic world and label the callNo real callsAdapters, sandbox contract tests, breakers, a gateway that scopes the platform key
Ledgercanon/ledger/api.py, canon/ledger/schema.sqlAppend-only by API; approvals; repeats; rollback; primary keys onlyAppend-only by database grants; unique key on incident, action, target and attempt; hash chain; Postgres
Human loopapp/routes/queue.py, app/real_services.py (approve and remember), handling.py (on-call draft), canon/agent/gaps.py; humans.yaml scripts a synthetic shiftDone, with a demo identitySingle sign-on, paging, deadlines, support-priority mapping
Shadow modeCANON_SHADOW=1 in canon/agent/handling.py and canon/ledger/api.pyDone, set at start-upSwitch per kind and per market at runtime
Measurementcanon/recal/metrics.py, matching.py, drift.pyAgainst the generator's answer keyProduction labels, sampling, planned faults, input drift
Money at stakecanon/agent/stakes.py, with rates from one table in canon/fx.py (AED per unit)Fixed demo exchange ratesReal rates
RegionsNoneAbsentOne region next to the feed; markets as keys, budgets and switches
Money, contracts and messages to clientsnever: in policy.yamlDoneNone

9. The first 90 days

One metric ties the plan together: for stuck orders in the UAE, the minutes from the 90-second mark until the order is in the POS or a person has taken it over. Its baseline is measured in the shadow weeks, when Canon watches and does not act.

MilestoneDaysDeliversDone when
1. Shadow in the UAE1-45Day 1: the gData destination and history sync requested. Feed reader; Redpanda, the keyed stream service, Redis and a Postgres ledger; stream forms of stuck order, token expiring and failing integration, with parity tests; the other detectors' reference SQL polled on the cold store; the controller as its own process, in shadow; a daily review screen for ops. Gate 1's two shadow weeks run on days 31-44Gate 1 passed; the metric's baseline measured
2. First live kind46-75Adapters for the POS relay re-send and the token refresh, with contract tests; the key gateway; kill switch; on-call paging; stream forms of overloaded store and menu drift, in shadow, which are the shadow weeks of busy mode and item switch-off (days 61-75). Gate 2's two live weeksGate 2 passed; the metric reported against its baseline; no action on a wrong target
3. More kinds, a second market76-90Busy mode and item switch-off, whose shadow weeks run in milestone 2 (days 61-75), go live one at a time through gate 3; the backup printer after day 90; KSA in shadow in the same region; definition changes through review and CI; a recorded leadership decision on active client, one canonical variant or three on purposeBusy mode and item switch-off live in the UAE beside the gate-2 kind; KSA in its shadow weeks; one definition change made through the procedure

The dates assume the feed flows by week two and write credentials arrive by day 46 (assumption); a slip in either moves what follows.

WhoRoleTime
Lead engineerController, policy, ledgerFull time
Data engineerIngestion, bus, stream jobs, parity testsFull time
Platform engineerAdapters, the key gateway, on-call toolingFull time
Ops reviewer, from the platformReviews the sample, labels rowsAbout an hour a day
On-call, from the platformAnswers queue items within 10 minutes at dinnerEach dinner shift
gData contact, from the platformPoints gData at Canon's database; runs the history syncWeek one
Integrations engineer, from the platformSandbox, contract testsAbout two days a month
Definition ownersApprove definition changesAbout an hour a week each