Part 3 of 7 · Menu sync series ~5 min read

How a change turns into updates

A change lands in the master menu — a price moved, a dish went sold-out, a new special appeared. The planner Lambda wakes up, reads the menu, and looks at one item-and-channel pair at a time. For each one it asks: does this channel already match? If not, is the change small enough to push on its own, or big enough to need a tap? The whole decision is plain comparison. No model. Every limit lives in the rules doc, where the owner can edit it without a deploy.

Key takeaways

  • The planner runs on each change, triggered by the menu mirror landing in S3.
  • Auto-sync limits live in the rules doc — for example a price change under 10% flows on its own; bigger waits for a tap.
  • Four moves per item-and-channel: in sync, push update, hold for approval, flag a rejection.
  • DynamoDB tracks what each channel currently shows so the planner only acts on real differences.
  • The planner itself never calls a model. The decision is entirely a comparison against the rules.

The decision flow, per item and channel

Decision flow per item and channel on each change A vertical decision flow diagram. At the top, an input box "Item from master menu" with the item's name, category, price, sold-out flag, and the channels it publishes to. Below that, a step "Compute the diff" — compare the item's current master values against what the DynamoDB state says each channel last accepted. Below that, a check "Already in sync?" — if the channel already shows the master values, route to "In sync" (do nothing this run). If not, continue. The next step "Look up the channel's rules" — pulls the auto-sync limits from the rules doc; for example a price change is allowed automatically up to ten percent. The next step "Within the auto-sync limit?" — checks the size and kind of change against the limit. If the change is routine and inside the limit, route toward a push. If it exceeds the limit or is a risky kind (removing a category, a big price jump), route to "Hold." The next step "Last push to this channel rejected?" — looks at the DynamoDB state to see whether the previous attempt failed. If a prior push was refused, route to "Flag." Otherwise, for a routine in-limit change, route to "Push update." Each terminal box — In sync, Push update, Hold, Flag — emits an event to EventBridge with the move and the item-and-channel context. A note at the bottom: the rules doc holds every limit; the planner's code only enforces them — change a limit in the doc and the next change uses the new value. Item from master menu name · price · sold-out · channels Step 1 Compute the diff master − channel state Step 2 Already in sync? read DDB ms-state table Step 3 Look up channel rules e.g. price auto up to 10% Step 4 Within the auto-sync limit? over limit → hold risky kind → hold Step 5 Last push rejected? read DDB ms-state table In sync do nothing Push update routine, in limit Flag prior push refused Hold over limit or risky if match match over ok was refused The rules doc holds every limit — change one and the next change uses the new value.
Fig 3. The planner’s decision tree, per item, per channel, on each change. Five steps decide which of four moves applies. The rules doc holds every limit; the planner only enforces them.

Auto-sync limits: 10% isn’t magic, it’s in the doc

The rules doc has one short section per kind of change. Each section names the limit in plain prose: “Price changes: flow automatically up to 10%; bigger waits for approval. Sold-out and back-in-stock: always automatic. New dishes: always hold for approval. Removing a whole category: always hold.” The numbers are the line between “push it” and “ask first.” Routine changes that the owner has decided are safe flow on their own; anything that could surprise a guest or hurt the numbers waits for a tap.

The limits exist for a reason. A small price tweak is the kind of thing that happens weekly and shouldn’t need the owner’s attention each time. A 30% jump almost always means a typo or a real decision that deserves a second look before it’s charged everywhere. Marking a dish sold-out is reversible and urgent, so it’s always automatic. Removing a category is rarely reversible in a hurry, so it always waits. Different changes carry different risk; the limits reflect that.

Per-channel overrides exist too. The rules doc can set a tighter limit for one channel — maybe the delivery app that charges guests a service fee should never auto-accept any price change, so every price move there is held no matter how small. That’s the right escape hatch for the one place where mistakes cost the most.

Four moves, always

Every item, every channel, every change lands in exactly one of four buckets. The names are simple on purpose.

  • In sync. The channel already shows what the master menu says. Do nothing. Most items, on most changes, are already in sync on most channels.
  • Push update. The change is routine and inside the auto-sync limit. Send it straight to the channel through its adapter. Write a row to the ms-state DynamoDB table marking that this push went out.
  • Hold for approval. The change is over the limit or a risky kind — a big price jump, a new dish, a removed category. Hold it and send the owner a one-tap approval card. Nothing ships to the channel until they approve.
  • Flag a rejection. A previous push to this channel was refused — a price field it wouldn’t take, a name too long, a category it doesn’t support. Surface it so somebody can fix the underlying problem. Mark the item-and-channel as flagged in DynamoDB; it stays flagged until the next successful push or a manual clear. Part 5 covers what fixing a rejection actually looks like.

State that makes the decision deterministic

The planner reads one DynamoDB table on each change. ms-state records the last accepted value per item per channel: (item_id, channel, last_value, last_push_status, last_push_at). With that one table, the move decision is a few dozen lines of comparison and zero magic. A given item with a given master value and a given channel state always produces the same move. Re-running the planner produces no extra pushes — the state shows what each channel already has, so an item already in sync stays in sync.

When a push succeeds, the publisher updates ms-state with the new accepted value. When a push is rejected, it records the rejection and the reason. The planner reads both on the next change. That’s the whole memory the system needs to tell “in sync” from “needs a push” from “something’s broken.”

Why the planner uses no model

The planner could call a model to decide whether a change is “big” or to rewrite a description for a channel. It doesn’t. Two reasons. First, the planner should be the one part of the system that is utterly predictable — if the rules say a price under 10% flows and a bigger one holds, that’s exactly what happens. A model in that loop introduces variance the owner can’t reason about, on the part of the system that touches what guests are charged. Second, model calls cost money, and the planner runs on every change, so the call would mostly be wasted on items that are already in sync.

Bedrock fires elsewhere — on the supplier-price lane in Part 2, and on the monthly summary mentioned in Part 6. Not in the planner. The planner itself is plain comparison that reads a menu and writes events.

Next post: how an update reaches each place — the per-channel adapters, the formatting templates, and the guardrails between the planner’s chosen move and the change actually landing.

All posts