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

How an update reaches each place

The planner picked a move — push or hold. Now the publisher Lambda has to turn that into a real change on a real channel: the right format for that place, an owner’s tap when one is needed, the right call to the channel’s adapter, and a record of whether it worked. Get any of those wrong and the update is worse than no update: a price in dollars where the app wanted cents, a description too long for the website, the same change pushed twice. Four small guardrails sit between the move and the change landing.

Key takeaways

  • Channel resolution: a per-item channel list beats the per-category default beats the all-channels fallback.
  • Routine pushes flow on their own; held changes wait for the owner’s one-tap approval first.
  • Each channel has an adapter that formats the change and speaks that channel’s own language.
  • Every push carries an idempotency key, so a retry never applies the same change twice.
  • The result — accepted or rejected, with the reason — is written to DynamoDB for the next run to read.

Four guardrails on every push

Four guardrails between the planner's chosen move and the landed update A horizontal flow diagram. On the far left, a "Move chosen" box: the planner emitted an event with the item, the channel, and one of two pushing moves — push or hold. Four guardrail gates sit in a row to the right, each drawn as a vertical bar. Gate 1: Resolve channels — looks up the per-item channel list first; if blank, falls back to the per-category default from the rules doc; if still blank, falls back to all configured channels. Returns the adapter to use for each channel. Gate 2: Approval — if the move is push, passes straight through; if the move is hold, sends the owner a one-tap approval card in Slack and waits, only continuing once they approve; a rejected approval ends the path without touching the channel. Gate 3: Format — runs the change through the channel's formatting template from the voice doc, trimming a description to the channel's length cap, converting the price to the channel's units (dollars or cents), and dropping fields the channel doesn't support. Gate 4: Idempotent send — attaches an idempotency key built from the item, channel, and value, so a retry of the same change is a no-op; calls the channel's adapter to apply the change. After all four gates pass, the update lands via the website content store, an order-platform menu API, the PDF generator, or the QR-code page. A note at the bottom: every result — accepted or rejected with the reason — is written to DynamoDB so the next run knows the state. Move chosen push or hold for approval Gate 1 Resolve channels item list? category default? all-channels fallback? pick the right adapter each Gate 2 Approval gate push? straight through hold? one-tap Slack card, wait to approve Gate 3 Format per channel voice template for channel trim length, fix price units, drop extras Gate 4 Idempotent send key from item, channel, value retry is a no-op; call the adapter Land — website store, order-platform API, PDF generator, or QR page adapter applies the change · reports accepted or rejected every result logged to DDB ms-state — the next run reads it Every gate is a deterministic check — no model calls, and a retry never charges a guest twice.
Fig 4. Four guardrails between the move and the landed update. Resolve the channels. Wait for approval on held changes. Format for the channel. Send idempotently. Then apply via the adapter and log the result so the next run knows the state.

Gate 1: resolve the channels

Three places the publisher looks to decide which channels an item belongs on, in order. First, the item’s own channels column in the master menu — if a dish is set to publish only to the website and the PDF (a dine-in-only special that shouldn’t appear on delivery apps), that list wins. Second, the per-category default in the rules doc (“all drinks publish everywhere except the delivery apps”). Third, the all-channels fallback — if nothing is set, the item goes everywhere. The fallback is the safe default for a normal dish.

For each channel in the resolved list, the publisher picks the right adapter. An adapter is a small piece of code that knows how to talk to one channel: the website’s content store, a specific order platform’s menu API, the PDF generator, the QR-code page. Adding a new channel later is writing one new adapter, not changing the rest of the system.

Gate 2: the approval gate

If the planner’s move was push, this gate is a pass-through — the change was already judged routine and inside the limits, so it flows straight on. If the move was hold, the publisher sends the owner a one-tap approval card in Slack: here’s the item, here’s the old value and the new value, here’s which channels it’ll touch, with Approve and Reject buttons.

Nothing ships to any channel until the owner approves. If they approve, all four gates run as if it were a push from the start. If they reject, the path ends cleanly — the master menu keeps the change (it’s still the source of truth), but the channels are left as they were, and the held item shows up in the weekly digest so it isn’t forgotten. This is the human-in-the-loop guardrail: no surprising change to what a guest is charged ever ships without a person seeing it first.

Gate 3: format for the channel

Channels disagree about everything. The website can show a long, lyrical description; an order app caps it at 120 characters. The website wants a price like “$18.50”; one platform’s API wants the integer 1850 in cents. The PDF wants a category header; the QR page wants a flat list. Gate 3 runs the change through that channel’s formatting template from the voice doc: trim the description to the cap, convert the price to the channel’s units, drop any field the channel doesn’t support, and map the category name to the one that channel expects.

The templates are plain text with placeholders, edited in the voice doc without a deploy. They’re the one place that encodes “this is how the short rib reads on the delivery app versus the printed menu,” so the rest of the system can stay channel-agnostic.

Gate 4: send it once, even on a retry

The last gate attaches an idempotency key — a short fingerprint built from the item, the channel, and the exact value being sent — before calling the adapter. (Idempotent just means “safe to do twice”: if the same change is sent again, nothing extra happens.) If a network blip makes the publisher retry, the adapter sees the same key it already processed and treats the second call as a no-op instead of applying the change a second time.

This matters most for price changes. Without the key, a retry during a flaky moment could bump a price twice, or flip a sold-out flag back and forth. With it, the worst a retry can do is confirm what already happened. The adapter calls the channel — the website content store, the platform’s menu API, the PDF regenerate job, the QR page — and reports back accepted or rejected, with the reason on a rejection. The publisher writes that result to ms-state in DynamoDB. The next run reads it and knows the true state of every channel.

Why the guardrails exist

None of these gates are exotic. They’re the kind of small care a careful manager would take if they were updating each app by hand — check which places this dish even belongs on, get a second pair of eyes on the big changes, format it the way each app expects, and don’t fat-finger the same price twice. Putting them in code as four small sequential gates makes them part of the design, not a habit you’re trusting a busy person to keep on a busy night.

Next post: what happens when a channel says no — how a rejected update gets flagged, surfaced, and fixed, and how the system keeps the menu honest about which places are out of step.

All posts