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

Definitions

The definitions the agent and Ask both run on. Each has an owner, a version, SQL and tests. Edits make a new version.

Menu drift

menu_drift unit: stores agent detector canonical: ops

An item a channel still sells that the POS cannot take: it is unavailable on the POS, or its item or modifier mapping no longer matches, so the POS rejects the order. One row per branch and item. Price differences between a channel and the POS are not drift: channel markups are deliberate.

opsCOO · ops v4canonical

Orders the POS rejected for a named item in the period ('item unavailability', or 'payload mapping error' tied to an item), with no successful menu sync on the branch's POS since the last of them: at least min_rejections for the item, or one when the POS change log shows an unsynced change for that item. channel_id is the channel most of the rejections came from. fix says what clears it: "switch item off on channel" for availability, "menu push needed" for a mapping mismatch.

latest change: One row per branch and item with channel_id and a fix hint; price differences between channel and POS no longer appear as drift.

  • pass count_betweenobserved 5, expected between 2.0 and 40.0
  • pass friday_eveningobserved 5, expected between 1.0 and 20.0
  • pass uniqueobserved {'duplicates': 0}, expected subject_id unique
  • pass not_nullobserved {'null_cells': 0}, expected no NULLs in ['item_id', 'channel_id', 'fix']
  • pass detector_shapeobserved {'missing_columns': [], 'bad_rows': 0, 'rows': 5}, expected columns ['subject_type', 'subject_id', 'started_at', 'evidence'], evidence is an object
SQL, rendered for now
WITH rejected AS (
  SELECT e.order_id, min(e.at) AS rejected_at,
         any_value(TRY_CAST(e.payload->>'item_id' AS INTEGER)) AS item_id,
         any_value(e.payload->>'pos_error') AS pos_error
  FROM order_events e
  WHERE e.type ILIKE '%reject%' AND (e.payload->>'item_id') IS NOT NULL
  GROUP BY e.order_id
),
rejections AS (
  SELECT o.branch_id, r.item_id, o.id AS order_id, o.channel_id, r.rejected_at, r.pos_error,
         o.rejection_reason = 'payload mapping error' AS mapping
  FROM orders o
  JOIN rejected r ON r.order_id = o.id
  WHERE o.status = 'rejected'
    AND (o.rejection_reason IN ('item unavailability', 'payload mapping error')
         OR o.rejection_reason ILIKE '%mismatch%')
    AND r.rejected_at >= TIMESTAMP 'Thu 23:00:00' AND r.rejected_at < TIMESTAMP 'Fri 23:00:00' AND r.rejected_at <= TIMESTAMP 'Fri 23:00:00'
),
per_item AS (
  SELECT branch_id, item_id, count(*) AS rejected_orders,
         min(rejected_at) AS first_rejected_at, max(rejected_at) AS last_rejected_at,
         mode(channel_id) AS channel_id, list(DISTINCT channel_id) AS channel_ids,
         count(*) FILTER (WHERE mapping) AS mapping_rejections,
         count(*) FILTER (WHERE NOT mapping) AS availability_rejections,
         any_value(pos_error) AS pos_error,
         list(order_id ORDER BY rejected_at) AS rejected_order_ids
  FROM rejections
  GROUP BY branch_id, item_id
),
syncs AS (
  SELECT e.integration_id, max(e.at) AS last_synced_at
  FROM integration_events e
  WHERE e.type = 'menu_synced' AND e.at <= TIMESTAMP 'Fri 23:00:00'
  GROUP BY e.integration_id
),
unsynced AS (
  SELECT b.id AS branch_id, TRY_CAST(e.payload->>'item_id' AS INTEGER) AS item_id,
         min(e.at) AS changed_at, arg_max(e.type, e.at) AS change
  FROM integration_events e
  JOIN branches b ON b.pos_integration_id = e.integration_id
  LEFT JOIN syncs s ON s.integration_id = e.integration_id
  WHERE (e.type ILIKE '%item_unavailable%' OR e.type ILIKE '%item_available%' OR e.type ILIKE '%price_changed%')
    AND e.at <= TIMESTAMP 'Fri 23:00:00' AND e.at > coalesce(s.last_synced_at, TIMESTAMP '-infinity')
  GROUP BY ALL
),
sync_failures AS (
  SELECT b.id AS branch_id, count(*) AS failures, arg_max(e.payload, e.at) AS last_error
  FROM integration_events e
  JOIN branches b ON b.pos_integration_id = e.integration_id
  LEFT JOIN syncs s ON s.integration_id = e.integration_id
  WHERE e.type ILIKE '%sync_fail%' AND e.at <= TIMESTAMP 'Fri 23:00:00'
    AND e.at > coalesce(s.last_synced_at, TIMESTAMP '-infinity')
  GROUP BY b.id
)
SELECT 'branch_item' AS subject_type, CAST(b.id AS VARCHAR) || ':' || CAST(p.item_id AS VARCHAR) AS subject_id,
       b.id AS branch_id, b.merchant_id, p.item_id, p.channel_id,
       p.first_rejected_at AS started_at,
       CASE WHEN p.rejected_orders >= 2 * 3 THEN 'high' ELSE 'medium' END AS severity,
       CASE WHEN p.availability_rejections >= p.mapping_rejections THEN 'switch item off on channel'
            ELSE 'menu push needed' END AS fix,
       json_object(
         'branch', b.name,
         'item', mi.name,
         'rejected_orders', p.rejected_orders,
         'availability_rejections', p.availability_rejections,
         'mapping_rejections', p.mapping_rejections,
         'pos_error', p.pos_error,
         'first_rejected_at', p.first_rejected_at,
         'last_rejected_at', p.last_rejected_at,
         'channel_ids', p.channel_ids,
         'rejected_item_ids', [p.item_id],
         'rejected_order_ids', p.rejected_order_ids[1:20],
         'unsynced_pos_change', u.change,
         'pos_change_at', u.changed_at,
         'pos_integration_id', b.pos_integration_id,
         'last_menu_sync_at', s.last_synced_at,
         'menu_sync_failures', coalesce(f.failures, 0),
         'last_sync_error', f.last_error
       ) AS evidence
