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.
- Definitions say what is true. A stuck order is what
stuck_orderversion 2 says it is, and every detection carries that version. Each definition names its owner. - 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.
- 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.
| Source | What it gives | How 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, Completed | gData 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 logs | Injection attempts and errors, token refreshes and expiries, POS version upgrades, print results | Not in the feed. A log stream or export from the integration service |
| Menu service | Items, modifiers, availability and mapping per store and channel | gData's menu tables and item-availability log, plus a full reconciliation every 15 minutes |
| Store management | Store status (online, offline), working mode (busy, busyUntil), working hours, channel listing status | Change events; otherwise a poll every 30 s |
| Dispatch | Delivery jobs: DELIVERY_JOB_CREATED, DRIVER_ASSIGNED, DRIVER_ARRIVED, DELIVERY_JOB_STARTED, DELIVERY_JOB_COMPLETED, DELIVERY_JOB_CANCELLED | The Delivery Manager API's status webhook, or an export |
| Support desk | Tickets with category, store, open and close times | Every 5 minutes into the cold store; also labels |
| CRM | Contract signed, onboarding stage, churn, account owner | Hourly into the cold store |
| Billing | Invoices, amounts, due and paid dates | Daily 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:
| Key | Fields |
|---|---|
| Store | Open orders by stage, with timestamps; open orders against capacity per 15 minutes; status per channel; working mode and busyUntil |
| POS integration | Injection 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 channel | Menu hash; items switched off; last successful menu sync |
| Every key | as_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
| Fact | Source | Maximum age at peak | Read by |
|---|---|---|---|
| Order placed, accepted, rejected, cancelled | Order feed | 5 s | stuck_order, failing_integration, menu_drift, duplicate_order, overloaded_branch, acceptance_risk |
| Orders per market and channel | Order feed | 60 s | aggregator_outage |
| Order injected into the POS | POS logs | 5 s | stuck_order, failing_integration |
| Injection errors | POS logs | 10 s | failing_integration, release_regression |
| POS version upgrades | POS logs | 15 min | release_regression |
| Token expiry, last auth event | POS logs | 5 min (the warning window is 30) | pos_token_expiring, stuck_order, pos_link_down, failing_integration, churn_risk (last 7 days) |
| Print results, printer status | POS or KDS logs (assumption) | 30 s | offline_printer |
| Item availability per channel | Menu service | 60 s (the docs set 5 s for an availability push to apply) | menu_drift |
| Full menu hash, menu syncs, POS change log | Menu service, POS logs | 15 min | menu_drift |
| Store status, working mode | Store management | 30 s | overloaded_branch, every store-facing action |
| Ready, picked up, delivery job status | Order feed, Dispatch | 30 s | courier_no_show |
| Tickets | Support desk | 10 min | churn_risk, labels |
| Contract, onboarding, churn | CRM | 1 h | active_client (growth), churn_risk |
| Invoices | Billing | 1 day | active_client (finance), overdue_invoice |
| Daily order aggregates, orders in the last 7 days | Order feed, batch | 1 day | churn_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
| Problem | Handling |
|---|---|
| Event time | Detectors 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 events | Still 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 order | Stages are monotonic (placed, accepted, injected, ready, picked up, delivered). A late status fills in history and never moves an order back |
| Backlogs | Act 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 |
| Keys | Event: 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) |
| Redelivery | An 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 ids | Same 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).
| Term | Form and trigger | Variant, version, owner | One incident is about | Inputs, maximum age | First action | Tier |
|---|---|---|---|---|---|---|
stuck_order | Stream, absence: accepted, not in the POS after stuck_seconds (90); high after 600 s or with a dead token | integrations v2, Head of Integrations | One order | Accepted, cancelled, rejected 5 s; injected 5 s; auth state 5 min | repush_order; refresh_pos_token first if the token expired | 0 |
pos_link_down | Grouping 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 min | Carries stuck_order v2 | One store's POS link, with its waiting orders | As stuck_order; auth events 5 min | refresh_pos_token if still expired, then repush_batch (up to 50 orders per store per hour) | 0 |
pos_token_expiring | Stream, absence: expiry within warning_minutes (30), no refresh since the window opened | integrations v2, Head of Integrations | One POS integration | Token expiry and refreshes 5 min | refresh_pos_token | 0 |
failing_integration | Stream, windowed rate: 3 or more failed orders and over 20% of orders in 60 min | ops v4, COO | One POS integration | Injection attempts and errors 10 s; accepted, injected 5 s; auth state 5 min | refresh_pos_token if the token expired; else switch the one mis-mapped item off, or pause the channel (tier 1) | 0 |
release_regression | Batch, 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 day | integrations v3, Head of Integrations | One POS release (vendor and version) | Injection results 10 s; version upgrades 15 min | open_partner_escalation to the vendor | 1, queued: a message to a POS vendor asks first |
overloaded_branch | Stream, comparison: open kitchen orders above capacity per 15 min | ops v1, COO | One store | Order stages 5 s; store status, working mode 30 s | set_working_mode_busy, at most 15 min | 0 |
offline_printer | Stream, absence: no print since 2 failed tickets, or since the printer reported offline; 3-hour look-back | integrations v2, Head of Integrations | One store's main printer | Print results, printer status 30 s | reroute_print_backup; without a backup, a store message (tier 1) | 0 |
menu_drift | Stream, comparison: 3 rejections for one item with no menu sync since (1 when the POS change log confirms) | ops v4, COO | One store and item | Rejections and reasons 5 s; availability per channel 60 s; menu syncs, POS change log 15 min | update_item_availability off; with no named item, a full menu push (tier 2) | 0 |
duplicate_order | Stream, windowed rate: same store, channel and total within 60 s, on a channel at a 0.4% pair rate or more | ops v2, COO | One order, the later copy | Placed orders 5 s | draft_message to the store | 1 |
courier_no_show | Stream, absence: ready 25 min ago, not picked up | ops v3, COO | One order | Ready, picked up, delivery job 30 s | draft_message: a logistics ticket to the delivery partner | 1 |
aggregator_outage | Stream, windowed rate: under 15% of the expected orders in 20 min, 20 or more expected, other channels at 60% or more | ops v1, COO | One market and channel | Orders per market and channel 60 s | open_partner_escalation to the delivery app; its stuck orders fold in | 1 |
acceptance_risk | Batch, hourly: under 98% over 7 days and getting worse, or projected to cross within 2 days | ops v2, COO | One store | Orders, rejections and reasons 5 s; the other open incidents that explain rejections | update_item_availability when one item dominates; else open_ticket_draft (tier 1) | 0 |
churn_risk | Batch, 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 days | cs v4, Head of CS | One store | Daily order aggregates 1 day; tickets 10 min; token expiries and order stages, last 7 days | flag_churn_risk | 1 |
overdue_invoice | Batch, daily at 08:00: a positive invoice unpaid 45 days past due | finance v1, CFO | One merchant | Invoices 1 day; orders in the last 7 days | escalate_finance | 1 |
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
| Change | Who approves | What re-runs first | What 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 it | Tests; old and new version over the last 7 days; the ledger replayed in shadow on the new version | Rows added and removed; actions that would have been added and removed; orders and money involved |
| The SQL or stream form | The variant's owner and a data engineer | Tests; the parity test; 24 hours beside the old version in shadow | The same, plus the diff |
| A new variant | Its owner | Tests | The new number beside the existing ones; nothing else changes |
| Making one variant canonical | The executive who owns the number (assumption: the CEO for active client) | Every report that reads the term | The number before and after, and why |
| The policy: tiers, budgets, runbooks | The policy owner (COO) | Last week's ledger replayed in shadow under the new policy | Actions 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:
| Variant | Owner | A merchant counts when | Default period |
|---|---|---|---|
| finance | CFO | At least one invoice for a positive amount was issued in the period. Credit notes alone do not count | This month |
| growth | CGO | A contract is signed and not churned at the time asked. Includes merchants still onboarding | As of now |
| ops | COO | Its 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:
| # | Check | Fails when | Result |
|---|---|---|---|
| 1 | Authority | Not in the policy, on the never-list, or tier 3 | Declined. An action the policy does not name never runs |
| 2 | Runbook | Not listed for this incident kind | Declined |
| 3 | Arguments | Unknown names, values outside the allowed set, or a target the detector's evidence does not name | Declined. The model cannot introduce a store |
| 4 | Conditions | Required evidence missing or false (a channel pauses only when 20% or more of its orders fail) | Declined |
| 5 | Production adds, before anything is queued | Evidence past its age bound; inside a maintenance window; kill switch on | Declined, so nobody is asked to approve on stale evidence |
| 6 | Ask-first, tier 2 | A pause over 30 minutes, a dish back on, a message to a POS vendor, a store offline, a full menu push | Queued for a person |
| 7 | Cooldown | Same action on the same target too recently | Declined; the repeat folds into the earlier row |
| 8 | Budgets | A store's hourly budget used up; the platform-wide per-minute limit reached | Queued with the reason; or waits for the next minute |
| 9 | Production adds | Blast radius | One queue item for the market or brand |
| Tier | Meaning | Actions in policy version 3 |
|---|---|---|
| 0 | Act, 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 |
| 1 | Act and tell the roles named in the policy | Pause 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 |
| 2 | A person decides | Full menu push; store offline or online; any tier 0 or 1 action caught by an ask-first rule or a used-up budget |
| 3 | Never | Refunds, menu prices, commission, contracts, credit notes, messages to a restaurant group's management or to guests, deleting data, and anything not listed |
| Limit | Policy version 3 | Production |
|---|---|---|
| Automatic service actions per store | 6 per rolling hour | Same |
| Automatic service actions across the platform | 30 per minute | Per 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 budget | Same |
| Re-sends | 3 tries per order; 3 single re-sends per store per hour; up to 50 orders per store per hour in batches after a token refresh | Plus 300 orders per market per minute |
| The same problem on the same subject | Handled once per 15 minutes | Same |
| Cooldowns | Per action, from 2 minutes to 24 hours | Same |
| Blast radius | None | A 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
| Action | What Canon calls | Documented | Idempotent by | Undo |
|---|---|---|---|---|
| Re-send an order, or a batch | The 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 platform | The 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 mode | PUT /stores/{storeId}/working-mode with busyUntil | The endpoint, yes (Store Management API); its direction, to verify | PUT; it ends at busyUntil (assumption: the platform lapses it) | Restore the previous mode |
| Store offline or online | PUT /stores/{storeId}/status | The endpoint, yes; its direction, to verify | PUT | Restore the previous status |
| Switch an item off | Triggers 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, no | Setting a value | Switch it on (tier 2: it may still be sold out) |
| Full menu push | Triggers the platform's full-menu push, which calls savemenu on each aggregator, a full overwrite; the docs describe the push as manual | The push, yes; an API trigger, no (assumption) | Same payload, same result | Push the saved previous menu |
| Refresh a POS token | The repo names POST /integrations/{id}/token/refresh | No (assumption) | One refresh in flight per integration | Not needed |
| Pause or resume a channel | The repo names PUT /stores/{storeId}/channels/{channelId}/status | No (assumption) | PUT | Resume |
| Print on the backup printer | The platform's KDS print routing (assumption: not in the public API) | No (assumption) | Once per incident | Route printing back to the main printer |
| Drafts, flags | Internal | Not applicable | Once per incident | Cancel 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
| Process | Holds | Can | Cannot |
|---|---|---|---|
| Proposer: rules and the model client | The model gateway key | Read evidence; write a JSON proposal | Reach the platform's APIs; write the ledger |
| Controller, with the adapters inside it | The platform credential, through a scoping gateway (below); the policy, read-only | Decide; call the platform; write decision rows | Edit the policy or the definitions |
| Operator interface | Approver identities | Approve, reject, roll back | Edit 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
| Value | Basis | |
|---|---|---|
| Orders per day | 350,000 | The platform's published figure |
| Average rate | 4 orders/s | 350,000 / 86,400 |
| Friday dinner peak | 15-19 orders/s | Assumption: 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 |
| Burst | 200 orders/s for a few minutes | Assumption: an aggregator that comes back after an outage releases its queue at once |
| Events per order | About 15 | Status changes, KOT updates, delivery-job updates, POS log lines (assumption) |
| Events per second | 225-290 at peak; about 3,000 in a burst | 15 x 15-19; 15 x 200 |
| Raw volume | About 5 GB a day at 1 KB per event; about 0.6 GB a day as compressed Parquet | Assumption: 1 KB per event, about 9x Parquet compression; 4 orders/s x 15 events x 86,400 s |
| Locations | 14,000+ active | The 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.
| Step | Repo replay | Production target, p99 (assumption) |
|---|---|---|
| Seen stuck after the 90-second mark | At 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 write | Within the tick | Under 0.1 s |
| Token refresh, when the token is the cause | In 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 store | At most one batch per 5 minutes | None: one batch in flight per store |
| Read-back from the POS | A query on the world | 1 s |
| The platform's POS relay re-send | A write to the world | 1 s |
| To the re-send | 63 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
| Value | Basis | |
|---|---|---|
| Detections a day, synthetic | About 110 on average; 435 on the Friday evening alone | Seed 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 scale | About 470 | Scaled from 4,700 to 20,000 stores (5.3's assumption); 14,000+ locations alone give at least 330 |
| Model share of decisions | About a third | Seed 23, Friday evening: 95 by rules, 50 by the model |
| Model calls a day | About 1,400 | Seed 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) |
| Sizing | 1,400-7,000 a day | Up to five times that for a noisier live feed (assumption, measured in shadow) |
| Throughput cap | About 21 a minute at the median, 9 at p95 | 2 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
| Item | Range |
|---|---|
| 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 one | The 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 source | What it tells | Delay | Bias |
|---|---|---|---|
| Ops review of a random sample of ledger rows, shadow rows included | Precision per detector; whether the action was right | Same day | Lowest; review blind, labelling from the raw facts before seeing Canon's decision |
| Approvals and rejections in the queue | Whether tier 2 proposals were right | Minutes | Covers only tier 2 |
| Reverts | Wrong actions | Minutes to hours | Undercounts: people revert what they notice |
| Support tickets matched to incidents | Misses, for recall | Hours to days | Recall 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 systems | Immediate | Only kinds that can be staged there |
6.3 Metrics
| Metric | How |
|---|---|
| Precision per detector per day | Confirmed rows over reviewed rows, with a 95% interval; at small samples the interval matters more than the point |
| Recall per detector per week | Problems found by people or by planned faults that Canon also found |
| Auto rate, revert rate | Per action |
| Time to detect, time to fix | Problem start to the first ledger row; first row to the resolving action; per kind |
| Cost per resolved incident | Model spend plus approver minutes |
| Freshness | Share of peak minutes each input was within its bound |
| Drift | The 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
| Gate | Scope | Mode | To pass (starting values, set with ops) |
|---|---|---|---|
| 1 | UAE: stuck order, token expiring and failing integration as stream forms; the other detectors as their reference SQL, polled on the cold store | Shadow, two weeks | Feed 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 |
| 2 | UAE, POS token refresh plus re-send of the waiting orders | Tier 0, live, two weeks | That kind's reviewed shadow precision at 95% or more; no row with a wrong target; contract tests green; kill switch tested live |
| 3 | UAE, more kinds one at a time: busy mode, item off, backup printer | A week in shadow, then live, per kind | The same, per kind; revert rate under 5% |
| 4 | KSA | In the same region, gates 1 to 3 again for its market | The 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
- 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.
- 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).
- 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.
- A named on-call per dinner shift who answers queue items, and an ops reviewer for 30 to 60 minutes a day.
- A ticket export for labels, the maintenance calendar, and what each aggregator supports for busy mode.
7. Failure modes and safety
| Failure | How Canon notices | What it does |
|---|---|---|
| Bus or stream jobs down | Heartbeats and offsets stop | No 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 stale | as_of older than a definition's bound | The detector reports "blind"; the controller declines actions on that evidence |
| Model down or slow | Timeouts; the breaker opens | Rules 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 down | Adapter errors | In production: the verb's breaker opens; one platform incident; queued items marked as waiting for the API |
| Credential leak | Found out of band | Revoke 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 loop | Budgets and cooldowns are hit | A 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 deployed | A 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 rise | In 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 change | A contract test fails; an unknown status or field appears on the stream | In production that adapter or detector drops to shadow; integrations is paged |
| Planned maintenance, 02:00-06:00 UAE | In production, the maintenance calendar | No 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 events | The source position was seen before | Dropped 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%.
| Component | Where the repo demonstrates it | Status | Gap to production |
|---|---|---|---|
| Sources and ingestion | canon/world/ generator, canon/world/schema.sql | Synthetic only | gData reader (CDC or poller), integration-log and store-state readers; none written |
| Event bus | None; detectors query tables each tick | Absent | Redpanda, topics keyed by store |
| Real-time evaluation | canon/agent/detectors.py runs each definition's SQL per tick; cadence.py paces hourly and daily checks | Polling, not streaming | Stream templates on the keyed consumer, processing-time absence timers, act horizon, parity tests |
| Hot state | DuckDB tables | No separate store | Redis projection with as_of |
| Cold store and replay | scripts/replay_evening.py, canon/agent/replay.py; 7 days of orders, 90 days of daily aggregates | One file | Parquet, retention |
| Freshness | canon/world/systems.py (systems, fact sources, refresh cadence; the Map screen) | Shown, not enforced | Input age bounds in definitions; "blind"; the controller's age check |
| Definitions as code | canon/dictionary/definitions/*.yaml, store.py, models.py, testing.py | Owner, version, history, parameters, SQL, tests | stream, inputs, approvers |
| Ambiguity policy | Dictionary.ambiguous, canon/ask/answer.py | Done | None |
| Lineage | Lineage in canon/ask/models.py; ledger definition_term, definition_version, evidence | To term and version | Source offsets per evidence fact |
| Changing a definition | Dictionary.set_param, set_canonical, _history.jsonl, canon/ask/what_changes.py | History and row diff; no approval | Review in git, CI, side-by-side shadow, rollback by version switch |
| Grouping alarms by cause | canon/agent/linkdown.py, canon/agent/outages.py, the release subject in detectors.py | Done | Blast-radius grouping per market and brand |
| Proposal | canon/agent/rules.py, workers.py, llm.py, parallel.py | Done; the model is optional | Throughput cap from concurrency |
| Controller | canon/agent/controller.py, policy.py, policy.yaml | Tiers, runbooks, arguments, targets, conditions, ask-first rules, cooldowns, two budget pools, never-list, unknown actions declined | Own process and credentials; evidence age, maintenance calendar and a runtime kill switch before the ask-first check; blast radius |
| Action adapters | canon/agent/tools/, endpoints.py: change the synthetic world and label the call | No real calls | Adapters, sandbox contract tests, breakers, a gateway that scopes the platform key |
| Ledger | canon/ledger/api.py, canon/ledger/schema.sql | Append-only by API; approvals; repeats; rollback; primary keys only | Append-only by database grants; unique key on incident, action, target and attempt; hash chain; Postgres |
| Human loop | app/routes/queue.py, app/real_services.py (approve and remember), handling.py (on-call draft), canon/agent/gaps.py; humans.yaml scripts a synthetic shift | Done, with a demo identity | Single sign-on, paging, deadlines, support-priority mapping |
| Shadow mode | CANON_SHADOW=1 in canon/agent/handling.py and canon/ledger/api.py | Done, set at start-up | Switch per kind and per market at runtime |
| Measurement | canon/recal/metrics.py, matching.py, drift.py | Against the generator's answer key | Production labels, sampling, planned faults, input drift |
| Money at stake | canon/agent/stakes.py, with rates from one table in canon/fx.py (AED per unit) | Fixed demo exchange rates | Real rates |
| Regions | None | Absent | One region next to the feed; markets as keys, budgets and switches |
| Money, contracts and messages to clients | never: in policy.yaml | Done | None |
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.
| Milestone | Days | Delivers | Done when |
|---|---|---|---|
| 1. Shadow in the UAE | 1-45 | Day 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-44 | Gate 1 passed; the metric's baseline measured |
| 2. First live kind | 46-75 | Adapters 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 weeks | Gate 2 passed; the metric reported against its baseline; no action on a wrong target |
| 3. More kinds, a second market | 76-90 | Busy 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 purpose | Busy 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.
| Who | Role | Time |
|---|---|---|
| Lead engineer | Controller, policy, ledger | Full time |
| Data engineer | Ingestion, bus, stream jobs, parity tests | Full time |
| Platform engineer | Adapters, the key gateway, on-call tooling | Full time |
| Ops reviewer, from the platform | Reviews the sample, labels rows | About an hour a day |
| On-call, from the platform | Answers queue items within 10 minutes at dinner | Each dinner shift |
| gData contact, from the platform | Points gData at Canon's database; runs the history sync | Week one |
| Integrations engineer, from the platform | Sandbox, contract tests | About two days a month |
| Definition owners | Approve definition changes | About an hour a week each |