FROM per_item p
JOIN branches b ON b.id = p.branch_id
LEFT JOIN menu_items mi ON mi.id = p.item_id
LEFT JOIN syncs s ON s.integration_id = b.pos_integration_id
LEFT JOIN unsynced u ON u.branch_id = p.branch_id AND u.item_id = p.item_id
LEFT JOIN sync_failures f ON f.branch_id = p.branch_id
WHERE (p.rejected_orders >= 3 OR u.item_id IS NOT NULL)
  AND p.last_rejected_at > coalesce(s.last_synced_at, TIMESTAMP '-infinity')
ORDER BY started_at, b.id, p.item_id
integrationsHead of Integrations · integrations v3

An availability change on the POS (item switched off or back on) with no successful menu sync since, before any guest hits it. One row per branch and item; channel_id is a channel where the menu snapshot still shows the item as available (the snapshot has no history, so this is evidence only). fix is "switch item off on channel" for an item switched off, "menu push needed" otherwise.

latest change: Availability changes only; a POS price change or a channel price that differs from the POS is no longer drift. One row per branch and item.

  • pass count_betweenobserved 3, expected between 2.0 and 400.0
  • pass uniqueobserved {'duplicates': 0}, expected subject_id unique
  • pass detector_shapeobserved {'missing_columns': [], 'bad_rows': 0, 'rows': 3}, expected columns ['subject_type', 'subject_id', 'started_at', 'evidence'], evidence is an object
SQL, rendered for now
WITH syncs AS (
  SELECT e.integration_id, max(e.at) AS last_synced_at
  FROM integration_events e
  WHERE e.type = 'menu_synced' AND e.at <= TIMESTAMP 'Fri 23:00:00'
  GROUP BY e.integration_id
),
changes AS (
  SELECT b.id AS branch_id, TRY_CAST(e.payload->>'item_id' AS INTEGER) AS item_id,
         min(e.at) AS since, arg_max(e.type, e.at) AS latest_change,
         list(json_object('change', e.type, 'at', e.at, 'detail', e.payload) ORDER BY e.at) AS pos_changes
  FROM integration_events e
  JOIN branches b ON b.pos_integration_id = e.integration_id
  LEFT JOIN syncs s ON s.integration_id = e.integration_id
  WHERE (e.type ILIKE '%item_unavailable%' OR e.type ILIKE '%item_available%')
    AND e.at <= TIMESTAMP 'Fri 23:00:00' AND e.at > coalesce(s.last_synced_at, TIMESTAMP '-infinity')
  GROUP BY ALL
),
still_listed AS (
  SELECT bm.branch_id, bm.item_id, min(bm.channel_id) AS channel_id, list(bm.channel_id ORDER BY bm.channel_id) AS channel_ids
  FROM branch_menu bm
  JOIN channels c ON c.id = bm.channel_id AND c.kind <> 'dine_in'
  WHERE bm.available
  GROUP BY bm.branch_id, bm.item_id
)
SELECT 'branch_item' AS subject_type, CAST(b.id AS VARCHAR) || ':' || CAST(c.item_id AS VARCHAR) AS subject_id,
       b.id AS branch_id, b.merchant_id, c.item_id, l.channel_id,
       c.since AS started_at,
       CASE WHEN c.latest_change ILIKE '%unavailable%' THEN 'high' ELSE 'medium' END AS severity,
       CASE WHEN c.latest_change ILIKE '%unavailable%' THEN 'switch item off on channel'
            ELSE 'menu push needed' END AS fix,
       json_object(
         'branch', b.name,
         'item', mi.name,
         'pos_changes', c.pos_changes,
         'pos_integration_id', b.pos_integration_id,
         'last_menu_sync_at', s.last_synced_at,
         'still_listed_on_channels', coalesce(l.channel_ids, [])
       ) AS evidence
FROM changes c
JOIN branches b ON b.id = c.branch_id
LEFT JOIN menu_items mi ON mi.id = c.item_id
LEFT JOIN still_listed l ON l.branch_id = c.branch_id AND l.item_id = c.item_id
LEFT JOIN syncs s ON s.integration_id = b.pos_integration_id
ORDER BY started_at, b.id, c.item_id

Parameters

change one, see what changes tonight, then save it as a new version with a note
now 3 · used by ops

History

append-only; nothing is edited in place
  1. definitionHead of Integrations integrations v3 Availability changes only; a POS price change or a channel price that differs from the POS is no longer drift. One row per branch and item.
  2. definitionCOO ops v4 One row per branch and item with channel_id and a fix hint; price differences between channel and POS no longer appear as drift.
  3. definitionHead of Integrations integrations v1 First version: channel menu rows that disagree with the POS (dine-in) row in the menu snapshot.
  4. definitionHead of Integrations integrations v2 Triggered by the POS change log instead of the snapshot, so branches without a dine-in channel are covered and replays do not see later menu states.
  5. definitionCOO ops v1 First version: at least three item-mismatch rejections at a branch within two hours.
  6. definitionCOO ops v2 Window widened to 24 hours, drift stays open until a menu sync succeeds, and one rejection is enough when the POS change log confirms it.
  7. definitionCOO ops v3 Matches the platform's rejection reasons ('item unavailability', 'payload mapping error') and requires the POS to name the item.