R
Docs

Release notes

Every change shipped to the Restora 360 platform, newest first.

  1. Unreleasedin progress

    Changed

    • Dashboard doorbell and kiosk menu poll on the POS change marks (2026-10-13, plan step 3 + item 1e). The dashboard's inbox/stream route is gone: it held a server-sent event stream open for the whole 300 s Cloud Run request timeout and probed the backend every 2 s from inside it, which on request-based billing charged the ui instance for every second a dashboard tab was open and made up ~65 % of backend requests. useTenantPushSignal now asks the new GET /api/dashboard/inbox/changes once every 30 s while the tab is visible (nothing while hidden; one shared poller per tenant), and that route answers from the backend's per-tenant change marks (GET /pos/admin/changes/:tenantId, one ~100-byte read via lib/pos/change-marks.ts) in the fixed order [tickets, kitchen, bookings, settings, catalog], falling back to the two whole-document updatedAt probes only while the marks route is not deployed or unreachable. The Express Kiosk's menu version (GET /api/public/kiosk/menu?v=) now carries the catalog and settings marks it was built from plus a validity instant (the next canteen daypart boundary, else 10 min) and the content hash (lib/kiosk/menu-version.ts), so an unchanged menu is decided from one marks read before the tenant blob is loaded or hashed; the hash stays as the second gate, so a mark that moved for something the kiosk does not show still answers unchanged at the old price, and with no marks the version is the bare content hash as before. Both kiosk clients already treat the version as opaque, so neither changed. The heartbeat (lastSeenAt in the tenant blob) is still written on every poll, so the cheap path's full saving waits on moving that stamp out of the blob.

    Added

    • Sales receipt: what is left on the gift card (2026-10-11, till 1.0.5846). A guest who paid with a Presentkort had to ask for a separate balance check. The paper the server prints for the sale now says it under the payment — "Kvar på presentkort ****WXYZ 320,00" — masked code only. The figure is stored on the sale's tender row when the card is debited, so a copy or an e-mailed receipt days later states what was true when the receipt was made, never today's balance. A sale without a gift card prints byte-identical paper. Server-printed paper only: the on-screen receipt text comes from the kernel renderer and is unchanged.
    • X/Z report: Dricks, and Växelkassa (2026-10-11, till 1.0.5846). Two things the server knew and the tills never printed. Dricks (ej försäljning) — tips per payment means, outside the sales: a card terminal settles sale + tip, so its day total could not be reconciled against a report that showed only the sale. Växelkassa — the float the shift opened with, at the top of the drawer section: a minimum field of both dagrapporter (SKVFS 2014:9 7 kap. 2 § h, 3 § i) that was on no line. New report fields openingFloatMinor; tipTotals was already sent. Both tills, four languages.
    • Presentkort: paper for the buyer, and for a balance (2026-10-11, till 1.0.5845). Owner: "upon buy will there be a receipt of purchase? and what if someone want to have a receipt for current balance". There was no paper anywhere in the gift flow. Two slips now print on the receipt printer, each from a BUTTON and never on their own (his ruling): Skriv ut köpbevis on the sale's confirmation and on the card's sheet in Historik — the value, how it was paid, when and on which till; and Skriv ut saldo under a balance that was just checked — what is left, the original value, validity. Neither is a kassakvitto and the paper says so: EJ KVITTO leads it at twice the size of every amount (the pro-forma rule in SKVFS 2014:9), with no receipt number, VAT block, organisation number or control code. The card's code is never on receipt paper — only ********WXYZ — and the last door before the printer refuses anything showing more than four characters: the physical card with its code is a separate card-printer device, and everything else is the gift e-mail or the website's share page (owner, same day). New: POST /pos/giftcards/cards/:cardId/print-slip, POST /pos/giftcards/balance/print (the code rides the body, never the URL), /api/internal/pos/print-gift-slip, lib/devices/gift-slip-print.ts. Four languages on the till and on the paper.
    • Order buzzers — the puck's number on the order (2026-10-10, tills 1.0.5841). Owner: "if a customer

    want to use the order buzzer which are the external devices, there should be a way to add buzzer number to order so kitchen or counter can press buzzer to inform customer when the order is ready." The till CARRIES the number; it does not talk to a buzzer (a paired or integrated buzzer is the stated next step, which is why the number is its own field and not a note). Kassa: a Buzzer door on the header row, first among the mode's controls so Välj bord keeps its place, in Servering and Avhämtning; it opens a number pad (one to four digits, Spara / Avbryt / Ta bort) and then states the number ("Buzzer 12"). A counter basket carries it on its first write (send to kitchen, or the paid sale); a bill the server already holds is written through POST /pos/tickets/:id/buzzer, which also re-stamps the kitchen cards still on the rail. Where it is read: the KDS card, the Beställningar row, the kitchen bong (BUZZER 12 under the order number) and the guest receipt (Buzzer 12 under Ordernr) — paper without a puck is byte-identical. Two switches: the venue's own under Inställningar → Funktioner (off until switched on, settings.buzzer.enabled), and Super admin → POS Order Buzzers per tenant (platform_data.pos_buzzer_policy, allowed unless switched off); /pos/registers/me carries the resolved answer. Web till, Windows and Android alike, in en/sv/no/da. Rules: backend/src/modules/pos/sales/buzzer.ts, pos/web/src/screens/buzzer-rules.ts, BuzzerRules.kt.

    • Order buzzers, two findings from the owner's tablet the same evening (2026-10-10, tills 1.0.5842).

    A freshly paired till drew no Buzzer button until it was restarted or somebody opened Inställningar → Funktioner: /pos/registers/pair did not carry the buzzer block that /pos/registers/me did, and a till builds its first session from the pairing answer. It carries it now (buzzer-on-register-wire.spec.ts). On a tablet the button did not fit the header's one row — it pushed Ny beställning onto a second row and cost a row of menu. Below 900 dp of header the door is now compact: a tap-sized bell with the number as a badge on its corner, one width whatever is keyed (BuzzerRules.compactDoor, the container query on .pos-kassahead in buzzer.css), and on the native tills it stands in the row's spacer slot instead of taking a gap of its own. Wide tills keep the worded "Buzzer 12". The compact door is confirmed by the owner on his tablet and locked in docs/design-locks/kassa-buzzer-door.md.

    • Guest page — a stand on the table opens the menu, rewards, Wi-Fi and feedback (2026-10-10). A new

    sellable module, guest-hub (an add-on in no base plan, unpriced until a super admin prices it — the store shows "Kontakta oss"; Enterprise and Pay As You Go include it by the standing "everything" rule). /tap/<tenant> is the venue's link and /tap/<tenant>/<code> one stand; both open ONE page in the guest's language (en/sv/no/da — the switcher, then the phone, then the venue) with a tile per part that WORKS: the menu (a stand's table number rides along), the rewards card, the guest Wi-Fi (network, password with one-tap copy, and a WIFI: QR another phone can join from) and a private star rating. A stand that is no longer listed still opens the page; a switched-off page shows the venue's name and one sentence. /dashboard/guest-hub (a tile on the Marketing hub) is where the owner sets it up: the four parts with a "guests see this" answer from the same rule the guest page uses, the Wi-Fi details (refused when they could not work on a phone), the Google review link (must be Google's), and the stands — each with its own link, QR downloads, and "Write to an NFC tag" where the browser can (Chrome on Android). Rules are pure and tested in lib/guest-hub/*; scripts/guest-hub-shots.mjs is the browser proof (12 checks).

    • The guest rewards card page speaks Swedish, Norwegian and Danish (/tenant/<id>/loyalty,

    lib/loyalty/guest-words.ts) — it was English only, and is now one tap from the guest page.

    • Guest page, round two (2026-10-10): sold per card, follow buttons, and "Ask about a dish".

    Price: owner — "29 SEK per NFC or QR card per month but adjustable price in super admin". A guest card is a licence seat (seat-guest-card, counted on the venue's stand list), priced ONLY in Super admin → Licensing (no number in code); buying one on /dashboard/licences opens the module for a plan without it. Only ADDING a card past the licence is ever refused (409 GUEST_CARDS_FULL, and only when platform enforcement is on) — a card on a table keeps working, and renaming, re-pointing or removing one always saves. The setup page shows cards in use, the licence, and the price per card. Follow buttons: Instagram, YouTube and TikTok, right on the guest page; the links are the venue's own settings.socials, editable on the setup page (https only). Ask about a dish: a guest picks a dish and reads its ingredients, allergens ("contains" / "may contain") and dietary labels, and can check one allergen. NOT an AI: every verdict is the regulated resolver's (lib/ita/allergen-qa.ts), verbatim, and a dish with nothing recorded says "ask the staff", never "free from" (lib/guest-hub/ask.ts; POST /api/public/[tenantId]/hub/ask, read-only, rate-limited). Nutrition is not answered — a menu item carries no nutrition figures.

    • **Guest page, round three (2026-10-10): typed questions with an AI layer, and nutrition, in "Ask about

    a dish".* Typed questions: one field — typing filters the dish list, "Ask" sends the words. Plain word matching (`lib/guest-hub/ask-question.ts`) finds the dish and the allergen first, FREE, in any of the four languages. Only when no dish is found, and only when the owner has switched it on, an AI READS the question and picks the dish (`ask-ai.ts`): the smallest model, dish names only in the prompt, a reply of a few numbers, the same question answered from memory for 30 minutes, a daily ceiling per venue. It is paid from the restaurant's own AI credit — the wallet every tenant already has, topped up on `/dashboard/ai-spend` — through the same gate and debit as every AI feature, as "Guest page · Ask about a dish". No credit, or no platform key: the guest still gets the list and sees no error. The AI never answers. What it returns is accepted only as dish numbers and registry ids; the answer is composed from the venue's records and the regulated allergen resolver, and there is no "dishes free from X" list — only dishes that DECLARE an allergen or carry a label. Nutrition:* a dish's nutrition per serving, worked out from its recipe and the ingredient catalogue (USDA FoodData Central, Livsmedelsverket, Open Food Facts) by the rollup the marketplace already uses (ask-nutrition.ts): the eight lines of a nutrition declaration, how many of the recipe's ingredients the figures cover, the sources named, marked as an estimate. Nothing recorded = "no nutrition figures recorded". The setup page shows the AI switch, the credit left, the typical cost of one AI-read question (from the platform's own price list and markup) and a top-up link.

    • **"Ask about a dish": misspelled dish names are matched for free, and the nutrition panel has a salt

    line (2026-10-10).** Owner: "is there any free ai service which can be used?" — the cheapest AI is the one not called. matchQuestion now also finds the ONE dish a question nearly names: a slip of the keyboard (edit distance: none on short words, one, two on long ones) or a Scandinavian noun ending ("har margaritan gluten" → Margherita). Two dishes equally near → nothing is picked and the guest gets the list. So the AI layer is left with what words cannot do (another language, a description). Nutrition: the American database states sodium, the Swedish one salt; where only sodium is known the salt line is sodium × 2.5, the equivalent a label defines. Seen on the developer stack on a dish with a recipe linked to three USDA rows (928 kcal a serving, 3 of 3 ingredients, source named).

    Changed

    • X/Z report: Kassahändelser lists only what was keyed by hand (2026-10-11, till 1.0.5846). The block listed every row of the cash ledger, and the tills had names for three kinds — so a shift with a cash sale printed sale_cash ×1 125,00, change_given, refund_cash as raw keys, restating the drawer row by row on an X. It is now an allowlist: Insättning, Uttag, Säkerhetstömning and cash tips (newly named "Dricks (kontant)"). Nothing left the ledger, the expected cash or the over/short; cash sales are still under Betalsätt and returns under Returer. NOTE: this reverses an earlier intent that change given should stay visible on the report — it is in the ledger, and on no report line.
    • Presentkort: the design row follows the finger and glides (2026-10-11, till 1.0.5845). Owner: "right now only one at a time it rotates which is good but give it more smooth effect … a continue effect, fast swipe and slow swipe". A swipe used to be a vote — past 48 px it stepped exactly one card, however fast or far, and nothing moved until the finger lifted. The row is now a position: it follows the pointer, keeps going at the speed it was released with and slows onto a card (a slow swipe lands on the nearest, a fast one travels several), and stretches a little past either end. At rest every card is exactly where the approved layout put it; the no-jump contract is unchanged and still pinned. One rule, twinned: GiftCarouselMotion.kt ↔ gift-carousel-motion.ts. On the web till the slots no longer carry a CSS transition — the component paints them per frame, and a transition would smear the row behind the pointer.
    • X/Z report: a card used as payment is now "Presentkort (inlöst)" (2026-10-11, till 1.0.5845) — so it cannot be read as cards sold, which have their own block (see Fixed).
    • Scan-to-rate follows Google's review rules (owner ruling 2026-10-10). The Google review link is

    offered to EVERY guest who rates, on the same terms, whatever they answered. It used to go only to four and five stars while lower ratings were kept private — "selectively solicit[ing] positive reviews", which Google's Maps contribution policy lists under Rating Manipulation. lib/reputation/route-rater.ts carries the rule and its tests; an unhappy guest is still offered the private follow-up as well. A venue with a live guest page gets this follow-up without the scan_route platform flag.

    • The cookie banner no longer covers /tap/… (essential-only surface, like /t/ and /scan/).
    • Super-admin → Help & Guides — a help centre of the admin's own, opened with the GreenGate guide set (2026-10-07).

    /admin/dashboard/help (sidebar: new "Help" group, "Help & Guides"): a search box, category chips and a card per guide; /admin/dashboard/help/[slug] renders the article with the FAQ, a "Recommended next" and the related guides, and a button to Control Units. Both pages are SERVER components behind the admin layout's super-admin redirect, resolving the locale like the Control Units page and the admin login (platform_lang cookie / ?lang= → en/sv). The articles are a module of their own, lib/docs/admin-articles.ts (ADMIN_DOC_CATEGORIES, getAdminArticleBySlug, searchAdminArticles…), NEVER mixed into the owner DOC_ARTICLES (bundled into every owner page, ratcheted by coverage tests) — only the DocArticle types are shared. The owner article page's section renderer moved VERBATIM to components/docs/ArticleSections.tsx with the class vocabulary as a parameter: the owner page passes nothing and renders exactly what it did; the admin pages pass gray-scale and accent classes the admin theme engine covers (vocabulary.test.ts passes). Eight guides, each in English and Swedish, category "Swedish control system (GreenGate)", written for the super-admin who holds the GreenGate contract: overview (the three gates, test vs production, what the till shows, no fallback), onboarding a customer step by step with a checklist, using the Control Units page (five sections, what each button sends and to whom, why the confirmations exist), adding or replacing a till, the Skatteverket hand-off and the manufacturing number, the reconciliation queue, the test tenant (the four pnpm greengate:* commands, what the simulator proves), and troubleshooting refusals (the five codes, cause, fix, log line). Every fact checked against the runbooks and the code; what GreenGate has not answered (the manufacturing number's scope, whether a POS-ID must be registered in advance, the status vocabulary) is written as "not yet confirmed by GreenGate". No credential, org number, serial number or customer name in any guide — pinned by test. The Control Units header gained a "Guide" link to the overview. Tests: lib/docs/admin-articles.test.ts (unique slugs, links resolve, sv shape = en shape, no placeholder or secret-shaped text, only the four pnpm commands and the log command as code, the unconfirmed claims say so), components/docs/article-sections.test.ts (every section kind renders; the default vocabulary is the owner page's; a passed one replaces it everywhere), app/admin/dashboard/help/admin-help-pages.test.ts (server components behind the guard like Control Units; the index and every article render in en and sv with no tenant token).

    • Super-admin → Control Units (Kontrollenheter) — the GreenGate reseller back office, one page per tenant (2026-10-07).

    /admin/dashboard/control-units (sidebar: Global Localization, beside POS Operating Mode): a tenant picker (tenants with Swedish registers, or any id typed for a first-time setup), a status strip (health badge, an unmissable TEST/PRODUCTION chip, "Check connection" that sends ONE RequestStatus to GreenGate and only when pressed — nothing is sent on page load — probing with a chosen register's kassabeteckning and showing the control system's serial number), the GreenGate account card (credential-free view; enter/edit form with write-only username + password, Digest default, test/production warning; a status switch with a confirm that says every Swedish sale is refused while disabled), the registers table (editable POS-ID cell on Swedish selling registers only, 1–16 Latin-1 chars, unique within the tenant checked against the other rows; a CHANGE or CLEAR is confirmed by retyping the value and names Skatteverket — the backend exposes nothing that says a kassabeteckning already has receipts, so every change is treated as one that matters), the Skatteverket hand-off block (org no, per register: name, kassabeteckning, serial number from the probe of THAT kassabeteckning or "run Check connection first"; one Copy button, plain text), and the reconciliation queue (oldest first, evidence on a deliberate second click, Resolve with the finding/evidence/note of the DTO and a confirm quoting the rule: never a control code, never a revived sale). BFF under /api/admin/control-units/*, super-admin guarded exactly like Markets; setBy and checkedBy are stamped from the session. Words in English and Swedish (page-local namespace, locale resolved like the admin login). Backend: one read-only route for the tenant picker (GET /pos/admin/registers/tenants?country=) and one status-only write (PATCH /pos/admin/fiscal/greengate/account/status), both InternalGuard with specs.

    Fixed

    • Printed till receipt: it said "Kort" for every sale (2026-10-11, server; released beside till 1.0.5846). The paper the server queues for a till sale never received the means of payment and defaulted to card, so a cash sale's kassakvitto read "Betalning: Kort · betald". It is now read from the sale's stored tender rows: one means prints the old line with the right word; a split, or a gift card that did not cover the bill, prints "Betalning · betald" and one row per means with its amount. A booking's prepaid deposit prints as "Extern betalning", not Kort. A paper whose means is not known prints no means at all. Card-only paper is byte-identical. New payload field payments[].
    • Table and parked bills got no original receipt (2026-10-11, server). Only a counter sale queued paper; a bill closed from a table or a parked bill did not, so on the native tills the guest's only paper was the print button's KOPIA, with a copy journalled for what was really the first print. Closing a bill now queues its original, as a counter sale always has — receipt only, never a second kitchen bong (/api/internal/pos/print-receipt). Refunds likewise: the refund's original is queued when it is made. The print button afterwards prints a copy, which is now what it is.
    • Refund paper claimed "Kort · betald" (2026-10-11, server). It states how the money went back — "Återbetalt: Kontant", or a row per means.
    • Dricks was never on the printed receipt (2026-10-11, server). The tip travelled to the printer and was not read. A tipped sale's paper now has its own "Dricks" row under the total — outside the total and the VAT table — and the payment row shows what the card was actually charged. An untipped sale's paper is byte-identical.
    • Presentkort: no payment into a closed shift (2026-10-11, till 1.0.5846). A card payment charges the terminal first and issues the gift card after; a shift closed from another till mid-session meant a charged card and a refused gift card. Till betalning now asks for the open shift before any payment starts and says "Öppna ett pass innan du säljer presentkort." A held attempt being resumed is not asked, and if the question cannot be asked the server remains the authority. Both tills.
    • X/Z report: gift cards sold were on no report (2026-10-11, till 1.0.5845). The owner sold two Presentkort and could not find them. A card paid in cash hid among the pay-ins as an unnamed "Insättning"; one paid by card or Swish was on no report at all, so a card terminal's day total could not be reconciled against the Z. Skatteverket's line is that a sold gift card shows on the dagrapport as its own item, outside the day's sales. The report now carries Sålda presentkort (ej försäljning) — how many, paid how, in total — kept out of the sales figure, the VAT table, the tender totals and everything the Z seal hashes, so no sealed figure moved; the gift cash stays in the drawer count and is no longer also printed as a pay-in. Every card sale now needs an open shift (it was only required for cash), so none can land on no report. NOT settled by anything we could read, and for an accountant or Skatteverket: whether the sale must also be a signed kassakvitto, and whether a venue with a single VAT rate is issuing single-purpose vouchers (VAT due at sale).
    • Presentkort on the tablet: `Till betalning` jumped under the finger (2026-10-11, till 1.0.5845). With no amount chosen, pressing it inserted a red card above the button; the rail had less slack than that card is tall, so the button and Rensa moved down and the note left the card. The reason now takes the note's own slot under the buttons, which is the same height whichever sentence it holds.
    • Web till: the Presentkort screen was Swedish only (2026-10-11). About twenty sentences — form errors, the card-declined and terminal-timeout texts, statuses, the gift-mail lines, the carousel's labels — were hard-coded. They come from the four-language table now, in the native tills' words.
    • A till added on the device page showed its OWN name where the venue's belongs (2026-10-11). The owner's tablet drew "Kassa 2" on the face of a Presentkort. Only the signup flow ever stamped venueName on a register; one created on the dashboard's device page has none, and a till with nothing better printed its own name. /pos/registers/pair and /pos/registers/me now answer an absent or blank venue name with the name the venue typed in its own settings — the one at the top of its receipts (merchant.name); a stamped name is kept, and with neither known the field stays absent. Server-side only: both tills already read the field, and a paired till picks it up at its next start. This closes the "still open" note in the 1.0.5843 card-face entry below.
    • Presentkort: a gift card sold at the till was in no history at all (2026-10-11, till 1.0.5843). Owner: "i sold a presentkort but i dont see it in any history". A card sale is a voucher issue — a card, a ledger row and a drawer pay-in, never a ticket — and the till's Historik read tickets and online orders only, so the money was in the drawer and on the Z-report and the sale was nowhere a cashier could look it up. GET /pos/kitchen/orders/history now merges a THIRD journal: cards sold at a till (source: 'giftcard'), under the same window, cap and search as the other two. The row is called by the MASKED code (********WXYZ, never the code), carries the face value, how it was paid, which register sold it and the recipient's name; it answers the Slutförd / Betald / Direkt chips and no others. Search finds a card by name, by the whole code, or by any part of the four characters the row shows — and by nothing longer, so the box can never be used to walk a code out. Only till sales: a dashboard or web issue is not a counter sale. New index tenant_createdAt on pos_giftcards. On the tills (Android, Windows, web; sv/en/no/da) the row wears a PRESENTKORT badge, its sheet is titled "Presentkort …" rather than "Order …", and it offers no reprint / e-mail receipt (there is no ticket behind it). Works on an older till too — the row simply has no badge there.
    • Historik: "Betalt med cash" (2026-10-11, till 1.0.5843). The order sheet printed the tender's wire word. It now says Kontant / Kort / Swish / Presentkort in the till's language, on every row.
    • Presentkort: the card face on the native tills printed the TILL's name (2026-10-11, till 1.0.5843). "Kassa 1" on a card handed to a guest. Android and Windows now use the venue's name first, exactly as the web till already did. Still open: a register created on the dashboard's device page carries no venue name, so its card face falls back to the till's name until the server supplies one.
    • Dricks: a freshly paired till did not ask for a tip until it was restarted (2026-10-10). The same

    gap the buzzer had, present since tips shipped on 2026-09-06: /pos/registers/me carried the tip block and /pos/registers/pair did not, and a till builds its first session from the pairing answer. A venue that takes tips went unasked for every guest served before the first restart of a new till. The pairing answer carries the block now (tip-on-register-wire.spec.ts). Server-side only; nothing about whether or how a tip is asked for changed — an unreadable policy still answers "do not ask".

    • Payment page (`/register/payment`): it says what THIS plan contains, in the owner's language — and an ended trial can be paid for (2026-10-07).

    Owner, on an expired Inventory Advanced trial: "on screen the renewal showing wrong information about package". The page printed four website bullets ("Digital menu on your own website · Website builder + QR menus · Table reservations · Food Bazar listing · Experience Studio") under every plan's name, and "Activate your account — One last step" in English to an owner whose trial had ended. The list is now derived from the plan itself (lib/billing/plan-contents.ts: included features → the side menu's own rows, trade-filtered, translated with the menu's table), the heading follows the account's standing (signup · trial · ended trial · overdue · lapsed · paid), and the wording lives in the marketing dictionary's auth.payment namespace (en/sv/no/da); the page speaks the owner's dashboard language. The price was already right (flat plans are billed yearly). Also fixed, found on the way: POST /api/billing/signup-payment answered "already active" for a TRIALING subscription and took no money — for a lapsed trial the dashboard sent the owner straight back, a closed loop, so the account could never be paid for. Only a paid period is "already active" now (lib/billing/signup-payment-step.ts). With a trial payable: a Stripe payment returns to the payment page and is confirmed there before the dashboard opens (no second "Pay" on a late webhook, no endless spinner); a production server without a Stripe key refuses to simulate a trial into a paid year; the Stripe webhook activates a plan only when the money has arrived; an older paid Checkout session is not applied twice; and "activate the website package" (domain settings) leads to the payment page only when the plan has the website builder. Operations note: production needs STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET before a customer's trial ends — without the key the page now says payments are not set up instead of activating.

    • Inventory & Suppliers: "back" links and words that pointed at the wrong page (2026-10-04).

    Waste, Stocktake, Storage places, Labels and Equipment each carried a back link to Receiving, left over from when they were buttons on that page (they became hub tiles on 2026-09-27). Receiving needs Purchasing as well as Inventory Management, so on a stock-only plan the link led to a page the reader cannot open. They now go back to the Inventory & Suppliers hub, named as the sidebar and the breadcrumb name it in all 20 languages (components/dashboard/inventory/InventoryHubLink.tsx). The Receiving upgrade prompt said "Part of Inventory Management" / "Included in the Inventory Management add-on", while every Receiving route requires Purchasing too. The text now says it needs both (en, sv, da, no). The gate is unchanged. Three sentences that send the reader to another page now use that page's own name in sv, da and no: the Pick out hint (Flytt mellan platser, was "platsförflyttningar"), the Stocktake hint (Lagerplatser, was "Förvaringsplatser") and the grocery note on Sites (Lagerflytt, was "Överföringar").

    • The super-admin Customer page stopped failing on half of its loads (2026-09-19).

    GET /api/admin/tenants/[tenantId] answered an EMPTY 500 — zero-length body, no content-type — on roughly half of all requests, for every tenant, and faster than it answered successfully. Cause: the page's audit trail asks for 24 months, and readAuditSegments turned that reach into ONE Promise.all — 24 simultaneous reads of master-audit-logs:YYYY-MM at the store. The backend's global throttler is ThrottlerModule.forRoot([{ ttl: 1, limit: 20 }]) and @nestjs/throttler v6 counts ttl in MILLISECONDS, so the budget is 20 per tracker per millisecond — and every internal UI→backend read shares one tracker (the ui container's address). Reads 21–24 came back 429, the adapter threw by contract, nothing caught it, and Next answered with no body. Whether 24 sockets landed inside the same millisecond was a coin flip, which is exactly the ~50/50 pattern that was observed. Three things changed: segment reads now go out in batches of AUDIT_SEGMENT_READ_CONCURRENCY (6, the reach every other audit reader already uses) so a long window costs round trips and never a wider burst; a 429 — the one status that means "ask again" — is retried twice, and StoreUnavailableError now carries status so that is readable without parsing prose; and the Customer page DEGRADES instead of dying, serving every other card with an auditError sentence on the trail card rather than an empty list that would falsely claim the customer has no history. This page is the only door to a module trial, so losing it means no module can be opened at all. The throttler's ttl: 1 was deliberately left alone: "correcting" it to 1000 would turn the budget into 20 requests per SECOND for the whole ui container.

    • An admin route that throws now says so (2026-09-19). New lib/api/route-error.ts

    (failLoudly): logs the stack with context server-side and answers the standard { error, code: 'route_error' } envelope with a real message. The Customer page's GET is wrapped in it, and the page itself now reads a failed response instead of parsing it as a success — the empty 500 used to make res.json() throw in the browser, leaving the spinner turning forever with no word of why.

    Added

    • Help guides for Inventory & Suppliers, in English and Swedish (2026-10-04). Five linked guides

    in a new help-centre category "Inventory & Suppliers" / "Lager & leverantörer": getting started, ordering and receiving, pick-out / prep production / waste, stocktake and storage places, and several sites with transfers (lib/docs/inventory-articles.ts). Help guides were English only. A guide can now carry translations (lib/docs/articles.ts: localizeArticle, localizeCategory, and searchArticles(query, lang)), which the guides list, the article page and the header's help search use. A reader in a language without a translation gets the English guide, as before. The 32 older guides are untouched and still English. inventory-articles.test.ts checks that every name a guide puts in quotes is a label in the dashboard dictionaries for that language, so a renamed button fails the suite until the guide follows. No new dictionary keys.

    • A breadcrumb on the Lager & leverantörer pages (2026-10-04). Product Owner's request: inventory had

    no breadcrumb and the only way back to the hub was the sidebar. Every page opened from the Inventory & Suppliers hub now shows Hub › Page above it (plus a last crumb for a tab or a supplier), and on a phone one 44 px link back to the parent. Built once: the trail is derived from the nav catalog (lib/dashboard/breadcrumb-trail.ts), rendered by the dashboard layout (components/dashboard/DashboardBreadcrumb.tsx), and hidden wherever the plan or the trade hides the hub or the page. Another area adopts it by adding its hub href to BREADCRUMB_HUBS; a page names a view the URL does not show with useBreadcrumbSubView(label, onBack?). No new strings, no route changes.

    • Change or remove lines on a live online order, paid or not (2026-09-07). Owner: *"someone calls and

    says he ordered a pizza but wants a burger instead, or he ordered two pizzas and one burger and paid, but wanted just one pizza."* The till's order door has an edit_lines verb (lib/orders/order-line-edit): remove and add in one call, priced from the venue's menu; an unpaid order's total moves; a paid order that shrinks is refunded the difference (drawer cash or a credit-note gift card for a counter charge, the payments layer for an online hold, stamped "manual" without one); a paid order that grows owes a balanceDue the counter's paid press collects. A started line is refused, or allowed with a manager's PIN under the new Setup Center › Ordering & Delivery choice. The kitchen card shows the struck line and the new one. Every till shows "Ändra artiklar" beside "Lägg till artiklar".

    • "Clear log" on Recent print jobs (2026-09-06). Owner: "can you empty Recent print jobs."

    DELETE /api/tenant/[id]/hardware/jobs (settings permission) empties the diagnostic job log — the receipts live on the sales, so nothing bookkeeping-shaped goes — and the Printers panel shows a Clear log button beside the heading, with a confirm.

    Changed

    • All scheduled jobs are off on every platform (2026-10-05). Product Owner: keep every cron off until

    the strategy for running them is reworked. vercel.json now declares no crons (it listed all 73, which a Vercel deployment would have kept firing), and cron-registry.test.ts fails if one is added there. Cloud Scheduler creates only the jobs named in enabled_cron_jobs (terraform/envs/*.tfvars), which is empty. The routes and the PLATFORM_CRON_JOBS registry are unchanged.

    • restora360.com redesign, round 2 — /products, /demo, barber + wellness (2026-09-09). Owner, on

    round 1: "looks good - keep going." /products is rebuilt from the prototype's products.html: seven core product cards (POS, website, appointments, inventory, staff, digital signage, reports & insights), the four restaurant-specific modules (online ordering, table reservations, kitchen display, express checkout) and the "how should I choose" guide; the Produkter menu and the footer now deep-link /products#<id>, and the Phase-75 /products → /services redirect is gone (/products/pos stays). /demo stops 308-ing to /try and becomes the seven-step "Boka en demo" flow — business, type, stage, needs, company, contact, time — wired to a real backend where the prototype ended in localStorage: POST /api/public/demo-request validates with DemoRequestSchema, stores the request as codes in MasterDB.demoRequests[] (a Norwegian request reads like a Swedish one), mails the team best-effort, is rate-limited and refuses disposable addresses; the form renders every refusal from its own dict. /industries/barber and /industries/wellness join the industry template as beauty sub-trades with a four-level breadcrumb (home › industries › beauty › the sub-trade) and an "also built for" cross-link. All copy ships in en/sv/no/da under site-dict-parity.test.ts; the sitemap and /llms.txt list the new pages.

    • restora360.com redesign — header, landing page, industry pages (2026-09-08). Owner, with a

    screenshot and an HTML prototype: "navbar is too big … it can be a decent size" and "the html is not exactly the premium style … text is too small to read, it should be clear and premium." The public site is rebuilt to that design with larger, clearer type: a floating pill header with a Produkter mega-menu (the eleven products, a promo aside), a Branscher menu (three photo rows), search and a four-language switch (SV · EN · NO · DA); the landing page in the owner's composition — hero with the industry chooser, product features, the guest-to-kitchen flow, product cards, industries, pricing without prices, support, FAQ, closing CTA; and new pages /industries plus /industries/{restaurant,retail,beauty} (restaurant = the full 360° platform, retail and beauty = a curated core). Every visible string comes from the dict in en/sv/no/da — site-dict-parity.test.ts fails the suite when sv, no or da lags behind English; the one exception, documented in TODO.md, is the header search's keyword fallback (lib/marketing/intent-search.ts), whose result labels and descriptions are still English on every locale. Swedish is the site default. SEO/AEO/GEO: per-locale metadata + hreflang (Swedish on the bare URL, the others ?lang=), Organization/WebSite/FAQPage/SoftwareApplication/Breadcrumb JSON-LD, /llms.txt and the Organization description repositioned to one POS platform for three industries, and a new OG image.

    • The kitchen bong on a Zebra is bigger, at its natural width, with room at the tear (2026-09-07).

    Owner: "can we do the bong a bit bigger and clear — plus add some extra space on top — the label is 80 mm." The ZPL encoder gave the bong the receipt grid's face: font 0 squeezed to 60 % of its width and wrapped on a budget twice the real advance, so the text used a third of the roll and "Gluten, Mjölk" broke over two lines. The kitchen ticket now has its own typography: the face at its natural proportion, sized ~43 % taller (4.3 mm capitals on 80 mm/300 dpi), wrapped by measured glyph widths (a per-glyph table of font 0), rules drawn as one thin bar, 12 mm above the first line and 5 mm under the last. Receipts and labels are byte-identical. Needs a box deploy to reach the printer.

    • The last eight sidebar dropdowns are tile hubs (2026-09-07). Owner: *"there were some nav actions

    left — can we fix all of them so the others get the card grid design."* Bokningar → Reservation Center, Lokalinställningar → Venue Center, Gästtjänster → Guest Center, Kedjor & grupper → Chain Center, Food Bazar → Food Bazar Center, Abonnemang & funktioner → Plan Center, Installationscenter → Setup Center and Plattformsadmin → Admin Center: each group is one flat sidebar link under its own label, and every page the dropdown held is a tile four to a row with its old href, label, feature gate and trade gate (the restaurant-only groups carry the trade onto each tile, so search hides them from a salon too). The sectioned settings page is the Setup Center's "Inställningar" tile; Sound & Atmosphere still opens in a new tab; the Admin Center sends a non-superadmin back to Home like the Admin Hub does. Labels ×20 languages, blurbs en + sv/no/da. Enheter keeps its two rows (Köksrouting was promoted back on purpose).

    • "Så får köket veta" with a help panel (2026-09-06). Owner, with a screenshot: *"compact and too

    hard to understand … maybe add the help text on a right side panel to keep the surface clean."* The kitchen-delivery card is now two columns: the controls on the left — four big quick-pick tiles (Papper · Skärm · Båda · Ingen), two plain switches, the per-till read-back chips and the exceptions table with channel names only — and every explanatory sentence in one "Så fungerar det" panel on the right (what it controls, what a quick pick does, the two switches, each channel's meaning, where the till-side switches live, the paper-has-no-delay note). Same fields, same writes.

    • Köksrouting in the tile pattern (2026-09-06). Owner: *"very messy today — I get confused how to

    use it, too much text and not so easy to understand."* The page is now a header with ONE live status line (the health verdict) and six tiles with a one-line blurb and a live count — Så får köket veta · Stationer · Kategorier · Varor · Hälsokoll · Logg — and the chosen section's panel opens right under the tiles. The editors are the same code; the explanatory paragraphs the blurbs replaced are gone.

    • Marknadsföring and Experience Studio open in the same tab (2026-09-06). Owner's report: the new

    tab read as the link not working. newTab: false now opts a non-/dashboard workspace into the same tab; the Marketing Hub's forced new tab is removed.

    Added

    • Five more sidebar groups are single links to tile hubs (2026-09-06). Owner: *"take all one at a

    time — Kunder, Ekonomi, Restora Profit, Pågående service and Lager & leverantörer."* Same recipe as Meny and Personal: each group is now one flat link — Customer Center /dashboard/customer-center, Money Center /dashboard/money-center, Profit Center /dashboard/profit-center, Service Center /dashboard/service-center, Inventory Center /dashboard/inventory-center — and every page the dropdown held is a hub-linked tile with its old href, label, feature gate and trade gate (the grocery-only Lager rows stay grocery-only; the grocery-nav-isolation guard now counts a hub tile as a door). Labels in all 20 languages, blurbs in en + sv/no/da.

    Fixed

    • The website's styling vanished after going back, until you refreshed (2026-09-09). Owner:

    "when I go to some link and go back the website doesn't load correctly, I have to refresh." Next bundles CSS per route, so website-base.css is copied into the landing chunk AND the industries chunk; whichever chunk loaded last sat last in the document, and its copy of the universal reset (.r360-site * { margin: 0; padding: 0 }) then beat every same-specificity page rule on source order alone — the landing page came back with all its spacing zeroed, and an industry page did the same the other way round. The whole base sheet now lives in @layer ws-base, so an unlayered page sheet wins regardless of chunk order or specificity: the relationship the two sheets were always meant to have. Reproduced and re-checked with Playwright in both directions, history-back and <Link>-back.

    • "Open 24 hours" never reached the website (2026-09-07). Owner, from a tester: *"to change the

    opening hours to 24h on all days it does in admin but the real website does not change to 24h … when he manually adds 09:00 – 23:00 and copies that to all days it does work."* The editor only flips the day's is24Hours flag and leaves its old open/close strings in place, and the storefront dropped that flag on the way to its hours blocks — so the site kept printing the stale range. The opening-hours block, the contact card (both variants, plus its open/closed pill), the premium templates' hours lines and {{hours.weekly}}, llms.txt and the Google Business export now all say "Open 24 hours" for such a day. Verified by rendering the blocks with a 24h week.

    • Booking picker said "Booked" on slots nobody had booked (2026-09-07). Owner: *"under booking

    table the time slots show 'booked' but there was no booking."* Two causes. The public book route served every venue a hard-coded 17:00–22:30 evening, ignoring the venue's opening hours (so the 24h change above never reached the picker either); and the picker greyed every arrival whose stay would run past that close with the word BOOKED — with the standard two-hour stay, 21:00 / 21:30 / 22:00 read "Booked" on an empty day. The route now serves the venue's own window for the date (the same openingWindowFor fact the tills read: hours, 24h days, special days, closures; a venue with no hours stored keeps the old evening), a slot that cannot fit the stay says "Past closing", only a booking on that table says "Booked", a closed day says so instead of drawing a grid, and the POST refuses a time outside the window with the same rule. A real venue without a floor plan no longer inherits the sample room's fake bookings either.

    • The booking picker's no-show deposit step was dark (2026-09-07). Found beside the fix above,

    fixed on the owner's go. The route had served the venue's deposit since Phase 37 and the picker had the consent step drawn and gated, but the merge between them never copied the field — so a venue with a deposit switched on took web bookings without the guest seeing the amount or agreeing to it, while the server still stamped the reservation with a pending deposit for staff to collect. The merge is now a pure, tested helper (picker-book-data.ts) that carries the deposit through; the route resolves the deposit currency the way the simple form's config does (owner choice → country → platform default) instead of a hard-coded SEK; and the figures print ISO-coded ("200,00 SEK"). Driven in Chromium: the step shows, Confirm stays disabled until the box is ticked.

    • Personal & behörigheter crashed for a tenant with an old staff member (2026-09-06). Owner:

    "the first grid box link /dashboard/staff is not working, others are working." The member list did createdAt.split("T") on a row written before that field existed, and one such row took the whole page into the error boundary. It now renders "—" for a missing date.

    • Meny and Personal are single sidebar links to tile hubs (2026-09-06). Owner: *"when i click on

    menu instead of collapsing the nav it can have only a menu link, and the page should be similar to our grid boxes design … then Personal — it is very big but i want the same layout, 4 tiles per row."* The menu group (restaurant) and the team group are now one item each — Menu Center /dashboard/menu-center and Team Center /dashboard/team-center, rendered flat under the group's own label (Meny / Personal) — and their pages moved to HUB_LINKED_ITEMS with the hub page as hubSource (same hrefs, labels, feature and trade gates; every old row name stays searchable as a keyword). The hubs use the Enheter tile family four to a row: Meny · Kombos · Lunch & buffé · Produktionsstationer · Importera meny · Näringsdeklarationer, and the eleven team pages in their old order (Rekrytering restaurant-only). Blurbs live in the new hubs dict namespace (en + sv/no/da); "Menu Center" / "Team Center" labels added in all 20 languages. The menu editor keeps /dashboard/menu (26 deep links point at it). A grocer or a salon keeps its direct "Tjänster & produkter" link — a one-page catalogue needs no hub. New components/dashboard/HubTiles.tsx.

    • Enheter › Förare: "Lägg till förare" right in the grid (2026-09-06). Owner: *"a new card Lägg

    till förare, then that card changes to the dispatch function so it becomes super easy to add a new förare or dispatch right from the grid card; then Förarlistan i Dispatch, then Så ansluter du."* The first tile is the Dispatch roster's add form (name · phone · vehicle, Swedish vehicle labels); on Add it creates the driver through the same /dispatch/drivers call Dispatch uses and immediately mints the phone code (/dispatch/pairing-code), and the tile becomes that driver's pairing card — the big single-use code, the QR, the /driver?pair= link with copy, a 15-minute countdown, "Lägg till en till" and "Öppna i Dispatch". Without the Own Delivery & Dispatch add-on the tile says so and links to Your Features. Order is now: Lägg till förare · Förarlistan i Dispatch · Så ansluter du; the old "Var koden hämtas" explainer folded into the tile. New components/dashboard/devices/AddDriverTile.tsx.

    • A till can be removed, not only spärrad (2026-09-06). Owner, on the new Enheter tiles: *"we can

    spärra other kassa today but we cannot remove that fully" — nine spärrade QA tills and an old Kassa 1 with no way out of the list. The ⋮ menu on a spärrad or never-paired till now offers Ta bort…*; a paired till still only offers Spärra (removing a till that can sell is an outage). The backend decides what removal means (POST /pos/admin/registers/remove): a register that never reserved a receipt number is deleted outright with its pairing codes; one that did is hidden from the fleet (revoked underneath, removedAt/By stamped) and its seriesPrefix stays taken for good, because a new till continuing S001-T03 would collide with receipt numbers already on paper. The confirm dialog says which will happen (hasReceipts on the register view). The series allocator reads the list with includeRemoved=1 so a hidden series is never handed out again.

    Changed

    • Enheter: the newest till comes first (2026-09-06). Owner: with 20+ tills the one just added

    must not be lost at the end of the grid. Device tiles sort by createdAt, newest first.

    • Enheter is now two levels of tiles, in the Home page's style (2026-09-06). Owner, right after

    locking Home: "same UI/UX style for Enheter — today it is confusing. From kassa to network 4 tiles in a row and 4 in the next; under Kassa the same grid: one for Öppna/Parkopplingskod, one for how to connect, one for Kassapersonal…" Level 1 (/dashboard/devices) is one tile per category — Kassor, Kök, Självservice, Displayer, Förare, Betalning, Hårdvara, Nätverk, trade-filtered as before — with the category's count and status line; the tab strip is gone. Level 2 (?tab=pos etc., the same deep links) is a grid: row one the tools (Lägg till enhet with its name field, then the category's helpers — Kassapersonal, Så ansluter du, Äldre Restora-surfplatta for Kassor — and link tiles such as Köksrouting), then ONE TILE PER DEVICE with its status, kitchen line, Funktioner, Öppna, Parkopplingskod and the ⋮ menu; a minted code shows inside the device's tile. A helper tile opens its existing panel full-width directly under the tools row, above the device tiles (the guide, the staff card, the terminal/printer/kiosk fleets, the reliability report — none rewritten). ?connect=1 still opens the Anslut enhet menu; the ${type}-header anchors stay. Behöver åtgärd / Senast använda render on level 1 only when something actually needs attention. New components/dashboard/devices/DeviceTiles.tsx; the fleet panel's row internals (status chip, ⋮ menu, live code card, kitchen note) are exported and reused.

    • Home is now a quick-actions page, for every trade (2026-09-06). Owner's brief with a

    reference image: the admin landing page should put the functions in front of the owner "so the user does not need to scroll or search". /dashboard is now one small line naming the business type ("Restaurang", "Livsmedelsbutik", "Klinik" — lib/verticals/business-type-labels.ts, sv/no/da; the welcome + tenant-name heading was dropped the same day as redundant with the sidebar's workspace switcher) and a 3×2 grid of big cards — Inställningar, the catalogue, Enheter (live "N kassor · N enheter aktiva" + "Lägg till ny"), Erbjudanden ("Skapa banner" / "Lägg till rabatt"), the trade's daily desk, Webbplats — and a help line. The reference's separate PoS card was folded into Enheter the same day (owner: they were the same door — the POS-registers page lives inside Devices). Which cards and which doors is decided per trade in lib/dashboard/home-quick-actions.ts (the catalogue card opens /dashboard/menu for a restaurant and /dashboard/catalog for a shop or a salon; the daily desk is Beställningar /dashboard/order-desk for a restaurant, Lager /dashboard/stock for a grocer, Bokningar /dashboard/bookings for a salon or clinic; a salon's devices line has no KDS), gated by the sidebar's own entitlement + trade axes so a card can never show a door the sidebar hides. Counts are read server-side (registers from the backend, the fleet through the new lib/devices/active-fleet.ts, which the devices summary route now shares) and degrade to no status line, never a confident zero. Three deep links were added so the buttons land where they say: /dashboard/devices?connect=1 opens the Anslut enhet menu, /dashboard/offers?new=1 opens the editor on a blank offer, /dashboard/site?panel=add&q=banner opens the website editor's Add palette filtered to banners. The KPI row, Operations Center, starred-shortcuts row and onboarding checklist no longer render on Home (their components remain; Orders / Intelligence / the sidebar keep their doors). Strings in en + sv/no/da (overview.qa*), fully translated — the dict ratchet is unchanged.

    Fixed

    • Themes now adjust to the tenant's trade, and two theme-seeded storefront defects (2026-09-05).

    Reported from ten_beauty-salon: applying a theme put a RESTAURANT hero on a salon's landing page, the contact page showed two "Send us a message" forms (one unthemed and posting nowhere), and Opening hours rendered a 14-row double week. (1) Trade-aware imagery: every restaurant stock photo the generic packs ship now has a verified services/grocery counterpart (src/lib/themes/vertical-images.ts); the swap applies at install AND preview (installer.ts splitPages), on the gallery cards (toCatalogCard), and at render time for sites that installed before the fix (PageBlocks heals known stock ids via the tenant's declared vertical — an owner's own images are never touched). Uploaded .r360theme.zip manifests can now declare verticals so a restaurant theme is never offered to a salon (schema + docs). (2) The generic packs' contact page no longer seeds a standalone form block next to contact-info (whose embedded form is the real, wired one); the live renderer also de-dupes an action-less form on already-installed sites. (3) The double week: the /onboarding wizard wrote mon…sun and /welcome/onboarding wrote Monday…Sunday next to the template's monday…sunday, and the settings PATCH shallow-merged them all together — both wizards now write the canonical keys, the PATCH replaces openingHours wholesale (self-healing on the next hours save), and groupWeeklyHours/foldWeeklyHours fold abbreviated + sv/no/da day keys into the canonical week (exact-keyed entries win a conflict).

    Added

    • Presentkort redesign on the tills, with the website's 26 designs (2026-08-29). The POS

    Presentkort screen is one premium two-column view (belopp → design → leverans; saldo → beställning → betalning) on the web, Android and Windows tills. The card designs are the website's own catalog (src/lib/gift-cards/greeting-templates.ts), reached through TWO GENERATED artifacts (pos/web/src/data/gift-catalog.generated.ts, pos/app/.../data/GiftCatalog.generated.kt) emitted by npm run generate:pos-gift-catalog and gated by src/lib/gift-cards/pos-catalog-parity.test.ts, which regenerates them in memory and fails on any byte of drift — count, ids, order, copy or token mapping. Till-sold cards with an email recipient are now delivered: the device issue carries buyer/recipient/greeting/ delivery, and a new cron, /api/cron/gift-pos-delivery (every 5 min), claims due cards from the POS store through POST /pos/admin/giftcards/claim-deliveries and settles them through POST /pos/admin/giftcards/delivery-result, sending the same gift email/QR the website sends. Presentkort is never offered as payment for a Presentkort (the tills' issue-method model has no such kind; the server's GiftIssueTenderDto refuses it). engine.ts no longer embeds a full gift code in the one error message that did.

    • A shop can author its own assortment from a desk (2026-08-28). /dashboard/catalog

    (Sortiment) is now in the grocery vertical's navigation and shows the whole grocery half of a product: barcode, PLU, VAT category, pant (amount + SE-PANT-CAN/SE-PANT-PET scheme), stock flag, age gate and weigh pricing (price/kg, tare, minimum net). Until now the ONLY door that could author any of them was the till's own quick-create, reached off a SCAN of a code the catalogue did not yet hold — so a shop could not put a product into its own assortment without standing at the counter with the packet in hand. The Menu page is deliberately not that door: translateLegacyMenu rebuilds variations wholesale from the tenant blob and emits none of those fields, so a grocery product authored there loses its pant at the next translate. The fields sit in a collapsible "Butiksuppgifter" section per variation, grouped by the question each answers (Identifiering / Pant / Lager & regler / Vikt) and open by default on any variation that already carries one — so a salon's form is unchanged and a shop's is one click from complete. Offered on any non-SERVICE product in EITHER trade, because a salon that sells shampoo has a barcode too. Header, empty state, add-panel title/placeholder and the type toggle fork on useTenant().vertical (*Retail keys, the same mechanism overview already uses), authored in sv/no/da so the dict-coverage ratchet holds.

    • `weighPricing` on the admin projection. The last grocery field listItemsForAdmin was

    missing: added with the others on 2026-08-25 and left off, so a scale product was priced by the till and readable by nobody. The projection's declared type also gained uom, priceMinor, currency and taxCategoryId, which the mapping had always emitted while the signature denied them.

    • `null` erases. barcode, sku, deposit, weighPricing and minBuyerAge accept null

    through the proxy and the DTO, and upsertItem DELETES the key rather than storing one. Absent-means-keep is what makes the merge safe and is also why an optional field without an explicit erase is write-once from every door but the database. (plu keeps its own contract, where the null is a real stored value.)

    Changed

    • `taxCategoryId` is the taxonomy, not a string. TAX_CATEGORY_IDS is now declared once in

    @restora/domain-types beside the TAX_RULES table keyed by it, with TaxCategoryId derived from it. VariationDto validates @IsIn(TAX_CATEGORY_IDS) (it was @IsString() @Length(1,64) — any string) and the proxy z.enum(TAX_CATEGORY_IDS). Four hand-written copies of the same five words now derive from it: ENGINE_TAX_CATEGORIES, the service-offers DTO, and the kernel's corpus and fuzz generators. Before this a typo ('standrad') created a product that SAVED, synced to every till and then could not be priced — surfacing only at the counter as "Motorn prissatte inte notan.", with a queue behind it.

    Fixed

    • A refused barcode no longer lands on the document. upsertBarcode has always refused a code

    another item owns (409 BARCODE_TAKEN), but it ran AFTER upsertItem(doc) — so the losing product kept the conflicting code while the registry still pointed at the rightful owner. Invisible while nothing displayed a barcode; the moment the editor showed one it became a duplicate the owner could see, was told nothing about, and could not save away. The check now runs before the write, matching createItemFromTill, which has always refused up front. The editor renders the conflict in the owner's language, naming the code.

    • Grocery wording leaks on both till kits. overviewBillsUnattached ("N utan bord") is

    suppressed on a retail till — nothing on a counter attaches to a table, so it fired for every open purchase — and the Systemstatus Bongutskrift row is gated on showKitchen rather than ungated, so it no longer sits green and permanent on a venue with no kitchen. Retail twins added for orderMenuLabel, orderMenuSubtitle, sectionOrderLabel, internalNoteHint, discountAppliedHeading, emptyTicketHeldBill, the releaseBill* family and outsideServingHours, in all four locales on both kits. The release verb is släppa, never "lämna köpet öppet": öppet köp is a Swedish consumer's right of return, and a button offering to leave a köp öppet reads as granting one. Every restaurant string is pinned byte-unchanged.

    • Bokningshistorik on the tills (2026-08-27). New operator-gated device endpoint

    GET /pos/registers/bookings/history (NestJS proxy → api/internal/pos/booking-history): the whole reservation book — every date, all four stored statuses and the no-show flag — searched, filtered (date range / status / source / table), sorted and keyset-paged in one ask per page. Same posBookingRow projection as the day list (no email, no cancel token), and deliberately NO money on the wire: the store holds no durable booking↔nota link, so the history renders an honest em dash rather than a guessed total. The operational "Alla" board stays today-and-forward exactly as 1.0.5746 shipped it.

    • `startAt` on every till booking row. The scheduled start as a venue-clock instant,

    computed by the same evaluator as checkInEligibleAt (bookingStartAtMs in check-in-window.ts; eligibleAt = startAt − the window, by construction). It is what the tills' derived Försenad (late) presentation turns on — presentation only, never written back to the store.

    Changed

    • Bokningssheet remake — the premium reservation popup (2026-08-28, 1.0.5750). The

    till's reservation details dialog, remade the same way in BOTH kits (pos/web BookingSheet.tsx ↔ Compose BordScreen.kt): 640-wide record dialog; a p5 state chip in the body wearing the board pill's exact colour families (Sittande/Incheckad/ Försenad/Kommande, neutral for closed answers) beside the quiet facts line (reference · källa · VIP · occasion); the three tabs on the kit's own segmented pill strip; the tab body, Ankomststatus, Bordsättning and Intern not each a bordered card; Avboka down from the 64-px coral bar to a default-size danger button (the two-step stays the protection); the "Syns bara för personalen" audience line VISIBLE under the note instead of a vanishing placeholder; and — the owner's named requirement — the guest's NAME leads Gästinformation whenever the booking carries one (customerName is the only guest identity on the wire; phone stays off tills, no email rides the read projection). No handler, gate, window rule, confirm step or wire contract changed; the sheet's pinned source contracts (early-door gate, deposit seam, one-flip timer) hold verbatim. New BookingSheetRenderTest renders the Compose sheet to PNG and pins the chip + guest-name anatomy; the pos/web preview harness's ?locale= was reconnected (it silently stopped switching at the 2026-08-17 locale split).

    • Enheter subpage cleanup — add/remove without scrolling (2026-08-28, v2). The register

    tabs' vertical sprawl is gone: the pairing widget is ONE wrapping row, the create control moved from below the fleet to a top action bar ("+ Lägg till enhet", visible without scrolling), and rename/spärra left their unlabeled icon buttons for a labeled ⋮ row menu ("Byt namn" / "Spärra enhet…", confirm dialogs unchanged). The healthy kitchen sentence and the Funktioner disclosure share one quiet meta line per row (warnings keep their own prominent line); Kassapersonal and Äldre Restora-surfplatta are collapsed single rows — the staff header keeps the person count and the "Inga varor på kassan" warning visible while collapsed. Betalning renders TerminalsPanel (with its Add a device form) before the binding/sandbox cards. No handler, endpoint or dialog changed.

    • Enheter page visual remake (2026-08-28). /dashboard/devices now matches the supplied

    reference design: header with the live online/offline/ej-parkopplade roll-up and one "+ Anslut enhet" entry point (a menu over the existing per-type pairing flows), a category tab row (Översikt · Kassor · Kök · Självservice · Displayer · Betalning · Hårdvara · Nätverk, live counts), a system-health banner, and the Behöver åtgärd / Senast använda enheter overview. UI-only in intent: every fleet panel, pairing widget, claim form, rename/revoke action and the first-run Kom igång card is the existing component re-homed into tabs (the collapsible boxes became always-open sections; ${id}-header anchors and the IA-2 ?tab= deep links still land, now selecting the tab that absorbed them). The two display types share the Displayer tab. One additive API change: GET /devices/summary now also returns kiosks, hardware.online and a per-device devices[] array (id/kind/name/online/lastSeenAt/model) computed with each fleet's OWN heartbeat window, so the overview can never disagree with the tab a row links to; the pre-existing top-level 5-minute counts are untouched (OperationsCenter). Dict devices title/subtitle updated (sv "Enheter"; en/da/no in step); statuses use the semantic .status-* tokens in both themes.

    • Void-reason wording: the POS Makulera reason Gästen gick now displays as

    Kunden avbröt (en Customer cancelled, no Kunden avbrøt, da Kunden afbrød) in both till kits, all four locales. The stored reason code guest_left is unchanged, so historical events render through the new localization untouched.

    Security

    • **The legacy Next.js fiscal rail could take real money and sign a Swedish receipt with a SANDBOX

    control code, in a market that was never opened (2026-08-20).** The NestJS till path has passed assertMarketOpenForSale since Phase 6; this rail — storefront checkout, QR pay page, kiosk, delivery ingest, counter payment, PSP capture — consulted no gate at all. certified: false was honest in the data and invisible on the receipt.

    Fixed at the CHOKEPOINT, not the call sites. lib/fiscal/se/market-gate.ts decides three refusals — MARKET_NOT_OPEN, MARKET_STATE_UNKNOWN (fail closed) and SANDBOX_SIGNATURE_ON_LIVE_SALE (a placeholder code may not sign a real sale even in an OPEN market). Every reservation in wire.ts (reserveFiscalSale, reserveFiscalPortionSale, reserveFiscalTraining) now takes a REQUIRED clearance and throws without one, so a stale client, a deep link, a hand-rolled POST, a replay or untyped JavaScript cannot reach a receipt number. A drift check refuses clearance won on a certified unit but spent on the sandbox. createTenantOrder refuses BEFORE the money moves on any collecting call.

    Deliberately NOT gated, each a standing rule: refunds and returns (structural — the extra reservation takes no clearance parameter at all), receipt copies and pro forma, and training mode. Onboarding a venue before its country opens still works.

    Test mode is an explicit declaration of TWO variables (FISCAL_DEV_ALLOW_SANDBOX=development and NODE_ENV=development), modelled on the dev-pairing door: a check reading "not production" passes when nothing was said. 19 adversarial cases in market-gate.test.ts.

    Fixed

    • Gift cards were minted in a currency the REQUEST chose (2026-08-17). Five mint doors read

    (body.currency ?? 'SEK') — the public purchase route, the public corporate portal and the dashboard's issue / bulk / recurring doors — and five UI pickers offered SEK·USD·EUR·GBP. A euro venue therefore minted SEK cards which its own redeem gate refused as foreign, after the guest had paid; a stored-value card cannot be re-denominated once sold. The denomination is now the VENUE's on every door, through one resolver (lib/gift-cards/currency.ts — venueGiftCurrency, giftCurrencyRefusal, sameCurrency — plus venue-currency.ts for the one await). A caller naming a different currency gets 409 `GIFT_CURRENCY_MISMATCH` before a card exists, and the card, the Stripe/Connect charge, the Swish/PayPal/Adyen leg and the presentkort invoice all read that single value, so charge and mint cannot disagree. Every picker became a read-only badge fed from the server (/buy, the embed widget, the corporate portal, the dashboard issue modal, bulk, recurring); rails.ts gates Swish on the same resolved currency so the button and the route can no longer disagree. On the redeem side, the six !== currency gates (engine tender / external / closed-loop, closed-loop preview, payment-intent, orders, pos/quote) now compare through sameCurrency, so a venue that stored "sek" can no longer have its own cards refused. Guest gift money prints its ISO code (formatGiftMoney — reader's language, the currency's own locale when none is known, never the ambient one). 15 new tests; ui tsc 0 errors.

    • **Ten confirmed defects across the HQ v2 p8/p9/p10 surfaces (2026-08-17), each at its

    root and each re-measured in the running app at 1920×1080.* - Beställningar' detail panel sat below the fold (p8). The rail opened in a row BELOW the KPI cards, 217 px further down than the design puts it, and the aside had no ceiling — its `flex-1 overflow-y-auto` never engaged (`scrollHeight === clientHeight === 532`) and "Acceptera & skicka till kök" landed at y 987 with the panel ending at 1138. The panel is now the rail of the WHOLE workspace, beside the KPI row exactly as p8 draws it, and the aside is capped (`xl:max-h-[calc(100dvh-15rem)]`, a MEASURED number). Re-measured: panel 217→1057, actions 906–1040, scroller 1004 over 568. - The relative estimate printed raw minutes. "(om ca 1043 min)" for a next-morning slot and "(om ca 2880 min)" for a pre-order two days out. `relativeSpan` (pure, spec-pinned) now rolls minutes into hours and days and the dict owns every word: the same order reads "(om ca 2 dygn)". - A tab switch left the previous order's panel open beside a list that did not contain it — most visibly on Parkerade, which prints in words that nothing there is listable. The selection now resolves against the FILTERED rows, so no filter, tab or search can show a row the list does not hold. The selection itself survives: return to the tab and the panel is there. - p8's trailing filter button was missing, replaced by a "Rensa filter" text button that only appeared once you had already filtered. The icon button is now always at the end of the filter row, and it says what it does (disabled + "Inga filter är aktiva" when there is nothing to clear). - The delivery slip printed "ORDER ##1203". `GET …/orders/[orderId]` hand-rolled its own copy of `buildDeliveryView`, and the copy had drifted: the shared builder strips a stored leading `#` because the slip prints its own. The route now calls the one builder, so the screen, the printed slip and the PDF cannot disagree about an order number. - A Beställ line lost its modifiers AND its "Allergier / notering" on the way back to the nota. `AdminTicketLine` carried neither, so a dish sent with a size, two extras and a note read as a bare "1 × Pizza Vesuvio" — the dashboard and the kitchen bong disagreeing about the same dish. The backend projection now ships the option NAMES and the note (no per-option money: it is already inside the line's engine total), the ui twin whitelists them, and p10's nota draws them under the dish. - The draft's "Totalt" was not what the send added. `bestallQuoteAdmin` priced the proposed rows as a standalone basket, so on a nota carrying "Rabatt 10%" it quoted 89,00 kr for a row that moved the bill by 80,10 kr — a figure an operator could read out to a guest and then fail to charge. The quote is now a DELTA: the live bill priced as it stands, then priced again with the rows appended, through the same engine call WITH the bill's own discounts. Verified live: nota 290,70 kr, p11 "Pris" 80,10 kr, draft Delsumma 71,52 + Moms 8,58 = Totalt 80,10. - Typing in "Sök produkter" deleted the whole category tab strip (3 tabs → 0), because the strip was replaced by a `<span />` while searching. p9 draws the tabs and the search field side by side, always; they now are. No tab is current while a search runs, and tapping one clears the search and takes you there. - Error text on the Beställ screens used raw text-red-600 (3.6:1 on the dark card). Now the guarded `.status-ink-danger`, and the one hover state uses the same `--status-danger-ink` token. - The table panel still drew "Senaste aktiviteter" + "Bokningsinfo" beneath the nota*, which p10 removes outright ("Det dubbla informationsområdet längst ner är borttaget… nås via flikarna högst upp"). Both cards are gone; a long nota has the panel to itself.

    Added

    • **Reservation check-in got a 15-minute window, a confirmation dialog and a protected

    early door (2026-08-20). The ONE rule lives in `lib/reservations/check-in-window.ts` (`CHECK_IN_EARLY_WINDOW_MINUTES`, `checkInWindowVerdict`) and is evaluated in the venue's own timezone via the venue clock; a stored time that cannot be judged fails CLOSED. Both mutation doors — the dashboard PATCH and the internal POS action route — refuse a too-early check-in with `CHECK_IN_TOO_EARLY` + `eligibleAt`/`serverNow`, and a no-show row with `BOOKING_NO_SHOW`; every surface (dashboard list/detail/table panel, web till, Android till, Windows till) renders a functionally disabled "Öppnar HH:MM" control that flips at the boundary without polling, and confirms the guest's identity (name · time · covers · table · phone ending) before any stamp. Tidig incheckning** lives inside booking details only, needs reservations.override (dashboard, admin-only) or owner/manager/ approve (till roster, proven server-side per request), demands one of three reasons, and stamps earlyCheckIn {reason, by, at, device} plus an activity-log row in the same transaction. Responses carry checkInEligibleAt and checkInWindowMinutes, so no client authors the 15. sv/en/da/no on every surface.

    • Flytta bord brought to HQ v2 p12 (2026-08-17). MoveTablePanel.tsx now draws the

    PDF's own anatomy: the source stat row (Gäster · Öppen nota · Aktiv sedan), the outlined "Från (aktuellt bord)" plate, the valid-target grid with p12's three tiers (Rekommenderad in status-ok green, its same-capacity peers Bra alternativ, roomier tables plain) under "Rekommenderat bord passar bäst för antalet gäster", the "Detta flyttas tillsammans" summary ending in a ticked "Serviceflöde bevaras", and Avbryt / Bekräfta flytt over the padlocked "Flytten kan inte ångras." The recommendation arrives ARMED — p12 draws it checked, and the PDF's own logic note is "systemet kan rekommendera lämpliga mål-bord men personalen kan välja manuellt" — while any other valid tile is one tap away. The ranking left the JSX for lib/reservations/move-table.ts (rankMoveTargets, moveParty) and is pinned by tests, including the rule that a party of unknown size filters nothing but may never be RECOMMENDED a table smaller than the one they are already sitting at. No second mover was built: it is still the one shipped door (POST pos/ticket/move-table → moveTableAdmin's rev-pinned CAS on boundTo.refId), with a covering booking riding the existing reservations move route afterwards. Nothing here settles: an OPEN bill is rebound to another table — no receipt number, no journal row, no tender — and the öppen nota figure is the board's server-priced string verbatim, rendered as an em-dash when the bill cannot price. New dict key ovMtFreeNow ("Ledigt nu"), because p12's move grid asserts availability at this moment where the map plate says the bare "Ledigt"; ovMtOnlyValidHint and ovMtActivitiesDetail now match the PDF's Swedish word for word ("noteringar", not "noter").

    • Beställ + Produktdetalj & modifierare (2026-08-17, HQ v2 p9 + p11). The ordering

    surface is now the SCREEN the PDF draws, not a rail panel: BestallSurface.tsx takes the whole overview (the NewBookingWizard regime) with the header "Beställ · Bord N" + state chip + Flytta | Mer | Avbryt, the automatic-draft banner, the menu on the left (Favoriter + category tabs + search, the product grid, "Alla produkter" with counts) and "Nya artiklar till Bord N" on the right, ending in one primary "Skicka N artiklar till kök". After a successful send the operator FLOWS BACK to the active table (p9's own note 3), where the nota and the aktiviteter have just updated — no second nota is drawn here and there is no competing "Spara". p11 is built, and it is built on the tenant's OWN menu. Tapping a product opens BestallProductDetail.tsx: the obligatory single-choice group, the multi-select tillbehör with checkboxes, "Allergier / notering" and a Sammanfattning card (Produkt · Val · Antal −/+ · Pris) with Avbryt / Lägg till på notan. The PDF's "Ryggbiff / Stekgrad" is an EXAMPLE of the pattern and is hard-coded nowhere: the groups, their options, their min/max rule and their money come from pos_modifier_groups through the bestall-catalog door, which now projects them (they were on that projection's never-leaves list for a reason that had expired — no dashboard surface could ask for an option). A venue with no modifier groups gets the same screen with the question area empty. Every figure on both screens is the server's. A new READ-ONLY door, POST /pos/admin/tickets/bestall-quote (BFF: …/pos/ticket/bestall-quote), builds the very lines the send would build — same catalogue, same server-resolved modifier pricing, same price ladder, same tax resolution under the TABLE's own scope — and prices them with the same evaluatePromotions/priceBasket pair every other view uses. It answers the configured line's "Pris" (debounced, so ticking an option re-asks rather than adds) and the draft's row totals + Delsumma / Moms / Totalt. Nothing is summed, multiplied or discounted in the browser; a figure the engine cannot produce is OMITTED, never 0,00. Proven by spec: a quote and the send that follows it return the identical figure for the same dish. The allergy note is real plumbing, not a decorative box. It rides ext.foodBev.note — the platform's documented channel for "utan lök" / "allergi: nötter" — so it lands on the stored line, the kitchen ticket line and the fire paper. It carries no money and nothing prices it. The required-choice gate is the money door's own minSelect read forward, so a single-choice required group opens pre-answered with the menu's default and the send can no longer fail on a question the screen never asked. Verified on glass against a real tenant (ten_shani-pizza, Bord 1): the size ladder substitutes the unit price server-side (89,00 "Liten 26 cm" + 15,00 "Extra ost" ⇒ the engine's 104,00 kr), and the draft's Delsumma/Moms/Totalt are the engine's own VAT buckets.

    • Beställningar — the order desk (2026-08-17, HQ v2 p8). A new dashboard workspace at

    /dashboard/order-desk: the day list a person WORKS at (avhämtning, leverans, över disk), as opposed to /dashboard/orders, which is the TV & Displays hub's watch-from-the-pass kanban. Same store, same doors, different job. It is a read surface, not a second order engine: GET /api/tenant/[t]/orders/desk reads tenant_data.orders once and projects the row (lib/orders/order-desk.ts); Acceptera and Markera utlämnad are the existing PATCH …/orders/[id] (which is where crossesKitchenAcceptance fires the kitchen bong and stamps the milestone), and Avvisa is the existing cancel-and-refund route. p8's own logic, implemented literally. An order WITHOUT a table belongs here, never in the booking list and never branded "saknar bord" — so a tableless dine-in order is on the desk and a seated one is not (one order, one screen). The three payment sentences read from the stored tender, not a guess: a PSP settled it before anyone touched it (Betald online), cash/in-person card/wallet could only have been taken with the guest present (Betald i kassan), pending + cash is the platform's own pay-at-pickup shape (Betalas vid hämtning) and everything else says Ej betald. A pending MARKETPLACE order is its own lane — a partner and a courier are waiting on the answer, which is why the PDF paints it amber. Nothing is invented where the platform stores nothing. Money is rendered ONCE, on the server, from a stored figure (Order.total, OrderItem.price, which the schema defines as the line total) — a line or an order without one renders no amount at all, never 0,00. The estimate line is drawn only off promisedReadyAt (stamped at acceptance) or the guest's own scheduledTime, so an un-accepted Foodora order gets no ETA rather than a fabricated one; a courier name appears only for own-driver runs. The Parkerade tab and KPI print IN WORDS that a parked bill lives on the register and cannot be read from here (only a table-bound one ever reaches the dashboard, and the projection that does rewrites it to accepted with no customer or lines) instead of an empty list that would claim there are none. p8 also draws an "Intern notering" box and a "Redigera" button; an order has no staff-note field and nothing in the platform edits an order's lines, so both are deliberately absent rather than fake — see the header comment in OrderDeskPanel.tsx for what they would cost. Rules are pure and spec-pinned (order-desk.test.ts, 17 tests); the day window is the BROWSER's own local-midnight instants, so a service running past midnight is never cut at the wrong hour by a UTC string slice. New dict section orderDesk (sv hand-written). Nav note: sv Orders was renamed Livetavla — p8 gives "Beställningar" to this desk, and two sidebar rows carrying the same word would have been the real regression.

    • Bokningsbekräftelse — the confirmation seam (2026-08-17, HQ v2 p3/p4). Server side.

    p3 puts a LIVE PREVIEW of the guest's message on the Ny bokning screen, above a character counter ("160/160 tecken"), with SMS and E-post as two channels of the same text; p4 shows the same message afterwards with a per-channel status and a "Skicka på nytt". Three things were missing for any of that to be true. (1) The text is composed in ONE place — lib/reservations/confirmation-message.ts, pure (so the wizard imports it directly for the preview rather than round-tripping to a server it has not written to yet), producing the PDF's own lines including Plats: Bord 1 + Bord 3 (12 platser) from the WHOLE combination, never tableId alone. Phrases are a copy pack (SV_ = the PDF's wording, EN_ for everyone else, picked from settings.defaultLanguage), months are a table rather than Intl — a small-ICU runtime would silently swap "maj" for "May" and change a character count the operator had already approved. measureSmsText reports the real cost: the GSM-7 alphabet (å ä ö are IN it, so a Swedish message is a 160-character message), the collapse to a 70-character budget the moment one pasted smart quote forces UCS-2, and the segment count. The booking REFERENCE is opt-in for exactly this reason: it is 18 characters on a 154-character message, i.e. the difference between one SMS and two for every till-created booking. (2) Channels (notify.ts) — email is untouched, still fire-and-forget, still unable to fail a booking; sms is a DISPATCH: awaited, per-channel, and honest. There is no reservation SMS provider in this product and none was invented — the channel is wired to the tenant's own smsConfig and every way it can be absent (disabled / no sender / no credentials / a dev process that only console-logs and returns true) is reported as not_configured with the reason, from the new smsReadiness() twin of isMailConfigured(). Nothing is ever recorded as sent that was not. (3) A door — POST /api/tenant/[t]/reservations/[r]/confirmation sends or re-sends on the chosen channels and answers per channel; GET serves the preview, the counter and what already went out. It stores Reservation.confirmationChannels (the latest attempt PER CHANNEL — a resend replaces, never appends, because this lives on the tenant blob) and confirmationSentAt, which moves ONLY on a real delivery. Provider I/O runs outside the write lock and the record commits inside it (the deposit seam's settle-then-persist order), and a cancelled booking is refused (409 BOOKING_NOT_OPEN) rather than turned into a second, competing cancellation rail.

    • Bordskombination — one booking, several tables (2026-08-17, HQ v2 p2–p4). Server side.

    A party of twelve in a room of six-tops is Friday, not an edge case, and until now the book had no way to say "Bord 1 + 3": the suggestion engine answered nothing fits today while the staff pushed two tables together off the record. Reservation.tableIds?: string[] is the full set, PRIMARY FIRST, and is absent on an ordinary single-table booking — tableId stays the primary and every one of its ~40 existing readers is untouched, which is the whole reason the field is additive rather than a rename. One helper decides the inconsistent cases for everybody (lib/reservations/table-set.ts: tablesOf / primaryOf / isCombined / tableBindingOf / applyTableBinding, plus the PDF's own label — "Bord 1 + 3", badge "BORD 1 + 3", built on the existing isBareTableName shape rule so a venue that named its own tables is never given the word twice). Occupancy now takes the whole set: a combined booking holds BOTH tables for the sitting — in occupiedTableIds, in the over-capacity shrink report (measured on SUMMED seats, so 12 on 6+6 is no longer reported broken), and in the public /book page's own window engine, where reading only the primary would have sold the second table to a guest. assign-table runs the same three checks over a set (all exist / all free / summed seats hold the party, with the named-staff override intact) and gained an auto-assign path; suggest-table proposes the smallest PAIR, then a triple, capped at three, preferring tables the venue put by the same landmark (DesignerTable.near — landmarks, NOT a table adjacency graph) and otherwise the least waste, with the biggest table anchoring the set. "Ingen bokning utan bord": the staff create and the till create/update doors accept tableIds and, when a caller names nothing, assign a table themselves, refusing with 409 NO_TABLE_AVAILABLE (carried through the POS bridge by code, never flattened into a 503) only when the venue HAS a room and none of it fits — a venue with no floor plan books exactly as before. A till edit that mentions no table KEEPS the tables the booking has rather than blanking them.

    • Bokningar & bordskarta — the KOMPLETT redesign (2026-08-16, owner's 14-page PDF). The

    dashboard's reservations page is rebuilt to the design, screen by screen, each one screenshot-compared against its page before landing. Server doors first (5ae3e1d5f): Reservation.checkedInAt/checkedInBy — ARRIVAL IS A FACT NEXT TO THE STATUS, never a fifth one, so every count and filter still reads exactly four (the noShow precedent), and arrival DELETES a standing no-show on both doors, in either tap order; the dashboard PATCH grew into the full edit door (check-in, VIP, inline guest/time/table edits) sharing the till's resolveTableAssignment room-check with the booking excluded from its own occupancy, refusing closed bookings with BOOKING_NOT_OPEN BEFORE any write; the till chain learned action: 'check_in' end to end; and the list route learned ?date= — a chronological day view plus dayCounts (incl. checkedIn and unassigned = the design's "Saknar bord" tab). Absent date returns byte-identical to before. Screens (988c715d1): the two-step Ny bokning wizard (p2/p3) reading the same availability and POSTing the same staff-create door as snabb bokning — "Föreslaget bord · Bästa passform", VALT BORD radio, ALTERNATIVA BORD, allergy folded into specialRequests through a tested composer; Alla bokningar (p5) with its date stepper, search, sort, Alla/Kommande/Sittande/Saknar bord tabs (Kommande derived on the LOCAL wall clock), azure BORD badges, .status-warn SAKNAR BORD rows and 14-per-page "Visar X–Y av Z"; and the p6 rail regime — the selected table's panel moved to the TOP of a rail widened to ~43% (the owner's explicit "the right panel is much wider in pdf" note), snabb bokning collapsing beneath it, Senaste aktiviteter + Bokningsinfo side by side. The p8 Bokning tab (61a23c812): inline-editable BOKNINGSINFORMATION rows submitting as ONE merge PATCH (probe-verified single call; allowOverCapacity only on the explicit "Utöka ändå" retry — the pull-up-a-chair act, stamped server-side), ANKOMSTSTATUS with Checka in / Ångra, confirm-gated Avboka that says when the guest is mailed, refusal codes rendering the dict's words. DELIBERATE DEVIATIONS from the mock, each with a reason: E-post became optional (the design says valfritt; StaffCreateReservationSchema now accepts '' and the recipient list already drops empties), and Skicka SMS ships visible but disabled — no SMS transport exists, and a button that silently does nothing is worse than one that says why.

    • Mer-menyn + Beställ — the sales doors behind the design's actions (2026-08-16, `03791495f`).

    TicketDoc gained covers/waiterName (doc-only, off the frozen domain-types IDL, like the park-era stamps beside them) with admin doors on the Betala-stamp pattern; the ticket-detail projection prefers waiterName over openedByName for the design's Servitör column and operator ids still never leave the server. Stäng bord voids ONLY an all-voided bill through a rev-pinned CAS — lines present refuse 409 TICKET_HAS_LINES naming Betala as the way, and a concurrent till resume makes the void lose the CAS rather than eat a bill that just gained lines. Slå ihop bord was promoted from scripts/merge-table-bills.mjs into a server operation keeping every script rule verbatim (earliest-openedAt survives, _mN line re-issue, fired ids remapped so nothing re-fires, absorbed bills VOIDED with mergedIntoTicketId rather than deleted, discount-carrying groups refused) — plus the refuter's catch: absorbed bills' kitchen cards now repoint to the survivor inside the merge transaction, so voiding a merged line still strikes the board. Beställ lets the dashboard order onto a table's nota through the SAME openTicket/addLine/fireKitchen doors a till uses — bound-bill adoption (never a second bill), kernel pricing, 86/serving gates, the allergen + modifier fold on card AND bong, post-commit bong, training isolation — idempotent on a client-minted opId so a double press cannot cook twice, and with ZERO tender/receipt/journal/stock/queue writes, proven by spec.

    • Guest name on the tills, and check-in there too (2026-08-16, `6af50d022`, owner ruling). A

    guest gives their name FOR the booking, so both tills now show it on booking rows and sheets; the PHONE stays off (the refuter found the Compose sheet still printing it — removed; find-by- phone and edit prefill stay, being the write flow and the background lookup key). Both tills offer Checka in for pending/confirmed bookings AND re-offer it over a no-show flag, so the server's heal is reachable from either surface; "Markera som utebliven" hides once a party has arrived; "Incheckad HH:MM" renders on day rows, find rows and sheets from the LOCAL wall clock.

    • **Modifier allergens — the fold into the dish's line (2026-08-16, owner ruling "modifiers can

    carry allergens").** A product OPTION can now carry confirmed EU-14 allergen ids (ProductOption.allergens; per-option chips in the menu editor's option groups, same ALLERGEN_REGISTRY as the product's own). THE MODEL IS THE OWNER'S: an option's allergens FOLD INTO THE DISH'S LINE — "Extra ost" makes the LINE contain milk; the cook reads the dish and the bong's allergen row stays per line, never a row per modifier. Where the fold happens, per rail: web/legacy orders at the write-time snapshot (snapshotKitchenAllergens unions the chosen options' facts by the new selectedOptions[].optionId/groupId the resolver now stamps — so the stored order keeps the union forever, R2a immunity intact); till fires / counter sales server-side in pos-sales where allergenChainOf already joins the catalogue item (new modifierAllergenIndex + foldModifierAllergens, threaded through the park/send fire, the explicit fire-kitchen door, the reconcile retry and the counter-sale bong — no new reads on the money paths); and the online→kitchen bridge now carries the LINE's stored chain (union-additive at ingest), closing the gap where a web order's option allergens never reached the native KDS board. Backend catalog: LegacyMenuDoc option allergens declared + modifierGroupFrom emits by key omission; CatalogModifier/ModifierGroupRead + admin upsert DTO widened. The kernel's applied-modifier wire is untouched (serde denyunknownfields). MIGRATION: nothing invents data — absent option fields stay absent (silence, never an "allergen-free" claim), no backfill; the first assertion an option ever makes is an owner saving it in the editor. Option-allergen corrections after order time do not reach live tickets (the read join stays product-only; the dish-level ceiling still rises).

    • Bokningar & bordskarta — Phase 4: the tills get the desk's rules (2026-08-16). The three

    POS surfaces (Compose Android+Windows via pos/app commonMain, pos/web) gain the dashboard's reservation intelligence with less desk chrome, every rule reached through the SHARED modules rather than re-derived: Nästa lediga on the till booking form (new device route GET /api/internal/pos/booking-suggest composing lib/reservations/suggest-table.ts over getAvailableSlots + sitting-aware occupiedTableIds; NestJS door GET /pos/registers/bookings/suggest, RegisterGuard) with a one-tap apply that sets time AND table; Flytta on the till's booking rows (a table-only full-state move through the EXISTING reservation-update → assign-table.ts CAS; refusals surface BY CODE — TABLEOCCUPIED / TABLETOOSMALL retryable with the over-capacity second tap / TABLENOTFOUND / BOOKINGNOT_OPEN — in the tills' own sv/en string tables, never the bridge's Swedish-only sentences); Senaste aktiviteter on the till table panel (new device route GET /api/internal/pos/ticket-activity reusing the ONE backend composer through the internal client and the SAME projection whitelist as the dashboard BFF; NestJS door GET /pos/tickets/:id/activity; compact last-6 + "Visa alla", honest verbs — "Klart i köket", never "Mat serverad", em-dash for stored-without-a-clock facts); and the identity QR on the till's förhandsnota paper (new POST /api/internal/pos/print-prebill + NestJS POST /pos/tickets/:id/print-prebill queueing the ONE förnota document — lib/devices/prebill-queue.ts, extracted from the dashboard's print route so both doors share the planner — restora:ticket:<id>, ^BQN on ZPL, caption fallback on ESC/POS; pos/web tries the server door first and falls back to the QR-less local-agent text). Deliberately dropped on the tills: zoom chrome, Kommande/Sittande tabs, deep Bokningsinfo (the 2026-08-16 call-out ruling — a panel opened in front of guests must not print who is coming).

    • Bokningar & bordskarta — Phase 2: the table detail panel (2026-08-16). Clicking a

    bordskarta tile now opens the full panel from the design (replacing Phase 1's stub card): header Bord N + state chip + guests·minutes, tabs Nota / Aktiviteter / Bokning. NOTA shows the live bill — engine-priced lines, Delsumma/Moms/Totalt as SERVER-rendered strings (new backend read GET /pos/admin/tickets/one, InternalGuard, live-bills-only 404; BFF GET /api/tenant/[tenantId]/pos/ticket with an explicit field whitelist — operator ids, customerEmail, internalNote and discount stamps never reach a browser). Betala is a hand-off, not a payment (owner ruling): it stamps the förhandsnota through the new ADMIN twin POST /pos/admin/tickets/prebill (dashboard-namespaced stamp, no settle/receipt/journal) and names the paired registers that can complete it via their existing claim + PaymentFlow. Skriv ut förnota prints through the existing DeviceJob queue (lib/devices/prebill-print.ts: FÖRHANDSNOTA/EJ KVITTO at 2× amount size, identity QR restora:ticket:<ticketId> a till scans to claim — never a payable link, no receipt number/VAT table/kontrollenhet). Dela nota is the split VIEW: lines assigned to parts, each part total ENGINE-priced read-only via POST /pos/admin/tickets/split-quote; every part is paid at a register. Flytta moves the BOOKING, not the bill — the room rule extracted from the till's reservation-update route into the shared lib/reservations/assign-table.ts (exists/fits/free-for-the-SITTING with seatingDurationMin, self-excluded) and served to the dashboard by POST /api/tenant/[tenantId]/reservations/[reservationId]/move (409 TABLEOCCUPIED / TABLETOO_SMALL with an explicit extra-chair override). AKTIVITETER is a Phase 3 stub. Dictionary: 48 ovPanel* keys, Swedish real, 19 languages English-fallback.

    • Bokningar & bordskarta — Phase 1 of the reservations-overview redesign (2026-08-16). The

    dashboard reservations page's flat list view is replaced by the two-panel overview from the design (azure tokens, not the mock's navy): LEFT a live bordskarta drawn with the SAME floor geometry as the guest picker (extracted to lib/reservations/floor-geometry.ts), tables coloured ledigt/bokad/sitter with the server-priced Nota <total> on seated tables, zoom controls, and a table LIST fallback below 768 px; RIGHT the SNABB BOKNING panel (party chips × Nu/önskad tid → next-free-table suggestion booked through the existing staff create route) over the Kommande/Sittande/Saknar bord booking list (amber SAKNAR BORD badge, confirm/reject/deposit actions preserved). New backend read GET /pos/admin/tables/live (InternalGuard, per-tenant 12 s cache over the same priceBasket path the till lists use; operator ids resolved to display names server-side and never shipped); new BFF proxies GET /api/tenant/[tenantId]/pos/table-board (explicit field whitelist + money rendered once via minorToSek+formatMoney) and GET /api/tenant/[tenantId]/reservations/availability (getAvailableSlots × occupiedTableIds × tableFitsParty composed in lib/reservations/suggest-table.ts — smallest fitting free table wins, ties on seats then name). Deposits/prep/calendar/settings/blocked tabs unchanged; table detail panel (betala/nota/aktiviteter) is Phase 2. Dictionary: ov* keys, Swedish real, others English.

    Removed

    • **Google Fonts is gone from the RUNTIME — no visitor's browser fetches type from a third party

    any more (2026-08-16). Self-hosting on 2026-08-15 cured the BUILD (`next/font/google` had killed three production builds in four); it never touched the visitor. Nineteen files still shipped `<link rel="stylesheet" href="https://fonts.googleapis.com/…">`, two `preconnect`s to `fonts.gstatic.com`, or a CSS `@import` — so every guest who opened a receipt, an order tracker, a delivery slip, a storefront, `/bazar`, `/try`, `/wow` or the login page had their IP handed to Google before consenting to anything (the practice German courts have already awarded damages over, LG München I 3 O 17493/20), and every one of those pages only looked right while somebody else's CDN was reachable. All of it is now `@font-face` in `src/fonts/google-families.css` (172 faces, 41 families + the Material Symbols icon font), declared under the same family names** the CSS already asked for and imported once from the single root layout — so no font-family, --font-display/--font-body variable, tpl.displayFont or inline style changed, and nothing looks different. 65 new latin .woff2 joined the 15 already there, each with its gstatic URL, css2 request, byte count and sha256 in src/fonts/SOURCES.json and enforced by src/fonts/fonts-selfhosted.test.ts, which also now FAILS on any live fonts.googleapis.com / fonts.gstatic.com reference in src/ (comments still allowed — they are how the next person learns why it is absent). Deleted with it: TEMPLATE_FONT_LINKS, GENERATED_FONT_LINKS, buildGoogleFontsUrl() (the only runtime URL builder), every template's fontLink/fontLinks, and the builder API's fontLink field.

    Added

    • The fiscal sweep — the missing half of "a paid order carries its fiscal record" (2026-08-15).

    Round 20 wired the recorder into every paid flip and gave contention a durable carrier; the OPEN window was process death between the paid commit and the recorder's first attempt — no stamp, no deferral row, and on Swish the redelivered callback is acked by the webhook dedup store, so nothing ever re-runs the handler. The outbox relay's existing cycle now carries a sweep (lib/fiscal/se/sweep.ts): it FINDS paid orders still awaiting their record past a 5-minute grace window using the recorder's OWN gate (orderAwaitsFiscalSale, wire.ts — so training, non-SE, owner-disabled and unsupported-VAT absences stay legal by the same rules), HEALS them loudly through the same idempotent recorder (an order stamped in the meantime is never re-numbered), hands what cannot heal to the existing deferral carrier in the hash-chained integrity journal (once — never an infinite retry), journals sales older than the carrier's 48 h window for a human instead of minting backdated receipts, and SURFACES stranded Swish payments (id claimed in the dedup store, order never flipped) as payment.swish.stranded journal rows carrying the human runbook — journal + log only, never an auto-flip, because flipping paid without re-verifying with Swish would invent money. Rides the relay's tenant loop, so finding costs no extra reads; skipped on single-tenant hot-path nudges, exactly like the deferred-write drain.

    Fixed

    • **A TILL SALE IS NOW ACTUALLY PROJECTED — the endpoint the backend has been POSTing to did not

    exist (2026-08-13).** SalesService builds a projection payload for every closed ticket and parked bill and POSTs it to /api/internal/pos/till-order at settle time; that route was never written, so every dispatch 404'd, the dispatcher classified the 4xx as a definitive refusal and dropped it. A counter sale therefore became visible only if a human happened to have the dashboard Orders board open, whose 30 s poll did the projecting — a coincidence, not a pipeline. The route now exists (internal-secret authenticated, tenant-scoped, idempotent on re-post) and keeps four rules: it never mints a public number (a ticket carrying neither queue number nor löpnummer is refused 422, not numbered here), it copies money instead of computing it (totalMinor → SEK through the one conversion point), it carries ticketRev as sourceRev so a late park cannot overwrite the close that superseded it, and a PARKED bill projects active + payment pending — a held bill reachable from the guest's own tracking token must not read "paid".

    • The projection stopped discarding the venue: `locationId` was hardcoded `'default'` (2026-08-13).

    contextOf stamped DEFAULT_LOCATION_ID on every row, on the write path AND the read path (which wins the merge), so the audit stamp the order itself carries was never read back and a location-filtered pickup search answered 200 with an empty list at every venue of a multi-venue tenant — "no such order" rather than "misconfigured". Rows now carry the order's own stamp.

    • The 30 s poll no longer rewrites the whole projection to store nothing (2026-08-13). Every pass

    replaced a document allowed to hold 2 000 rows, once per open dashboard tab, because lastProjectedAt was rewritten every time and nothing compared the entries. The pass now diffs the ROWS (ignoring projectedAt, which is the eviction clock and changes by construction) and skips the write when nothing changed; an unchanged row keeps its stored projectedAt, so retention runs from the sale rather than restarting on every poll.

    • A retryable refusal is retried instead of swallowed — all four consumers (2026-08-13). The 409

    WRITE_CONFLICT introduced by the CAS guard says in its own body that nothing was stored, and every client had been written when a 4xx could only mean "the request is wrong": submitPosOrder DISCARDED the sale and told the cashier it was rejected, useOfflineOutbox DELETED the queued item, and both order boards let it fall through a >= 500-only gate as success — leaving an optimistic tick on screen for an order the kitchen was never told about. One shared classifier (lib/offline/retryable.ts) now decides, BODY-DRIVEN so a validation 400 / 404 / 402 stays final and the outbox cannot become a permanently stuck queue; a refused sale is queued with the SAME clientRequestId, a refused status bump is kept, and the replay budget is bounded — an exhausted item stays queued and visible (failed / retryFailed()), never silently dropped.

    • The un-redeployable Android APK's timeout retry is idempotent server-side (2026-08-13). It sends

    no clientRequestId, so a retry after a timeout was a second order: two public queue numbers, two kitchen fires, one guest charged twice. An IN-PERSON sale arriving without a key now gets one DERIVED inside the create lock from the request's own content (tenant, location, register, device, operator, type, table, tender, gift/canteen/loyalty refs, total, basket), replaying only within a 45 s window and recording a genuinely later identical sale under its own suffixed key. Customer channels are deliberately excluded — two guests ordering the same thing in the same minute is a lunch rush, not a retry. The collision this accepts, and why it is preferable to a double charge, is documented in lib/orders/derived-idempotency.ts.

    • **THE MENU WRITE PATH IS NOW SERVER-AUTHORITATIVE — the browser was the only thing enforcing the

    authoring rules (2026-08-13). `validateOptionGroups` (seven rules, 71 tests) and `findDuplicateProduct` both existed, both passed, and both ran in `ProductEditorModal` and nowhere else; `CreateProductSchema` accepted `optionGroups: z.array(z.any())` and the PATCH handler read `await req.json()` with no schema at all. So a curl, an older client, a second tab or a replayed request could author a nameless group (the till renders a blank heading), a malformed price ladder (nothing can decide which group sets the price), or a second identical dish — and did: the reporting tenant had both groups stored as `name: { en: "" }` and two Margheritas with byte-identical browser-minted option ids. `POST /api/tenant/[tenantId]/menu/products` and `PATCH .../products/[productId]` now call the same two checks through the new `lib/menu/product-write-guard`: 400 OPTION_GROUP_INVALID (with `issues[]`, each carrying its stable code, `groupId` and optional `optionId`, so the editor can point at the row) and 409 DUPLICATE_PRODUCT* (with the `id` of the item that already exists) — the two codes the menu page already renders. Refusals happen BEFORE the blob write and before the `menus` mirror write, so a rejected payload cannot leave the two stores disagreeing. Judged for what the write actually writes: an availability-only PATCH (the 86 flag, also sent by the support agent's action cards) is never refused over authoring debt it did not touch, while a `discountPrice` / `weighPricing` change on a product that already has a ladder is refused with LADDER_ only.

    • `optionGroups` has a real schema (`z.array(z.any())` → `OptionGroupSchema`) (2026-08-13). The

    one field whose contents decide what a guest is CHARGED was the one field nothing validated: an option with no id (pricing is id-addressed — resolveLine matches group + option BY ID, so an idless option has no price), a price as a string, an options list that is not a list, or an unknown pricingMode all parsed. Per-option imageUrls are now sanitised on the way in through sanitizeOptionGroupImages, which had been written with zero callers anywhere; group and option names are trimmed in EVERY language on save, which finally kills the live 'Smal ' (trailing space) that has been shipping to tills. ABSENCE STAYS LOAD-BEARING: a pricingMode of 'add_on' is accepted (an old or hand-written payload) and DELETED before storage, so an add-on group round-trips byte-identical and the literal is never written.

    • **Every dashboard menu write now re-projects the POS catalog — a saved option group finally reaches

    the tills (2026-08-13).** pos_modifier_groups has carried pricingMode/isDefault since the catalog work landed and its prune tombstones a deleted dish, but NOTHING on the dashboard write path ever triggered the projection: every TILL write re-projects, the menu editor never did. So the owner could author the price ladder, watch it save, and every register would go on selling the old shape — and a deleted dish stayed sellable. One named door (lib/menu/pos-catalog-reproject → POST /pos/admin/catalog/translate, tenant-scoped, idempotent) is now called after the blob write by product create / update / delete / reorder / move, category rename / delete / reorder, menu reset and AI menu import. Loud but never fatal: a backend outage is logged and the owner keeps their edit, exactly like the menus mirror beside it.

    • **THE PRICE-LADDER OVERCHARGE IS CLOSED ON EVERY CUSTOMER CHANNEL — one basket, one price,

    whichever button the guest presses (2026-08-13). `pricingMode: 'sets_price'` shipped on 2026-08-12 and the POS honoured it; the CUSTOMER channels did not. Each of them carried its own copy of the same fold — `optionAdjustment += opt.priceAdjustment` then `baseUnitPrice + optionAdjustment` — so the moment the owner used the control that had just shipped, their own website, kiosk and table-QR would charge 100 + 250 = 350,00 for a Familjepizza that costs 250,00*, and three of the four website doors CAPTURE that amount (Stripe / Adyen / PayPal) rather than merely invoicing it. Six copies are now ONE call: `priceQuoteLine` in `lib/menu/price-quote.ts` — a thin, id-addressed entry into `resolveLine`, the tier's only ladder-aware resolver (spec rule 2: lifted, never duplicated). Fixed at `api/public/[tenantId]/orders`, `.../payment-intent`, `.../adyen-order`, `.../paypal-order`, `api/public/kiosk/order` and `lib/tables/table-ordering.ts::priceGuestTableItems`. Per channel, base 100,00 + rung Familjepizza 250,00: before 350,00 → after 250,00; 2 × = 500,00 (was 700,00); with a 25,00 add-on = 275,00 (was 375,00); Smal 100,00 = 100,00 (was 200,00 — the clearest proof the amount is a price: adding it doubles the pizza). The rung rides the line at `priceAdjustment: 0`, so no receipt or bong prints "+250" beside the size. ABSENCE STAYS LOAD-BEARING:* a group with no pricingMode takes the untouched delta branch (100,00 + 25,00 = 125,00, and the add-on still shows as +25,00), a negative add-on still discounts (−5,00 → 95,00), and the literal 'add_on' is never written anywhere. VAT follows the price, per line and per rate: a 250,00 dine-in Familjepizza is netto 223,21 + moms 26,79 at 12 % (was 312,50 / 37,50 — output VAT over-declared by 10,71 per pizza), takeaway 6 % is netto 235,85 + moms 14,15 (was 330,19 / 19,81), and on a mixed bill the 25 % alcohol bucket does not move by one öre.

    • **The guest's SCREEN can finally see the ladder — three menu feeds carry pricingMode (+

    isDefault) (2026-08-13). A client cannot render a price it was never sent, so the kiosk showed "+250 kr" beside a 250,00 size and totalled 350,00 before anything was ordered. `kioskOptionGroups` / `kioskProduct` (`lib/kiosk/menu-payload.ts`), the storefront/marketplace item payload (`lib/bazar/restaurant-payload.ts`, new `BazarPayloadOptionGroup.pricingMode?: 'sets_price'`) and the table-QR / ITA menu feed (`api/public/[tenantId]/ita/menu`, which also gained `isDefault` so a required ladder can open on its default rung) now emit the mode — only** for a ladder, so an add-on group's document is byte-identical for every existing build.

    • **The QR/website wire format resolves selections BY ID now, with the name path kept as a

    documented fallback (2026-08-13).** Guest clients key selectedOptions by group NAME and value by LABEL, and a name is not an identity: on the tenant that reported this, both groups were stored with name: { en: "" } — under name matching, every paid selection in a nameless or renamed group is silently dropped, and a dropped ladder rung falls back to a base price nobody sells. The shared resolver now takes the group id as the key and the option id as the value when they are present, and falls back to any-locale name matching (with the cosmetic "(+…)" suffix stripped) for cached/in-flight clients. No wire-type change was needed — the existing z.record(z.array(z.string())) carries ids as-is. The PayPal rail gained the other half of this for free: it was matching English-only names with a (+$…)-only suffix strip, i.e. dropping every paid option on a Swedish-only menu.

    • Onboarding step 3: the Terms checkbox now sits ABOVE the buttons (owner ask, 2026-08-12).

    It rendered under the Back / "Start free 7-day trial" row, and the trial button is disabled={!acceptTerms} — so a signer-up met a dead primary action on the LAST step of signup with the reason printed below it, and on a short viewport the checkbox could be below the fold entirely, leaving nothing on screen to explain why pressing the button does nothing. Pure reorder inside the same footer (pt-3 → pb-3); the consent copy and the SIGNUP_LEARNING_DISCLOSURE sentence are unchanged. NOT verified in a browser yet — the local ui container serves ui/src from its image and does not bind-mount the source (see docs/infrastructure/LOCAL-STACK.md trap 5), and a rebuild would have baked in another session's in-flight edits. Verified by typecheck and by DOM order.

    Added

    • A SIZE GROUP CAN SET THE PRICE NOW — "Prissättning: Sätter priset (ersätter grundpriset)" in the menu editor (2026-08-12). Reported live on a restaurant tenant created the same day: the owner authored a size group as ADD-ONS and typed the ACTUAL price of each size (100 / 125 / 150 / 250) against a 100,00 base price, so a Familjepizza would have rung up at 100 + 250 = 350 — a coherent price ladder typed into the only kind of group the product offered. The field was a bare number with a tiny literal + beside it, and nothing in the product ever said which of the two it was. `OptionGroup.pricingMode` (`'add_on'` | `'sets_price'`) is the whole feature, and ABSENT is load-bearing: an existing group carries no key, takes the untouched branch and prices EXACTLY as it does today (proved by a control test that deletes the key from the same fixture and asserts the old 350). 'sets_price' means the chosen option's amount IS the line price and REPLACES the base price. ONE number per option, deliberately — an option can never carry a delta and an absolute price that disagree, and the owner's live 100/125/150/250 become CORRECT the instant the toggle flips: no script ever rewrites a tenant's menu. In the editor: a fourth control beside Typ / Min / Max; choosing a ladder FORCES radio + required + min 1 + max 1 and disables those three ("En prisstege är exakt ett val."); the money column changes prefix AND label per mode (+kr / Tillägg vs the plain symbol / Pris, aria-label + title on the input); a ladder shows "Grundpriset kr100.00 används inte för den här gruppen."; and min="0" was REMOVED from the add-on field, because a negative add-on ("utan lök, −5 kr") is supported by the projection and by the kernel and this editor was the only thing forbidding it. The guest side is closed in the same change: lib/menu/price-quote.ts — the canonical quote path behind pos/orders, pos/quote, training receipts and the voice concierge — substitutes the ladder price on the LINE and records the chosen size with priceAdjustment: 0, so a Familjepizza quotes 250, sauces still ride as deltas on top (250 + 15 + 14.99 = 279,99), a malformed rung is ignored rather than invented, and weight still beats the ladder (the scale is the legal price). The kernel is not touched at all — a ladder is a SUBSTITUTION where the line is BUILT, exactly like a service-offer band price, so the rounding step count, the wire shape and corpus.json are unchanged.
    • THE WARNING THAT WOULD HAVE CAUGHT IT — an amber banner in the group header with the mistake spelled out in money (2026-08-12). In an add-on group that is a single FORCED choice with at least two options, where EVERY amount is at or above the item's effective price (campaign price if one is running, else the base), the editor says: "Det här ser ut som hela priser, inte tillägg. Som tillägg skulle Familjepizza slå in på kr350.00 i kassan. Gör om till prisstege?" — with a one-click Gör om till prisstege that changes the MODE and applies the shape lock and does not touch a single number. The signal is deliberately narrow so it can never nag a genuine upsell menu: one option below the base price, an optional or multi-select group, a single option, or an unpriced item all silence it (each pinned by a test).

    Fixed

    • AN OPTION GROUP CANNOT BE SAVED WITHOUT A NAME ANY MORE — least of all a required one (2026-08-12). Both groups on the owner's only pizza were stored as name: { en: "" }, which the POS projection shipped to the tills as the empty string — a required group whose heading is blank is a question nobody can read. Spara is now gated by one shared validator, ui/src/lib/menu/option-group-validate.ts (validateOptionGroups), and THE BROWSER IS NOT THE RULE: the same function is exported for the two menu write routes to run before any blob write (400 OPTION_GROUP_INVALID). Rules: R1 a group with options needs a non-blank TRIMMED name in at least one language (the editor is bilingual, so a filled sv with an empty en passes — and an empty group still being built is never nagged); R2 the same on a group that is required / min ≥ 1 / a ladder, kept as its own code so relaxing R1 can never silently relax the required case; R3 at most one ladder per product; R4 a ladder is exactly one choice; R5 every ladder option > 0 (a negative ADD-ON stays legal); R6 no ladder on a by-weight product; R7 no ladder on a product with a running campaign ("Avsluta kampanjen först…"). The offending group gets a red border and an inline message, the Alternativ badge turns red, and the footer hint names the first violation. Save also TRIMS every group and option name in every language, which fixes a live 'Smal ' that has been shipping to tills with its trailing space.
    • ONE CREATE CANNOT BECOME TWO PRODUCTS FROM THIS DIALOG ANY MORE (2026-08-12). The owner created one dish and got two — and the till, which nothing had ever asked to re-project, still showed two after one was deleted. The editor's half of that: Spara was disabled by !canSave alone, onSave is typed => void so nothing awaited it, the page's saving flag was never passed in, and the dialog closed only in finally — so a second click on a slow network submitted the SAME payload twice (proved by the two mirror products carrying byte-identical browser-minted grp_/opt_ ids). Now ProductEditorModal takes saving, its button is disabled={!canSave || saving} with a spinner and Sparar…, and a NON-OK response keeps the dialog OPEN with the server's message in the footer instead of silently discarding the owner's unsaved edit. A button state is not the whole fix, so the 409 predicate is written and tested too (ui/src/lib/menu/duplicate-product.ts: same category + same trimmed case-insensitive name + same price, compared in minor units) for the write route to refuse the duplicate that a second tab or a slow network will always produce.
    • "BETALA" IS A FULL-SCREEN PAYMENT VIEW NOW — `Betalning`, ~90% of the screen, the ORDER on the left and the money on the right, on all three surfaces (2026-08-12). Owner ask, verbatim: "see example for 'betala' a new UI/UX a big popup maybe covers 85%-90% of the screen maybe". IT IS A NEW SHELL, NOT A NEW PAYMENT ENGINE, and nothing inside the money path moved: the receipt number, the fiscal decision, the training-mode refusal, the gift-card precheck, the outbox envelope, the öre rounding, the terminal authorization and the split remainder are all exactly where they were (PaymentFlow.tsx / PaymentState.kt). Every figure on the screen is one the engine already rendered — Delsumma is the per-rate net the kernel's own receipt renderer prints under Netto, and the Moms rows are the engine's vatBreakdown verbatim, one row per RATE the basket actually resolved to, so a bill carrying 12% dine-in food beside 6% takeaway food shows two rows and never one. The left column reuses the same TicketLineRow/PosTicketLine the ticket panel draws, with the same tileTint dot keyed on the same product and the same lineKitchenStatus derivation off the server's own fired ledger, so Skickad 14:05 cannot say one thing on the ticket and another on the screen that takes the money. The chooser replaces a column of five tender buttons with `Kort | Swish | Kontant` in front of the amount plus one disclosure for the two exceptional tenders (Presentkort / annat), and every chip arms a door that already existed: card runs the authorization the tender step always ran, cash opens the keypad (the öre rounding and the change live there), and Swish opens its confirmation — record-only means the confirmation is not skippable, because a primary that booked it would record money on the word of whoever pressed a button. The card STEP was retired on both surfaces: it existed only to name the two card doors honestly, the chooser now does that in front of the amount, and the missing-terminal sentence is printed only when a reader is actually missing (with the external door opening itself). `Avbryt betalning` returns to the ticket with every line intact — a pure paymentCancelled transition that hands back the same lines array, so the promise is a test rather than a comment (payment-view.test.ts, plus a Compose UI test that mounts the tree, clicks the button and asserts the basket behind it survived); it is refused while a settle is in flight (the receipt number may be spent and the sale posting) and hard-refused while an authorization is unresolved, and it WARNS before dropping committed split parts because one of them may be a card the acquirer already authorized. Swedish + English, both surfaces, from the same key set.
    • THE PAYMENT VIEW'S RED BOX SAYS SOMETHING TRUE, and it was verified against the code before it was written (2026-08-12). "Töm nota finns under Fler och kräver bekräftelse. Skickade köksartiklar får inte försvinna utan spårbar åtgärd: en nota som köket har fått ägs av servern och kan inte tömmas alls, och rader tas bort en i taget och stryks på serverns nota." Every clause was checked: the clear is under Fler on both tills and does ask (window.confirm on pos/web, a confirm dialog on the Compose tills); and firing food requires a SERVER ticket (sendKitchenRefusal(hasServerTicket=false) → 'no_bill', canSendToKitchen = ticket.serverTicketId != null && …), so a bill the kitchen has is server-owned and resetBlockedReason / TicketClearRule.refusal refuse the clear outright rather than merely confirming it — the promise the code keeps is stronger than the one the mockup asked for. The box is drawn only about a bill whose food the kitchen actually has; on a bill nothing was fired from the sentence would be true and about somebody else's order, which is how an operator learns to read past a warning.
    • THREE THINGS IN THE PAYMENT MOCKUP DO NOT EXIST, AND THEY ARE INERT WITH THE TICKET PANEL'S OWN REASONS (2026-08-12). Rabatt (no till door and no capability — discount_override is a declared permission nothing reads), Intern anteckning (a ticket document has no note field at all) and Dela nota (nothing anywhere divides one bill into several notas). The sentences are reused verbatim from i18n/ticket-panel.ts / RegisterStrings rather than reworded, so one refusal wears one wording wherever an operator meets it — and they are printed under the controls, never in a tooltip a tablet cannot show. Dela betalning is real (split_tender) and is one tap from the amount. The mockup's − qty + and delete are deliberately not wired: the settle prices the basket the view was handed when it opened, so an editable stepper would charge the old amount for the new bill, possibly with a card already authorized against the previous figure — the column is read-only, says so, and the edit is one tap away.
    • THE PAYMENT VIEW STACKS AT PHONE WIDTH INSTEAD OF CLIPPING, and the first cut of that genuinely did not (2026-08-12). Below the threshold the two columns become one with the money first — on the smallest surface the operator must not have to scroll a bill they have just come from to reach the primary. Measured against the SHEET (a container query on the web, BoxWithConstraints on Compose), never the viewport: the same component is a 1600dp station and a 360px phone. The first attempt kept CSS grid for the stack, and at 360px Chrome sized the two auto rows to 303px and 292px of the available 606px — a grid's default align-content behaves as stretch, and stretch applies to auto ROWS — so each column got half the sheet while its real content was 765px and 942px tall, and with the inner scroll wells switched to visible the money column's e-mail checkbox and split explainer painted straight through the order column's dish rows. Found by screenshot, not by reading. The stack is a column flex box now; align-content: start alone did not lift it. The big sheet also states box-sizing: border-box explicitly, because a min(96vw, …) width plus the sheet's padding is an overflow, and the last padded box that met a width with no box-sizing on this stylesheet family had 22px eaten off the right edge of every receipt row — where the MONEY column is.
    • DISCOUNTS EXIST NOW — `RABATT 5/10/15/custom %` AND CODES LIKE `NEWYEAR2027`, LIMITED TO DAYS AND TO A USAGE COUNT (2026-08-12). BACKEND ONLY: the routes, the storage, the validation and the tests. No till surface calls them yet. Owner requirement, verbatim: "we forgot to add 'discount' buttons 5%, 10%, 15% custom discount %, discount code like NEWYEAR2027 limited to days, limited to quantity like first 100 usage etc.." — which is two features, and keeping them apart matters because one is a keypress by a trusted operator and the other is a guest-facing campaign with server-held state. THE KERNEL WAS NOT TOUCHED, AND THAT IS THE DESIGN. The money engine already prices six promotion types deterministically and identically on every surface, and a code is caller policy, never kernel surgery: a discount becomes an ad-hoc PromotionDef handed to evaluatePromotions, and not one line of this change computes money. A manual percentage is `percent_off_line`, deliberately not `basket_threshold`, for three reasons that are all money rather than taste: (1) THE BASE — basket_threshold takes its cut of Σ remaining + Σ deposits, so "10% off" a crate of bottles would be 10% of a number that includes the venue's own pant liability (asserted: 6 × (12,00 + 2,00 pant) at 10% discounts 7,20, not 8,40); (2) THE ALLOCATION — one truncated percentage PER LINE means each VAT group's taxable base shrinks by exactly its own percentage, where a basket figure split largest-remainder can hand a stray öre to a line in the other rate bucket; (3) THE PARAMETER — basket_threshold refuses to fire without a minBasketMinor, so a discount with no minimum spend would have to invent a fake 1-öre threshold, a lie in stored data that also declines to fire on a bill pricing to zero. A coded campaign may still be basket_threshold, because "100 kr off over 500 kr" is a threshold the owner meant. `discount_override` HAS BEEN A DECLARED OPERATOR PERMISSION SINCE PHASE 5 AND NOTHING HAS EVER READ IT — this is the reader, so a discount always has a name against it. Owner and manager pass regardless of the permission list, and that is deliberate rather than lax: because nothing ever read the grant, no operator record in any live tenant was curated for it, so requiring the permission alone would lock every owner at all four pilot venues out of the button the owner just asked for — and the "fix" would be a bulk write across pos_operators in live tenants. Identical reasoning to mayEditCatalog. THE CUSTOM PERCENTAGE IS CAPPED AT 50%, and the refusal says so in words instead of clamping silently: at 100% nothing is being discounted, the food is being given away, and a give-away is a comp — which belongs on the void path that already carries reason codes and an audit stamp. Anything in between is a manager decision this platform can express the day the existing pos_approvals machinery is wired to it (ApprovalReason.DISCOUNT_OVERRIDE is already defined and still unread). ONE manual discount per bill — re-applying replaces it, because two operators each pressing 10% must not silently produce 19% and "the discount on this bill" has to be one answerable thing for the Z-report. A DISCOUNT IS REFUSED ON A BILL THAT IS NO LONGER OPEN, with its own code and its own sentence: rates and totals are pinned at sale time, so a discount applied afterwards would rewrite a receipt the guest is holding; a parked bill is told to resume, because there is exactly one editable holder of a bill at a time. THE THREE TRAPS, each of which yields a wrong NUMBER rather than an error. (1) The venue's clock. days_of_week is read by the engine off the UTC fields of the sale instant, and 00:30 on a Saturday in Stockholm is 23:30 on FRIDAY in UTC — so a weekend code judged in UTC is live all Friday evening and dead in the small hours of Saturday. Each campaign stores its own IANA zone (validated by using it, never against a hardcoded list) and its window is evaluated through the one venue-clock evaluator, isRuleOpenAt, failing closed on an unreadable zone because a discount is a thing that costs the venue money. The kernel is untouched; instead the approved definition carries no `TimeWindow` at all, which is the subtle half — a window riding along would let the engine re-read it in UTC and silently drop a discount the server had already approved, between the quote and the charge. A redemption is decided once, in the venue's zone, and pinned for the life of the bill including one parked overnight. Pinned at the exact instant the trap fires, plus the summer case at UTC+2 so nobody replaces Intl with an offset table. (2) The usage cap cannot live in the kernel — the engine is pure and "first 100" is server state. Claimed by an atomic $inc whose filter carries the cap as a field-to-field $expr comparison, so the check and the increment are one indivisible act: the same shape as the order_counters fix that replaced String(1001 + countDocuments) after two concurrent reads minted one order number twice. matchedCount 0 is an honest refusal, not an error. The ordering is the rest of the answer: everything judgeable from stored data first, then the redemption ROW (whose partial unique indexes ARE the single-use-per-guest check, before the cap on purpose so the common refusal never writes to the hot campaign document), and last the claim, because it is the only irreversible act. session is a required parameter of redeem, not an optional one — so "what happens when validation fails after the increment" is nothing is compensated, because nothing was committed: claim, row and ticket write share one transaction and a losing CAS aborts all three. A compensating write was rejected as the primary mechanism because a compensation can itself fail; the only compensating path is the deliberate release of a mis-keyed code, guarded twice so a stray release cannot make room for a 101st discount. Two tills redeeming the hundredth code concurrently: exactly one wins, count lands on 100. (3) A discount now crosses TWO VAT RATES. On a real mixed-motion bill — 145,00 dine-in food at 120‰ beside 129,00 takeaway food at 60‰ — 10% off moves 1554 → 1398 in the 12% bucket and 730 → 657 in the 6% bucket, total 27400 → 24660, and Σ group grosses still equals the total to the öre. Both buckets, not one. FOUND AND FIXED WHILE IN HERE: a refund of a discounted bill was going to hand back the full gross. refundTicket reprices negated lines against today's promotions, where a ticket-carried discount would not have been; the engine is odd-symmetric, so passing the original ticket's own definitions makes the refund return exactly what the guest paid (−24660, both buckets reversed). Also: a campaign's TYPE could never be changed (the old money fields survived the patch and the coherence check then correctly refused them, with no way to clear them) — the update now $unsets the fields the new type ignores, and strips undefined from $set because the driver writes it as null and Mongo rejects one path named in both operators. OFFLINE: refuse the CODE, never the SALE. Single-use and "first 100" are server facts and a redemption replayed an hour later cannot be refused after the guest has left, so there is no queued form of one — SYNC_OP_TYPES stays a closed six-entry list, now pinned by a test so adding a discount op requires changing that case. A disconnected till still closes the bill at full price. No POS capability was minted (sell + the operator permission): a fresh capability needs a preset grant plus a backfill, and skipping that has twice removed a shipped feature from new tenants. Storage is two new pos-native collections rather than pos_promotions, because everything in that collection is fed to every basket by listActivePromotions — a code-gated campaign parked there would discount the whole restaurant and the code would be decoration. New: backend/src/modules/pos/discounts/ (the six-file module), sales/ticket-discounts.ts (the pure definition builder and the cap), and four specs — the mixed-motion VAT arithmetic, the campaign rules, the routes, and the offline op surface. Full record of what shipped vs what the spec said: docs/pos-rebuild/kampanjkod-design.md.
    • THE TICKET PANEL CHANGES SHAPE WITH THE ORDER TYPE, AND EVERY LINE SAYS WHETHER THE KITCHEN HAS IT (2026-08-12). Owner mockups, two of them: a SERVERING panel (table heading, per-line kitchen status, "Skicka 1 ny artikel till köket", then Betala / Dela betalning / Dela nota) and an AVHÄMTNING panel (a På plats | Telefon second level, no table, no per-line status, then Betala & skicka → Skicka till köket – betala senare → Parkera köp). Plus the correction to the day's earlier work: `Välj bord` and `Kundnamn` share ONE ROW now — the first cut stacked them and the owner's complaint was space, on the narrowest column of the till. The shape is decided in ONE place per surface and rendered from that one answer by all four mounts (pos/web/src/screens/ticket-shape.ts + TicketShape.kt, twins, tested to the same table of cases): the Compose wide station and its compact phone sheet, the web ticket column and its optional side panel. A CTA list per shape, not three copy-pasted feet — the last control spelled out per-mount shipped on a tablet and not on a phone. THE PER-LINE STAMP IS A REAL ONE, AND THE LEDGER GAINED THE ONE FIELD IT WAS MISSING. Skickad 14:05 needs a time, and the fire time was recorded nowhere a till could reach: kitchenFiredLineIds is a bare string[], KitchenTicketDoc.firedAt is per BATCH in a collection no register reads, and the all-no-prep fire writes no kitchen ticket at all — so those rows had no recoverable time even in principle. markLinesFired now stamps TicketDoc.kitchenFiredAt (lineId → ISO) in the same statement, in the same transaction, by the same and only writer, so "did it fire" and "when" can never describe two different moments; fire-kitchen returns that same instant so the till never guesses one off its own clock; and the whole map already reaches every till through toView, so a resumed bill's rows say the hour the food actually left whichever till sent it. It is not a second notion of "sent" — the SET is still the only answer to whether a line has fired, and a stamp without a set entry can never make a rung dish read as cooking (pinned by test on both surfaces). Rendered through fmtClock / PosClock, never a substring: the stamps are UTC and a substring prints 12:05 on food that left the pass at 14:05 in Stockholm. A row fired by an older build says Skickad with no hour, which is true. `AUTOSPARAD` IS DRAWN ONLY WHERE IT IS TRUE, AND NO AUTOSAVE LOOP WAS BUILT. The badge is a claim about the SERVER: it appears exactly when the server already holds this bill (a parked bill picked up, a bill taken back). On a fresh basket with a table merely ARMED it is absent — that binding is local, nothing on the server has the lines, and badging that basket "saved" is how bills get abandoned: the §3d incident class, four real ones. Minting a ticket behind the operator's back to make the badge true was refused as an owner decision, not a side effect of a badge (showsAutosaved, with the reasoning in the source). `Parkera köp` is a real, pre-existing wire capability, not a second park: ParkTicketDto.fireToKitchen: false — "the bar-tab shape where nothing should cook yet" — which no till had ever sent. It is refused on a bill the server owns, out loud, because ParkExistingTicketDto has no such field and a re-park always fires. `Betala & skicka` and `Skicka till köket – betala senare` say so only where it is true: both depend on kitchen_routing AND the register's own auto-send toggle, the two gates the server itself applies, so a till with sending switched off shows the plain words and offers no send-and-park at all. What already existed vs what is honestly dead: Dela betalning EXISTS (PaymentFlow's own split step, split_tender) and is now one tap from the ticket via a new split payment entry on both surfaces; Rabatt, Intern anteckning and Dela nota exist NOWHERE in this product — no route, no service method, no capability, no screen — so each is drawn INERT with the reason printed beside it rather than omitted or wired to nothing. Fler is a container, not a new function: it holds the actions that used to sit in a second foot row, and two of them (Kvittokopia, Öppna kassalåda) were previously reachable only from the optional side panel that is off by default. The mockup's `3 gäster` was NOT built and was not faked — a party size is recorded nowhere on a POS ticket, and reading the nearest booking's guest count would be a guess about who is sitting there, so the sub-line states the table's own capacity in its own words ("4 platser"). Totals are the engine's: Delsumma is the per-rate net through the same net = gross − vat the kernel's receipt renderer prints under Netto, and the Moms rows are vatBreakdown verbatim — one row per rate the basket actually resolved to, never the mockup's hardcoded 12%, because per-line motion means takeaway food is 6% today. Singular/plural is the string tables' own business in both languages (1 ny artikel vs 2 nya artiklar), tested. New: ticket-shape.test.ts (17) and TicketShapeTest.kt (14).
    • Fixed while screenshotting the new rows: every receipt row was 22px wider than the panel holding it, and the money was the part that got cut. .pos-ticket__line had width: 100% plus padding and a border with no `box-sizing` (the sheet has no global reset), so a 340px ticket drew a 362px row and .pos-ticket__swipe's overflow: hidden quietly ate the difference off the right edge — where the reserved amount column lives. Measured, not guessed: 278,00 rendered as 278,0. Also grid-template-columns: 1fr floored at min-content, so a long dish name held the first column open and pushed the amount out; it is minmax(0, 1fr) now, and below a 460px container width (the panel, not the viewport — the same ticket is 486px on a station and full-width on a phone) the row stacks so the controls take their own line. Money is never truncated; a name wraps.
    • "SKICKA TILL KÖK" IS A BUTTON ON KASSA NOW, AND THE TABLE MOVED UP BESIDE THE CUSTOMER NAME (2026-08-12). Owner requirement, verbatim: "on PoS - can you also add a button 'send to kitchen' on 'kassa', move 'välj bord' to near 'kund namn' and instead add 'send to kitchen' beside 'Parkera nota'". The route already existed and no till had ever called it. POST /pos/tickets/:ticketId/fire-kitchen shipped with Phase 10b — its own comment has read "Skicka till kök" — fire a table ticket's unsent food lines ever since — but there was no caller anywhere in pos/app, and the only web caller was the Tables flow. So food reached a kitchen on exactly three exits, every one of which also did something ELSE: a paid sale's auto-fire, an atomic park, and a re-park. The consequence is the owner's own earlier scenario turned inside out — a course added to a bill the guests are already eating from could only be sent by parking the bill again, which takes it off the screen the operator is working on and out of their hands mid-service. This is that door, and it keeps the bill. A press syncs the basket's delta onto the server ticket (the re-park's own order: the fire sends the rows the SERVER holds, and the kebab rung ten seconds ago is not one of them), then fires, then reports what happened. THE CAPABILITY DECISION, and it is a deviation worth reading. Widening the route from kitchen_routing to sell was specified, considered, and REFUSED — the premise it rested on is false for this capability. kitchen_routing is labelled Köksbongar in the dashboard (ui/src/lib/pos/register-view.ts) and its catalogue entry reads "send lines to kitchen/KDS stations" (pos/shared/domain-types/src/capability.ts): it is the ROUTING grant, not a claim that a screen exists. Both the restaurant and quick_service presets grant it (VERTICAL_CAPABILITIES in integration.service.ts, and device-surfaces.ts), and the bong is PRINTED FROM the kitchen ticket this route creates — so paper is a subset of kitchen_routing, never an alternative to it, and a paper-only venue's selling till holds it. Decisive: all three existing fire doors are gated on this same capability, so a manual button on sell would become the one door that bypasses the venue's own Köksbongar switch — and KitchenDelivery (registers.interface.ts) already assigns the three refusals their own remedies, documenting auto_send_off as the state where "lines go to the kitchen only when somebody presses Skicka till kök" (this button) while not_granted is a dashboard fix and off_at_register is the till's own Funktioner switch. A button must overrule neither. No 403 ever reaches an operator: all three tills draw the control only when the session's EFFECTIVE capability set holds kitchen_routing, exactly as Parkera is drawn only on park_bills. The route's authorization is therefore unchanged (@RequiresOperator + @RequiresCapability('kitchen_routing')), the online-order ingest gate (hasKitchenRegister) was not touched, and no capability was minted — the trap that removed Parkera nota from new tenants twice. A BILL THE SERVER OWNS, deliberately — and the one thing this does not do. fire-kitchen fires a TICKET, and a fresh counter basket is not one yet; minting one on press was refused because it would turn the busiest screen's basket server-owned behind the operator's back (Rensa/Töm kvittot and Välj bord both go inert on that fact) and an open ticket is recoverable only from Parkerade's stranded-open list — the §3d incident class, four real bills. The counter basket already has two exits that fire (Betala, Parkera), so on it the control is drawn INERT with the sentence that names them; what had no door at all was the bill in the operator's hands, and that is what this is. THE COOKED-TWICE HAZARD, closed. The server MINTS a lineId for every row a sync hands it, and syncLines/syncResumedTicket threw that answer away — harmless while every caller emptied the basket straight after, fatal for one that keeps it: the next diff would find no match for any row, void every line the kitchen is cooking, and re-add them as brand-new work, because kitchenFiredLineIds names the dead ids. Both tills now read the line the add route has always echoed (AddLineResult.line), return it as a SyncedTicket { rev, serverLines, serverLineIds }, and re-key the basket — only the identity moves, so an offer snapshot, a recorded age check and a pinned rate all stay exactly as the operator rang them (a rebuild from the server document would have dropped serviceOffer). "Is there anything to send" reuses the existing notion of unfired lines — FOOD_BEV rows absent from the ticket's own kitchenFiredLineIds, now transcribed onto the wire types that already carried it (ResumedTicketDto, ParkedTicketDto, OpenTicketDto, ResumedTicket) — so nothing invents a second ledger and the control is live exactly when a press would do something. It reports the outcome, always, including zero: "1 rad skickad till köket", "3 rader skickade till köket · 1 rad behövde ingen tillagning", "Inget nytt att tillaga skickades" — rows that routed nowhere or need no preparation are counted apart from food a cook is making, because the server marks them fired too and the operator must not stand waiting for them. All three surfaces. Compose commonMain (Android + Windows): the table control moved into the ticket header directly under the customer-name field, refusal sentence and all, and TicketPanel's paired foot row is now Skicka till kök + Parkera nota — the 2026-08-07 comment pairing table+park is rewritten rather than overwritten, recording both decisions and why the new cut is the honest one (table and name are ATTRIBUTES of the bill; sending and parking are both DISPATCH). pos/web mirrors it in both mounts of the foot (the ticket column and the optional side panel), with the picker moved up beside the name field and the release control kept beside it. Bilingual on every surface (sendToKitchenAction plus four sentences in the Sv/En register strings; a typed pair in the web till's registerText), with "Skicka till kök" taken verbatim from the endpoint comment so three surfaces repeat one phrase. Outcome copy renders on the register's own notice stack in the brand Info tone — never Danger, which is what every other bill notice is — and is dismissed by hand, never on a timer. New tests: backend/src/modules/pos/sales/manual-fire-kitchen.spec.ts (8 cases — the gate is kitchen_routing and the bong alarm is still sell, a quick-service bill with no table fires, autoSendToKitchen: false still fires, the delta is the delta, and a non-food bill creates no kitchen ticket and never consults the ingest gate), SendToKitchenTest.kt (10) and pos/web/src/screens/send-to-kitchen.test.ts (11, against a new pure module so the shipped decision is the tested one).
    • ONE GUEST, ONE BILL, TWO FULFILMENTS — and the correct VAT on each (2026-08-11). Owner requirement, verbatim: "if a customer comes and eat on the restaurant a pizza, then while he was eating and he ask to the restaurant to make the kebab plate so he can take with him to home. so when he will pay the both items together can be in same order." This is a money and tax fix, not a UX tweak: pos/core/src/tax.rs resolves the SE food rate from the line's MOTION, and the two rates differ today — food SERVED is 120‰ with no end date, while foodstuffs taken away dropped to 60‰ on 2026-04-01 (provisional through 2027-12-31, note P13). A till that could state only ONE fulfilment for the whole ticket therefore charged one of those two lines the wrong VAT, by a factor of two, on every mixed bill. The kernel and the wire always supported it (TicketLine.motion sits beside tax_rate_permille; AddLineDto.motion is per line and reaches the kernel unchanged) — what was missing was any way to CHANGE a line's fulfilment once it was in the basket, because this module has no update-line endpoint at all. New `POST /pos/tickets/:ticketId/line-motion` (SetLineMotionDto → SalesService.setLineMotion), which re-resolves the rate through the kernel's own rule and re-pins it on that one line. Re-resolving a pinned rate is lawful only on a still-OPEN ticket — taxRatePermille is documented as pinned at sale time so a closed ticket cannot move when the rate table does — so a closed, voided or parked ticket is refused loudly with its own code (TICKET_NOT_OPEN, and the parked case says to resume first) rather than through the generic status gate, because "the rate on a registered sale moved" is a fiscal defect and not a state mismatch. Only the MOTION dimension is re-resolved: taxCategoryId stays as it was pinned when the line was rung, so a menu edit since cannot silently re-classify a dish the guest has been quoted, and nothing computes a rate, a VAT figure or a total by hand. THE ALREADY-FIRED RULE, stated out loud: a line the kitchen has never seen changes freely; a FIRED line is work a cook may be holding, so it is refused LINE_ALREADY_FIRED until the operator confirms (force: true, the same idiom as DiscardTicketDto.force), and a confirmed change re-tags the matching kitchen line(s) in the same transaction (the voidLine pattern, so a till saying AVHÄMTNING over a station screen still saying SERVERING cannot exist) while deliberately printing no paper — a bong already on a rail cannot be recalled and an automatic second one is how a dish gets cooked twice, so kitchenUpdated is reported and the till tells the operator to speak to the pass. Transactional and re-reading in-session for voidLine's reason: markLinesFired bumps no rev, so the {rev, status} pin cannot see a fireKitchen that commits mid-flight. Deliberately not a void-and-re-add, which is what a motion change would otherwise have to be on a server bill: on a dish the kitchen already has, that strikes it off every board and enqueues a brand-new one, so a pizza the cook is holding reads as cancelled and re-ordered. Per-line `delivery` is refused (LINE_MOTION_VALUES = ['dine_in','takeaway']) — a courier collects a whole order — and no capability was minted: sell, which every selling till already holds, because a fresh one needs a preset grant plus a backfill. All three tills. Compose commonMain (Android + Windows): PosSwipeToReveal grew a START-edge strip (PosSwipeSide), sharing the axis lock, the 8dp floor — not touchSlop, which is ~0.1px for a mouse and would cancel clicks on Windows — the tween, the rubber band and the one settle rule with the left swipe, so a right swipe on a ticket row uncovers the fulfilment choice while left keeps the delete; nesting a second wrapper would have put two pointer handlers on one row to fight over the same drag. PosTicketLine grew a pressable fulfilment chip showing the row's own word — always drawn, unlike the hover-revealed trash, because it carries information as well as an action and because it is the non-drag path on a mouse-driven Windows till. pos/web mirrors both in TicketPanel.tsx (SwipeSide, a start strip, the same chip) with the tokens in theme.css. Bilingual on every surface (chooseFulfilment* / fulfilment* in Sv+En register strings; a typed registerText pair on the web till), and a desktopTest reads LINE_MOTION_VALUES out of the backend source so the sheet cannot offer a fulfilment the server refuses. The bong tags each line — one bong per fire, not two, because it is one guest and one bill and the pass must see it together: buildKitchenTicketSegments prints 1 x Kebabtallrik [AVHÄMTNING] beside the name, only when the order carries more than one distinct fulfilment (judged over the WHOLE order, never the station-filtered subset, so a mixed order split across two stations keeps the tag on the bong of the cook who has to find a box). An unmixed bong is byte-identical — the PINNED.kitchen / PINNED.kitchenNoStation digests were re-derived by rendering the pin's own fixture and reading the output, and came back unchanged, which is the evidence a pin exists to produce. motion travels to paper on both till paths (TillKitchenFireLine/TillFireLine for a fire, TillPrintLine/TillSaleLine for the atomic counter sale) and is additive on OrderItem; the RECEIPT is untouched because a mixed bill's money already states it — priceBasket groups VAT per rate, so 12% and 6% print as two rows (SKVFS 2014:9 7 kap. 1 § j), verified end to end. New tests: backend/.../line-motion.spec.ts (17 cases, including the money one — 145,00 dine-in + 129,00 takeaway = 274,00 with 15,54 VAT at 12% and 7,30 at 6%, against 13,82 had the takeaway line been taxed as service), the mixed/unmixed bong cases in kitchen-bong.test.ts and till-print.test.ts, and LineFulfilmentTest.kt.
    • A kitchen bong that fails to print now ALARMS on the till, with one-tap reprint (2026-08-11). Decision 2 of docs/pos-rebuild/kitchen-fulfilment-design.md, promised on 2026-08-11 and unbuildable until that same day added the mechanism (DeviceJob.kitchenTicketId). A restaurant may run paper as its only kitchen surface — the owner's small pizza shop that takes the order, gets a bong, makes the pizza — and for that venue a bong that never printed is a lost order with no screen to fall back to. Until now its only trace was a red row in a dashboard job log nobody at the counter is looking at. Terminal failures only: kind === 'kitchen-ticket' + status === 'failed' + not mock + not acknowledged — 'failed' is set only once a job is out of MAX_JOB_ATTEMPTS, so a job still retrying is not yet an alarm, and a MOCK (unclaimed) device is excluded for the reason printer-health.ts already states: storing its job IS its execution, so it cannot fail and carries no signal about hardware. Naming the order was the hard part, because three producers carry three different handles: a till FIRE (kitchenTicketId set, orderId a synthetic till_fire_<id> that resolves to no row), a till COUNTER SALE (till_<receiptNumber>, and no ticket id at all — a venue with no kitchen_routing register mints no pos_kitchen_tickets document for a counter sale, which is precisely the paper-only case), and a legacy ORDER (the only family that can also be asked for its table). orderNumber is already the human name in all three, so the row reads off that and reports the FAMILY rather than inventing a table the job never carried. Reprint re-plans through whichever planner owns that bong's lines, and the SERVER picks so a till cannot ask for a weaker path: a fire goes through the existing reprintKitchenTicket → planTillFireJobs (reprint: true — stored kitchen lines, voided ones filtered, and it refuses a cancelled or training ticket, a check nothing on the legacy side can make); a legacy order through planKitchenTicketJobs, the same call the KDS board's Skriv ut makes, marked OMTRYCK / REPRINT on the note row and filtered to the printer that lost it (the others printed their share; a second copy on a rail that is not missing one is a dish cooked twice); a counter sale re-queues the failed job's own rendered bytes through the same newDeviceJob producer — byte-identical is the strongest possible "cannot diverge", and nothing printed, so it is the FIRST paper rather than a duplicate. A double press queues a SECOND bong, deliberately, and is not deduplicated — a human pressing again is saying the paper still has not appeared, and the design's rule is that a human asking for paper always wins: one extra bong costs a cook a glance, a suppressed one costs the guest their dinner. bongReprints makes it visible ("Omtryck skickat 2 ggr") instead of silent. Reprint does NOT acknowledge, because paper being queued is not paper being seen and a printer that failed three times can fail a fourth; only Kvittera removes the row, it is a server write (a local dismiss would clear the alarm on one till and leave it standing on the next), and it is terminal. Three additive `DeviceJob` fields (bongAckedAt, bongReprintedAt, bongReprints), all derivation pure and unit-tested in the new lib/devices/kitchen-bong-alarm.ts (19 tests), reusing printer-health.ts for the per-device failure count rather than counting the same thing twice. Transport is the internal channel the tills already use for print settings: GET/POST /api/internal/pos/kitchen-bong-failures (ops ack | reprint | stamp) behind x-internal-secret, proxied by the new KitchenBongAlarmBridge to GET /pos/kitchen/bong-failures + …/:jobId/ack + …/:jobId/reprint. Those three routes are gated on `sell`, never `kitchen_routing` — a paper-only venue holds no register with that capability, which is exactly what makes it paper-only, so gating on it would hide the alarm from the one venue it was built for — and carry no `@RequiresOperator()`, same posture as the board's Skriv ut: the person looking at a printer that produced nothing is whoever is standing there. No capability minted (no preset grant, no backfill — the trap that removed Parkera nota from new tenants twice), and nothing in the kernel, the receipt series, the fiscal signer, tender rows or stock is touched: a reprint is paper, not a sale. Unlike the print bridge next to it the alarm bridge throws rather than swallowing — an empty banner on a till whose kitchen was never told is the exact silence this feature ends, so a failed read renders "Kan inte kontrollera köksbongarna just nu" and only a 403 (a paired kitchen display with no counter) stays quiet. All three tills: Compose commonMain (data/BongAlarmRepository.kt + screens/BongAlarmBanner.kt, placed in PosApp.kt's shell column under TillTopBar and above the rail so it shows on every destination, 15 s poll, one chime per NEW failure through the existing order-alert plumbing, bilingual via BongAlarmStrings/SvBongAlarmStrings/EnBongAlarmStrings, clocks through PosClock and never a timestamp substring — the server stamps UTC and a substring prints 16:42 on a bong lost at 18:42 every summer) and pos/web (data/bong-alarm.ts + screens/BongAlarmBanner.tsx in Shell.tsx, with a typed bilingual `i18n/bong-alarm.ts` and its own locale guard, deliberately not adding to the hardcoded-Swedish defect in the newer web panes). Colour is the design kit's DANGER family on both, never the amber warn family: the brand is azure, amber reads "heads up", and this reads "food is not being made". Neither surface blocks selling — no dialog, no overlay, no focus trap, and nothing rendered at all when there is nothing wrong. Still not told, and stated rather than designed around (full list in the design doc's new § The failed-bong alarm, as built): a bong that was never planned (no printer registered, paper channel off) produces zero jobs and so cannot alarm; a mock printer never alarms; a fire whose kitchen ticket has been purged is refused rather than falling through to the stored bytes, because only the ticket knows the order was cancelled; and the stored-bong reprint carries no OMTRYCK mark.
    • The kitchen DELIVERY POLICY — screen, paper, or both, per venue and per order source (2026-08-11). Owner requirement, verbatim: "even if the kds is switched off the order will still [be] made — think like a small pizza shop [that does] not have the KDS… then some restaurants do have KDS both digital and pap[er] (bong) flow active… that module should cover all thinkable scenarios out of the box." Until now one question had one answer for two different things: whether there is work to be done, and how the kitchen hears about it. `settings.kitchenDelivery` (deep-partial, resolved at read, lib/orders/kitchen-delivery.ts) separates them. Precedence is per CHANNEL, not per object, so bySource.thirdPartyDelivery = { screen: false } still inherits paper — "delivery cooked and printed but off the board" is one toggle. The source vocabulary is the existing DisplaySource keyed by classifyOrder(), so all 21 Order.source values already map, the TABLE still decides counter-vs-waiter, and an unmapped source still fails open (and cannot be overridden). The policy never gates a write: a kitchen ticket is the venue's kitchen journal and the reason a guest has a queue number, so it is minted for cookable content whatever the policy says — only delivery routes are governed. Paper's venue-wide default was deliberately NOT moved: it stays settings.printing.autoKitchenBong, the field the tills already write through POST /registers/print-settings, so that shipped three-surface switch keeps its full meaning (it moves the default every non-overridden source inherits) and needed no change on Web, Android or Windows. There is no `kitchenEnabled` — "does this venue make food" and "is the screen pipeline available" have opposite correct failure directions, so screen availability stays the register's kitchen_routing and paper availability stays hasKitchenPrinter, and a venue that wants neither channel simply turns both off. Defaults are derived, never backfilled: with the setting absent every source screens and papers iff autoKitchenBong !== false, i.e. exactly today. Enforced at dispatchHardwareForOrder (one paper decision for all four of its callers), planAcceptedKitchenBong, both till paper paths, and the legacy KDS board — where the screen filter deliberately never touches unaccepted orders, because that board is the only accept surface a paper-only venue has and filtering them would make a settings toggle un-accept the venue's orders. The till's own sale-print path keeps printing for autoKitchenBong === false venues (kitchenPaperForTillSale), including the table-bound sale that an earlier draft of this design would have silently silenced. Surfaces: a card at the top of Köksrouting (archetype picker Papper/Skärm/Båda/Ingen with a derived Blandat, the two channel masters, honest read-back for a missing printer or a till that reaches no screen, and a per-source table with an explicit Ärver state), mirrored read-only with a deep link in Devices → Köksskärm and in the Printers panel. Its own PATCH /api/tenant/[tenantId]/pos/kitchen-delivery with a deep merge, because the generic settings PATCH shallow-merges and would drop bySource. No capability minted — tenant settings behind settings.edit. Known limitations recorded in docs/pos-rebuild/kitchen-fulfilment-design.md § What actually shipped: paper has no deferral (a 19:00 pre-order prints at acceptance), the rebuilt NestJS KDS board does not honour the screen channel yet, and the promised till-side failed-bong alarm was not built (its missing mechanism, DeviceJob.kitchenTicketId, was added) — the alarm landed later the same day; see the entry above.
    • Tills can take a REAL Stripe test-mode card payment now, behind a switch that cannot lie about the fiscal signer (2026-08-11). Owner ask: "i was using stripe (test) to test the full flow so card payments can be applied. i want that also in PoS… i dont want to use mock but the real test flow so we can test everything without dead-ends". Before this the till's card tender was a mock — PAYMENT_MODE=emulator on the box unconditionally selected a random ~85%-approve simulator, and no Stripe code in the repo was reachable from a till at all. What landed: (1) `StripeCardProvider` behind the existing `PaymentProviderPort` (backend/src/modules/pos/payments/providers/stripe.provider.ts) — real test-mode PaymentIntents over Stripe's REST API, real pi_… references in the Stripe dashboard, real brand/last4 on the tender row, and nothing marked SANDBOX/EMULATOR because nothing is simulated. It refuses loudly when unconfigured, derives its mode from the key prefix (sk_test_ → test, sk_live_ → live, unrecognised → unconfigured), and refuses a LIVE key outright: it confirms Stripe test card tokens, so it is not a card-present rail and cannot become one by swapping a key. Non-card errors and requires_action/processing throw rather than reporting a decline — a decline is a promise that no money moved — and every message it surfaces passes through a redactor, because Stripe quotes a bad key back only partially redacted. (2) A new per-tenant go-live switch `cardRail: off | test | live` (default off, platform document pos_card_rail, super-admin only, audit row written in the same save) with its own section on /admin/dashboard/pos-operating-mode. It is deliberately a second document, not a third value on `pos_operating_mode` — that switch conflates the acquirer with the Swedish control unit, so "let me test card payments" would silently also have bought a fake kontrollkod, which is precisely the POS_FISCAL_SE_MODE defect this platform removed. POS_PAYMENT_PROVIDER=stripe is refused, the same doctrine that keeps the control unit's simulator out of its env-selected map: an env var that can switch on a money rail is a second door around the audit trail. (3) A Swedish capture is now refused BEFORE the card is charged when no control unit covers SE (409 CONTROL_UNIT_REQUIRED, till copy leading with "Kortet debiterades INTE") — without it a test-mode capture succeeded and the gated /close then 409'd, charging a guest for a sale the platform will not receipt, which is the exact harm the market gate was moved in front of the capture to prevent. Refusal order, tested end to end: market → card rail → control unit → provider-config → provider; refunds stay ungated on every one of them. Flipping the rail to live still implies nothing about the fiscal signer, and Sweden still cannot complete a real sale without a certified kontrollenhet — the refusal now names the three honest alternatives instead of appearing to work. Full trace in docs/pos-rebuild/till-card-payments-design.md.

    Fixed

    • Every card sale ever taken on the web till was invisible to settlement matching (2026-08-11). payCardFull() settled with no providerRef, provider, cardBrand or cardLast4 and SaleTenderPayload had no fields for them — even though POST /payments/terminal returns all four and TenderInputDto already accepts and persists them. The reconciliation engine reports any card tender with an empty providerRef as no_provider_ref: unmatchable against the acquirer's payout file, forever. All four now ride from the authorization onto the tender, and each split part carries its own acquirer answer (sharing one would point two tender rows at one settlement row). Same pass: the terminal reference is a real ulid() instead of pos_<Date.now()> — not merely undocumented, but non-unique across two tills tapping in the same millisecond, and this value is the acquirer's idempotency key on every retry of one physical payment. Still open: the Compose till (Android + Windows) has the identical defect one layer down — it parses all three fields and then drops them at LiveSaleGateway.kt:176, where CardAuth.Approved maps onto a payload-less TerminalOutcome.Approved.
    • `STRIPE_TEST_MODE` read as set and did nothing (2026-08-11). The code checked === '1' while .env.example, DEPLOY.md, knowledge/ui/modules/billing.md and check-readiness.mjs all documented and emitted true — so every deployment that followed the documentation set a flag with no effect, and test mode worked only by the accident of an sk_test_ key prefix. Both spellings (plus yes/on, trimmed, case-insensitive) are honoured now, check-readiness.mjs reads it as a flag rather than comparing it to "true", and a test fails if anyone reintroduces the single-literal comparison. A flag that reads as set and is not is worse than no flag.

    Changed

    • The till's payment-emulator env is `POS_PAYMENT_EMULATOR`, not `PAYMENT_MODE` (2026-08-11). The old name sat one letter from the web tier's PAYMENTS_MODE and the infra audit repeatedly read the two as duplicated drift. They are not duplicates and must never be merged: this one selects the till's mock card reader inside NestJS, that one is the Next.js payment kill switch, and unifying them would couple a dev/QA convenience to a production kill switch across two runtimes. The rename ends the confusion; pos/payment-emulator/README.md now says so where a future audit will look. The old name still answers for one release (the staging box's docker.env carries it) and logs one deprecation warning at first use; check-readiness.mjs warns on it, and on POS_PAYMENT_EMULATOR being set at all, since it overrides every tenant's sandbox flag and card rail.

    Added

    • A dead printer no longer looks identical to a working one (2026-08-11). We are about to let a restaurant run paper as its ONLY kitchen surface, and the Hardware & Devices panel (PrintersPanel.tsx) could not tell an owner whether that paper was actually printing — the job log rendered kind/order/time but silently dropped status, attempts and error, all of which the API already returned unfiltered (GET .../hardware/jobs and .../hardware/devices were never the bottleneck; the fields simply weren't read). Three additions, all client-side, all sourced from data already fetched: (1) per-job outcome — every job in the log now carries a status pill (Utskriven/Köad/Skriver ut/Misslyckades/Förhandsvisning), the attempt count out of MAX_JOB_ATTEMPTS while a retry is live, and the agent's error text under a failed job; failed rows get a status-danger-toned background so they read at a glance without opening each one. (2) A failed-count banner at the top of the panel, present only when countFailedJobs (new, lib/devices/printer-health.ts) finds an unresolved failure, absent entirely otherwise. (3) "Har aldrig skrivit ut" — the never-accepted-a-byte state, the single most valuable signal for a shop planning to run on paper alone: a device that has an agent claimed (RW-3) but has NEVER had one real job reach 'done', distinct from "worked before, the last job failed" (failing). Mock (unclaimed) devices are correctly excluded — their jobs are always recorded 'printed' the instant they're created (storing IS the mock's execution), so they carry no signal about real hardware and are labelled simulated, never flagged. All derivation lives in the new, dependency-free lib/devices/printer-health.ts (computePrinterHealth, groupJobsByDevice, countFailedJobs — 9 unit tests, structurally typed with zero imports from registry.ts/dispatch.ts so it has no surface into either module) — nothing in lib/devices/registry.ts or dispatch.ts was touched. The panel's job fetch widened from limit=40 to the route's own limit=100 cap for better "ever" coverage; no new endpoint, no new polling. Colour choices follow the existing status-* token vocabulary (globals.css), never Tailwind's amber-*/orange-*/yellow-* families (brand-remapped to azure) — the same pattern AttentionBar.tsx and the tables floor-plan already use. Swedish copy matches the panel's existing kitchen-bong/test-print strings; the panel's older headings ("Recent print jobs", "Devices", "Add device") remain English — a pre-existing inconsistency, not swept here.
    • The Customer Order Display finally tells the truth about the whole venue, and the owner decides what it shows (2026-08-10). Root cause of the owner report ("SSK orders show, counter POS orders never do"): the public queue feed read ONLY the legacy tenant-blob db.orders[], and a rebuilt-till counter sale lives in pos_tickets/pos_kitchen_tickets and never touches that array — a structural split, not a filter (docs/pos-rebuild/customer-order-display-routing.md has the full trace). What landed: (1) counter sales now board — a new internal POS projection (GET /pos/admin/display/queue, till-fired kitchen tickets → accepted/preparing/ready via the same read-time derivation history uses) merged into BOTH display feeds (/api/public/[tenantId]/live-orders + the paired-TV feed), fail-open so a dark POS backend costs one poll's till entries, never the board. (2) One public queue number for kiosk AND till — recordSale draws from the SAME per-tenant sequence every kiosk/web/QR order uses (new internal allocation route bumping db.orderNumberSeq under the tenant lock — numbers cannot collide across devices), stamped on the ticket, the KDS card (orderRef — cooks stop seeing "Direkt"), the printed receipt's Ordernr and the till done screen; fail-open, training sales excluded. The test123-shaped "order numbers" were never a generator: counter sales had NO public number, so surfaces showed the fiscal receipt series (free-text prefix, test-harness registers in the shared dev DB) — receipts keep their fiscal number, guests now get digits. (3) Owner-configurable visibility (settings.liveOrdersDisplay.visibility, deep-partial + resolver = recommended defaults with NO migration): master switch (hides all, preserves toggles, board falls back to welcome state), quick service (kiosk / counter POS / eat-in / takeaway-pickup — all default ON), table service (waiter / table-QR — default OFF: the waiter serves the table), remote (direct online pickup ON, third-party delivery OFF). Evaluated per poll by ONE canonical policy (lib/orders/display-visibility.ts) — never persisted on an order, so toggles apply on the next 4–5 s poll, and KDS routing reads none of it (hiding an order can never keep it out of the kitchen). A POS can create counter OR table-service orders — classification uses the actual data (dine-in WITH a table = table service), never source alone. Visibility PATCH requires settings.edit, audits old→new to the activity log, and the admin panel groups the toggles by workflow with a reset-to-recommended confirm. Deliberate behaviour changes: waiter + accepted table-QR orders stop appearing until enabled (they showed before), third-party ingests stop appearing by default.

    Fixed

    • The green status chip was never legible, and nothing could have told us (2026-08-11). --status-ok-ink (#12905a) measured 3.63:1 on its own --status-ok-bg wash and 4.07:1 bare on a white card — under WCAG-AA's 4.5 for normal text, and the chips render at 10–12px. It was the only one of seven status families to fail, in either scheme (the rest measure 5.1–12.1:1), and it surfaced only because the new printer-health states lean on that palette. Ink darkened 13% at the same hue to #107d4e → 4.61:1 on the wash, 5.17:1 on the card; --status-ok-solid is deliberately untouched, since it is consumed only by .status-dot-*, a non-text component whose threshold is 3:1 (it gives 4.07:1). The durable part is lib/theme/status-contrast.test.ts: it parses the real token declarations out of app/globals.css (not a copy, which would drift), alpha-composites the translucent dark washes over --color-card because measuring the rgba() as if opaque reports a ratio no eye will ever see, and asserts AA for every family in both schemes — including a guard against the parse matching nothing and passing vacuously. Verified to bite: restoring the old value fails exactly two assertions with the ratios above. This is the 2026-08-06 defect class ("almost unreadable text", found at 1.30:1 and 1.21:1 the moment someone computed instead of grepped) turned into a test.
    • A paper-only restaurant could not receive a web order on paper AT ALL (2026-08-11). The owner's archetype 1 — a small pizza shop that takes the order, gets a bong on paper, makes the pizza — was broken end to end, not merely ungoverned. A venue running no register with the kitchen_routing capability got: no kitchen ticket (the online→kitchen ingest refuses outright on hasKitchenRegister), therefore no till-side accept to tap (the till resolves an online order through that online_<orderId> ticket), and nothing at all from the dashboard accept — PATCH /api/tenant/[tenantId]/orders/[orderId], the only accept surface that venue has, contained no kitchen dispatch call. So a REGISTER capability ("is there a screen?") was transitively deciding whether PAPER happened. Fixed by separating the two questions: paper is now decided by the venue's hardware alone (hasKitchenPrinter + the new pure planAcceptedKitchenBong in lib/devices/dispatch.ts — nothing in that path reads registers), and the dashboard accept dispatches the bong exactly as the till accept has since the acceptance split. The bong still fires at ACCEPT, never at creation (owner, 2026-08-01: an online order is not the kitchen's until a human accepts it) — creation's skipKitchen for guest channels is untouched. One bong, from any surface: the new shared crossesKitchenAcceptance predicate (lib/orders/kitchen-accept.ts) keys off the same persisted acceptedAt the till's replay guard uses, so a double-tap, a till accept that already printed, and an offline-outbox replay all print once — and it keys off the status rank, not the word 'accepted', because the outbox collapses accept→preparing→ready into one PATCH and a word-match would silently lose that kitchen's paper. A screen-only venue is byte-identical (zero jobs), a both-channels venue gets both, printing.autoKitchenBong === false still suppresses the automatic print on this path too, and a venue with no kitchen printer loses nothing — the order sits on the dashboard and legacy KDS boards (both read db.orders, needing neither register nor printer) with its status advanced, and the skip reason is now logged instead of swallowed. No new POS capability minted (no preset grant, no backfill — the trap that removed Parkera nota from new tenants twice) and the backend ingest gate is deliberately unchanged, so no shop/salon tenant starts accruing kitchen tickets. Still out of reach and stated rather than designed around: paper has no deferral and this codebase has no scheduler, so a paper-only venue accepting a scheduled pre-order prints the bong immediately, not at the serve time.
    • Unpaid waiter orders finally reach the cook board (2026-08-10). The 2026-08-09 "never the kitchen" fix set autoAccept for waiter/POS orders, but the backend gate demanded paid too — and a waiter's dine-in order is unpaid by definition (the guest eats first), so it stranded on the FOH view awaiting an acceptance step that does not exist in that flow. New explicit staffOrigin flag on the kitchen ingest: staff-placed orders fire unpaid; guest channels (web, QR, unpaid kiosk) never carry it and keep the anti-prank accept gate unchanged.
    • Manual dashboard orders could mint the same public number twice (2026-08-10). String(1001 + countDocuments) raced (two concurrent creates read the same count) and counted the POS read-model rows the relay upserts into the same collection. Replaced with an atomic per-tenant $inc counter (order_counters, unique-indexed, seeded once from the legacy count so numbering continues where it left off).
    • The dashboard was blind to what a till had switched off — one PIN tap could stop every kitchen ticket and nothing outside that touchscreen said so (2026-08-11). Owner report: "we added yesterday on/off toggles for which orders will go to KDS — i am not able to find those options under windows PoS or Android PoS, not even on restaurant admin panel." The toggles are real and they work; they live on the till, and the Devices page could not read a single one of them, which is the worse half. RegistersService.toView — the projection behind GET /pos/admin/registers — stopped at capabilities + capabilityRev. disabledCapabilities, the subtract-only layer a manager writes at the counter (Inställningar → Funktioner, PIN required), was projected only to /registers/me, so the fleet panel reported Ordrar/Bord/Presentkort as present on a register where the venue had switched them off: a "granted" tab that is visibly absent, with nothing on the page that could explain it. And autoSendToKitchen — per register, defaults ON, settable only from the till's own Inställningar → Kök — appeared nowhere under ui/src at all, which is exactly why the owner could not find it. Additive projection (no write path, no resolution semantics and no new capability touched — the venue layer stays subtract-only, pinned by test): RegisterView gains disabledCapabilities, effectiveCapabilities (granted − disabled, the same shared resolver RegisterGuard applies to every route, so an admin screen and the till cannot disagree), disableableCapabilities (sent per row rather than mirrored client-side — a dashboard holding its own copy of a server list is how park_bills went missing from every "Skapa"-created till for six days), and kitchenDelivery: a resolved four-state discriminant — active / not_granted / off_at_register / auto_send_off — carrying the precedence, because a missing grant makes the venue switch irrelevant and a venue switch makes the auto-fire flag irrelevant, and any surface re-deriving that order is how two screens come to disagree about one till. (autoSendToKitchen alone is a trap and is documented as one: it resolves against the GRANT, so it stays true on a till that switched Köksbongar off.) The fleet panel now states each register's effective state: a per-row kitchen sentence naming which switch stopped the orders and which screen to change it on, a box-level "2 enheter når inte köksskärmen just nu" banner, and a folded per-capability list where active / av på enheten / ingår inte differ by colour, icon and wording. Deliberately read-only, and the copy says so — no dashboard writer exists for either layer (the venue layer is till-only by design; the grant is set when a device is created), so the panel points at the till instead of implying a control it does not have. The five new fields are optional in the ui types: ui and the NestJS backend deploy separately, and absent means "the server did not say", never "nothing is switched off" — the panel renders nothing at all rather than reissuing the same lie under a new heading. Also fixed while screenshotting both modes: the panel's genuine-amber status colour (#b45309, a flat inline style that cannot answer a .dark ancestor) was a dim ~2.4:1 on the dark dashboard — now mode-aware, light values byte-identical, which also repairs the two pre-existing uses (the currency refusal and "Väntar på parkoppling").

    Changed

    • Every booking gets a number now, not just the till's (2026-08-08, follow-up). The cancellation mail for a website-made booking showed no number — because only the POS create path minted one. mintReservationReference moved to lib/reservations/reference.ts and ALL FOUR create paths use it: the public widget, the floor-picker wizard (whose old 4-digit ref lived only in specialRequests text and matched nothing searchable — the stored field, the text prefix and the returned ref are now one value), the dashboard staff create, and the till. Every future booking is numbered in every mail and findable by number from the POS; bookings created before today keep having none.

    Added

    • Reservations grew a full guest-notification story and a no-account self-service door (2026-08-08, fifth round). Owner asks off a live email: (1) the booking number now rides every reservation mail — the owner's new-request notification, the guest's received/confirmed mails — not just the confirmed template. (2) Cancellations mail the guest from every surface that can cancel (POS action 'cancel', dashboard status flip, guest self-cancel): a new canceled branch on reservationStatusEmail, because an inbox holding a confirmation for a table that is gone was the worse silence. Both tills' cancel-confirm copy now states it conditionally ("om bokningen har en e-postadress" — the till never sees the address), and the after-state reports the server's guestNotified honestly. (3) Reschedules mail too — new reservationChangedEmail fired by the till's update route and the guest's own changes; the till edit confirmation now reports mailed/not-mailed instead of "the guest is not notified". (4) Manage without an account: guest reservation mails carry a manage link (/tenant/[id]/reservations/manage?id&token — the cancelToken IS the authentication) to a new page that shows the booking and lets the guest move date/time/party/table or cancel, backed by new public GET/PATCH /api/public/[tenantId]/reservation/[id] running the ONLINE rules (availability engine when the slot moves, party bounds, table fit with no staff override, occupancy excluding self). Till-created bookings now mint a cancelToken so their guests get the same door.

    Changed

    • Booking-form and floor polish off the owner's fourth hands-on pass (2026-08-08). (1) A table already sold for the chosen date+time now says "Upptagen" ON ITS TILE — dimmed, warn-toned, untappable — using the same exact-slot rule the server refuses with, read from the day's own list; a time step that sells the chosen table out clears the selection where the operator is looking, and the server's TABLE_OCCUPIED stays behind it as the final word (the client set is a fetch old). In edit mode a booking never blocks itself. (2) The RB booking number is bold on both tills' confirmations. (3) Floor legibility: the BOKAT chip's near-black-on-azure ink became the on-primary white, and table-top labels are held to the shape's SAFE width with ellipsis — a circle's text band is its inscribed width, so "WINDOW" and the money line no longer run off the curve mid-letter.

    Added

    • The till's booking desk is complete: numbers, online slots, find, cancel, reschedule (2026-08-08, third round the same day). Owner asks off live testing, all on both tills. (1) Booking numbers: till creates mint RB-###### (unique per tenant, additive Reservation.reference), shown first on the confirmation, on the sheet's facts, in search rows and in the guest's confirmed-email (new optional reference on reservationStatusEmail). (2) The online widget's time slots as quick picks: GET /pos/registers/bookings/slots → BookingsBridge.slots → new /api/internal/pos/booking-slots running the SAME getAvailableSlots engine as the website — guidance chips beside the manual clock, never a gate (the person in the room outranks online pacing); the form itself reorganised into bordered section cards (Datum/Tid/Sällskap/Gäst). (3) Find a booking: GET /pos/registers/bookings/find?q=&today= (operator required — an all-dates sweep over guests' names is an errand, not floor decoration) matching number, name or phone digits across every date and status, served from a projector now SHARED with the day list (lib/reservations/pos-projection.ts) so the two reads can never drift on what a till may know about a guest; a debounced search sits in the bookings panel on both tills. (4) Cancel from the till: action 'cancel', legal from pending AND confirmed → 'canceled', mailing nobody (no cancel template exists anywhere; the caller IS the notification, and the confirm step says so out loud). (5) Reschedule/edit: POST /pos/registers/bookings/update → new /api/internal/pos/reservation-update — full-state for the till-visible fields (a blanked phone IS a cleared phone), customerEmail structurally untouchable (the till never sees it, so an edit cannot wipe it), occupancy re-checked EXCLUDING the booking itself, BOOKING_NOT_OPEN for closed ones; the create dialog reopens prefilled as the editor, email field hidden, and the confirmation states plainly that the guest is not notified automatically. Also fixed live: `guestNotified` no longer lies on a box that cannot send mail — the response is now gated on isMailConfigured() (found when the staging box's SMTP lived in ui/.env.local, which the runtime container never receives; the keys now travel via docker.env).

    Changed

    • The till's booking form grew up on first contact with its owner (2026-08-08, same day). Four asks off the first hands-on test, all landed on both tills: (1) a REAL calendar — a custom-drawn month grid (new PosCalendar in the Compose kit, mirrored markup on pos/web, shared arithmetic in NewBookingModel/booking-calendar.ts pinned by twin tests) replacing the one-day-at-a-time stepper; the platform-date-picker doctrine holds, the grid is ours; (2) the table picker's pills ("3 · 4") became TILES — name on one line, "4 platser" on the other, chosen tile solid primary (the PosSegmented lesson about quiet tints); (3) the Telefon/På plats question is GONE — either way the booking is taken at the till, so the server now stamps every till create source: 'pos' (new vocabulary value, rendered "Kassan"/"Till"; older tills' source field is accepted and ignored rather than 400ing the not-yet-updated); (4) the Bord side panel's way back from a selected table — a real "‹ Alla bord" leading the header where the old trailing ghost went unseen by the owner looking straight at it, and "+ Ny bokning" got its own full-width row instead of wrapping under the day stepper.

    Added

    • The till can now TAKE a table booking, not just answer one (2026-08-08). Owner ask: a guest on the phone or at the counter had no door into the book from the POS — the operator wrote it on paper and typed it into the dashboard after close, or didn't. New end-to-end create path on all three POS surfaces (pos/web for browsers+Windows station, the Compose till for Android/Windows/wasm): a "Ny bokning" dialog on the Bord surface with source (Telefon/På plats), a stepped day (floored on today, +365 cap, no calendar widget per the kit doctrine), quarter-hour clock, party stepper, optional REAL-table chips, guest name, optional phone/email, and free-text önskemål (→ specialRequests, which already flows to the floor cards, dashboard list and prep board). The chain mirrors reservation-action deliberately: POST /pos/registers/bookings/create (RegisterGuard + operator, no capability — the reservation-route doctrine) → BookingsBridge.create → new internal route /api/internal/pos/reservation-create doing the write inside ONE mutateTenantDB CAS with the staff route's exact checks (table exists, tableFitsParty with an explicit operator override that stamps seatedOverCapacityBy, occupiedTableIds), then the shared notifyReservationCreated — never a second notifier. Born `confirmed`, and that is a rule: the operator taking the call IS the venue answering, and the native alert engine chimes only on new pending rows, so a phone booking taken on till A must not ring till B or inflate any Bord badge. A booking with no email reports guestNotified: false and the confirmation says so ("bekräfta med gästen direkt"). Reservations gain an additive createdBy operator stamp. Validation drift between the DTO edge and the authoritative zod is carried as an honest 400 VALIDATION ("kontrollera fälten"), never flattened into the 503 outage message. Adversarial review (3 lenses over the combined diff) found no blockers; its parity findings — a half-hour-boundary default-time drift between tills, 1 gäster grammar, named-table chip wording, the web till not re-reading a stepped-to future day after a create, missing input caps on Compose, missing stepper aria-labels on web — were all fixed before landing.

    Changed

    • The animated hero is back on the landing page (2026-08-07). Owner ask: "do you remember we hade the animated hero on our website home page or landing page before. is it possible to get that back?" It was never deleted — components/landing-v2/HeroSection.tsx was orphaned when the new landing page (#81) replaced it with a static headline-and-screenshot block, and had simply stopped being imported. Restored on top of the new page: only the static hero it replaces is gone, everything else (HomeNav, JourneyStrip, the trust band, StickyCta, TestingBanner) is untouched. What moves again: the live order demo stepping New → Accepted → Preparing → Ready with its pulsing status dot (rPulse), and the "Today's revenue" card floating above it (rFloat). The two stylesheets could not simply both be imported — landing-theme.css and restora-landing.css share five selectors and all five are root ones (.r360-landing, .r360-landing *, html, the skip link), so loading the old sheet second would have re-based the whole page's tokens, reset and typography on top of the new design. Instead the hero's 30 custom properties, 12 rules and 2 keyframes were lifted into hero-legacy.css, with the tokens declared on the hero element rather than the page root so they cascade to its children and can reach nothing else. The warm token names (clay/honey/pine) are legacy; their values are the azure brand (--clay-500 = #1570ef) and stay that way per AGENTS.md.

    Fixed

    • "Tables & QR Codes" showed only table management — every QR affordance on the page was gated behind having tables (2026-08-05). Reported as "QR code, it was working there, now it shows table management instead of QR code generation". Root cause: on /dashboard/tables, the "Menu QR codes" button was wrapped in {tables.length > 0 && …} and the per-table QR buttons render inside the table list — so a venue with no tables yet landed on a page the sidebar calls "Tables & QR Codes" and got a floor-plan empty state ("No tables yet — click Add Table to set up your floor plan") with no route to QR generation at all. Compounding it, the standalone QR Menu Generator (/dashboard/qr) is a HUB_LINKED_ITEM — deliberately not rendered in the sidebar, reachable only via the Marketing hub — so once the per-table buttons vanished there was no visible QR entry point anywhere in the nav. Fixed by making QR reachable from this page regardless of table count: a permanent "QR generator" link in the page header, and an empty state that now says per-table QR codes appear once tables exist while pointing at the generator for menu/takeaway QR codes, which need no tables at all. The sidebar IA is left as-is (the hub-linking was a deliberate de-duplication); this restores the discoverability it cost.
    • The grocery till was overcharging for weighed goods, live — and the till that wasn't overcharging couldn't sell them at all (2026-08-05). The #1 item in the grocery backlog (OUTSTANDING-WORK.md §3g). On a weighed variation priceMinor is the price per kilo, but pos/web's local CatalogVariation interface never declared weighPricing — the exact TypeScript twin of the Kotlin ignoreUnknownKeys defect that had already bitten this same field — so every add path treated a kilo price as a unit price. Tapping Lösgodis (99,00 kr/kg) charged 99,00 kr for whatever the bag weighed: a 320 g bag overcharged the customer by 67,32 kr, silently, with a plausible-looking amount on the receipt. pos/web now declares the field and refuses weighed items by name — the identical posture, and identical check order (sold-out → weighed → age), the Compose till already took. Also added a server gate at both independent write doors (buildTicketLines, serving sale.record + park; and addLine for open tickets): a weighed variation with no ext.weigh is refused, mirroring the minBuyerAge → ageCheck refusal directly above each. It costs nothing today because both tills refuse — which is exactly why it could land now: the overcharge is structurally unreachable from any client, including ones not yet written. Adversarial review then caught that the fix was incomplete in three ways, all fixed: (1) TablesFlow is a third hand-maintained copy of the same wire shape and still walked straight into the new server gate — a waiter tapping a weighed tile would have gotten a raw English server sentence naming internal ids in a Swedish till (table service really does sell weighed food; the SE journal fixtures pin a "Salladsbuffé" uom: 'kg' line); (2) the new spec never exercised the sale/park door's accept path, so a gate that refused every weighed line — even a correct one — would have kept the suite green (both gates are now proven non-vacuous by deleting each in turn and watching the suite go red); (3) onScan's new non-404 branch claimed "no contact with the server" even for a 500/401 where the server answered, and discarded the precise message the API client had already written. Separately fixed on the same path: onScan used to treat every failure as "unknown barcode" and open Ny vara prefilled, so a network blip while scanning a real product invited a duplicate item bound to a code the catalog already used — now only a definitive 404 does. Published to all three POS surfaces (web / Windows-via-web / Android assets; APK rebuild still required to reach devices). The kernel needed nothing — verified, not assumed: unitPriceMinor is literally "per 1.000 uom", qtyMilli is net grams, and the golden 0.734 kg line is pinned across the Rust/Kotlin/C# engine ports. Manual weight entry and the prefix-2 EAN parser remain blocked on owner decisions (the prefix-2 layout is company-internal and absent from this repo; a parsed item reference has nothing to resolve against; and whether a hand-keyed weight is legally sellable is a metrology question) — all now written up in §3g rather than guessed at.

    Added

    • /shop now serves the grocery vertical — grocery's first working public catalogue page (2026-08-05). Found while auditing other verticals for the same "no public surface for the real catalogue" gap as the services /shop fix earlier the same day: the grocery demo tenant's /menu (the only page grocery doesn't already 404 on) reads the legacy tenant_data.products blob — empty, since a grocery till writes to pos_items instead — so it rendered generic restaurant-template chrome ("Vår meny... Färska råvaror, lagade med kärlek") with zero of the tenant's real 12 products. Every grocery tenant has this gap, not just the demo one. Extended (not duplicated): listGoods's vertical gate widened to services-or-grocery; a new resolveGoodsTenant resolver added (deliberately separate from resolveSalonTenant, so /book//availability//booking stay services-only — grocery has no appointments); /shop's page gate widened the same way; a hasShop nav flag added so grocery gets the Shop link without gaining book or losing its existing menu/reservations. Weighed items (loose fruit, deli) now carry weighPricing through the projection and render as "kr/kg" instead of a meaningless flat unit price. Adversarial review caught 3 more real gaps: the sitemap generator and the builder's IndexNow re-crawl ping both still gated /shop on services-only, so grocery's new catalogue page was invisible to search engines even though it 200s — both widened to match; and fmtPrice's default whole-number rounding, reused unchanged for the new per-kg display, silently mangled the normal case for Nordic weighed pricing (39.90 kr/kg showing as "40 kr/kg") — not a rare edge case, since psychological/fractional pricing is the Nordic grocery norm — fixed with an explicit fraction-digits override for the /kg call site only (flat prices unchanged).

    Fixed

    • Closed a latent, defensive gap of the same shape as the navbarConfig CTA bug below, in the footer. SiteFooter.tsx already filters menu/reservations out of nav-derived footer links for a services trade, but an owner-authored (or template-seeded) footerConfig.bottomLinks entry bypassed that filter entirely — free-text hrefs, never checked against vertical anywhere. No live tenant currently has a bottomLinks entry pointing at a dead route, so this wasn't actively broken for anyone, but it's the exact same "stored config bypasses the vertical guard" pattern found live in the navbar CTA — closed the same way before it could bite someone.
    • A real pilot tenant's live storefront showed a dead "Order Pickup" button (2026-08-05). Found while auditing other services-vertical tenants for the same bug class as the day's earlier nav/shop fixes: Nordic Barber Studio (ten_nordicbarber) has its own settings.navbarConfig (left over from a restaurant "forno" template installed before the tenant had a declared vertical), and resolveNavbarConfig() (ui/src/lib/nav/navbar-appearance.ts) returned that stored CTA verbatim whenever a tenant had one — silently bypassing the vertical-aware suppression SiteHeader.tsx's own legacy CTA already relies on (showBookButton && !isServicesTrade). The result: a barbershop's header showed a button linking to /reservations, which 404s for that trade. resolveNavbarConfig now takes an isServicesTrade flag and forces the CTA off in both branches (stored-config and fallback), closing this regardless of which shape a tenant's chrome is in — not just today's specific case. SiteNavbar.tsx's mobile CTA (bookHref) inherits the fix automatically since it reads the same resolved config; no separate change needed there. The tenant's stored navConfig.items was also missing book/shop/account (same gap ten_hasalong had) and was repaired the same way. The stored navbarConfig.cta itself was deliberately left untouched — the code fix alone makes it inert for this trade, and rewriting the owner's own raw editor data risked exactly the kind of silent-data-loss surprise this session's earlier fixes were about.
    • Every guest visiting any tenant's public salon page (`/book`, `/shop`, availability, cancel) was rate-limited as if they were the same single client (2026-08-05). Found while closing out the /shop feature's adversarial review: internalGet/internalPost/etc. (ui/src/lib/http/backend.ts) never forwarded the incoming request's x-forwarded-for header to the backend, so UserThrottlerGuard.getTracker — which falls back to x-forwarded-for → req.ip whenever no req.user is set, true for every guest BFF call — tracked every anonymous visitor across every tenant's storefront as the SAME client: the Next.js server's own outbound IP. Fixed by threading the caller's Request through all 5 internal* helpers (optional, backward-compatible — none of the ~79 existing callers needed to change) and wiring it through the 6 guest-facing salon-booking routes (services, goods, availability, book, booking/[id], booking/[id]/cancel). New tests pin that the header is forwarded when present and omitted (unchanged) when no request is passed.

    Added

    • A public /shop page for services-vertical retail products, and the productType bug that made it necessary (2026-08-05). Follow-up to the same-day Mason nav fix: the salon owner's items ("Happy happy", "Argan-oil") appeared in the dashboard catalogue but never on the public website. Root cause: the native till's "Ny vara" (new item) dialog has no product-type picker at all — just name/price/category/VAT — and the server-side legacy-menu translator (translateLegacyMenu) hardcoded every till-authored item to productType: 'FOOD_BEV' regardless of vertical, a meaningless tag for a salon that also had no public page reading it. Fixed: for a services-vertical tenant, a brand-new till item now defaults to GOODS instead (a real retail-product type — the SAF-T export already maps it correctly to "04003 Sale of goods" vs FOODBEV's "unclassified", and it's excluded from KDS routing). Added a new public `/shop` page (mirrors `/book`'s structure — same vertical gate, same ThemeInjector/SiteHeader/SiteFooter usage) with a new backend `listGoods` projection (sibling of `listServices`) and Next.js proxy route, plus a `shop` nav link (`source: 'custom'`, same protection as `book`/`account`). **Adversarial review caught 5 further real issues before deploy**: (1) the productType default was computed per-tenant, not per-item, so ANY unrelated till write (an 86, a reprice) re-translated and silently re-stamped EVERY item in the tenant's legacy blob to the same default — fixed by preserving each item's already-stored productType and only defaulting genuinely new items; (2) a failed tenant lookup degraded silently to the old FOODBEV behaviour with no logging — fixed with a warning log; (3) neither listGoods nor the pre-existing listServices had a server-side vertical check (only the Next.js BFF gated them) — fixed by gating both, failing closed; (4) the sitemap generator was 100% vertical-blind, advertising dead /menu+/reservations URLs and never listing the live /book//shop pages for any services tenant (a pre-existing gap /shop would have inherited) — fixed for both the subdomain and platform-prefixed sitemap branches; (5) the IndexNow re-crawl ping list had the identical gap — fixed. Also added a TTL cache for the new per-write tenant-vertical lookup so restaurant/grocery tills don't pay a new Mongo round-trip on every catalogue write for a vertical check whose answer never changes their output. The friend's two existing items were reclassified to GOODS directly in Mongo (owner-approved) so they appear on /shop immediately rather than waiting for an unrelated till write to self-correct them.

    Fixed

    • A live customer's "Boka tid" (Book) nav link vanished on an unrelated colour-theme save, and new POS items looked invisible on the public site (2026-08-04). Reported by a services-vertical (salon) tenant created same-day: picking a new colour preset in the Mason website builder — no nav edit at all — silently deleted the "Boka tid" link from the live header, while /menu and /reservations (pages that don't exist for a services trade) appeared in its place. Root-caused to THREE compounding layers, all in ui/: (1) MasonShell.tsx's save() unconditionally re-sent the full navItems array on every save regardless of what was actually edited — fixed to gate it behind the same HEADER_DIRTY_KEY dirty-tracking the Menu/Header tabs already use. (2) getDefaultNavItems() (nav-service.ts) never marked the services-only 'book' link (or 'account') as source: 'custom', so the server's reconcile treated it as an orphaned page link and dropped it — fixed by tagging both source: 'custom'. (3) normalizeMasonPages()'s fallback for a tenant with no saved page list (pages.ts) was a hardcoded, vertical-blind restaurant shape (home/menu/reservations/blog/contact) — the reconcile then saw 'menu'/'reservations' as legitimate pages worth auto-linking. Fixed with a per-vertical fallback (defaultMasonPagesForVertical) that drops menu/reservations for services. Adversarial review caught two more, more severe instances of the same class after the first fix landed: gating navItems client-side made a SECOND, previously-dead server branch in builder/route.ts (fires whenever builderPages is sent without navItems — now the common case) start wholesale-rebuilding nav purely from the page list with no reconcileNavItems call at all, re-dropping every custom link (book/account, on EVERY vertical) on almost every ordinary save; and the GET handler's own normalizeMasonPages() call (missing the vertical arg) was handing a fresh services tenant a polluted page list that got persisted into builderPages on its first save, regardless of the fix. Both closed: the sibling branch now reconciles the tenant's existing nav against the updated pages (same non-destructive merge the Menu-tab branch already got) instead of discarding it, and GET now resolves + threads vertical through its normalizeMasonPages call too. Also fixed a narrower, separate latent instance of the same vertical-blindness in the theme-install-with-demo-bundle path (installer.ts's applyChrome, ruled out as this incident's trigger but real). New regression test in nav-from-pages.test.ts pins the services Book/Account-survive-reconcile case (caught failing twice at intermediate stages before landing correctly — proven non-vacuous). The friend's tenant (ten_hasalong)'s live navConfig/builderPages, corrupted by the original bug, were repaired directly in Mongo to match exactly what the fixed code now produces — live-verified after the repair: the header shows Home/Book/Blog/My Account/Contact and /api/public/salon/hasalong/services still returns all 5 of the friend's POS-added services.
    • Paper reprint for a closed sale — and a live bug it caught along the way (2026-08-04). The tender-flow "Print" button was a dead onClick = {} stub, and History had no reprint action at all (a code comment said the seam didn't exist yet). Both now call one new, awaited backend action (POST /pos/tickets/:ticketId/print, mirroring email-receipt's exact shape) that reuses the print-job pipeline every sale already auto-queues at settle, marked kopia (or ovning for training) since it's always a second copy. Native: HistoryRepository.printReceipt + a ReprintReceipt action beside the existing email-resend button; PaymentStep.Done gained a ticketId so the Done step's Print button has something to call. Adversarial review caught a real, previously-invisible bug: a training-mode sale's receiptNumber is deliberately blank, but the internal print-sale/email-sale routes hard-required a truthy one — so every training reprint was a guaranteed 400 dressed up as a false "try again" 503, and (traced further) this had ALSO silently broken the auto-print and auto-email-at-settle for every training sale since those shipped, fire-and-forget, invisible. Fixed at both routes: a payload marked ovning is now accepted with no receipt number. Verified live against a seeded grocery tenant: reprint answers {"queued":0} (honest — no printer paired) and the previously-broken email resend now answers {"sent":true}.
    • Grocery module phase 1 — the native till learns to sell groceries, and `scan_first` finally does something (2026-08-04, `pos/app` commonMain → Android + Windows + wasm from one tree). Owner ask: "now can we start with grocery store module for PoS both for windows and android." Plan of record: docs/products/pos-verticals/GROCERY-MODULE-PLAN.md (a six-reader state map with adversarial spot-checks, gap matrix against MARKET-RESEARCH-2026-08.md §4b's ranked top-10). The finding that shaped the pass: the kernel/backend were grocery-first all along (golden scenario 1 — the mixed 6/12/25% weighed+pant+age basket — is literally called "THE EXIT CRITERION") and the web till already sold groceries; the NATIVE till had the skeleton and none of the organs, two of them live money bugs. Shipped: (1) the Kotlin catalog DTO finally declares `deposit` + `weighPricing` — the server had sent both all along and ignoreUnknownKeys silently ate them, so a natively-rung Läsk priced without its pant while the same scan on the web till priced with it. (2) Pant attaches and is priced by the ENGINE (a pant row's amount is a one-line priceBasket, never Kotlin addition; Pant chip on the row) — and the resumed-bill half was a live defect: adoptServerTicket dropped ext.goods, so a pant bill parked on the web till re-priced WITHOUT its pant on native pick-up and the close's exact-tender check refused the very total the screen showed. (3) The age gate exists on the native till — before this the server refused every minBuyerAge sale (no dialog could record the check), so the tobacco pilot's own till could not sell snus at all. Blocking Ålderskontroll dialog; a repeat merges into its checked row without re-prompting; the recorded check (id_document) rides all four write shapes (sale lines, park lines, add-line via the sync diff — a qty step on a resumed checked line is a void+re-add and needed it — and survives resume via ext.goods.ageCheck). (4) Weighed items are refused honestly ("1" of lösgodis is a kilogram at the per-kilo price; the web till still silently sells it — flagged for parity). (5) Scan-to-create: an unknown scan opens Ny vara with the code prefilled (prefillBarcode was dead code) — on a definitive 404 only; the web till's create-on-ANY-error defect (a network blip while scanning a real code invites a duplicate item) deliberately not ported. (6) Quantity multiply + the scan-first counter: scan_first gates something on a selling surface for the first time on any platform — last-added feedback card (reads the LIVE row, so merges and steppers keep it true) + a digit pad arming a multiplier for the next scan (buttons, not a field — a focused field eats the wedge's keystrokes), and the armed count survives the age dialog (the web till drops it and hardcodes 1 — not ported). Also: CapabilityRailTest's "verbatim from seed" fixtures had drifted (shop claimed 5 caps; the seed grants 10) — reconciled with the lesson in the fixture comment; new GroceryTillTest (16 cases) pins the DTO decode, wire presence/absence of the check, deposit-through-the-engine, the age-gate cadence and the armed-qty rules. Gates: full Compose desktop suite green (613), Android + wasm compile clean, Swedish-literal ratchet held at 0. Remaining (ranked in OUTSTANDING-WORK §3g): weighed goods both flows, paper receipt at tender (the print button is a stub), offline barcode index, pant registry/RVM tender, pos/web scan-first parity, dashboard grocery enablement (signup tile still "Coming soon").

    Added

    • Backend half of "email the customer their receipt" (2026-08-04). TicketDoc.customerEmail (opt-in, per-sale, never indexed or joined — no CustomerDoc/CRM write) added to RecordSaleDto and CloseTicketDto (both @IsEmail). SalesService.buildTillPrintPayload extracted from the inline body of printTillSale and generalised to build a TillPrintPayload from either a live PricedBasket (record/close time) or a persisted closed TicketDoc (lineNetGrossMinor from exports/money-format.ts reconstructs each line's post-discount total from the SAME stored fields the pricing engine used — no new totals computed, no re-derivation of VAT). TillPrintBridge.email() posts to a new POST /api/internal/pos/email-sale (mirrors print-sale/route.ts: internal-secret guard, ephemeral order, buildReceiptFiscalFromTill, no persisted Order, no minted receipt number). Auto-send hooks in recordSale and closeTicket fire void, after commit, never awaited — the same "must not be able to fail a sale that already happened" posture as printTillSale. New deliberate-resend endpoint POST /pos/tickets/:ticketId/email-receipt (409 if not closed, 400 if no address on file or given, 503 if the send genuinely fails) IS awaited, since a person tapped "send" and needs a real answer. Email body is the exact legal receipt layout via buildReceiptSegments + a new renderSegmentsPlain (render.ts) — deliberately NOT renderSegments(…, false) (raw ESC/POS control bytes, garbage inside HTML) nor renderSegments(…, true) (stamps a "not a valid receipt" preview watermark that would be a lie on a genuine one). Template (lib/email/receipt-till-email.ts) is the azure #1570ef card from receipt-email/route.ts, sv/en via settings.defaultLanguage. /receipt/[tenantId]/[token] and receipt-store.ts untouched. Verified: backend tsc --noEmit 0 errors; ui tsc --noEmit 0 NEW errors (pre-existing unrelated failures in theme-packages/experience-studio/plans untouched); all 202 pre-existing backend/src/modules/pos/sales tests still pass. NOT done in this pass (separate agents' scope per the design doc): pos/web and pos/app capture UI (checkbox + email field at tender time) and the History "Skicka kvitto" resend button on both rails — the backend endpoint and auto-send are ready for them to call.

    Added

    • The Windows till closes an architecture question left open since 2026-07-27, and gains real printing (2026-08-04). Owner ask: "my windows PoS is behind the android tablet app. can you also update windows app so it is same as my android PoS also connect that to my barber shop tenant to my linux box over internet." docs/pos-rebuild/OUTSTANDING-WORK.md §5b had four documents disagreeing on what "Windows POS" even was — WinUI 3/C# (never built), a Tauri/WebView2 shell wrapping the web till (windows/station, real code, real recent investment), or the Kotlin Multiplatform Compose desktop target (pos/app/app/src/desktopMain, also real, sharing commonMain with Android almost entirely already). Owner ruling: the native Kotlin build is the POS till — Android and Windows now run the literal same source tree, not two ports kept in sync, which is a stronger fulfillment of the "one kernel" guarantee than the original C# plan would have been. windows/station keeps its TV/kiosk/display and hardware-bridge roles (unchanged), just not the till UI. The actual parity gap against Android turned out to be small: printer/cash-drawer transport, which returned NotSupportedHere on desktop with a comment saying Windows should print through the Station agent instead. That assumption was wrong and caught same-session, before commit: there is no local channel from the Kotlin process into the Tauri process to route through. The real fix makes the Kotlin till its own independent print agent — PrinterTransport.desktop.kt ports Android's java.net.Socket RAW/9100 transport near-verbatim (same connect-timeout/watchdog trick for the JVM's missing write-timeout), PrinterDiscovery.desktop.kt does a LAN TCP sweep reusing the already-shared, already-tested PrinterRoster (no mDNS on the JVM — a known, smaller, non-blocking gap vs Android's NsdManager). Both poll the SAME /api/public/hardware/* job queue Android already uses via the existing commonMain PrintAgentRepository — zero new backend surface. A venue running both a Kotlin till and a Windows Station against one printer is prevented structurally by pairing (single-use code, one claimant), not by mutual-exclusion code. Docs corrected in the same session across docs/POS-Rebuild-Master-Plan.md §2.2, windows/AGENTS.md, and docs/pos-rebuild/OUTSTANDING-WORK.md §5b — twice, since the first correction pass itself carried the wrong (Station-routing) assumption and a verification agent caught it before commit. Built, installed and running on a real Windows machine already on the same tailnet as the box (https://restora-server.tail8deedd.ts.net, confirmed reachable) via :app:packageMsi + tools/install-windows.ps1. Not yet done: re-pairing this till from its previous ten_pilot-demo register to a ten_nordicbarber register — needs a dashboard-minted pairing code (15-minute TTL, so minted at the moment of use, not ahead of time). Gates: :app:compileKotlinDesktop clean, :app:desktopTest 597/597 pass (3 new real-socket tests, no mocks), :app:packageMsi BUILD SUCCESSFUL.

    Added

    • Audit correction: the owner caught a live miss, so the sweep methodology got fixed, not just the symptom (2026-08-04). After the three-wave trade-leak audit below was reported complete, the owner posted a screenshot of Nordic Barber Studio's live /dashboard/orders page — still the unfiltered restaurant kitchen kanban ("Tillagas" column, "Börja tillaga" button) — and asked "are you sure". It wasn't. Root cause: the audit's page-scoping walked NAV_GROUPS (the collapsible sidebar) exhaustively across all three waves but never enumerated NAV_PRIMARY (Home/Intelligence/Quick Settings/Quick Links/Orders) as a category to check — Orders lives in NAV_PRIMARY and was structurally invisible to every audit prompt written. Fixed the two live Orders leaks (kanban column + action button forked on vertical !== "restaurant", sv override added), then ran two further repo-wide sweeps rather than re-declaring done: a "No restaurant connected" boilerplate grep (8 more core-page instances fixed: quick-links, sell, menu-tv, media, designer-studio, connections/seo/offers dict), then a broader restaurant|Restaurant sweep across every dashboard page, filtered for comments/type-names/already-vertical-aware code and triaged one-by-one against nav-catalog.ts gating. ~25 more genuine leaks fixed, all on pages with zero vertical gating (reachable by every trade): the super-admin panels (addons/global-media/monetization/tenant list — these manage tenants of every vertical, so "Restaurant" table headers and "restaurant owners" copy were platform-wide leaks, not barbershop-specific ones), the dashboard error boundary (error.tsx — fires for every tenant on any error, was "Restaurant data unavailable"), escrow, marketing hub, QR, activity log, team-comms, the Google Business connect guide + attributes + insights pages (Google Business Profile is generic to any local business — none of that copy should ever have said "restaurant"), social, branches (plus hid the cross-branch "Shared Menu" tab for non-restaurant — it pushes the legacy menu system, not the Sortiment catalogue a services tenant actually manages its stock through), and the Settings page's business-name fallback (myRestaurantFallback, shown whenever a tenant hasn't set a custom name — a common, not edge-case, state). Also corrected the Swedish `dict-langs.ts` overrides for the specific keys touched (English-only fixes silently do nothing for a Swedish-locale tenant on these OLD, fully-translated namespaces — the merge prefers the stale generated override over the corrected English source). Left deliberately unfixed and flagged instead of hacked: Experience Studio's AI photo/caption tool and the Restora Profit setup wizard are restaurant-shaped by design (dish/cuisine prompts, food hashtags, a "Restaurant type" segment picker), not by a wording accident — a label rename would be cosmetic since the underlying options stay food-only; both need a real services-appropriate variant, tracked in TODO.md. Also tracked: the other 19 languages' stale "restaurant" wording for the keys fixed here (Swedish was prioritised as the pilot tenant's locale). Gate: tsc --noEmit held at the exact pre-existing 60-error baseline through every edit in this pass — zero regressions.
    • The trade-leak audit closes — every restaurant option the owner kept finding on the barbershop panel (2026-08-04). Owner: "i still se some options for restaurant on admin panel. this time i want you to detect and remove from barber or salong module." A five-surface audit (native till, web till, dashboard home, every dashboard page, the email/receipt/site periphery) swept the whole product for restaurant-specific wording and dead-end affordances still reaching a services tenant; every finding was fixed in three landed waves. Headlines: Features → Get more no longer invites a barbershop to buy a Kitchen Display, and Your Features stops reading "Table Reservations" — FeatureDef gained the same verticals convention as the nav catalog, an already-GRANTED feature always stays listed. Setup Center's Ordering & Delivery tab (Uber Eats/Wolt) is gone for services. The Devices page no longer offers a Kitchen Screen to connect, and its first-run trade picker now opens on the tenant's own declared trade instead of defaulting everyone to "Restaurang". QR Generator, Supplier Marketplace/Quotes/Orders, Hiring, and Restora Profit's menu-engineering rows are trade-annotated. The home page's Upcoming Bookings widget (table reservations) is gone for non-restaurant, its onboarding checklist offers "Add your first service" linking the new catalog editor, and its KPI counts today's actual appointments instead of a permanently-zero reservation count. Help Center, email campaign templates (four new services-flavoured twins added), the tax regulation calendar, Setup Center's Integrations tab, and the Notifications tab are all trade-filtered. Copy forks (services wording, never restaurant text mutated) landed across Setup Center, SEO (menu-completeness check dropped for services), Profit Margins (COGS/recipe language skipped for non-restaurant), Customers/Guest-profile, Intelligence's AI-question catalogue, Marketing Hub, the AI Assistant page, Demand & Weather, Shifts, and Loyalty (now sources reward items from the services catalogue instead of a Menu that doesn't exist for that trade). Neutral "No restaurant connected" → "No business connected" across every no-tenant surface. One genuine money-correctness bug was found and flagged, not silently patched: lib/android-terminal/receipt.ts hardcodes restaurant_dinein in its VAT-split function — a real defect that needs its own tested change, tracked below. Gates: full tsc --noEmit stays at the exact 60-error pre-existing baseline (confirmed via git stash diff before this work started) across all three waves — zero new errors; nav-catalog 15/15, seller-lite 23/23, regulation-watch 27/27 total, block-features + gift-cards i18n 20/20.
    • The salon till behaves like a salon till — checkout that seeds the bill, a booking chime, and the last of the menu-Swedish (2026-08-04, both tills + audit sweep). Owner asks: "booking > ta betalt takes me to kassa and kassa is empty… orders still restaurant specific… some kind of highlight upcoming booking… 3 beep sounds… settings still have kioskläge, uppdatera meny." (1) Ta betalt now hands over a bill: the booking rides the same handoff discipline Parkerade uses, the service line enters through the exact door a tile tap uses (engine pricing, tax pinning, merge rule), the customer's name lands on the ticket — and when THAT sale settles, the till completes the booking against the real ticket id (offline-queued sales get an honest "link queued" notice; a service the catalogue no longer carries refuses rather than inventing a price). Booking cards now also SAY what was booked (service name + catalogue price). Named gap: the native sale envelope carries no per-line ext.service (a money-envelope change), so the web's closed-ticket recovery scan can't find native sales — commented at the seam. (2) Attention channel on both tills: a NÄSTA highlight on the next open booking, a 30-minute upcoming badge on Bokningar (web), and a TRIPLE chime when a new online booking lands — same sound path as the order alerts, first-load and midnight-rollover silent, status flips never re-announce (18 new tests across the diff/selection/engine). No competitor documents booking sounds — this one's ours. (3) Trade wording end to end (audit-driven, both tills): Kioskläge hidden for tills with no kitchen/scan/self-checkout shape; "Meny/Uppdatera meny" → "Sortiment/Uppdatera sortiment" on non-food tills (grocery-ready); "Tillagas" → "Pågår" on Orders and History; History drops the kitchen-flow chips and the whole Typ row; Ny vara speaks the trade ("Ny vara i sortimentet", "T.ex. Herrklippning", VAT chips lead with Standard 25 %); the bare "KÖK" settings header no longer floats over the printer section; the empty-catalogue screen stops calling every venue "restaurangen"; the offline error stops saying "i hela restaurangen". Non-food line motion now derives from the item (SERVICE→service, else retail) exactly like the web's booking payment path. Gates: Compose desktopTest + assembleDebug exit 0; pos/web tsc 0 + 91/91; bundle republished.
    • A services tenant can finally edit what it sells: the Sortiment editor at `/dashboard/catalog` (2026-08-04). Owner ask, verbatim: "i cant rename or add or remove categories, and items nor on web or PoS. the system should be super easy and very flexible." The restaurant's catalog editor is the Menu page — correctly hidden for the services vertical — which left a barbershop with NO surface anywhere to manage its services, products and categories. The new page is the owner's window onto pos_items (the store the till actually sells from, not the tenant-blob menu): categories as sections with inline rename, items as rows with price/duration/active editing, add-item with category pick-or-create (a category is created by filing an item into it — an empty category would be invisible on a till), and soft remove with a collapsed bring-it-back list. New nav group "Services & Products" (services vertical, DAILY zone, translated in all 20 languages), posCatalog dict section with hand-written Swedish, and three BFF proxies under /api/tenant/{tenantId}/pos-catalog/*. Backend grew GET /pos/admin/catalog/items (whole-catalogue admin projection) and POST /pos/admin/catalog/category-rename — categories are DENORMALISED onto items, so a rename IS a bulk re-stamp of every item filed under the id. The admin upsert now MERGES with the stored item, so a projection-shaped save can no longer wipe the fields the editor never shows (barcodes, stock flags, offer markers, routing facets — and a live campaign's price is refused with the same CAMPAIGN_PRICE_LOCKED the till already localises). New items copy the catalogue's own currency/VAT category — the page invents no tax logic. Backend catalog specs 154/154, nav guard 15/15.
    • The template gallery knows the tenant's trade, and five trade-neutral looks join it (2026-08-04). Owner ask: "i choose some tempaltes which are for restaurants... there should be generic 5 different unique looking template with 6 color combination... which can be applied to any kind of business." Every existing template/starter/demo-pack is now trade-tagged (verticals, absent = everyone — the nav-catalog convention) and the builder's theme gallery filters on verticalOfTenant (the install route also refuses an out-of-vertical slug from a stale tab). Found and fixed a deeper leak on the way: EVERY theme install ran an auto-map that injected a menu-preview block and a /menu-CTA hero onto the site — a services tenant now gets a no-menu guarantee and a /contact CTA fallback, and the guarantees apply to the home page only. Five new GENERIC packs — Impact, Editorial, Showcase, Meridian, Mono — each a genuinely different layout built only from trade-neutral blocks, each with the same six complete 9-token colour combinations (Azure & Navy, Forest & Cream, Charcoal & Gold, Plum & Blush, Teal & Sand, Monochrome) opening on different defaults, install = "use my content" (no demo bundle, navConfig untouched so per-trade nav defaults rule). Tests: 56/56 incl. new vertical-filter, trade-cleanliness, services-no-injection and layout-distinctness assertions.
    • The booking loop closes on both sides — owner actions, timeslot editing, and customer self-service with a cancellation policy (2026-08-04). Owner asks: "where can i cancel the booking or ask for new time against customer? where can i customize the timeslots?" and "i need to add email too while reserving... the owner can choose... cancel without any cost time, and if a specific time then the customer will be charged." (1) Owner side — the dashboard Bokningar page gains per-booking Avboka (confirm-first, the till's idempotent cancel via new POST /pos/admin/bookings/cancel) and Flytta (a 14-day free-slot picker; new POST /pos/admin/bookings/reschedule moves via create-then-cancel ordered so the customer never holds nothing, with same-resource self-overlap handled by cancel-create-restore), plus a Schedule & time slots editor per chair (weekly open windows, slot length 5–60 min, active toggle, add-chair) over the existing admin resource/schedule endpoints, and a Cancellation policy card (free-until hours; late fee none / full service price / fixed amount) stored on settings.bookingPolicy. (2) Customer side — /book now requires email; the confirmation emails a manage link (HMAC key over tenant+booking — an email click is the whole authorization, no account) to /book/manage, where the guest sees their booking and the policy verdict for right now ("free until…" or "cancelling now will be charged X") and cancels; a late cancel records cancellationFeeMinor on the pos_bookings row (the no-show-fee discipline — charging happens at the till, never online). Booking contract additively extended (customer.email, cancellationFeeMinor) in the frozen IDL + backend + ui mirrors. (3) More restaurant leakage out of the barbershop — Chains & Groups (Restauranger/Gruppmeny/…) is now verticals: ["restaurant"], and the overview's Köksskärm tab learned the trade axis (a salon's PLAN may include kitchen-display; its trade has no kitchen).
    • A customer can book a haircut online — /book writes the till's own book (2026-08-04). Owner ask: "as a customer i can't book haircut from tenants website" — the Book buttons dead-ended (the seed pointed them at /contact, which does not even exist as a tenant page and fell through to Restora's company contact). Now the services trade has a real guest flow: app/tenant/[tenant_id]/book (services-vertical-gated, restaurant tenants 404 the mirror of /reservations) renders a three-step wizard — service (from the POS catalog's SERVICE items via new GET /pos/admin/catalog/services), day + free time (new GET /pos/admin/bookings/availability; the page offers only start times where EVERY covered grid slot is free on one resource, the same arithmetic the server's unique slot index enforces), then name + phone (REQUIRED online — an anonymous online booking is a no-show generator) → POST /pos/admin/bookings/create, a REAL pos_bookings row with createdBy absent (the "guest self-booking (web)" case the persistence doc always reserved). The till's Bokningar and the dashboard book show it the moment it lands; a lost slot race comes back as 409 and the page refreshes the grid. Public BFF routes under /api/public/salon/[slug]/{services,availability,book} gate on active tenant + services vertical and a 60-day horizon. Every Book CTA now points at /book: the seed's hero + pricing tiers (tier links join the BlockRenderer rebase list so path-served sites work too), a Book item in the services default storefront nav (chrome-translated in all 21 languages), and the SiteHeader trade filter learned the difference between /book (the trade's own page — kept) and #book/reservations (table affordances — dropped). Times render UTC HH:mm — the same wall-clock the till, the dashboard and the owner's authored schedule all show, until per-tenant timezones move every surface together.
    • A barbershop signs up as a barbershop — TenantRecord.vertical finally has a writer (2026-08-04). The field every reader learned this week (dashboard nav, storefront, route gates, JSON-LD) had NO producer: nothing in the product could declare a tenant's trade. Now the /register page carries a three-tile trade picker (Restaurant default / Barbershop / Grocery "Coming soon"; ?trade=barbershop preselects — the /products/pos card links straight in), Barbershop swaps the business-type options (barbershop/hair-salon/beauty-salon/massage) and the page copy ("Create your business"), and the submit carries vertical: 'services' — validated by RegisterSchema (z.enum(VERTICALS), never derived from free-text businessType), stamped on the TenantRecord and passed into createTenantDatabase so the services website seed fires from the first byte. Onboarding follows the trade end to end: the /welcome wizard's steps/copy/next-steps branch (services: Bookings → Sell → Customise your site), the Setup-guide tab renders onboardingStepsFor(vertical) (services: profile, hours, staff, website, till — with auto-detection only where the data genuinely distinguishes owner action from seed defaults), and branch creation inherits the HQ's vertical so a barbershop's second shop is not born a restaurant. The public landing wizard stamps vertical: 'restaurant' explicitly — it is a restaurant funnel by design. Restaurant flow byte-identical throughout when the field is absent.
    • The marketing site sells the POS by trade (2026-08-04). The Product mega menu gains a FEATURED panel — a navy band with a big azure icon tile, per the owner's "so nobody can miss it" — linking to the new /products/pos page (/pos itself is the till SPA's route): hero "One POS, built for your trade", a three-card vertical grid (Restaurant → /apps/pos, Barbershop & Salon → /register?trade=barbershop, Grocery & Retail "Coming soon"), the presets-over-one-kernel line, the three-surfaces band and a Start-free CTA. Data-driven (featured on the nav item, one definition for desktop dropdown + mobile accordion), azure-on-navy tokens only, registered in sitemap.xml + llms.txt. Brand-guard tests 11/11.
    • The till adapts fully to the barbershop trade — park, split and the KDS side doors (2026-08-04, all three POS surfaces). Owner ask: "the barbershop module should work according to what barbershop needs, they dont have kds, park bills, split bills." Three leaks closed. (1) `park_bills` minted (21/25 of the capability budget; the owner sentence above is the written decision capability.ts demands): park was the ONE bill surface no capability governed — universal on both rails, capability-free on the server. Now the restaurant/quick-service/shop presets carry it, salon does not; the server gates park creation only (POST tickets/park, :id/park) while list/resume/discard stay register-token-only, and both rails apply a drain rule — a till that lost the capability while bills were parked keeps Parkerade visible until the count reads zero, so a preset flip can never strand money (the §3d incident class). Backfilled on the box and local dev: every non-salon till granted park_bills (capabilityRev bumped), the salon till's seed-granted split_tender stripped (the seed contradicted the preset — reconciled seed-side). (2) Native payment-sheet parity: Compose's "Dela notan" and gift tenders were ungated while web gated them since Phase 10 — PaymentFlow now takes canGift/canSplit from the register's real capability set. (3) KDS side doors closed: web ?kds, Windows --kds and the Android KDS launcher booted the board on pairing alone; a salon device became a permanently dead kitchen screen (every KDS endpoint 403s, only 401 unpairs). Both apps now refuse with words and a way out (back to till / re-pair) — fail-OPEN on an empty capability snapshot so an old cache can't brick a real kitchen board. Gates: Compose desktop suite green (rail test now asserts the universal THREE + salon rail without park), pos/web 82/82 + tsc 0, backend tsc 0, bundle re-published to ui/public/pos.
    • The barbershop's appointment book reaches the web dashboard (2026-08-04). Owner ask: "the bookings from PoS are not visible on web." The till has written pos_bookings since Phase 6 and rendered it on all three POS surfaces, but the ADMIN side could not read a single booking — the backend's InternalGuard surface covered resources + schedules only, and ui/src had no page, route or nav entry for appointments at all (the nav-catalog comment even names the gap: a salon's appointments are "a different subsystem entirely"). Now: GET /pos/admin/bookings (backend, InternalGuard; BookingsService.listDay narrowed to take a bare tenantId — the one field it ever used), a thin proxy at /api/tenant/[tenantId]/pos-bookings (requireTenantAccess → internalGet, bookings + resources in one read), and /dashboard/bookings — a read-only day view (day nav, per-resource filter chips, status pills matching the till's vocabulary, 30s follow-along refresh). Read-only BY DESIGN: creating, cancelling and checkout stay on the till, where the operator stamps, money paths and the double-book unique index live. Times render UTC like every other booking surface (the module's honest v1 time model). Nav: new Bookings group, verticals: ["services"], in the DAILY slot where a restaurant sees Reservations — label translated in all 20 languages (test-enforced), page strings in en + hand-written sv (posBookings dict namespace; other languages fall back per the dict design). NOT confused with /api/internal/pos/bookings, which is the restaurant TABLE-reservation bridge on the tenant blob.
    • A services tenant gets a services WEBSITE, not a pizzeria's (2026-08-04). The other half of the owner's ask ("the website for client is still for restaurants"): TenantRecord.vertical had exactly one consumer — the dashboard sidebar — while every public-site path was born restaurant. Now vertical-aware end to end: newTenantTemplate('services') seeds a booking hero / services-and-prices / team / testimonials / hours / contact home (all existing SectionRegistry blocks — a different SELECTION, no new components), salon opening hours, no Food Bazar listing row (bazarSettings disabled — a barbershop is not listable on a food marketplace); createTenantDatabase takes vertical and words the SEO bootstrap per trade; the default storefront nav drops Menu/Reservations and the Book-a-Table + cart chrome for services (getDefaultNavItems(vertical), threaded through SiteHeader/SiteFooter and the nav + builder APIs via a 60s-cached verticalOfTenant); /menu and /reservations are notFound() for the trade (route-gated, not just unlinked); JSON-LD emits HealthAndBeautyBusiness instead of Restaurant and drops servesCuisine/ReserveAction/hasMenu; home metadata fallbacks stop promising a menu. Existing services tenants were stamped with restaurant blocks at creation, so scripts/reseed-services-website.ts swaps the factory-stamped parts (guarded — owner-authored blocks are never touched without FORCE=1). An owner-authored navConfig always wins untouched.

    Changed

    • A till shows only the surfaces its trade has, and a second till inherits the first one's trade (2026-08-03). Owner ask: "the admin panel is specifically designed for restaurant systems, where barbershop or grocery store will have a different requirements, i mean a very few specific items upon core system which will be the same. how do we handle that?" The kernel already answers it — pos/shared/domain-types/src/capability.ts opens with "Restaurant" / "grocery" / "barbershop" are PRESETS that select a set of these … the words never appear in kernel, sales or fiscal code, 20 capabilities against a hard budget of 25 — and pos/web has honoured it since Phase 10. Two places had not. (1) The native till's rail was a FIXED list rendered unfiltered (PosApp.kt), so the pilot's Butik register — created with no tables capability — still showed Bord on Windows and Android, and tapping it reached @RequiresCapability('tables') and came back CAPABILITYDENIED. A control that is visible, enabled and then refuses is worse than one never drawn: the operator does not learn "not for this trade", they learn "this till is broken". `canReach` now gates Bord on `tables` and Presentkort on `giftcards, matched on the SEALED TYPE rather than on id strings — a when over ids would have fallen through to true the day an id changed, and GiftCards.id is "gift", not "giftcards", so that near-miss was not hypothetical. Deliberately a whitelist of the gated few, never a default-deny: Kassa/Ordrar/Parkerade/Historik belong to every trade, and a preset that merely forgot to name one must not hide a till's own sales history. A destination that becomes unreachable (re-pair into a different trade) sends the till home to Kassa, which is always reachable, so the redirect terminates. (2) **Order type is a TAX input, not a label** — tax.ts resolves the rate per line from (country, taxCategory, Motion, date) — and the native till rendered Servering/Avhämtning/Leverans unconditionally and defaulted every line to dinein`, which is a restaurant-service claim about a bottle of shampoo. It now hides the control and sends `retail` on a till without `kitchenrouting, exactly as the web till has since Phase 9. **Second-till inheritance:** the four-way trade picker on /dashboard/devices only renders while registers.length === 0, and every device after that took deviceSurface('pos').capabilities — which IS the restaurant preset. So a grocery chose **Butik** once and every till after it was born a restaurant. The trade is persisted nowhere (ConnectPosDto.vertical picks capabilities once and is discarded, so nothing in the product can ask "is this tenant a grocery?"), but the answer is already written on the tenant's own tills and the device route had that list in hand the whole time — it was reading country and currency off it and stopping there. inheritedCapabilities copies the OLDEST sibling till (the one whose trade a human chose by hand; a later till created under a wrong preset must not become the template every till after it copies), skips empty capability lists, and inherits POS-from-POS only — a KDS is kitchenrouting` by definition and copying a till's capabilities onto a customer display would hand a guest-facing screen the ability to sell. **Explicitly NOT done, and named because it is invisible otherwise:** a barbershop still cannot check a customer out on two of three surfaces — `bookingscheckout gates seven server routes and an 824-line BookingScreen.tsx that exists only in pos/web, with no Bookings destination in Kotlin at all. Also rejected: inferring the vertical from the existing free-text businessType by substring, which an adversarial pass killed — that field is z.string().min(1).max(50) with no enum, and spa is a substring of *"Spanish"* while market is one of *"Fish Market"*, so it would silently misclassify live pilot restaurants. Gates: Compose 45 suites / 556 tests (+9, four mutations caught: both gates removed, default-deny, wrong capability string); ui/src/lib/pos 101 tests (+12, seven mutations caught including two aliasing bugs where the returned list was the sibling's array or the module-level preset itself); ui npx tsc --noEmit zero errors in changed files (64 pre-existing in ui/scripts/`).
    • Enheter — every device surface collapsed into ONE page with a non-technical IA (2026-07-29). Owner ask, verbatim in spirit: "In navigation I have Devices, POS Registers, Köksrouting, Devices & apps — so many nav menus for the same purpose. Make a single page 'Devices'. It should be easy to understand, easy to connect, clear separation between devices so users can see easily. A non-technical person must be able to connect PoS, KDS, SSK, customer display, menu display, printer, payment terminal and other hardware." `/dashboard/devices` is now the only door to hardware. It merges the freshly-rebuilt POS-enheter page (four register surfaces) with the six-tab Hardware Center into eight collapsible boxes, one per DEVICE TYPE, every box shaped identically: header carries Status · Öppna · Parkopplingskod, expanding it explains how to connect that device from Webbklient / Android / Windows. Boxes: Kassa (PoS) · Köksskärm (KDS) · Självbeställning (SSK) · Kunddisplay · Menydisplay & TV-skärmar · Betalterminal · Kvittoskrivare & övrig hårdvara · Nätverk & stabilitet. The tab bar is gone on purpose: a tab bar hid five of six fleets behind a click and answered none of "which of these is actually connected?", which was the question being asked — boxes show all eight statuses at once. Per-platform connection guidance is the new substance, and every line is verified against the thing it describes rather than written plausibly: the web row prints the literal address plus a one-click open (/pos, /pos-native/index.html?kds, /pos?display, /kiosk/station, /tv); Android names the launcher icon to tap — Restora POS Native for the till and Restora Kök for the kitchen, which are two genuine LAUNCHER activities in androidMain/AndroidManifest.xml, not aliases; Windows names the program (Restora360POS) and states that the KDS shortcut is the same app started with --kds (desktopMain/Main.kt, which also accepts --surface=kds). Install links come from NEXT_PUBLIC_POS_ANDROID_URL / NEXT_PUBLIC_POS_WINDOWS_URL and, because neither is set anywhere yet, render the honest line "Appen är inte publicerad än — installeras manuellt av Restora vid uppstart." instead of a button that 404s. A platform that does not apply says why, never an empty row: a receipt printer's Android row reads "utskriften går via Restora Windows Station på en dator i lokalen"; a payment terminal's web row reads "terminalen är en fysisk kortläsare, inte en sida man öppnar". Pairing direction is modelled, not narrated — it genuinely runs both ways and getting it backwards is the common support call: registers are mint (the dashboard mints a single-use 15-minute code you type INTO the till), while TV/kiosk/terminal are claim (the device shows a code you type in HERE), and printers are agent (registered here, claimed by a Windows Station). Every capability of both predecessors survives: terminal claim + enrollment QR, kiosk fleet + add-on gate, printer/drawer/scanner registry + job log, screens manager + QR pairing + the legacy shared-display link, reliability insights, owner-phone sessions, and the register-level Kassapersonal PIN admin (re-homed into the Kassa box, where a PIN is actually true). The old overview's universal three-mode pair form was dropped as pure duplication — TerminalsPanel and ScreensManager already own those flows — except its unique "POS tablet" path (pos-devices/claim, a 90-day session for the legacy android/ tablets), which became its own card worded so nobody reaches for it when they meant to pair a new till. Kitchen routing was deliberately NOT absorbed: it configures what a KDS shows rather than connecting a device, so /dashboard/pos/routing stays a real page and is hub-linked from inside the KDS box ("Konfigurera köksrouting →"). Honest-status discipline throughout: every count comes from a real endpoint (/pos/devices, /devices/summary) and a failed summary reads "Status okänd" rather than a fabricated 0; the printer box states in one line that without a connected Windows Station a receipt is stored as a preview in the job log instead of printing, because lib/devices/dispatch.ts documents the driver as a mock pending a real ESC/POS transport. Nav: the four-item "devices" group collapses to a single flat Devices link — /dashboard/pos joins ABSORBED_ROUTES as a PosRegistersRedirect stub, Köksrouting moves to HUB_LINKED_ITEMS, and Go Mobile moves to Venue Setup (despite the name it is the roaming seller's stop schedule feeding the "Find Us Today" website block — no device, no pairing, and the sole reason this group still needed a dropdown). All retired labels stay searchable as group keywords; old ?tab= deep links from the IA-2 stubs still land, mapped onto the box that absorbed them and scrolled into view. A11y: each box is a real disclosure — <button aria-expanded aria-controls> with the panel as a labelled role="region" — and the header actions are siblings of that button, never nested inside it (an <a> inside a <button> is invalid and unreachable by keyboard). 320px: title block and the three actions stack instead of overflowing. Gates: ui npx tsc --noEmit zero errors in every changed file (53 pre-existing errors elsewhere in storage/themes/experience-studio/media untouched); nav-catalog.test.ts 15/15 pass, including the absorbed-route stub, hub-link-liveness, 20-language label and retired-vocabulary guards.

    Added

    • Food could be handed over with the money recorded nowhere — the whole arc, from the switch that prevents it to the chip that finds the debts (2026-08-03, `788793d0` → `de31862c`). Owner report: "i ordered an online delivery order, everything was smooth, but i didn't pay it and i was able to deliver and close the order #0020." Confirmed on the pilot tenant and systemic, not a one-off: five of twenty orders (#0001, #0002, #0003, #0012, #0020) sat at status:'delivered', paymentStatus:'pending', zero rows in `paymentLedger` and no kassakvitto — 1 855 kr of food out of the building, existing nowhere in the system. The path map (an adversarial pass, two independent verifiers per finding) established the shape of the fix: the rider's Delivered tap reaches /api/internal/pos/order-action action:'delivered' TWICE (the delivery mirror, and closePosOnlineOrder → markOnlineOrderDelivered) and the till's hand-over control posts the same action, whose entire body was appendTimeline({...order, status:'delivered'}). recordOrderPayment lives only in the 'paid' branch and recordOrderFiscalSale only under it, so neither was reachable. One chokepoint, so the rule lives there once — a guard on the rider's tap would have left the till's button open, the exact shape that had already cost two bugs the same night. (1) The hand-over does NOT refuse, and that is the design. Cash on delivery is legitimate — the order is genuinely unpaid until the door — and a person in a stairwell must never be blocked by a server. Worse: if naming a tender were the only way past, a rider whose customer has no cash would name one, and the books would then assert money that does not exist — worse than the bug, because an uncollected order is at least visibly unpaid while a false "cash" is a lie nobody can see. So the hand-over always succeeds and the DEBT IS RECORDED: handedOverUnpaidAt + unpaidAmountAtHandover, set-once, and only when the order is genuinely unpaid AND actually owes something, so a prepaid order and a gift-card-covered basket both carry nothing. (2) The reason unpaid orders existed at all. CheckoutForm seeded its method list with ["cash"] under the comment "cash is always available" — no restaurant on the platform had ever been able to decline pay-later, which is how a Swedish venue that expects prepayment collected five debts. New paymentSettings.cashEnabled, ABSENT MEANING TRUE so nothing changes on upgrade, honoured at checkout and re-decided server-side in createTenantOrder (hiding a radio button is not a rule) with carve-outs for an already-settled channel order, a ZERO total and a gift-card basket. checkout.noPaymentMethods authored in all 21 storefront languages. The dashboard switch sits above the payment rails, because everything below decides HOW money arrives and this decides whether food may leave before it does; both positions explain themselves, since a switch whose copy describes only its "on" state leaves an owner guessing what turning it off did. (3) The rider could not see what to collect. DeliveryRunDoc carried deliveryFeeMinor and a currency and nothing else about money — the phone could not have said 49 kr was due even if the rider had wanted to collect it, which is a defect BEFORE any missing ledger row. New amountDueMinor, and deliberately NOT behind `driverSeesEarnings`: that flag hides what the RUN EARNS from a salaried rider and defaults off for staff drivers, so gating the two together would have blinded exactly the venues where cash on delivery happens. Presence is the whole signal — the server omits it for a paid order and never sends a zero, so "collect 0 kr" cannot appear and a prepaid delivery looks unchanged. (4) The collection itself, on both driver clients: the amount as the hero, then Kontant / Swish / Kort — and in the same list at the same target, "Kunde inte ta betalt". The tender rides the SAME paid action the till's Betald button posts (which already writes the ledger row and registers the kassakvitto), mirrored BEFORE the delivered one because the registration mints the löpnummer the receipt must carry — pinned by a test asserting the index of one against the other. Swish joined the tender vocabulary by name, since folding it into cash would put a Swish collection in the drawer's takings and make the Z-report disagree with the till. It rides the HELD op beside the POD, so the ten-second undo takes the money record with it and a dead spot delays rather than loses it. (5) The debts are visible: a new owing chip on the order history, on all three POS surfaces. Deliberately NOT a synonym for unpaid — an order placed five minutes ago for cash on delivery is unpaid and fine, so the chip needs BOTH halves, unpaid AND already handed over, with cancelled excluded because an order nobody received is not a debt. It issues no till read at all (a closed till sale is paid by construction). Verified live on the pilot tenant: owing returns exactly the five, unpaid returns nine. The five are deliberately NOT backfilled — nobody knows whether that money was collected, and marking them paid would be inventing a fact; they show as what they are. One shape appeared three times in one day and is worth naming: a request body assembled field by field silently drops anything not listed — the backend write-back dropped method (every door collection would have arrived as unnamed cash), the web opWireBody dropped the tender, and the Kotlin DriverDeliverRequest would have too. All three are now explicit with a comment saying why. Gates: backend POS 196 sales / 199 delivery, Compose 531, pos/web 75, ui orders+fiscal 156, Swedish ratchet at baseline, pos-publish-gate green.
    • Find one order from the counter, a cancelled order that finally goes away, and a promise that stops moving (2026-08-03, `d2b772ee` + `0a274c18`). Four owner reports from the same session, two of which turned out to be the same defect shape as the night before. (1) "order history under Ordrar" — the scenario given was a telephone call at the counter: "suppose a customer calls and say i ordered a pepsi but i didn't received it, the cashier can quickly bring the order from history and double check if the pepsi was really inside that order." That makes search the feature and placement the smaller half, because an order from three days ago is on no first page. GET /pos/kitchen/orders/history gains q, matched against the order reference OR the customer name and pushed into both journals' queries — a search filtering rows the cap had already cut would hide the single row the feature exists to surface, which is precisely the chip-filter defect fixed hours earlier. The TYPED TERM is normalised, never the stored value: a leading # is stripped (it is how the product prints an order number, never how it stores one, so leaving it in makes the most natural thing to type the one thing that never matches), and leading zeros go only when what remains is all digits — #0042, 0042 and 42 are one answer while 007agent keeps its zeros, and 000 stays a search for 0 rather than becoming a search for everything. Blank or a lone # means NO search, never "match nothing". Every regex metacharacter is escaped, so a cashier typing ( gets no match rather than a 500. Ordrar gains a Live/Historik segmented control mounting the SAME component the rail destination mounts — deliberately not a fork, commented on all four screen files because Historik owns refunds and somebody will otherwise "dedupe" it. Found on the way: the Compose history screen drew a network error AND "no orders in this period" together; conflating those is what made the original complaint invisible. (2) "why order #0016 is visible in my PoS there is no way for me to hide that from my order view." There was not: the FOH Orders read had a tenant, a channel, a 24-hour window and a deliveredAt check and no status predicate at all — and a cancelled order never gets a deliveredAt, so it sat on the till for a full 24 hours with nothing on screen able to clear it. Cancelled tickets now leave that view, excluded rather than aged out on a shorter timer because "why is this still here" was the question and a 30-minute answer is still 30 minutes of it. It also makes the FOH view agree with the KDS board, which has always dropped cancelled tickets — the kitchen never saw #0016, only the front of house did. Nothing is lost: the cancellation is in the journal and history has a cancelled chip. Pinned at the QUERY, because it is a filter clause and a service test over a mocked repository would pass with the clause deleted. Verified live on the pilot tenant: #0016 absent from the board while present in history, and q=16 / q=#0016 / q=0016 each returning exactly it. (3) + (4) "the timer resets to 35 min when i accept" and "add set delivery time while accepting — 10, 15 … 60 and custom like 2 hours" — one feature, because accepting is when the promise is made. TrackerClient computed the target on every render as preparingStartedAt ?? acceptedAt ?? createdAt + prep, so the anchor moved forward every time a later timestamp appeared: createdAt before acceptance, acceptedAt after (the reported reset), and preparingStartedAt after that — a second reset nobody reported only because they had stopped watching. A promised time recomputed from whichever stamp is newest is not a promise. It is now minted once at acceptance and stored as an absolute instant; what crosses the wire between the POS and the legacy order is that instant and never the minutes, since re-anchoring minutes on the far side's clock lands seconds away from what the operator was shown. It rides the same conditional awaitingAcceptance: true update as the acceptance, so a losing double-tap cannot move a time a guest is already counting down to. `createdAt` is gone from the anchor chain entirely — placing an order is the GUEST's act, not the venue's promise, and treating it as one is what put a 35-minute countdown on screen before anyone in the kitchen had seen the order; no acceptance now means no countdown, which the tracker type already had a spelling for (null ⇒ hide). The picker: presets 10/15/20/25/30/35/40/45/50/60 in a grid where tapping one accepts (two taps for the common case), "accept without a time" always one tap away in the same sheet so speed never became a required field, and custom one extra tap giving hours and minutes as steppers rather than arithmetic in a minutes box. Validation is a RANGE (1–480), not the preset grid, because 150 is a legitimate answer on no grid; eight hours is the ceiling since past that it is a booking, not a prep time. An absent value stores no promise rather than inventing one, which would silently overrule the venue's configured preparation time. Every accepted card now shows the promise as a wall clock ("Klar 19:35") so an operator quotes the clock the guest is watching. Legacy orders fall back to the venue estimate anchored on acceptedAt (set-once, so it cannot drift); an unparseable promise degrades to that estimate and an unparseable anchor hides the chip rather than rendering NaN; and un-accepting now strips the stale promise, or a re-accept with no chosen time would resurrect a countdown the kitchen is no longer working to. Gates: backend 54 suites / 740 tests, Compose 531 (+ wasmJs compiles), pos/web 75, ui order-tracking 38, Swedish ratchet at baseline 0, pos-publish-gate and check-addressing green, bundle republished so web and Windows carry it too.
    • Order history that actually contains the orders, on all three POS surfaces — plus the Bord side panel and a test print that speaks the printer's language (2026-08-03, `7566c1ec`). Three owner reports from one evening of live testing, each of which turned out to be a different piece of code lying about what it had done. (1) "i cant see the order history on my PoS android tablet." The Historik tab existed — wired to GET /tickets?status=closed, which the server pins to the asking register. So it showed that till's own receipts and never once an online, pickup or delivery order, because those have never been POS tickets. New register-token route `GET /pos/kitchen/orders/history` (RegisterGuard + capability sell, no @RequiresOperator so a manager may look at a signed-out till, and no tenantId from the client — the token resolves the scope) merging the two POS-owned journals: pos_tickets closed sales tenant-wide (the half the old read could never answer) and pos_kitchen_tickets online orders, plus one $in over pos_tenders for payment method. Window clamped to 31 days (default 7, never into the future, an inverted range falls back rather than 400s), limit capped at 200, both reads verified to ride the existing tenant_closedAt / tenant_channel_firedAt indexes, and no money arithmetic anywhere — every figure is a stored integer copied out of a document, and online lines get no price rather than one derived from the total. Two defects the adversarial pass caught before it shipped, both of which would have reproduced the original complaint: truncated watched each journal's own cap while the CUT was applied to the merged array, so 60 till + 60 online at limit 100 capped neither source and the flag said "complete" while slice() dropped the 20 oldest (now rows.length > limit || tillDocs.length >= limit || onlineDocs.length >= limit — the second clause still needed, because one source capped at exactly limit gives rows.length === limit, not greater); and the chips filtered in memory, after the per-source cap, so asking for cancelled read the newest 100 of each and then filtered — an empty list meaning "your filter fell off the end of a cap you cannot see" while reading as "there are no orders". Filters are now Mongo predicates built per journal, and a chip no document in that journal could match skips the read entirely rather than round-tripping for nothing. It also landed on ONE surface, which ships nothing: `pos/web` now reads the same route with the same behaviour and `ui/public/pos` is republished in the same commit — without that the browser till and the Windows station keep serving the old screen and check-pos-surfaces stays red. (2) "on 'tables' section there are three rows named 'Nota' 'inget bord' looks messy." Those are bills the floor plan cannot draw — deleted tables, tables with no designer coordinates, counter sales bound to nothing — crammed into a 168 dp scroll box between the floor tabs and the room. They now live in a right-hand panel (BoxWithConstraints, the same 820 dp / 1180 dp breakpoints and PosVerticalDivider as RegisterScreen, so the two screens agree) which also carries today's bookings including the unseated ones the old derivation fetched and threw away, and for a selected table its seats/area/floor, its bills and its booking. That content is the dialog previously reachable only by a three-second hold; the dialog survives as the narrow-screen fallback. 15 findings raised in verification, 14 refuted — and what came back clean is what mattered: the bill-ownership guard, the also-on-this-table set, and primaryAction being branch-identical to the old open(), so no action can target a bill the screen is wrong about (four bills were stranded by this exact flow in the past). The one survivor is fixed: the compact bar could advertise "Notor utan bord (2)" and reopen onto a table tapped earlier, cleared on entry rather than on close, because after a window resize nothing closed and a reset on the close path leaves half the bug alive. (3) Three test prints to a reachable Zebra produced no paper — and the app said they succeeded. printOne() has always resolved the printer's language and encoded accordingly; testPrint() and connectTo() called escPosText() unconditionally. A ZPL printer discards anything outside ^XA…^XZ, so the socket accepted every byte, the write flushed, the transport honestly reported Written, and the screen reported success. A silent false success is the worst failure this agent can have, and it is the same bug class printer-language.test.ts already pins on the web side. Both test paths now resolve once through resolvePrinterLanguage (stored → inferred from the printer's announced name → ESC/POS) and encode accordingly, with an on-device ^XA…^XZ test label opening ^CI28. Inference is never persisted, so a later discovery cannot overwrite an operator's explicit choice, and unpair()/setTarget() keep the language for the same reason they already keep the address. The settings card asks in plain words what kind of printer it is (naming the command set in parentheses for whoever needs it) and states out loud what happens if you pick wrong. Gates: backend POS 701 tests, Compose desktop 507, pos/web 51, tsc clean, Swedish-literal ratchet unchanged, pos-publish-gate and check-addressing green.
    • Food Delivery — own drivers, end to end (2026-08-02, R360-C12). A restaurant's OWN delivery fleet: roster + phone pairing, order-to-driver dispatch, live tracking for dispatcher AND customer, proof of delivery, offline-safe driver taps, settings, analytics. NOT the aggregator integration — isOwnDeliveryOrder() remains the discriminator and lib/delivery/providers is untouched. Plan of record: docs/products/food-delivery/FOOD-DELIVERY-PLAN.md; runbook beside it. Backend: new PosDeliveryModule (backend/src/modules/pos/delivery/, six-file anatomy) over 7 typed collections (delivery_drivers/devices/pairing_codes/runs/driver_frames/events/settings, indexed same-PR). A driver phone is deliberately NOT a pos_registers row (no fiscal seriesPrefix, no SE-journal enumeration): it pairs like a customer display — dashboard mints a single-use 8-char code (15-min expiry, 7-day explainable retention), phone exchanges it for a 48-byte token, sha256-only at rest, x-driver-token → DriverTokenGuard → DriverScope that never sets posScope, so a leaked driver token reaches exactly one module. Run machine has FIVE tappable statuses (pending→offered→accepted→picked_up→delivered, terminals failed/canceled) — "heading to restaurant / driving / near customer" are derived from GPS, never tapped. Every driver tap carries a ULID opId spent in delivery_events INSIDE the same transaction as the CAS transition; the E11000 replay answer is caught AROUND the transaction (swallowing it inside commits an aborted txn and loops to the 120 s cap — the sales-path lesson) and must prove same-run/same-kind/same-driver against the stored event, or a spent opId POSTed at another runId would hand back that run's customer sheet. "One live run per order" is database-backed (openKey = orderId while non-terminal, unique partial index, $unset on terminal transitions) so a dispatcher double-click cannot send two drivers to one customer. Offers expire by clock ON READ (no cron), CAS-guarded across replicas, with the expiry bound pinned in the accept CAS (+15 s grace) so a stale accept refuses deterministically; auto-reoffer walks online drivers with capacity, remembering who declined. Live positions are a display-frame-courier clone: one replaceable TTL doc per driver, rev bumped only on real movement, >200 km/h implied-speed frames stored but flagged suspect and excluded from ETA. Dashboard: /dashboard/dispatch (Board/Drivers/Settings/Insights) behind new own-delivery feature (registry + nav + 20-language label + dispatch→orders.manage); three-lane board with keyless-OSM driver map, 2-tap assign, pairing modal with giant code + QR, POD/offer/leave-at-door settings, analytics in integer öre. 15 thin proxies under /api/tenant/[tenantId]/dispatch/* (requireTenantAccess + requireFeature + requireWritable/requirePermission on writes). Driver app: /driver (proxy-bypassed, mobile-web v1 — the backend contract is byte-portable to the Kotlin/Compose port per the plan's decision table), sv/en, huge one-primary-action screens, ?pair= deep link, Navigate/Call deep links, KdsOutbox-rule offline outbox (dedupe-key supersede, FIFO drain gated ahead of every poll, queue-on-undecided/drop-on-decided, revocation keeps the queue; drain re-reads the live queue after every await so a mid-flight tap is never clobbered, and a live send queues behind ANY queued op for the same run so a deliver can't jump its own pickup), GPS publisher (8 s visible / 30 s hidden-with-active-run), POD sheet (photo downscaled to a ≤200 KB scheme-pinned data-URL — honest v1 seam, TODO device-scoped media upload; signature canvas; PIN recorded-not-verified). Customer: both existing trackers (/track and the emailed /bazar/orders/…?t=) now render a "Your driver" card — first name ONLY (the write-back itself sends only the first name, since the public tracker returns the order timeline raw), vehicle, server-computed ETA, live mini-map only while picked_up AND the tenant allows it, coarse-rounded coords, POD line at delivered. Order mirror rides the existing idempotent /api/internal/pos/order-action seam with three new set-once actions (driver_assigned/out_for_delivery/delivery_failed) — order status union deliberately untouched; deriveStatus advances on the new outForDeliveryAt timestamp. Fixed en route: the OUTSTANDING-WORK §4 dead-code item — status-transition logic extracted to pure apply-status-transition.ts, wired into the dashboard PATCH route, backward moves clear forward stamps including outForDeliveryAt. Gates: backend tsc clean + full jest 560/560 (68 delivery tests: pairing incl. cross-tenant name-inheritance refusal and claim-before-mint ordering, run machine incl. replay-mismatch refusals and the double-click race, route-plan 2-opt, ETA clamps, frame throttle/suspect, track-info privacy); ui delivery/driver/tracking/dispatch node tests green (outbox 20, tracking+model+templates 89, dispatch 9); ui tsc zero errors in every new/touched file. Load harness: scripts/delivery-sim.mjs (N virtual drivers over the real HTTP contract, with a deliver-replay idempotency probe).
    • One gift-card store — the parallel-systems split closed end to end (2026-08-01, P1 `3eb80c6f` / P2 `5735d083` / P3). Gift cards were TWO systems that never read each other: db.giftCards on the tenant blob (24 modules — buy page, corporate bulk, subscriptions, wallet passes, risk, closed-loop; major-unit floats) and the POS's pos_giftcards + ledger (integer minor, transactional). A website card was refused at the till, a till card was invisible to the dashboard, and account 2421 drifted structurally negative. Owner ruling: one store — the POS collections win the storage, the legacy design wins the features (docs/pos-rebuild/one-giftcard-design.md). P1 (backend): five InternalGuard endpoints — full-fidelity mirror issue (pinned legacy codes normalized so GIFT-K3FX-9A2P stays findable, legacyId idempotent from either side, a caller-pinned code REFUSES on collision rather than rerolling), find by code/cardId, windowed list, and an online redeem / refund-credit / void trio: own transactions, deterministic-opId idempotency, a void records the dying balance in the ledger before zeroing so Σ ledger ≡ balance survives. P2 (the cutover machinery): GiftCard.posCardId marks a card as pos-authoritative; getCardByCode/ById dual-read (marked cards overlay LIVE balance, misses fall through to the pos store — which is how till-issued cards appear everywhere at once); every in-lock mutator refuses marked cards (apply THROWS — a route reaching it has a routing bug; reverse SKIPS — it runs in rollback locks, and the pos-side reversal owns the credit), making the cross-store double-spend unrepresentable rather than unlikely. Online redemption lands in the ONE seam (createTenantOrder) + payment-intent: detected pre-lock, settled post-lock like the closed-loop precedent, idempotent per order, live re-capped, a refusal applies 0 and the PSP charges the full remainder so a failure can cost the guest nothing; rollbacks credit the pos card back money-first (no refund row unless the credit committed). markPurchasePaid mirrors each activation into the one store best-effort (stamp only after commit; the re-runnable migration sweeps failures). The dashboard list merges both stores and says posStoreIncluded:false instead of degrading silently. Scripts, written NOT run (rule 6): the migration now stamps + re-heals posCardId on the blob; tombstone-legacy-giftcards.ts renames giftCards → giftCardsMigrated only after verifying every card has a pos twin — one uncovered card refuses the whole run. P3 (books): till-issued presentkort now CREDIT 2421 through the same purchased-card shape the buy page books (tillIssuedGross reports it); pre-cutover windows keep their credits post-tombstone via giftCardsMigrated; an unreachable gift store raises a critical books notice instead of quietly booking nothing. Gates: backend giftcards 31, ui gift-cards 74 (incl. the new guard suite, proven non-vacuous — disabling the guard fails exactly its throw-tests), orders 72; tsc + eslint clean; both scripts dry-run clean.

    Fixed

    • Paying at the counter for an online order printed nothing — and the pin that should have caught the kitchen bong drifting had been red for days (2026-08-03). Owner report from a live test: an order was placed online, accepted at the till, marked paid by card, and no receipt came out. Not a printer fault. planOrderJobs gates the receipt on order.paymentStatus === 'paid' and it runs once, at creation — an order placed "pay on pickup" is pending at that moment, so it correctly plans no receipt, and nothing ever asked again. The till's Betald button posts to /api/internal/pos/order-action with action:'paid', which wrote the ledger row, flipped the status, and dispatched no hardware at all; every guest paying at the counter for an order they placed online has been handed nothing since the acceptance split landed. New pure planCounterPaymentJobs + its fire-and-forget wrapper, in the shape this file already uses (pure planner, thin I/O). Deliberately NOT a second `planOrderJobs` call, which would plan all four documents off a now-paid order: a second delivery slip (creation already printed one — it fires regardless of payment precisely so a COD driver has paperwork, and two slips in a driver's hand is worse than one) and a second kitchen bong (acceptance owns that; a duplicate ticket landing on the pass mid-service is the exact failure dispatchHardwareForFiscalExtra was carved out to avoid). So: the receipt, plus the drawer kick on cash — the till is not running its own sale flow here, so nothing else would open the drawer. It does not invent a kassakvitto: buildReceiptFiscal is called exactly as the sale path calls it, and an unregistered sale yields undefined so the renderer prints no receipt number and no VAT rate table rather than fabricating either. Explicitly left open and raised with the owner rather than closed quietly: whether this path should also REGISTER the sale. recordOrderFiscalSale is called from four places (order create when born-paid, the payment-rail settlement, both POS payment routes) and not from here — so on a Swedish tenant, an online order paid at the counter is money taken with no fiscal registration. That is a compliance decision about money, not a rendering bug, and it is the owner's call. The second half: printer-language.test.ts's PINNED.kitchen / .kitchenNoStation had been FAILING on master since the richer-bong work (cac877d5) and the allergen chain (4b.2) landed without re-taking them. A red pin protects nothing, and this one guards what a cook reads off the pass — so it sat unguarded while exactly the content it watches was being changed. Both tickets were rendered and read before the digests moved (the deliberate act the file's header demands): four lines are new versus the layout the old note describes, and a kitchen needs all four — the chosen option (• Storlek: Familj), added toppings (+ Extra ost), the line note (! extra het), and, as 4b.2 intended, the order note reaching the pass (Obs: allergi: nötter), which is the only one here that could put somebody in hospital. RECEIPT and DELIVERY SLIP digests untouched, which is how we know the change was contained to the bong. Gates: ui devices suites 210/210, tsc --noEmit clean in every touched file.
    • A rider standing in his own kitchen marked a delivery complete, and there was no way back (2026-08-03). Owner report from live use: "delivered button should be removed as i accedentally pressed the delivered button while i was at home." Two changes, because one would not have been enough. The list can no longer finish a delivery. driverCardAction is now the only thing deciding what a home card offers, and it has no branch that returns a completing action — DriverCardAction.completesDelivery is false for every value it can produce, and the test asserts that over the whole status enum rather than over the cases somebody remembered to write. Finishing moved inside the run, where the rider has already chosen that order and can see whose door it is. And the tap is no longer instant. A deliver op is queued with an ABSOLUTE deadline ten seconds out; the drain steps over it until the deadline passes, so undo is a REMOVAL FROM THE QUEUE and never a reversal on the server — nothing to compensate, nothing to explain to a customer whose tracker already said "delivered". The deadline is absolute so it survives process death (relaunch inside the window redraws the strip with the correct seconds remaining), a held op does not wedge the ops behind it (except one carrying the same runId, which must not overtake its sibling), a hold is not counted as "waiting on the server" so a 4-bar phone never paints itself Offline, and a clock that lies in EITHER direction still ends with the delivery sent — a wrong clock can cost a rider their undo, never strand a delivery. Delivery instructions also got their own box, having been one more line in the address block, which is where a rider stops reading. Built on both clients from one rule (DriverRunPresentation.kt / list-action.ts). Gates: DriverDeliverHoldTest 24, DriverRunPresentationTest 8, full Compose desktop suite 485/485 including the Swedish-literal ratchet; web driver suites 94/94.
    • A printer that nobody talks to falls off the network, and the POS now holds it awake (2026-08-03). Owner report: a Zebra dropped off the network about a minute after its last conversation, every time — hold the feed button, the network light goes green, a minute later it is gone. Ordinary power management (a printer with nothing to print lets its radio sleep), invisible until the moment it matters, which is a kitchen ticket at half past seven. Nothing was going to fix that at the printer; keeping it awake is the POS's job and it costs one TCP connect every twenty seconds on a LAN. The probe writes ZERO BYTES. Port 9100 is a one-way stream with no reply channel, so there is no "are you there" command — any non-empty probe would be a document, and a document on a thermal printer is paper; a venue whose roll ran out overnight because the POS was checking on it three times a minute would be right to be furious. An empty write still requires the radio awake to complete the handshake, which is the whole thing being kept alive, and it physically cannot print, cut or kick a drawer. Twenty seconds because the observed drop was "a minute or so" and vendor idle timers are commonly 60 s with no way to read the remaining time from outside — three touches inside the shortest window actually observed, so one missed probe does not cost the connection. It is not a print and the counters do not say it was (recordOutcome is not called, so printed stays a number about tickets that came out); a FAILED probe does set lastIssue, because a printer that has gone away is what that line is for, but a successful one never CLEARS it — a jam, an open cover and an empty roll all answer their socket perfectly, and a screen that went green twenty seconds after a real failure would be worse than one that stayed red. New KeepAlive step covers the case the owner is actually in (a printer chosen in settings, no print-agent pairing code ever redeemed); the zero-cost promise holds for everyone else, since a configured target is the only thing that turns a quiet tablet into one that opens sockets. Stated honestly in the KDoc: this keeps an AWAKE printer awake and cannot wake one already asleep with its radio down — nothing on a tablet can. +8 tests in PrintAgentPolicyTest; full Compose desktop suite green.
    • `/ingredient` said "being populated. Check back soon." forever, and the weekly ingredient-catalog refresh had been silently failing since the docker-compose migration (2026-08-02). Owner report: the ingredient directory was broken, and the weekly USDA/Livsmedelsverket refresh the owner had configured with real API keys wasn't reaching the database. Two infrastructure bugs, both on the box, neither in application code: (1) the owner's CRON_SECRET and USDA_FDC_API_KEY were sitting in ui/.env.production.local — a file the OLD bare-next start/pm2 deployment read directly, but the current docker-compose setup never loads; the running container saw neither variable. (2) the crontab's dispatcher script (restora-cron.sh, not tracked in this repo — box-local ops tooling) was still hardcoded to http://127.0.0.1:3001, the old pm2 port; the docker-compose ui container has always been on 3000, so every cron route routed through this script — not just the two catalog imports, also fsn-job-expiry, weather-refresh, daily-briefings, inventory-reports, fsn-account-purge, bazar-visit-reminders, tv-ads-settle, scheduled-reports, anomaly-alerts — had been connection-refused-failing silently since the migration, logged to a file nobody was watching. Fixed both: the real keys now live in docker.env (the file docker-compose actually reads), and the script's BASE now points at :3000. Verified live: catalog-usda-import run three times back-to-back pulled the complete USDA FoodData Central dataset — 8,187 real records — into platform_data/food-catalog; /ingredient now serves it. Found in the process, not fixed (it isn't ours to fix): Livsmedelsverket's own public list API (dataportal.livsmedelsverket.se/livsmedel/api/v1/livsmedel) returns totalRecords:0 right now for every valid parameter combination (confirmed against their published Swagger spec — offset/limit/sprak, none required beyond the path version), while their by-id nutrient lookup for the same dataset still serves real historical data — an external, upstream issue on the Swedish agency's side, not a bug in our importer, and not something a code change here can fix. What WAS ours to fix: the cron route treated that empty response identically to a healthy end-of-cycle wrap (ok:true, processed:0) — the same "missing dependency renders as an empty success" shape this platform keeps re-learning the hard way. It now distinguishes a fresh-cycle empty response (cursorAtStart===0 && total===0) from the normal wrap and logs it as an error with the exact upstream URL to check, plus a sourceEmpty flag in the response — loud instead of indistinguishable from success. tsc --noEmit zero errors; eslint clean.
    • Two public layouts flashed a hydration mismatch on every load: `data-theme` was hardcoded `"light"` in server-rendered JSX while a no-FOUC script overwrote it before hydration (2026-08-02). (bazar)/bazar/layout.tsx and fsn/layout.tsx both render data-theme="light" unconditionally, then a plain <script dangerouslySetInnerHTML> reads the saved preference / system setting and calls setAttribute('data-theme', …) on that same element before React hydrates — the standard technique for avoiding a flash of the wrong theme on first paint. Every OTHER instance of this exact pattern in the codebase (AtmosphereShell.tsx, StudioShell.tsx, ShopShell.tsx, admin/atmosphere/AdminShell.tsx) already carries suppressHydrationWarning on the target element for precisely this reason — only these two were missing it, so every dark-mode visitor to Food Bazar or FSN got a real (if functionally harmless) hydration-mismatch error in the console, plus a secondary "script tag" warning as a side effect of React discarding and regenerating the subtree. Fixed the same way as everywhere else: suppressHydrationWarning added to both root elements, element-scoped so genuine mismatches elsewhere in the tree still surface. No behavior change — the attribute was always correct after the no-FOUC script ran; only the warning was spurious. tsc --noEmit zero errors in both files; eslint clean.
    • `tombstone-legacy-giftcards.ts` retracted before it was ever run for real (2026-08-02). The P2 gift-card cutover (previous entry) called for a final step: rename db.giftCards → db.giftCardsMigrated once every card was mirrored into pos_giftcards, on the assumption that blob writes would stop after cutover. That assumption is false — a full-repo grep found 15+ files outside the gift-cards module (gift-card analytics + its narrative, disputes, CRM guest360, marketing analytics, referral tracking, wallet passes/disputes, a payments refund route, bulk + transactions routes, a scheduled-release cron) reading db.giftCards directly, every one guarded with ?? []. Renaming the field would not have crashed a single one of them — it would have made all of them silently report zero gift cards forever, discovered by an owner or auditor months later rather than by an error anyone saw at the time. Corrected architecture: db.giftCards is permanent — the feature/compliance/analytics store, standing forever alongside pos_giftcards (the money store), tied by posCardId. The script was renamed to giftcards-migration-coverage.ts, lost --apply entirely (hard refusal if passed), and now only reports mirror coverage, read-only. The now-dead giftCardsMigrated schema field and its bookkeeping-route fallback read were removed. Verified live on the staging box against a purpose-built DK test tenant (created via the real signup API, never touching real tenant data): the full lifecycle — issue, find, list, dashboard merge, redeem with a proven idempotent replay, void — against real Mongo, ledger reconciling to exactly zero (10000 − 3000 − 7000 = 0), and the P3 bookkeeping credit (tillIssuedGross) confirmed live. The market gate (a separate, unrelated, pre-existing control) was independently confirmed identical and correctly closed for both Sweden (the real pilot tenant) and Denmark (the test tenant) — no bypass taken; that gate has no override by design and faking it would be exactly the class of surprise this correction exists to prevent.
    • Seven more surfaces told the outside world nothing, or told it the wrong language (2026-08-01). A sweep for the rest of the class behind the two fixes below, done before it could be reported one surface at a time. All of them read owner-authored locale-keyed text with a chain that stops at English, and all of them speak to someone outside the business. The worst was the AI assistant's own restaurant context (api/tenant/[tenantId]/ai/route.ts): the description read .en, every product name read .en, and the category heading interpolated cat.name — a Record<string,string> — raw, so the system prompt for any Swedish venue read [object Object]: , , , and the model was handed an empty description above a menu of blank names. It then answered questions about the restaurant confidently, with nothing in the prompt to signal that everything it had been told was missing. The Google Business Profile sync declared one language and sent another: sync-engine.ts shipped languageCode: s.defaultLanguage and, two lines later, description: s.description?.en ?? '' — telling Google the text was Swedish while sending the English field, or an empty string when there wasn't one. Its twin in sync-diff.ts carried the identical expression, so the two are now pinned as a pair: if they ever diverge, the diff computes one description and the engine pushes another, and the profile reports a phantom "description changed" on every sync forever. Also fixed: the Food Bazar marketplace listing (api/bazar/restaurants), whose card description was .en ?? '' for the object shape and a flat '' for the legacy plain-string shape, so a Swedish restaurant appeared in the public marketplace with no description at all; `llms.txt`, the restaurant's self-description for AI crawlers, blank for the same reason, and its Popular-dishes list which preferred English; `seo/generate`, whose fact pack fed the model an empty description and English item names when generating meta for a Swedish site; and the voice concierge's audit trail, which recorded productName: p.name?.en ?? p.id — so the record of what a machine changed on someone's menu said prd_till_01j… instead of naming the dish. Two defect shapes, one cure: .en ?? "" dead-ends and renders nothing; .en ?? Object.values(rec)[0] never blanks but prefers English, so a venue that translated its menu reads "Shrimp sandwich" on its own Swedish pages — the exact swap the backend's resolveText was fixed for. Both now ask translate() in the venue's language. Explicitly NOT changed, and the distinction matters: the dashboard's editor endpoints (api/tenant/[tenantId]/menu/products/*, ProductEditorModal.tsx) read .en because they edit the English field — a fallback there would put the Swedish text in the English input and save it back over the English one, so the same reasoning that makes a fallback right on every surface above makes it data corruption there. The guard's GUARDED list grew from 3 files to 10 with that exclusion written into it, and its pattern widened from "dead-ends at a string literal" to "any fallback chain built around .en". 26 tests; the two new guard shapes proven non-vacuous — reintroducing a .en ?? Object.values(…) chain fails with the file and line, and desynchronising the Google pair fails the pair test. tsc --noEmit zero errors in all eight changed files; eslint clean. (Unrelated pre-existing failure left alone: `google/rwg.test.ts` asserts `luigi.restora360.com` and gets `luigi.localhost:3001` — an env-dependent host, in a module that imports neither file touched here.)
    • An untranslated blog post rendered as "Untitled" over an empty article — including in the share card (2026-08-01). Found while fixing the till-authored dish below; same defect, one surface over, and left open at the time because it was outside what was reported. BlogList.tsx and blog/[slug]/page.tsx both resolved post.title and post.content as [lang] ?? .en ?? 'Untitled' / '', so a post written only in the venue's language — the normal case, since the dashboard leaves untranslated locales blank rather than duplicating text — showed on a site in any other language as a card titled "Untitled" with no excerpt, and opened to a headline over a blank article. generateMetadata had the same two lines, so the <title>, the Open Graph title and the description were "Untitled" and empty too: the version that gets shared into a chat, indexed by Google and previewed by a link unfurler, which is the only thing anyone sees before deciding to click. All three now resolve through the shared translate() (lang → en → whatever the post actually has), so the article renders in the language it was written in rather than not at all; "Untitled" survives only as the last resort for a post with genuinely no title, which is a different thing and still worth labelling. Falling back can show a reader a language they did not ask for — deliberate, and the same call already made for dish names: the thing it replaces is an empty page. The guard grew rather than being duplicated: storefront-menu-names.test.ts became lib/i18n/storefront-text-fallback.test.ts, moved to the module that owns the rule, and now walks a GUARDED list of the three storefront surfaces that render owner-authored locale-keyed text, asserting each imports translate and declares no chain terminating at English. Its pattern was widened from .en ?? "" to .en ?? <any string literal>, because ?? 'Untitled' is the same dead end wearing a name — proven by reintroducing that exact line and watching the guard name it. 11 tests, all green; tsc --noEmit zero errors in every changed file; eslint clean.
    • A dish added from a till showed on the website as a price with no name (2026-08-01). Owner report, verbatim: "menu page on website is not updating completly the name of for menu item is missing. my friend from his windows PoS added 'redbull pink' but it just shows the price not the item name on website." Nothing was missing from the database and nothing failed to sync — the dish was in the tenant blob, the dashboard listed it, and the home-page menu teaser rendered it correctly. Only the full menu page was blank, because MenuClient.tsx held two resolvers for the same localized record and the page rendered through the wrong one: loc() at line 451 was rec[lang] ?? rec.en ?? Object.values(rec)[0] ?? '', while t() at line 957 — the one behind every category heading, dish name, description and ingredient list on that page — was val[lang] ?? val.en ?? "", one fallback short. A till writes a dish under the venue's language only ({sv:'Redbull pink'}, deliberately: a till has one text field, and inventing translations is worse than leaving them visibly untranslated for the owner to fill in), so on a site whose language is not that one, t() returned "". The price is a number and rendered fine; only the name vanished, which reads like a font or a sync bug and is really a missing ??. The same shape had already been found and fixed on the backend — resolveText once preferred en unconditionally and showed "Shrimp sandwich" for "Räksmörgås" (menu-fidelity.spec.ts); this was that bug on the website's read side. There is now one resolver in the file, delegating to the shared translate() from @/lib/i18n, which also guards the plain-string names old seeds and imports still carry (Object.values('Margherita')[0] is 'M'). `QuickAddButton` had the same defect and mattered more: its name string becomes the cart line name, so a guest ordering a till-authored dish sent a nameless line to the order, the receipt and the kitchen — not just a blank label on a page. The root cause on the write side is fixed too: POS_AUTHORED_LANG in catalog.service.ts was hardcoded 'sv' while its own comment said "the venue's" — the hardcoded-locale twin of the hardcoded currency, made on the write side this time. It now follows settings.defaultLanguage from the menu blob (lowercased, two-letter codes only, so an owner-typed 'English' or 'sv-SE' cannot mint a key no reader ever asks for), falling back to the primary market only when a tenant has set no language. No data migration is needed and none was written — items already stored under sv now resolve through the fallback on every surface, and the till reads them back through resolveText, which has the full chain. Gates: storefront-menu-names.test.ts (6 tests — the venue-language-only render, the requested language still winning when a dish is translated, the plain-string case, plus a source guard that fails if the menu renderer ever re-declares a fallback terminating at English) and till-authored-item-shape.spec.ts grew from 14 to 19; both guards proven non-vacuous — reintroducing val[lang] ?? val.en ?? "" fails the source guard with the offending line number, and pinning the write back to 'sv' fails exactly the three venue-language tests. tsc --noEmit zero errors in every changed file; eslint clean; the whole POS catalog suite (126) green.
    • A guest can no longer book more people than the table seats — and shrinking a table under a live booking is no longer silent (2026-08-01). Owner report, verbatim: "When booking 2 person table. The option for add more guests should be disabled. But today I can add more while booking table online." — reported minutes after editing table 1 from 6 seats down to 2 while someone was mid-booking on it. Both are the same missing invariant. The rule now has ONE owner: lib/reservations/table-occupancy.ts — the module whose own header already called itself "the ONE rule the public floor picker and the reservation POST must agree on" but only implemented occupancy — gains tableFitsParty / tableTooSmallMessage / maxPartyForTable / overCapacityBookings, and every path imports them instead of re-deciding. Server first, on every write path that binds a tableId: POST /api/public/[tenantId]/reservation (the wizard flow) had no capacity check at all — a party of 10 on a 2-seat table was a 201; it now 409s with the shared sentence. POST /api/public/book/[slug] keeps its check but through the shared helper, also refuses guests.length > party (a hand-rolled body could smuggle 8 covers past a party of 2), and no longer persists a tableId when the venue has no saved layout to validate it against. POST /api/tenant/[tenantId]/reservations — which declared itself "called from the public customer site — no auth check needed" and validated nothing (no schema, no party bounds, no availability, Number(guestCount) happily storing NaN, reachable by anyone on the internet, and with no in-repo caller, because the public site posts to /api/public/*) — is now what it lives under: requirePermission(tenantId, 'reservations.manage') + the new StaffCreateReservationSchema. The manager override is deliberate and lives in exactly one place: a restaurant genuinely does seat three at a two-top by pulling up a chair, so the STAFF route (and only it) accepts allowOverCapacity: true — explicit, never the default, absent from every guest-facing schema — and stamps Reservation.seatedOverCapacityBy plus an activity line, so the floor sees that a human chose it rather than that a rule leaked. All four checks are now atomic with the write: the two public POSTs and the layout PUT moved from lock-free getTenantDB→saveTenantDB onto mutateTenantDB, so capacity/occupancy/availability are decided inside the same CAS section that appends — a concurrent designer shrink can no longer land in the gap, and a booking can no longer be erased by whichever whole-document write finished last. The control the owner actually touched: BookingFloorPicker's party clamp was a hard-coded Math.min(12, n) that never consulted the table, and raising the party silently NULLED the selected table and time — leaving canContinue true, confirm() early-returning, and a live-looking Confirm button that did nothing. The ceiling is now maxPartyForTable(selectedTable, biggestTableInTheRoom); the "+" stepper and the "Add another guest" button are disabled at it with the reason printed underneath ("Table 1 seats 2 — pick a larger table to add more"); canContinue re-tests table + time so the dead button is unreachable; the stale-closure in the old arrival reconciliation and the setParty-inside-a-setGuests-updater in removeGuest are gone. The floor plan is re-fetched on the way to the last step, so a guest whose table shrank mid-booking is told and returned to the map before typing in six guests' names, not refused after. ReservationWizard gets the same rule: the picked table now carries its seats, and changing the party size drops a table that no longer fits. The shrink, from the owner's side: PUT /api/tenant/[tenantId]/table-layout contained zero references to db.reservations. It now diffs the incoming room against every live future booking and refuses the save with 409 + the casualty list ("3 bookings no longer fit the tables you drew") until the owner acknowledges it; the designer shows who it breaks, offers Undo my change (which reloads the saved room, so the canvas can't diverge from the server) or Save anyway — I'll handle these, and remembers what was answered so the 900 ms autosave never re-nags. An acknowledged save stamps each affected booking's `staffNotes` ("Table 1 was resized to 2 seats — it can no longer hold this party of 6") and writes an activity line, so the conflict outlives the dialog. The dashboard reservations list rings the guest count in red with "Table seats 2" / "Table removed" / "Extra chair · <who>", and /api/internal/pos/bookings now hands the till tableSeats + overCapacity, so the Bord card warns instead of quietly drawing two chairs for six people — both computed against the current layout, never stored, so growing the table back clears the flag by itself. Deliberately NOT done: no auto-reassignment, no auto-cancel and no guest email on a shrink — moving a party from the window two-top to the corner six-top is a service decision with a real consequence for that guest, and a PUT handler is the wrong thing to be making it; staff get the list and the call. Gates: table-occupancy.test.ts grew from 5 tests (occupancy + PII only, zero capacity coverage) to 13, covering party == seats, party == seats + 1, deleted tables, past/cancelled/table-less bookings, and the staff-approved case; tsc --noEmit zero errors in every changed file; eslint clean on all of them.
    • No native browser dialogs left in the POS/devices surfaces (2026-07-29). no-native-dialogs.test.ts (a HARD RULE guard: native dialogs ignore the theme and leak the raw host name in "restora-server… says") was failing on four call sites and is now green. The POS staff card's PIN reset and operator removal, plus Köksrouting's station-delete and force-delete confirmations, moved to the themed promptDialog / confirmDialog. The PIN prompt gained what the native one could never have: inputType: "password" and numeric: true, so it masks the PIN and raises the numeric keypad on a touch device.

    Added

    • Schemalagda beställningar — the owner's switch for "I order at 11:00, I collect at 13:00" (2026-08-01). Owner ask: a guest ordering ahead should have the online order, the POS and the kitchen all see when the food is wanted, so it is served warm and freshly made — and the whole thing must be optional, flipped on or off by the restaurant from settings and effective everywhere at once. This entry covers the OWNER CONTROL half. New `TenantSettings.scheduledOrders` = `{ enabled?, maxAdvanceDays?, minLeadMinutes? }`, absent meaning OFF — cooking immediately is what every existing venue already promised its guests, so a new behaviour must never switch itself on during an upgrade. Two knobs and no more, because each one is something an owner has to understand: how far ahead a guest may book, and the shortest notice the kitchen will take. The read side is a module, not a field lookup (lib/scheduled-orders/config): resolveScheduledOrders fills in the defaults (7 days / 30 minutes) and clamps a stored record that predates a bound or was edited by hand, scheduledOrderWindow produces the bookable range, and normalizeRequestedServeAt is the ONE decision point every order-create path calls — absent time = ASAP exactly as today, and a time that arrives while the venue has the feature off is dropped rather than stored, silently, because the guest was never shown a picker and their order is a perfectly valid ASAP order. A stored time nobody honours is a cold meal. The bounds live in that module and UpdateSettingsSchema imports them, so the API and the resolver cannot drift, and the guest picker is built from the same numbers it will be judged against — a slot offered at checkout and then refused on submit is a bug the guest finds for us. Dashboard control: Settings → Ordering & Delivery gains a switch with copy that says what actually changes (guests can choose a later time; the kitchen is told when to START rather than cooking straight away), plus the two limits, gated on settings.edit and saved server-first through the existing tenant settings PATCH — the control only moves once the save lands, so nobody walks away believing they enabled something the kitchen never got. That tab hosted no editors by design (the IA-1 dedupe rule: one owner per setting); this is not an exception, it is a brand-new venue-wide ordering behaviour whose one owner is that tab. Gates: npx tsc --noEmit zero errors in every changed file (59 pre-existing errors elsewhere untouched), eslint clean on all touched files, 7 new pure-policy tests covering the off-by-default gate, the drop-on-off rule, junk/out-of-bounds knob resolution, the window arithmetic, the four serve-time verdicts and the settings-schema contract.
    • Ny vara — a till can create a menu item, and it lands in the web menu (2026-07-31). Owner ask, repeated: "build that functionality also 'reverse direction (authoring on Android → appearing on web) doesn't exist. The till has no menu-editing UI and the translator is one-way by design.' i have been telling you about that before." The translator ran web → POS only; a tablet could sell the menu and never change it. New device route `POST /api/v1/pos/catalog/items` (RegisterGuard + @RequiresOperator(), no tenant path param — the tenant comes from the register token) writing the tenant blob, which is the authoritative store the web menu editor writes unconditionally and reads back. So an item typed on a tablet appears in the dashboard Meny page, on the tenant website and on every other register — not only on the device that typed it. An atomic $push, not a read-modify-write: two tills adding at the same moment must both survive. The POS catalog is re-translated immediately after, so the till that made the edit shows the item without waiting for a sync tick. The dialog asks four things and no more — name, price, section, VAT category — because those are the four a cashier standing at a register can answer correctly. Allergens are a legal statement about a dish, translations need someone who speaks the language, a photo needs a camera roll and a crop; those are left ABSENT rather than defaulted, so the item arrives in the dashboard visibly incomplete for the owner to finish instead of silently marked "no allergens" by a device that never asked. The name is stored as a single-entry localised map ({sv: "..."}), so other languages read as untranslated rather than duplicated. Nothing is preselected, including VAT — an item quietly created at 12 % is indistinguishable from a correct one until an audit, by which point it has been sold a thousand times; the server refuses a missing vatCategory and the dialog refuses to submit without one. The price is never parsed by the UI: the field takes KEYSTROKES like the cash pad (1-2-5-0-0 previews "125,00"), pos-core converts and formats, and with no engine installed the dialog refuses to send rather than inventing a price the whole restaurant would then sell at. Permission: owner and manager by ROLE, extended by a new catalog_edit operator permission so an owner can delegate to a shift lead without promoting them — deliberately NOT folded into register_settings, because changing how one till behaves and changing what the venue sells are different acts with different blast radii. Reading the permission alone would have locked every existing owner out of their own menu, since no operator record predating this carries it, and the remedy would have been a bulk write across pos_operators in live tenants. The server re-derives the answer from the operator record on every call (mayEditCatalog, 6 pinned cases); the device control decides what is OFFERED, never what is allowed. Verified end to end on a paired Lenovo tablet against the staging box: "Vitloksbrod / 49,00 / Pizzor / Mat 12 %" created on the till, then read back out of GET /store/tenant-data/{tenantId} — the exact payload the dashboard menu page renders — as one of four products, carrying createdFrom: {surface: "pos", registerId, at}. Three defects were found by driving the device rather than by the suite and are fixed: the shared @restora/domain-types package is consumed from a TRACKED compiled dist/, so a commit of its source alone type-checked locally and would not compile anywhere else (new pre-commit gate rebuilds the package to a temp dir and diffs against the git INDEX, verified to block in exactly that case); a fifth header button rendered as a column of single letters down the right edge (the header actions are a FlowRow now, so they wrap rather than squeeze); and the atomic $push was handed to a helper that wraps everything in $set, which Mongo rejects — fixed to updateDocumentMatching, which also never upserts, because a conjured blob would SHADOW a venue's existing menus menu and show one dish where there were forty. Gates: backend tsc --noEmit 0, 275 backend tests, Compose app compiles for Android + desktop + wasmJs, desktop suite green including a cross-language test that reads ENGINE_TAX_CATEGORIES out of catalog.service.ts and asserts the till's VAT chips are the same list (verified non-vacuous). Not yet: creating a CATEGORY from the till, and the pos/web React till has no authoring UI — both tracked.
    • POS-enheter — one collapsible section per surface type, so nobody has to ask which button connects what (2026-07-28). Owner ask, verbatim: a collapsible list on the POS Registers page, ONE SECTION PER SURFACE TYPE (PoS · KDS · SSK/Självbeställning · Kunddisplay), every row showing exactly three things — Status | Öppna | Parkopplingskod. The blocker solved first: a register had no surface type. RegisterDoc carried name + capabilities and nothing saying whether a device was a till, a kitchen screen, a kiosk or a customer display, so the dashboard GUESSED from the name — and the old page's three "Open POS ↗ / Kitchen display (KDS) ↗ / Customer display ↗" buttons opened surfaces unrelated to the device you were looking at. New backend field on the autoSendToKitchen pattern: RegisterDoc.surface?: 'pos'|'kds'|'self_order'|'customer_display' with ABSENT MEANING 'pos' (every pre-existing register is a till → no backfill, no behaviour change), resolved on every read into RegisterView.surface and /registers/me, accepted on register CREATE and on the EXISTING admin settings PATCH (extended, not a new route — it rides the same capabilityRev bump so a device re-reads it like any other setting). Dashboard (/dashboard/pos, rewritten): four controlled disclosures with a per-section count that stays visible when collapsed; an empty section shows "Ingen enhet ännu" plus its own name+Skapa form that creates a register pre-set to that surface with a sensible capability preset (till = the same restaurant preset "Anslut POS" gives, KDS = kitchen_routing only — a kitchen screen does not sell, kiosk = sell+self_checkout+kitchen_routing+offline_capture); status is a chip — Parkopplad (green) / Väntar på parkoppling (hex-amber status pill, never the brand-remapped Tailwind amber) / Spärrad (red) — that a live pairing code takes over with "Kod aktiv · N min kvar"; Öppna opens THAT surface's URL in a new tab, verified against `pos/web/src/App.tsx`: /pos/ (till), /pos/?kds, /pos/?display — and because the bundle has no self-order branch, the kiosk's Öppna renders DISABLED with a tooltip saying the surface is not built yet rather than a link that goes nowhere; Parkopplingskod mints against the existing single-use endpoint and shows the 8-character code big and monospaced with a copy button, a live 15-minute countdown, an "öppna enheten med koden ifylld" deep link (?pair= — honoured by PairScreen), and an honest "koden har gått ut" when it lapses instead of the panel silently vanishing. Codes now appear in exactly ONE place — the row of the device they belong to — including the one minted by the first-run "Kom igång" button, which keeps its real value (it also creates the first personal-login and imports the menu) but no longer competes as a second connect path. Whole page moved to Swedish operator copy; the three actions stack rather than overflow at 320px. Plumbing: 2 thin proxies under api/tenant/[tenantId]/pos/devices/** (requireTenantAccess on both, tenantId always from the authenticated path, Next 15 await params) — the list reads GET /pos/admin/registers because that is the projection carrying surface/status/deviceInfo, and create resolves seriesPrefix (next free S001-Tnn), country and currency from the tenant's OWN registers so a Norwegian venue's second screen is not silently created SE/SEK and an operator never has to know what a receipt series is; capabilities are server-chosen, never accepted from the browser. New shared lib/pos/device-surfaces.ts is the one definition of the four surfaces (label, blurb, open-query, capability preset) used by both the page and the create proxy. Gates: backend npx tsc --noEmit exit 0; ui npx tsc --noEmit zero errors in the changed files (49 pre-existing errors elsewhere in storage/themes/experience-studio untouched).
    • Köksrouting — the kitchen-routing admin lands in the dashboard (2026-07-27). New page /dashboard/pos/routing (sidebar: Devices → "Köksrouting", NEW badge): the dashboard surface for KDS stage 1. Stationer — create/edit prep stations (name, short code, sort order, target prep time, expo flag), activate/deactivate/delete with the backend's loud-refusal flow surfaced honestly: a 409 ROUTING_STATION_IN_USE becomes a panel naming the blocking categories/items and offering "flytta till annan station" (reassign) or "fortsätt ändå" (force, every orphan logged); losing the last active station shows the pass-through warning verbatim. Kategorier — default station per menu category ("Pizzor → pizzaugn"), "Ingen tillagning" toggle, a warning pill when a default points at a deleted station, and the '' category rendered as "Utan kategori" (per-item routing only, controls disabled since the backend refuses an empty categoryId). Varor — per-item override table with the LIVE fire-time decision as a colored chip (routed = green with station + source; fritext = hex-amber status pill; unrouted = red "Ingen station"; no-prep/passthrough = slate), station override picker and the Ärv/Aldrig kök/Alltid kök tri-state, plus name search and a "bara problem" filter (render-capped at 250 rows). Hälsokoll — the pre-service check: mode, active-station count, per-kind item counts, the named unrouted dishes (red, backend messageSv verbatim), dangling-category list, and one-click "Skapa station av namnet" for each legacy free-text station. Logg — the routing diagnostics log (operator-facing Swedish rendered verbatim, code chip, Kassa/Online channel badge, 24h/7d selector). Plumbing: 9 thin proxy routes under api/tenant/[tenantId]/pos/routing/** (all requireTenantAccess-guarded, tenantId always taken from the authenticated path), a new internalPut helper + backendErrorResponse mapper in lib/http/backend.ts so structured backend refusals (status + code + details) reach the browser instead of collapsing into a generic 400. Also fixed the failing nav i18n guard: "POS Registers" (and the new "Köksrouting") translated in all 20 languages.
    • SB-1/SB-2 — per-portion split-bill kassakvitton, built to completion DORMANT behind a per-tenant flag (2026-07-20). Implements the split-bill fiscal design (Model B1): with settings.fiscal.splitReceipts: 'portion', every captured payment portion registers its OWN sale kassakvitto at its OWN payment moment — each paying guest is offered a receipt, an abandoned split leaves received money correctly registered, and X/Z per-method totals match the drawer by construction. Engine (SB-1): slicePortionBuckets (B1 residual allocation — BigInt proportional shares, largest-remainder within a portion so slices sum exactly to the payment; the FINAL portion takes each bucket's remainder for gross/net/VAT, so Σ portions == the whole-order receipt öre-exactly per rate, mixed VAT included) + reserveFiscalPortionSale (pure) + recordOrderFiscalPortion (in-lock, idempotent on portionRef = the ledger charge row id — the exact mirror of refundRef; self-gating on the flag; mode-straddle safe: fiscalSales presence wins over config); the whole-order paid-flip hook becomes the SAFETY SWEEP in portion mode (and stays the path for no-ledger-row orders: canteen wallet, zero-due). Journal/X/Z/status derive Order.fiscalSales into the ONE gapless journal. Refund/copy mapping (§4.3): returkvitto resolves its target portion via the refund row's charge linkage (greedy lowest fallback), the cumulative return cap re-anchors at Σ portion gross, the full-refund VAT mirror merges portion buckets, and kvittokopia becomes once-only PER kassakvitto (receiptNumber on the copy route; ambiguous portioned order → 422; a portion copy reproduces the ORIGINAL stamp öre-exactly). Routes (SB-2): the POS settle route registers the portion at each capture (partial AND final) and finalizeSeamCharge registers an async-rail portion at the APPROVAL moment. Default `'order'` = byte-identical to today (regression-proven). Go-live per tenant = the config flip, gated on the design doc's ⚠ VERIFY register (Skatteverket written query + control-unit vendor). Adversarial review (probe-driven): 8 findings, ALL FIXED — refund-then-recollect no longer dead-ends (registered returkvittos NET out of the position for the cap, final-portion detection AND prior slices, so re-received money re-registers — probe-confirmed the old permanent refusal); slice VAT moved to CUMULATIVE rounding + clamp/rebalance (a 4×3-öre split can no longer print a negative-VAT kassakvitto: 1,0,1,0 not 1,1,1,−1 — Σ still öre-exact by telescoping); the portion hook got the ADVISORY pre-read gate (dormant means dormant: order-mode tenants no longer pay a locked doc-write per capture on the hottest money path); gift/prepaid tenders on UNPAID orders register their portion at CREATE time via the new sweepOrderPortionsAsync (the abandoned-split rationale now holds for gift money too); the paid-flip sweep resumes PENDING portion stamps (live signing failures self-heal); untargeted multi-portion returns walk portions by remaining capacity; mid-split order-total drift logs loudly and the clamps keep the math self-consistent; copy route survives a JSON null body. Gates: 10 portion tests incl. F1/F2 regressions (7048 total, 0 fail), tsc 0, full build 0, sb-portion-qa.mjs flag-ON E2E ALL PASS on the FIXED build (2-portion cash+card split → gapless kassakvitton at capture time, X-report exact per-method öre deltas, per-portion once-only copies) + flag-OFF pos-wheel-qa.mjs 11-gate regression ALL PASS.
    • Register panel Wave 3 — park/resume, ÖVNING training mode, Android parity, Windows Station PoS surface, split-bill fiscal design (2026-07-20). Owner: "go ahead all in". Web park/hold: the panel's Park key shelves the in-progress sale (cart + armed kitchen note + rabatt) per-device/per-tenant (lib/sell/park.ts, cap 20, shape-guarded storage — nothing fiscal happens at park time), with a resume/discard list in the panel; resume refuses over a non-empty cart. ÖVNING training mode: a session-only violet switch in the panel — while armed, every tender routes to POST pos/training-receipt (pos.operate, live-menu priced via the ONE PriceQuote engine, same rabatt clamps): NO order/ledger/stock/KDS/tally effect, only a marked training registration in the NEW TenantDB.fiscalTraining store — same gapless per-register counter, never the grand total, counted as trainingCount on X/Z, honest violet "ÖVNING — ej ett riktigt köp" confirmation (never the Paid screen). Fiscal exactness: an exact-FULL returkvitto now mirrors the sale's per-line VAT split öre-exactly (3×999 @25%: 600 vs the aggregate's 599 — a fully-reversed sale now nets 0 VAT on the Z, mixed-VAT sales included); SB-0 — tenderSplit derives the sale receipt's tender from the PAID ledger rows when they sum to the total, so a 2×cash+1×card SPLIT bill no longer registers as all-card (X/Z per-method finally reconciles with the drawer on splits). Android `:pos` parity (device-verified on the Lenovo TB330FU): "Register panel" switch in the settings sheet (default OFF, SettingsStore.registerPanel) → right-hand panel on wide layouts: numpad ×-grammar (verified 3×→300 SEK on device), server-clamped Rabatt with exact preview (−10% → 270 SEK verified; gift card + rabatt mutually exclusive so the shown total always equals the booked one), kitchen msg (order-level notes — Android never sent it before), once-only Kvittokopia (409/422-aware client), lådöppning, park/cancel; PosOrderRequest gained registerDiscount. Windows Station: PoS = the 4th wrapped surface (pos → /dashboard/sell + launcher picker button; per windows/AGENTS.md the web wheel/panel/themes come along free; cargo build exit 0). Split-bill fiscal semantics: full design doc docs/architecture/split-bill-fiscal-design.md — Model B (one kassakvitto per settled portion, portionRef idempotency mirroring refundRef) staged SB-0…SB-5 behind a per-tenant flag, every legal claim tagged [LAW]/[PRACTICE]/[CHOICE] with a ⚠ VERIFY register (incl. "PRO FORMA" vs "EJ KVITTO" marking); the TablePayment/barTab fiscal gap + abandoned-split gap documented in KNOWN_ISSUES. Gates: 7038 unit tests 0 fail (park + training + VAT-mirror + SB-0 tests), tsc 0, full next build 0, pos-wheel-qa.mjs 11 gates ALL PASS (park→resume, ÖVNING ring leaves takings untouched), :pos:assembleDebug 0 + on-device verify, Station cargo build 0.
    • Register functions panel (TruePOS-style quick actions, our UI) + the Swedish non-sale fiscal rail: kvittokopia · returkvitto · pro forma · lådöppning (2026-07-19). Owner decision: an on/off switch under POS settings — DEFAULT OFF so our PoS UI/UX stays exactly as-is; switching it on ADDS a right-side functions panel ("we will keep things in our own way" — TruePOS functionality, never their look). Wave 1 — the panel (components/dashboard/pos/RegisterPanel.tsx + register-panel-pref.ts, new "Layout" switch in the settings wheel): numpad-first register grammar (type 3 → press × → tap a product adds 3; clamped to the 99/line server cap incl. merges — new addToCartQty), ad-hoc Rabatt entry (numpad value → % or kr; SERVER-side application in the pos/orders route AFTER combo/coupon/brand-promo on the remaining total, percent capped at 100, clamped ≥ 0, recorded into discountAmount so the fiscal receipt prints the mandated negative Rabatt line; the till only previews via previewDiscountedTotal), Köks-medd. (order-level kitchen note through the existing notes field, themed prompt — also added as a plain field on the waiter order ticket), cash change calculation (type the tendered amount → change previews live and lands on the Paid screen), and one-tap tender buttons (Kontant/Kort/Swish reusing the exact existing charge rails). Wave 2 — the fiscal issuance paths the engine supported but nothing ever issued (lib/fiscal/se/wire.ts reserveFiscalExtra/recordOrderFiscalExtra, pure + in-lock like the sale path): Kvittokopia — once per order EVER (Skatteverket), requires the recorded sale stamp, own gapless receipt number, never moves the grand total, panel button opens the print page with the mandated oversized KOPIA marking (409 on a second attempt); Returkvitto — wired into ALL THREE refund chokepoints (sync provider/gift refunds, async refund confirm, Swish refund) fire-and-forget so every money-out now registers a RETUR receipt referencing the original and advances the |sale|+|return| grand total; Pro forma nota — "Print bill (PRO FORMA)" on the waiter sent screen (no sale stamp required, capped 10/order, marked PRO FORMA — ej kvitto på köp); Lådöppning — a nosale cash-drawer movement (amount forced 0) journaled with who/when/why and counted as drawerOpens on the shift Z-report. Journal + X/Z reports now derive order.fiscalExtra into the SAME gapless journal (copies/pro-formas counted, never money; returns net out of sales). New routes pos/orders/[orderId]/receipt-copy + /proforma (staff-auth, engine enforces the bounds); live control-unit path reserves in-lock + signs post-lock identical to sales. Adversarial review (2 independent agents over the money/fiscal diff): 10 defects found, ALL FIXED — returkvitto made idempotent PER REFUND EVENT at the engine (refundRef = the refund's ledger row id, threaded through all three chokepoints — the sync route and the rail webhook observe the SAME row, so their race can no longer double-register; cumulative returns capped at the sale gross); a failed live signing of an extra now RESUMES on the next identical request instead of dead-ending (and for a copy, permanently forfeiting the only legal copy), the signing finder is register-scoped, and the response returns the SIGNED stamp; the direct POS settle route (the 4th place a sale becomes paid — register Swish!) now records the fiscal sale, and the zero-due branch (newly reachable via a 100% RABATT) emits order.paid + the receipt; client caps now mirror the server zod caps exactly (discount ≤100%/999 999 kr, note ≤1000, qty merges clamp at 99) so an over-cap OFFLINE sale can no longer queue → fail zod on replay → be silently dropped (a lost cash sale); toggling the panel OFF clears armed discount/note/multiplier (UI price and server booking can never diverge); the Paid screen's change calc ignores a parse-failed total-0; receipt markings are now SERVER-TRUTH (fiscalMarks from the journal stamps — a spoofed ?mark=return&nr=999 can't fabricate an official-looking returkvitto, and once a kvittokopia exists the plain print URL auto-marks KOPIA); return stamps record the money-out tender so X/Z per-method totals net correctly; X/Z + status certified now covers non-sale registrations; kvittokopia/pro-forma require pos.operate (once-ever copy must not be burnable by a viewer role) and internal engine faults map to 5xx not 422. Gates: 27 fiscal/journal/drawer unit tests (7032 total, 0 fail), tsc 0, full next build, extended pos-wheel-qa.mjs (panel toggle, ×-multiplier, −10% + cash + change, kvittokopia once-only + KOPIA banner, drawer feedback). Known minor (tracked in TODO): return-VAT is split from the aggregate (±1 öre drift vs per-line on a full refund of odd-öre lines).
    • Android POS — the settings wheel lands on the tablet register too (2026-07-19). The :pos app's four loose top-bar icons (tablet mode · theme toggle · sign out; Cast kept visible as the live customer-display indicator) consolidated under one gear opening PosSettingsSheet: a Light/Dark/Colorful theme picker (new SettingsStore.posTheme, migrates from and stays in sync with the old appDark flag) + quick actions (the new PosCalculatorDialog — same chained-op/till-% math as the web calculator — plus Customer display, Switch tablet mode, Sign out). Colorful mode amplifies the register's existing CATEGORY_HUES palette: category chips go from a small color dot to a full hue fill and the product tiles keep their category tints — the classic colorful-register look on the light canvas. Verified on a real Lenovo TB330FU: theme migration, live colorful flip + persistence across relaunch, calculator math (12×9=108). Gate: :pos:assembleDebug exit 0.
    • POS settings wheel — one gear on the selling screens: consolidated settings, quick calculator, quick actions, and a Light/Dark/Colorful theme (2026-07-19). Owner ask: a settings wheel on the PoS with everything under it, a quick calculator, quick actions, and selectable dark/light/colorful themes. New shared components in components/dashboard/pos/ mounted on BOTH selling surfaces (/dashboard/sell and /dashboard/order): (1) `PosSettingsWheel` — a gear that opens a slide-over with three sections: Theme picker, Quick actions (Calculator + big tap-targets to Cash Drawer / Table Billing / Bar Tabs / Orders / KDS + per-screen extras: "New sale" on Sell, "Change table" on Order), and Settings & configuration — grouped links into the EXISTING settings homes (Setup Center payments/ordering tabs, Menu, Devices & Apps, Tables & QR, Payroll & tips, All settings) so every setting keeps its one home and RBAC (consolidated access, not duplicated pages). (2) `PosCalculator` — a classic till calculator modal (chained-operation math, % with the till convention 100 + 25 % = 125, ± ⌫ C, full keyboard support), pure UI utility that never touches the cart or money rails. (3) `pos-theme` — Light / Dark / Colorful: Light and Dark DRIVE the one tenant theme mechanism (useTenantThemeMode — no parallel theming); Colorful rides the light canvas and colorizes the category chips on both screens with a fixed 10-color saturated palette (deterministic per category index; deliberately avoids the orange/amber/yellow scales the brand remaps to azure). Also: /dashboard/sell (the lite-mode landing) is now reachable from the full-shell sidebar — added to Live Service as "Sell (Counter)" with the label translated in all 20 languages. Gates: 16/16 nav-catalog + dark-vocabulary guard tests, tsc 0, full next build exit 0, scripts/pos-wheel-qa.mjs (new Playwright QA: sheet, all 3 themes apply, calculator math, both screens, 390px).

    Changed

    • In-Venue Ops IA — the 15-item group split into three, and Table Bills + Split a Bill merged (2026-07-19). Owner question: competitor systems (Toast/Square/Lightspeed/SpotOn/Trivec) don't scatter table bills, split-a-bill, cash drawer, bar tabs across back-office nav pages — they're actions inside the POS check screen, with the dashboard holding only setup + reporting. A 14-agent audit (every page read, six competitors benchmarked, an adversarial skeptic) confirmed the "one mega-view" temptation is a trap: the pages are nine separately-entitled, separately-RBAC'd verticals over three disjoint data rails (orders / tablePayments / barTabs) that only meet at the payment ledger, so a naive merge would leak toggled-off features past navHasFeature, 403 waiters on owner-only tabs, break saved deep links, and imply an order↔bill linkage the backend doesn't have. Shipped the two safe wins the audit endorsed: (1) nav regroup — the one "In-Venue Ops" dropdown (15 items) split into Live Service (Take Order · Table Billing · Bar Tabs · Weigh Station · Cash Drawer), Venue Setup (Tables & QR · Floor Designer · Delivery ×2) and Guest Services (Catering · Canteen · Atmosphere · Voice · Table Assistant); pure regrouping — every route/featureId/permission unchanged, "In-Venue Ops"/"Operations (in-venue)" kept as search keywords, 3 new group labels + 1 item label translated across all 20 languages. (2) Table Bills + Split a Bill → one `/dashboard/table-billing` surface with two URL-driven tabs (?tab=bills default / ?tab=split, features-page pattern); both are settlement UIs over the same ledger with the same pos-operations entitlement + owner/manager posture, so the merge is presentation-only. Old routes are redirect() stubs (guard-test convention) → links, bookmarks, Quick-Links stars and the docs-center/payments deep links all keep working (the two in-app links repointed at the merged tabs). Gates: 15/15 nav-catalog guard tests (redirect stubs, 20-lang i18n, iconKeys, findNavItem), full next build exit 0, all three routes in the app manifest.

    Added

    • PoS + Waiter & PoS complete user guides at `/apps/pos/guide` and `/apps/pos/waiter-guide`, with real product screenshots (2026-07-19). Two new in-depth guides on the public site, following the Staff & Shifts guide's pattern. PoS guide (owner/manager): concepts chapter, a 17-step setup-to-operations walkthrough (floor plan · seating-chart designer · open a table + guest QR · take an order · table bills · split a bill (honestly scoped: web is even/custom only, the Android POS app adds seat/item split) · bar tabs + fast-pour + happy hour · age verification + refusals · cash drawer open/movement/close · terminal pairing + fleet · the Swedish fiscal/Kassaregister panel · payments & refunds), a Sweden fiscal-law chapter (Kassaregisterlagen, kvitto content rules, X/Z-reports, the Skatteverket kassaregister control visit — holds the same honesty bar as the fiscal engine itself: the product ships compliant-format receipts/journals against an uncertified sandbox control unit until a real certified cloud unit is connected, never claimed certified out of the box), 12 scenarios, 16 FAQs. Waiter & PoS guide: a practical, floor-staff-voiced companion — 13-step shift walkthrough (clock-in cross-ref · take an order by seat/course · offline handling · table bills · split · bar tabs · transfer/close a table · end-of-shift cash) with a cross-link to the PoS guide's legal chapter instead of repeating it, 10 scenarios, 12 FAQs. 56→40 real screenshots (20 unique screens × web 1440px + mobile 390px, several shared across both guides where the underlying screen is identical) captured by new scripts/guide-shots-pos.mjs against demo data seeded by new idempotent scripts/seed-pos-waiter-demo.ts (tables, an open table session + in-progress order, a bar tab with pre-auth/rounds/happy-hour, a cash-drawer session, a paired Android terminal, Swedish fiscal activation) on the same luigi-pizza demo tenant. Catalog: FeaturePage gained guideLabel/moreGuides/guideFooterNote (additive, staff-shifts' existing footer copy preserved via the new field) so pos.ts can link BOTH guides from the hero + FAQ footer; also fixed pos.ts's stale liveHref (pointed at the read-only online-order Kanban board, not the real waiter order-builder at /dashboard/order). Gates: both guides + /apps/pos verified 200 with populated content; full next build exit 0.
    • Staff & Shifts complete user guide at `/apps/staff-shifts/guide` — owner + worker + Skatteverket + Swedish law, with real product screenshots (2026-07-19). The first in-depth product guide on the public site: 6 chapters (the three surfaces & the 4-digit code · a 17-step owner walkthrough from first hire to locked payroll · a 12-step worker guide written to the staff member · the Skatteverket kontrollbesök play-by-play incl. the no-PIN terminal register view and the 12,500/2,500/25,000 kr control fees · a Sweden legal reference with statute citations (SFL 39 kap., SKVFS 2015:6, ATL, semesterlagen, sjuklönelagen, kollektivavtal/no-statutory-minimum-wage, AGI/bokföringslagen, GDPR art. 17(3)(b) vs the 2-year register window) · 12 handled real-world scenarios) + a 16-question FAQ, FAQPage + BreadcrumbList JSON-LD, English "Sweden edition" first. Every step shows the REAL product — 56 screenshots (28 steps × web 1440px + mobile 390px) captured by scripts/guide-shots.mjs from the live dashboard/worker-portal with the dashboard forced to English and dev-noise banners stripped, against demo data seeded by the new idempotent scripts/seed-staff-shifts-demo.ts (staff PINs/encrypted personnummer/rates, 30 terminal punches incl. flagged manual + corrected ones, published & draft weeks, pending time-off/swap/availability requests, locked + draft payroll runs, training/review/kudos/announcement/chat) — both scripts committed so shots regenerate after UI changes. Honesty bar held: AGI/Bankgiro described as download-only MOCK, payslips as "Preliminär · schablonskatt", push/SMS as keys-gated, clock-in as terminal-only by design. Catalog: additive guideHref on FeaturePage renders "Read the full guide" on the feature page hero + FAQ footer (staff-shifts only so far). Gates: guide + feature page render 200 with populated content; full next build exit 0.
    • Native mobile payslip view in the :staff and :owner Android apps (2026-07-19). Owner UAT: the payslip screen rendered the A4 PDF as bitmap pages — "not mobile friendly" no matter how crisp. The payslip is now NATIVE phone UI: net-pay hero, Earnings (hours / hourly rate / base / overtime / OB premiums / tips / gross), Deductions (tax withheld + line deductions → net), Employer cost (info), and the schablon honesty note when the run is mock; the legal PDF demoted to a "Payslip document (PDF)" card with View (full-screen pages + tap-to-pinch-zoom) and Share actions — web portal unchanged. Server: GET /api/worker/[tenantId]/payslips list projection extended with the worker's OWN full line breakdown (employmentType, hourlyRate/salaryPeriod, base/overtime/premiums/tips, employerLoad, withholding, deductions) — additive, still never a colleague's line or run totals; older APKs ignore the new keys, and the new APK falls back to gross/tax/net/hours against older servers. Gates: 6/6 payslip-access tests, both APKs assemble, full next build.

    Fixed

    • Feature pages — full truth-audit after owner report that content didn't match the products (2026-07-18). /apps/food-bazar described supplier ordering; the real Food Bazar is the CONSUMER marketplace (diners browse/order; restaurants get listed). Root cause: content was grounded in the /services grid blurbs, which themselves conflated surfaces. Corrected by grounding every page in the actual code: 5 rewrites — food-bazar (listing pitch: auto-list gate from lib/bazar/readiness.ts, dashboard controls/boosts, ONE ordering switch; moved to Discover), printing→"Labels & Printing" (QR product/shelf label sheets, not a print shop), waste→"Waste Tracking" (scan-to-waste stock movements, not pickups), equipment-suppliers→equipment "Equipment Manager" (registry + per-machine QR, not a marketplace), inventory-suppliers (supplier ordering = supplier marketplace, not "Food Bazar suppliers"). Then 19 adversarial verifiers (one per remaining page, each reading the real surfaces) found ~100 defects — plan-inclusion errors (inventory is an add-on, not in Basic), nonexistent features (KDS "all-day view", reservations floor-plan claims, analytics daily summaries), demo-only iOS apps sold as live (POS/Owner platforms now exclude iOS), mock/flag-OFF rails phrased as live (payments, SMS/email connect), 5s-poll phrased as "instant" — all applied by 19 fix agents, including a full terminal rewrite (it's a locked payment terminal: PIN shift → amount/pushed charge → tip → NFC tap; Android-only; no Swish/KDS/ordering claims). /services card copy + FeatureMock visuals corrected to match (bazar→consumer, terminal→payment, waste→scan-log, printing→labels, equipment→registry, KDS chips). Gates: tsc 0, integrity checks (icons/related/counts), full build exit 0, all corrected pages render on-subject.

    Added

    • Feature pages — a dedicated `/apps/[slug]` marketing page for every app & capability (2026-07-18). The Product mega-menu links + the /services "apps & consoles" grid all pointed at one grid page; now each app and capability has its own SEO/AEO/GEO-complete page. New engine (`lib/apps`): pure-data catalog (types + 24 feature files + registry, importable by SEO surfaces) feeding a server-rendered FeaturePageView — hero, plain-language "what is it" explainer, who-it's-for, 3-step how-it-works, feature bento, honest outcome stats, pricing hook, native <details> FAQ accordion, related-apps rail, ambient food-photo CTA — with per-app stylised device-UI mock illustrations (FeatureMock) and a dynamic branded OG image per page (opengraph-image.tsx). 24 pages across ops/grow/supply/discover (POS & Waiter, Kitchen Display, Terminal, Self-Serve Kiosk, Table Reservations, Staff & Shifts, Owner App, Online Ordering, Website & App Builder, Marketing & Loyalty, Social Media, Loyalty & Rewards, Gift Cards, Site Health Check, Analytics, Food Bazar, Inventory & Suppliers, Equipment Suppliers, Ingredients, Printing, Waste & Recycling, Menu TV, Atmosphere, Food Social Network) + a /apps hub. SEO/AEO/GEO: per-page title/description/keywords/canonical/OpenGraph/Twitter, SoftwareApplication + BreadcrumbList + FAQPage + speakable WebPage JSON-LD, all 25 URLs added to sitemap.xml, and an "Apps & features" section added to llms.txt. Honesty held: real plan prices (149/299/2999 + Pay-As-You-Go 0+7%), 0% commission on own sales, 7-day trial; no invented adoption/rating stats; allergen data flagged deterministic-never-AI; music licensing framed "handled where available". Rewired: the /services app + service cards gained "Learn more" links to the dedicated pages; the nav "What you can do" items repoint to /apps/* (applied on disk — the LandingNav.tsx change ships with the concurrent i18n session that owns that file). Content authored via a 23-agent workflow against a hand-written exemplar; gates: tsc 0 + full next build exit 0; all pages verified 200 + JSON-LD present + OG images render (image/png).
    • Phase 76 — Restora360 Academy, waves AC-4…AC-13 (2026-07-18): the LMS completed end-to-end. Building on AC-0…AC-2 (engine + learner rail + first surface). AC-4 calculators: 5 new pure functions on the ONE Launchpad engine (roiPayback, vatBreakdown, foodCostShare, packagingCost, wholesaleMargin) + AcademyCalculator — all 14 kinds in one touch-first component, learner's own numbers, no AI cost, planning-not-advice disclaimer, mounted on any lesson with a calculator ref. AC-5 verifiable certificate: renderCertificatePdf (pdf-lib A4-landscape, azure-on-navy, embedded QR → public verify, WinAnsi Nordic glyphs) + a global verify store (app_documents:'academy-certificates') + first-exam-pass issuance inside the learner row lock + public /learn/academy/verify/[serial] (authentic/tampered/revoked/not-found, LinkedIn/X/Facebook share, employer note) + the PDF stream route + dashboard certificate cards. AC-6 Nordic legal (CKG trust seam): CountryLegalBlock renders the learner's country cell LIVE from the Launchpad CKG through the publish gate (verified+cited steps, or an honest "in preparation" global-floor when unverified) + a country picker (SE/DK/NO/FI) + the authored Finland home-bakery seed (Vero / PRH-YTJ / Ruokavirasto / municipality — cited, needs-review, allow-listed hosts added) closing the Nordic gap; every legal fact stays gated behind human verification, never re-authored in course prose. AC-7 gamification: UTC-day-key streaks + 14 deterministic achievements (recompute-on-read, honest-empty) on the dashboard. AC-9 Course Builder CMS: super-admin /admin/dashboard/academy — list + validated JSON editor + publish/draft/archive/schedule/duplicate/delete, on the ONE validateCourse engine; a CMS save detaches the course from its code seed. AC-8 AI study tools (flag-OFF `academy_ai`): explain-simpler/deeper/summarize/checklist grounded ONLY in the authored lesson, metered to a designated system-tenant wallet through the ONE appendLedgerEntry path — nothing renders while dark. AC-11 lesson discussion (flag-OFF `academy_discussion`): per-lesson Q&A reusing the ONE community forum with a lessonRef scope. AC-12 SEO: catalog + per-course sitemap URLs + the FI guide cell. AC-13 flagship content: Modules 4–20 authored by a 17-agent workflow, machine-validated and integrated — the course is now 20 modules · 102 lessons · 20 quizzes · a 100-from-102 randomized, timed final exam (seedVersion 2); no fabricated stats, no jurisdiction legal claims in prose. Adversarial review (5 dimensions × verify): 16 confirmed defects, ALL FIXED — the final-exam per-question score oracle (fail now reveals pass/fail only; a submission must answer the whole drawn set; a shrunk/empty draw is voided, never auto-passed at 100%), the full exam pool leaking through course-detail (final exam excluded from the projection; delivered only as a server draw), attempt-cap eviction (durable per-quiz counter, not the evictable log), certificate HMAC now covers every displayed field (name/course/hours/score) + crypto 32^10 serials with a store uniqueness guard + lost-mirror self-heal from the durable enrolment copy, seed/legal-node resurrection after admin delete/reject (tombstones + a one-time marker + lifecycle-preserving upgrades), partial-verified legal cells now show an honest "more steps being verified" banner, per-learner AI rate-limit, and the discussion flag enforced on the API. Gates: full next build exit 0 · 74/74 academy unit tests (incl. new security regressions).
    • Phase 76 — Restora360 Academy, waves AC-0…AC-2 (2026-07-18): the enterprise LMS foundation + first surface, DEPLOYED + prod-QA'd. Plan: docs/Phase-76-Restora360-Academy.md. AC-0 engine (ui/src/lib/academy/): the Course→Module→Lesson content model (video/images/worksheets/embedded calculator+quiz/per-locale bodies/CKG nodeId binding for country-dynamic legal facts) + AcademyDB on app_documents:'academy' (never a MasterDB array) + docker/initdb/017_academy.sql (flag-gated academy_courses/academy_enrollments/academy_certificates mirrors incl. the global verify-by-serial index); a 9-question-type quiz engine (mcq/multi/trueFalse/fillBlank/ordering/dragDrop/image/scenario/calc) with deterministic server-side grading, a leak-proof field-by-field publicQuiz projection, timed/randomized exam draw+shuffle and unlimited-retry policy; a signed verifiable-certificate core reusing the loyalty HMAC signer (ACADEMY_CERT_SECRET). Consolidates the workforce-training + designer-academy quiz/cert primitives into the ONE lib/academy/* engine. AC-1 learner rail: the learner IS the platform's consumer identity (getShopCustomer — platform bridge means dashboard credentials work), a per-learner doc (academy-learner:<id>, per-learner row lock) holding enrolments/progress/quiz attempts/bookmarks/notes with a server-drawn, time-limited final-exam lifecycle (expired draws void without consuming attempts; first pass wins; best-score-stands; XP derived, never stored), and 8 rate-limited API routes with grading INSIDE the row lock (exam = no-oracle, lesson/module = instant practice feedback). AC-2 surface: /learn/academy/courses catalog + course detail (3-state CTA, free-preview badges, Course JSON-LD) + the lesson player (sticky nav, honest video/worksheet placeholders, notes/bookmarks, the 9-type QuizRunner — touch-first, no drag lib) + the /learn/academy/join wall (ONE consumer account, source:'academy' email branding) + the student dashboard (XP/progress/resume/bookmarks); guests browse everything, lesson content beyond previews requires the free account — enforced SERVER-side. Flagship course seed slice: "From Home Cookie Baker to Profitable Scandinavian Business" Modules 1–3 fully authored (15 lessons + 3 module quizzes, 6 question types; no fabricated stats, no jurisdiction legal claims — those bind to the verified CKG in AC-6). Gates: full builds exit 0 · 47/47 academy tests · 11-step live prod QA (guest gate 401, zero answer-key/locked-content leaks at both access levels, register→enroll→lesson→quiz 100%→notes→bookmark→dashboard, XP exactly 10+25). Left: AC-3+ (calculators in-lesson, certificate PDF/verify, SE/DK/NO/FI legal via CKG + Finland seed, gamification depth, AI flag-OFF, Course Builder CMS, discussion, full 20-module content).
    • Phase 75.3 — Owner feedback round 3 (2026-07-18): crisp nav, no public Ask-AI, /try finally azure. (1) The floating nav pill and its mega-menu panels are now near-opaque (0.94/0.98 pill, 0.99 panel) so they stay crisp over the dark photo hero. (2) The "Ask Restora AI" bubble is OFF the public website — the owner had removed it before and a session brought it back; PublicAskAi is unmounted from the root layout with a loud guard comment, and a standing memory records the rule (portals keep their own AskAiWidget mounts). (3) The /try funnel's pre-rebrand ORANGE finally died: WowUnifiedImport carried ~40 hardcoded warm hexes (#e67e22 orange CTA/spinners/checkbox, #dcc1b1 tan borders, #564337 brown text) — all converted to the locked azure ramp (#1570ef / #b3d7ff / #475569 / #0f172a), same for the Launchpad client's five orange accents and the orange eyebrows on /launchpad, /recipe-card and /start guides. (WowPremiumImport still carries warm hexes but is dead code — no page renders it.) Verified: /try screenshot fully azure, homepage FAB count 0, tsc 0, full build, deployed.
    • Phase 75.2 — Landing round 2 (owner feedback, 2026-07-18): slider retired for real sections + photo hero. Owner: "remove this slider and break it down into sections — each function is big, complex and must-have" + "instead of dark background, a high-quality restaurant image with cash counter, digital signage and PoS under dark transparency". Hero: a real restaurant photo (open café space with wooden counter, POS on the counter, hanging menu-board signage — public/landing/hero-restaurant.png, Unsplash license, next/image priority) under a left-weighted navy transparency gradient, so the page opens inside an actual restaurant while the text column stays AA-readable. The auto-playing 11-chapter RestoraExperience slider is off the homepage, replaced by FeatureDeepDives — three full alternating sections, each a former "must-have" chapter with its own room and a hand-built responsive mock (no autoplay, no timers): "Every order. One screen." (channel chips → one live order inbox), "The kitchen sees it instantly." (dark KDS board with New/Preparing/Ready columns, progress bars, and the guest-phone status card — the chapter the owner highlighted), "Stock that reorders itself." (low-stock alert → suggested reorder via Food Bazar → Ta Emot Varor scan row). Each links to its /services anchor. Bento reworked to "and everything else" so it no longer overlaps the deep dives: featured website-builder card now uses the restaurant photo, plus reservations, Menu TV (free), payments & gift cards, staff & scheduling, and the navy marketing card. Gates: tsc 0, linkcheck 199/199, full build, desktop+mobile screenshots verified.
    • Phase 75.1 — Landing page visual redesign + nav icons (owner feedback round 1, 2026-07-18). Owner: "expecting redesign, not just layout exchange" + "liked the navigation — add icons and one-liner descriptions; mobile menu without descriptions" + free rein to add/remove sections. Hero rebuilt as a full-bleed navy stage (azure radial glows + faint dot grid pulled up behind the sticky glass nav): white serif headline with an azure gradient italic accent, glass eyebrow chip, azure-glow primary CTA + translucent ghost, the deterministic intent search as the focal input, and the interactive order-demo as a bright card floating on the dark with a deep shadow; the honest trust chips (0% commission · 7-day no-card · live in an afternoon · GDPR · /status) became the hero's bridge row into the light page (standalone TrustStrip band retired). Feature grid → bento (Apple-style modular): a 2×2 featured "Online ordering — 0% commission" card with a live-looking ticket mock, a navy "Marketing & loyalty" accent card with capability chips, a photo-topped website-builder card, and three standard cards — every card still a real link into /services anchors. How-it-works → timeline: three cards on a dashed connecting line with big italic serif numerals in a sunken band + a "Start step one now" CTA. Final CTA → navy mirror of the hero (glows + dot grid + gradient accent) so the page closes the way it opens. ForAgencies removed from the homepage (owner-focused funnel; the agency pitch stays in nav/footer//partners). Navigation: every mega-menu entry now carries a lucide icon in a tinted tile that fills azure on hover + a one-liner description (Service-providers and Company groups got descriptions written); the mobile accordion renders icon + label only (lean 44px rows); fixed a hover-then-click interaction where clicking an already-hover-opened trigger closed the menu (click is now open-only; Escape/outside/mouse-leave close). Verified: tsc 0, zero page errors, desktop + mobile + open-menu screenshots, full build + linkcheck gate before deploy.
    • Phase 75 — Website Conversion Redesign, batches B1–B5 (2026-07-18). The public marketing site rebuilt for conversion — premium warm-Scandinavian on the LOCKED azure-on-navy brand, every claim honest, grounded in an 8-agent audit + five committed reports (docs/architecture/website-conversion-redesign/). B1 homepage/nav/footer: hero-first section order (the animated Experience player no longer owns the first fold — mobile finally sees a headline + CTA; fixes the h3→h1 outline break), an honest TrustStrip (product truths only — 0% commission on own orders, 7-day no-card trial, Swedish AB, GDPR, live /status), a 5-item nav (Product/Partners/Resources mega menus with real aria-expanded/Escape/outside-click + Pricing + Academy), a real-links footer (13 dead # links gone; /status /community /learn/academy /launchpad wired; fake social icons + cosmetic region cycler removed; newsletter input labeled), a deterministic IntentSearch combobox in the hero (~35-entry keyword→route map, zero AI cost), a SavingsCalculator (your sales × commission % vs the flat plans — honest math, visitor-adjustable), mid-page CTA paths (feature cards now link into /services anchors, Bazar band → /bazar, apps band → /services, inline CTA band), FAQ grown to 6 items incl. pricing, "Cancel anytime" wording replaced with the honest "trial ends quietly" everywhere (hero chips, FinalCta, Experience chapter 11), Experience player a11y (aria-hidden auto-mutating stage, keyboard prev/pause/next on mobile stories), skip-to-content link, landing-theme structural tokens (spacing/radius/duration/focus-ring/hairline from the reference DS — azure values). B2 pricing + de-fabrication + trust: a real /pricing page (live plans registry via getOfferablePlans — admin overrides flow through; billed-yearly totals on every monthly figure; add-on rules incl. the 299 kr kiosk exception + free Menu TV; a what's-included matrix GENERATED from the entitlement registry; pricing FAQ + FAQPage/Product+Offer JSON-LD; /plan 301→/pricing, /products 301→/services); every fabricated stat/testimonial removed from /partners /agents /designers /suppliers (invented "1240+ restaurants/£1.8M paid/99.9% SLA/4.8★" counters + 9 fictional named testimonials + THREE fabricated schema.org aggregateRating blocks → honest program-mechanics stats verified in the commission/payout engines, £→kr, 14→7-day trial, designer membership honestly 500 kr/yr, "PCI-compliant" → "payments processed by Stripe"); /about founder story (3 chapters + Swedish-company facts + support-promise cards with the real email); /contact shows support@restora360.com + a topic selector incl. Book a demo (the missing sales path) + the previously-unthrottled contact API now rate-limited like newsletter. B3 Restaurant Academy: the EXISTING /learn/academy engine extended (never parallel) — a new Founder Roadmap track ("Idea → Open → Profitable → Multi-location") whose 9 beginner/intermediate/advanced lessons carry real teaching bodies adapted from the Launchpad's advice-clean foundational lessons, dedicated lesson pages at /learn/academy/[trackId]/[lessonId] (ISR, LearningResource JSON-LD, prev/next, mark-complete), server-first progress (new GET on the progress route; localStorage stays the anonymous fallback; AcademyClient merges both), journey ladder + free positioning on the landing, and the pre-azure #a8531a orange hover leftovers fixed across academy/webshop/growth/community. B4 one login, many workspaces: /welcome/workspaces — a calm chooser composing the SHIPPED re-mint endpoints (/api/restaurants/switch + POST /api/auth/portals; cookie stays single-scope, zero guard changes); the portals GET now returns a workspaces enumeration (ALL active owned restaurants — a multi-restaurant owner finally chooses instead of silently landing on the first — plus partner hats); login sends multi-workspace users to the selector (single-workspace keeps the direct redirect; ?next= and 2FA untouched), fixes its dead "Forgot password?" # link (→ /contact until a reset flow exists), points signup at /onboarding, and "Switch workspace" entries mounted in the tenant sidebar + agent + designer shells. B5 SEO/perf/gate: metadataBase + a generated root opengraph-image (social shares had NO image anywhere) + twitter card; canonicals on / /about /contact /pricing /services; /services finally has metadata (new layout.tsx — the main-nav "Product" target was invisible to search) + 7 jobs-to-be-done anchor ids; sitemap += /pricing /services /about /contact, −/register (robots contradiction); homepage FAQPage JSON-LD + Organization contactPoint; unused Geist(sans) font load dropped; AskAiWidget + GhostTextAssistant (1100+ lines) now lazy-loaded out of every page's first-load JS; scripts/marketing-link-check.mjs (npm run linkcheck:marketing) validates every internal marketing href against the real route table + redirects + hash targets — 194 links green. Also fixed: a broken committed test fixture (commission-engine.test.ts missing createdBy). Deferred honestly (tracked in TODO): graded Academy quizzes/certificates ride the existing derived-tier system for now; add-role widening beyond supplier; per-tenant staff memberships; careers/press/blog; real customer testimonials (slots ready, never faked); sv translations (structure ready).
    • Phase 74 — Designer Growth Platform, wave DG-10: growth analytics + dark mode + 21-language portal + mobile (2026-07-18) — PROGRAM COMPLETE (11 of 11 waves). Growth analytics (designer_events, the audit-logs stream posture): a new append-only Phase-D table (docker/initdb/016_designer_events.sql, flag DESIGNER_EVENTS_TABLE, assertReady boot guard) records profile views, case-study views, contact enquiries and share clicks — written fire-and-forget (analytics NEVER blocks a page, never touches the hot designers doc; flag off = honestly dropped, never buffered). Capture: an anonymous rate-limited public beacon (/api/public/designers/[slug]/events — sendBeacon target, 204 even for missing designers so it's no existence oracle, case-study events validated against the author's own PUBLISHED projects, no PII — coarse country header + bounded ref only) mounted on the public profile + case-study pages; the contact route records the enquiry server-side once its guards pass; the Share kit counts share clicks per network. Read: a pre-aggregated GROUP BY (never row scans) feeds a "Your reach — last 30 days" dashboard card (4 KPIs + an SVG daily-views trend) that renders ONLY when the rail is on — no fabricated zeros, ever. Dark mode: the designer portal now mounts the ONE TenantThemeApplier (same navy tokens + toggle as the owner dashboard; no-FOUC script extended to /designer without matching public /designers/*; dashboard-shell scope class) and the dark-vocabulary test grew TWO NEW ROOTS over src/app/designer + src/components/designer (0 pale-hex findings). 21 languages: a new designer-i18n chrome dictionary on the Phase 55 rail — English schema + 20 agent-generated languages (validated: full key coverage, intact {placeholders}, no entity contamination), per-namespace English fallback so a missing key can never render undefined; language persisted on the SAME UserRecord.language field + PATCH /api/auth/me endpoint the owner dashboard uses (no parallel persistence); a picker in the portal topbar; sidebar/topbar/dashboard fully wired (inner pages migrate onto the namespace incrementally — tracked). Mobile: the fixed 240px sidebar became responsive — desktop aside from md up, hamburger-opened slide-over drawer with backdrop below (the portal now works at 320px instead of crushing content). Gates: clean-worktree full build exit 0, full suite 0 fail, 2-lens adversarial review (10 raw → 10 confirmed → 6 distinct defects, ALL fixed: pg DATE→local-midnight-Date parsing shifted every trend-day key one day back on the UTC+ box (day now selected ::text, series anchored on the DB's newest key); the "30-day" SQL window was 31 days inclusive; the beacon lacked the house global rate ceiling (forged-IP-header rotation could inflate counts unbounded — now 20k/h backstop like the analytics tier) and stored a client-suppliable country header unclamped (now strict 2-letter-or-nothing); the language picker was hidden below sm — unreachable on the very 320px shell this wave ships (now always visible, compact); the mobile drawer gained body scroll-lock + Escape-close + dialog semantics, and RTL languages (Arabic) now flip the whole shell via the registry rtl flag).
    • Phase 74 — Designer Growth Platform, wave DG-9: designer community + agency teams + Designer Academy (2026-07-18, ships FLAG-OFF). Two new fail-safe-OFF master flags. Community (`designer_community`): designers follow each other, like/save published case studies, and get a feed — FSN domain shapes keyed on WebDesigner.id (no parallel account namespace), with block/report from day one: blocks are mutual-invisibility (drop follows both ways, silently refuse new edges — never revealed by any response), reports are idempotent per open (reporter, target), self-report-refused, rate-limited (10/hr + global flag-bomb ceiling) AND capped in storage, feeding a super-admin moderation queue (enriched target/reporter previews; resolve/dismiss durable, no double-moderation; a project takedown unpublishes the case study; deliberately NOT flag-gated so moderation outlives the flag; account actions ride the EXISTING admin designer suspension). Storage per the enterprise rule: social edges live in their OWN designer-community doc (the hot designers doc is untouched) with a new Phase-D Postgres mirror — docker/initdb/015_designer_community.sql (designerfollows / designerengagements / designerblocks / designerreports), flag-gated DESIGNER_COMMUNITY_TABLE reconcile inside the write txn, db:backfill:designer-community, and an assertReady boot guard. Agency teams (`designer_teams`): the shipped Organization+groupRole model mirrored designer-side — DesignerAgency + WebDesigner.agencyId/agencyRole (owner/admin/member), founding, consent-based invites (by public directory slug; the invitee accepts/declines — nobody is added to a team without agreeing), role changes with a single-owner invariant (transfer demotes the old owner; the owner can't leave without transferring), self-leave, member cap. HARD BOUNDARY: a team never pools money — commissions/payouts/invoices/wallets stay strictly per-designer; the roster shows read-only substance counts only. Designer Academy (rides designer_community): a deterministic learning track ("Restora 360 Certified Designer" — 6 lessons of real platform content: 10%/5% earning model, delegated access, portfolio conversion, share engine, pitch engine, professional standards) with a server-graded quiz (correct answers never leave the server; varied answer positions; lessons gate the quiz; passing certifies durably — a failed retake can't un-certify) feeding a new academy achievements group that shows on the public profile badges. Surfaces: three portal pages (Community feed+discover+report/block dialogs, Agency Team roster+invites, Academy lessons+quiz) under a new "Grow" sidebar section — every page self-gates to a clean "coming soon" while dark. 15 new unit tests (block semantics both ways, report lifecycle incl. cap + durable moderation, RBAC boundaries incl. ownership transfer + invite consent, quiz gating/durability/answer-leak). Gates: clean-worktree full build exit 0, full suite 0 fail, 3-lens adversarial review (12 raw → 11 confirmed → 5 distinct defects, ALL fixed + regression-tested: the reigning owner could self-demote and orphan the agency ownerless forever (now only the atomic transfer path steps an owner down); a failed invite-accept (member cap) silently destroyed the consent artifact (now consumed only on success); the team roster leaked privacy-hidden availability to teammates (isFieldVisible now gates it — privacy gates EVERY surface, teammates included); self-reports of own projects were accepted and draft projects were reportable (an unpublished-id oracle — now published-only + own-work refused); and the follow response shape revealed a block (now byte-identical to success, FSN ghost semantics) + block/unblock left the denormalized follower counters stale).
    • Menu blocks 2.0 — LIVE dish cards + the dish manager (MT-SIG-2, owner ask 2026-07-18). The flexibility pass on menu blocks, everywhere the canvas engine runs (Menu TV Screen Designer, Experience Studio, kiosk hosts): (1) `display:'card'` — a menu block can now render its dishes as big LIVE cards (photo medallion + name + price + story) that EQUALLY FILL the block: choose 1 dish and it spans the whole block, 2 split it in half, up to 6 in one stripe row, 7+ wrap into an even grid — the professional-signage mode (Color Stripes/product walls) with zero hardcoding; names/prices/photos/86 stay bound to the live menu, so a menu edit re-skins every published design instantly (wire-safe conditional hash → one poll). Per-dish itemStyles.bg paints each card's stripe colour. (2) The dish manager replaces the pick-a-dish dropdown in the inspector: one list where the CHECKBOX picks which dishes the block shows (itemIds; all-ticked = follow the menu so new dishes keep appearing), DRAG reorders (itemOrder, feed-honoured on every surface), and CLICK opens per-dish styling (colours/sizes/weight + the stripe colour in card mode) — plus the existing "Separate dishes". (3) Templates went live: Color Stripes now materializes as ONE live card block pre-bound to the tenant's first four dishes (stripe colours pre-filled per dish); Premium Smoke + Salad Bar menu areas became live card grids; the create route and the in-designer materializer both seed ctx.items (first dishes, menu order). Sanitize/resolve/hash follow the MT-SIG wire-safety idiom (display/bg absent unless used; natives fall back to list rows — same content, XS-9 divergence rule). Gates: tsc clean (our files), studio-templates-uat.mjs ALL PASS incl. the new "LIVE stripes, no $00.00 placeholders" assertion, menu-tv-designer-uat.mjs ALL PASS (dish manager: tick/untick, style box, card toggle, Separate), screenshots verified.
    • Phase 74 — Designer Growth Platform, wave DG-8: designer AI wallet + writing suite (2026-07-18, ships FLAG-OFF). The owner-locked commercial model lands: an active designer membership includes a 50 SEK/month AI allowance that resets monthly, plus persistent purchasable top-ups — held in a new per-designer aiWallet (DesignerAiWallet + öre-integer ledger, capped entries). Money core (lib/designer/ai-wallet.ts, pure + unit-tested): designerWalletBalance is the ONE place the math lives (allowance requires an ACTIVE membership — lapsed = 0; balance can never go negative), chargeDesignerAi draws allowance-first-then-top-up and is idempotent on ref (a retried settle can't double-bill), topupDesignerAi idempotent likewise; month rollover resets the allowance and preserves the top-up. Writer endpoint /api/designer/[id]/ai/write behind the new fail-safe-OFF `designer_ai` master flag (404s while off — GET and POST both): generates ONE draft (bio / headline / case-study summary) through the RIC narration cutover (Anthropic legacy transport, claude-sonnet-4-6) at a flat 1 SEK per draft shown before generating; honesty-fenced system prompt (use ONLY the designer's real profile facts — professions/specialties/location/experience + an optional hint; never invented stats/awards/clients); own-id-gated; 402 pre-check before any model spend, 503 without a provider key, failed/empty generation charges NOTHING. Charge-on-delivery under the doc lock — and the adversarial review's confirmed finding is fixed: the unlocked pre-check meant two concurrent generates at an exactly-sufficient balance could both reach settlement, with the loser's draft handed out FREE — now no delivery without a settled charge (the in-lock charge is the real gate; a refused settle returns 402, not the text). Drafts are never auto-saved: the new self-gating AiAssistButton (renders NOTHING on 404, so the editor is unchanged while dark) previews the draft with Use / Regenerate / Dismiss on the profile editor's headline + bio fields, with live "X SEK left" wallet status. Deferred to go-live/DG-9 (tracked): the top-up purchase route (core is ready; needs the payment rail), the portfolio-manager summary button, and social auto-post via the social vault. 5 unit tests (allowance/membership gating, idempotent charge, never-negative refusal, rollover + top-up persistence, idempotent top-up). Gates: clean-worktree full build exit 0, full suite 0 fail, 2-lens adversarial review (wallet-money + gating/scope; 1 confirmed finding — the free-draft race — fixed + re-gated).
    • Phase 74 — Designer Growth Platform, wave DG-7: designer template marketplace (2026-07-18, ships FLAG-OFF). Designers can package the restaurant sites they build and sell them as templates, keeping 70% of every sale (platform 30% — owner-locked). Behind the fail-safe-OFF designer_marketplace master flag: every marketplace route 404s and existing platform themes are byte-unchanged until it's flipped (go-live also needs a buyer payment rail). Money core (lib/designer/template-sales.ts, pure + unit-tested): a sale records the author's 70% as a CommissionLedgerEntry (new sourceType: 'template') in the designer ledger — flowing through the existing Stripe Connect payout with zero new rail — idempotent per (template, buyer) so a retried purchase can never double-credit, and withheld (full audit) when the author's account/membership isn't active. Submission → moderation → sale: a designer packages a restaurant they actively MANAGE (not a referred link) via the existing tenant→package exporter into a namespaced slug (can't collide with or shadow a platform theme), stored pending; a super-admin approves/rejects; approved+priced templates list to buyers; the owner (owner-only — a delegated designer session can't spend the restaurant's money) installs one, which credits the author. Critical isolation: designer submissions are excluded from the normal owner theme gallery (listCatalog) — they're never a free installable theme, only reachable priced+moderated+flag-gated through the marketplace helpers; a self-deal (a designer buying their own template into a tenant they manage) is blocked. Surfaces: a designer-portal Marketplace page (submit form + submissions + earnings; clean "coming soon" while the flag is off — no dead-end) + sidebar entry; three flag-gated APIs (designer templates, admin moderation, tenant marketplace). Admin-moderation and buyer-catalog UIs are deferred to go-live prep (the APIs are complete; nothing is reachable while dark). 8 unit tests (70/30 split, idempotency, withheld gating, earnings netting, catalog-leak prevention, moderation gating). Gates: clean-worktree full build exit 0, full suite 0 fail, 2-lens adversarial review (money-integrity + catalog-leak — both agents probed the install/register/resolve leak vectors + double-pay/flag/self-deal paths, 0 confirmed findings).
    • Phase 74 — Designer Growth Platform, wave DG-4: ambassador referral program (2026-07-18). Every designer becomes a Restora 360 ambassador: a personal referral code (WD-XXXXXX, minted on demand under the doc lock, unique, unambiguous alphabet) rides the same ref_code cookie rail as sales-agent codes (one attribution engine, first-touch-wins across partner types — validateReferralCode became a discriminated union branched by the reserved WD- prefix). A restaurant that signs itself up through the link gets a `kind:'referred'` DesignerRestaurant link with hard semantics enforced everywhere: earns the flat 5% referral rate (owner-locked sales-agent parity; per-designer CommissionRules never boost it — those price management work), but NEVER grants delegated access (excluded in requireTenantAccess, requireDesignerTenantAccess, and the access route's guard + in-lock re-check), never counts as "built" (restaurants list + restaurantCount recompute exclude it — leaderboards/achievements stay honest), and never anchors a verified review (a referral is attribution, not service). When a tenant carries both a managed and a referred link, the managed relationship wins deterministically in the commission engine. Register attribution is best-effort with a designer-side self-referral block and audit trail; the sales-agent branch is byte-preserved, and the SA-only forensic event store is never written for designer codes. Share & Grow gained a "Refer a restaurant — earn 5% for life" section: referral URL + QR + network intents + honest stats (referred signups, netted referral earnings) from a new own-id-gated referral API. 3 new unit tests (flat 5% ignoring rate rules, managed-beats-referred selection, referred-can't-anchor-reviews). Review: the subagent review pool was unavailable (session limit), so the adversarial checklist was executed inline over every designerRestaurants consumer repo-wide — it caught 9 additional consumer gaps, all fixed: the tenant-dashboard SHELL gate (defense-in-depth access exclusion), the maintenance cron's two restaurantCount recomputes (would have re-inflated counts with referred links), the designer-dashboard KPIs, the profile page's verified-review eligibility set, the admin designer-list count, the Growth-BI partner-leaderboard input ("sites live" ranks building work), and both link-resolutions in the owner review route. The build typecheck proved the validation-union rename complete repo-wide (it caught the one consumer grep missed). Gates: clean-worktree full build exit 0, 6954 tests 0 fail.
    • Phase 74 — Designer Growth Platform, wave DG-6: discovery filters (2026-07-18). The public "Find a Web Designer" directory grew a full marketplace filter set: profession (the curated DG-0 taxonomy, with its own facet), availability (Available now / Limited), work mode (remote/hybrid/on-site), max hourly rate (budget search), and has case studies — alongside the existing country/specialty/service/featured/search filters, in an expanded More-filters row (all breakpoints). Privacy carries through discovery: the availability and rate filters only ever match designers whose privacy toggles expose those fields, and the cards show availability/hourly-rate only when visible — a hidden field can neither be matched nor inferred. Honesty fix: the directory's status column used to render a blanket green "Available" pill for every designer regardless of reality — it now shows the designer's actual exposed availability, or an honest "—". Cards gained headline, profession chips, published-case-study counts, and "from X SEK/h" pricing; the search haystack now includes headline + professions. City landing pages (Citation-Hub rail) stay deferred until real designer density exists — tracked in TODO.
    • Phase 74 — Designer Growth Platform, wave DG-5: recognition — deterministic achievements + public leaderboards (2026-07-18). Achievements (lib/designer/achievements.ts): a pure, recomputed-on-read derivation from real numbers — restaurant milestones (1/10/50/100/500/1000 from the denormalized count), portfolio milestones (first case study, Portfolio Master at 6), reputation (Customer Favorite at 4.5+/10 reviews, Trusted by Clients at 3 verified reviews), trust (Fast Responder ≤4h, Verified Professional), earnings milestones (first commission, 10k/100k SEK from EARNED ledger statuses only) — never an LLM, never a stored counter, honest-empty for new designers. Surfaced as an Achievements card on the designer dashboard (earned chips + top-3 next-up progress bars) via an own-id-gated API, and as badge chips on the public profile — with the earnings group EXCLUDED from public display (income-tier privacy). Leaderboards (lib/designer/leaderboard.ts + public /designers/leaderboard): three boards — most restaurants built, highest rated (minimum 3 reviews before a rating ranks), best portfolios (published case studies) — over active+listed+verified designers only, with a country pill filter, honest-empty states, stable tie-breaks, top-10 caps, medal styling, profile links, and a become-a-partner CTA; linked from the public nav and the sitemap. No earnings data on any public surface. 6 unit tests (milestone progress, earned-status filtering, tie-breaks, min-review gate, published-only counts, country filter). Gates: clean-worktree full build exit 0, full test suite 0 fail, 2-lens adversarial review.
    • Experience Studio — menu-board templates as starting points (MT-SIG × Studio, owner ask 2026-07-18). The PowerPoint model the owner described: open the Studio, pick "Menu TV screen" / "Digital signage", and start from one of the 6 signature designs — the template lands on the canvas as EDITABLE elements (themed background + a live menu block pre-bound to the tenant's real sections + title/hero/callout pieces), then everything is freely movable/reshapeable while staying on-theme. New lib/studio/menu-templates.ts (STUDIO_MENU_TEMPLATES): the 6 signature compositions re-authored in the starter-template idiom with a new menu() element helper; palettes come from THE Menu-TV registry (getTemplateMeta(...).art) so a Studio template and its TV twin can never drift; deterministic, size-adaptive, zero AI cost. Three attach points, all additive: (1) the project wizard's "Starting point" step gains a "Menu board designs · live prices from your menu" group for TV/signage/kiosk/customer-display types; (2) the create route resolves both registries and seeds a menu template with the tenant's category ids (menu order) so the board arrives PRE-FILLED server-side; (3) the Studio designer toolbar gains a Templates button + gallery — materializeTemplate replaces the current page in ONE undoable commit (confirm-first when the page has content) with the live board already in component state. Per-dish parity closed: the Studio's CanvasInspector now receives boardCategories + onSeparate (it didn't before), so "Style one dish" and "Separate dishes" work identically in the Studio designer as in the Menu-TV Screen Designer (separateEl mirrored onto the Studio page model, MAX_CANVAS_ELEMENTS-capped). StudioStarterTemplate.build gains an optional ctx (categoryIds) — the 10 classic starters ignore it, signature untouched. Gates: project tsc clean (remaining errors are the concurrent session's designer/marketplace WIP), studio unit tests 10/10, scripts/studio-templates-uat.mjs ALL PASS (real login → create signage project → materialize all 6 → live Luigi prices resolve on the stage → cleanup), screenshots of every materialized template.
    • Phase 74 — Designer Growth Platform, wave DG-3: verified owner→designer reviews (2026-07-18). Restaurant owners can now leave verified reviews of the designer who manages them — the trust core of the marketplace. Verification anchor (supplier-reviews precedent): an ACTIVE designerRestaurants link AND a commercial relationship — commissionEligible link OR an actually-PAID subscription. The adversarial review proved the bare-link anchor was farmable end-to-end at zero cost (designer creates a free demo → claims it with a puppet owner account using the demo password the designer set themselves → the link stays active → unlimited verified 5★ self-reviews, with the demo slot resetting each time) — the paid-relationship requirement dead-ends the chain while a real claimed-demo client qualifies the moment they pay. One review per (tenant, designer), resubmits update in place, auto-published (authenticated + relationship-bound); admin unpublish is now DURABLE (second review finding: the author's resubmit could silently re-publish a moderated review — a new adminUnpublished flag survives resubmits; content updates save but stay hidden, and the form tells the owner honestly). Self-review blocked at both layers: the page gate and the API's owner-only check reject web-designer delegated sessions (a designer can never verified-review themselves through any session shape). Surfaces: the public profile swaps in a VerifiedReviewForm for the signed-in owner of a managed restaurant (overall stars + communication/design/delivery/support category stars + would-hire-again + prefilled from the existing review); review cards render category chips + a "Would hire again" badge; reviewer identity comes from live master records, never request input; the denormalized rating recompute is now ONE shared function (recalcDesignerRating) used by both the owner path and the admin moderation route. 8 unit tests incl. the full farming-chain and durable-moderation reproductions. Gates: clean-worktree full build exit 0, 6940 tests 0 fail, 3-lens adversarial review (3 confirmed findings — the farming chain + durable-moderation ×2 — all fixed + re-gated).
    • Phase 74 — Designer Growth Platform, wave DG-2: Share Engine (2026-07-18). Every share now markets the designer AND Restora 360. Branded OG cards: two next/og convention routes (profile + case study) on the proven wow posture — slug-regex guard before any DB read, rate-limited, degrade-to-generic-card, unfurl cache headers; azure-navy brand gradient with "Created with Restora 360 · restora360.com" on every card; the case-study card honors the portfolio privacy toggle. Deterministic length clamps + font step-down (an adversarial verifier RENDERED the zod-max case and proved Yoga collapsed the layout into overlapping text — fixed), latin-only strings (a ★ glyph would have triggered a runtime Google-Fonts fetch inside the crawler-facing route — replaced with "Rated X/5"). metadataBase fix (major): the self-hosted box absolutized convention og:images against http://localhost:3001 (Next's fallback without metadataBase) — a new requestMetadataBase() helper (env override → forwarded headers, mirroring publicOrigin) now feeds both public designer pages, making every unfurl image fetchable. Share kit (/designer/share, sidebar "Share & Grow"): profile URL + QR + copy + 7 network share intents (LinkedIn/Facebook/X/Pinterest/Threads/WhatsApp/Reddit) with per-network UTM tagging, per-published-case-study share rows, unlisted-profile warning; every share affordance renders only with a real target URL (fail-closed on profile-load errors). Print collateral: GET /api/designer/[id]/share/pdf?kind=portfolio|card — a pdf-lib portfolio book (branded cover + profile QR + one page per published case study with embedded cover image, badges, testimonial, live/public URLs) and a print-ready business card (navy/azure, QR to the profile); WinAnsi-sanitized text with a truncation-correct wrapper (review fixes: exact-fit text was getting a spurious "..."; overlong single words hard-slice instead of overprinting the QR), images embed only from the designer's own /uploads namespace through the traversal-guarded seam, renders from the PUBLIC projections only, refuses (409) while the profile is unlisted and prints a cover-only book while the portfolio toggle is off — printed QR/links can never 404. Gates: clean-worktree full build exit 0, 6935 tests 0 fail, 3-lens adversarial review (14 raw → 12 confirmed → 7 distinct defects, ALL fixed + re-gated).
    • Phase 74 — Designer Growth Platform, wave DG-1: Portfolio 2.0 + money-engine lock conversion (2026-07-18). Portfolio case studies: new DesignerPortfolioProject entity (cap 24/designer, gallery ≤8, bounded fields) with a full manager at /designer/portfolio (create/edit/publish/unpublish/delete, gallery uploads through the hardened media route, before/after pair, Built-with-Restora360 / Experience-Studio / Restora-AI badges, client testimonial, live/video links) — a new "Portfolio" sidebar entry, and one-click "Save to portfolio" on the Before & After pitch page (persists the pitch's before/after images as a draft case study; pitch results were previously ephemeral). Public: a case-study page at /designers/[slug]/work/[projectSlug] (published-only, active+listed designers only, rendered exclusively from the new publicPortfolioProject whitelist projection; CreativeWork JSON-LD, canonical, OG image; before/after figures, gallery, paragraphed body, testimonial block, and a designer + Restora 360 CTA — every case study markets both), a Case studies grid on the designer profile (respects the portfolio privacy toggle; legacy flat portfolio links remain as "More work"), and sitemap entries with Google-image extensions for every published case study. CRUD API under /api/designer/[designerId]/portfolio (requireWebDesigner + own-id, slug minted under the doc lock, slug stable across edits so public URLs never break, 409 at the cap). Money engines on the lock (deferred from DG-0): commission-engine.ts and payout-engine.ts rebuilt around PURE exported cores (applyCommissionRecord / applyCommissionReversal / applyBatchCreate / applyBatchSettle) wrapped in mutateDesignersDB — closing two real races: concurrent same-invoice webhooks could double-record a commission (idempotency now checked under the lock), and concurrent payout runs could double-pay the same pending entries (batch-create + entry flip now atomic). The lock is never held across the Stripe transfer. 17 new money-path unit tests (idempotency, withheld gating, per-designer rate rules, clawback semantics, batch atomicity, fraud-hold + override, settle success/failure) + 5 portfolio tests. Gates: clean-worktree full production build exit 0, full test suite green, multi-lens adversarial review.
    • Phase 74 — Designer Growth Platform, wave DG-0: foundation + rich profiles (owner-approved program, 2026-07-18). The shipped Web Designer module starts its growth into a worldwide creative-partner platform (LinkedIn/Behance-bar; waves DG-0…DG-10 planned; commercial decisions locked: template rev-share 70/30, designer AI wallet + monthly allowance, referred-not-managed = 5%). Concurrency foundation: new mutateDesignersDB (Postgres row lock via mutateDoc + in-process mutex on JSON, in-place default-fill honoring the mutate-in-place contract) and ~16 designer routes converted off the racy get→save pattern — profile PATCH, public reviews, support ×2, invoices ×2 (the WD-YYYY-NNNN mint now lives INSIDE the lock so concurrent creates can't dup a number), delegated access, membership (cancel/DEV-activate), Stripe Connect (account-create moved before the lock; an existing stored id always wins the race — never orphan-overwritten), connect-webhook designer branch, admin reviews/escalations/edit, register (slug-uniqueness inside the lock), restaurants link-push. Response shapes/status codes byte-preserved; the commission/payout engines (cron-serial, multi-doc) convert in DG-1 with money-path tests. Adversarial-review fix: the restaurants onboarding closure no longer re-checks account/demo-limit after the tenant is already provisioned (that path would have orphaned a fully-created tenant + burned its subdomain) — it now only takes the lock for the atomic link-push + count recompute, with the policy enforced by the pre-provisioning guards exactly as before. Media hardening: the designer upload route now decodes→re-encodes through sharp (EXIF/GPS stripped, orientation baked, ≤2400px, JPEG q85, pixel-bomb guard, 64px floor, spoofed-MIME killed) and is rate-limited; public designer review + contact endpoints gained rate limits. Rich profiles (all additive): curated 25-profession taxonomy (lib/designer/professions.ts, chip picker, max 8), headline, cover image, hourly/project/min rates + currency, availability, work mode, timezone, working hours, years of experience, education/certifications/awards, socialLinks widened from 4 to 21 platforms (same field), 12 freelancer-platform links (Upwork/Fiverr/Toptal/…), and per-field privacy toggles enforced ONLY in the new whitelist server projection lib/designer/public-profile.ts (secrets — OTP codes, verify tokens, Stripe ids, commission rate, VAT, phone, admin notes — can never reach a public response; unit-tested incl. secret-leak assertions). Profile editor 2.0 (/designer/profile): imagery section with cover preview, profession chips, rates, credentials line-editors, 21+12 link fields, privacy eye-toggles, guarded load/save states. Public profile 2.0 (/designers/[slug]): cover hero with overlapping avatar, headline, Verified badge (from the existing verification lifecycle), availability pill, profession chips, credentials columns, social + "Also on" freelancer-platform chip rows, rates card — rendered exclusively from the projection; SEO upgraded to the shared <JsonLd> + a pure buildDesignerJsonLd builder (sameAs from links, knowsAbout from professions) + alternates.canonical. Gates: clean-worktree npm ci + full production build exit 0, full test suite green (6787, 0 fail) incl. projection secret-leak tests, multi-lens adversarial review (2 confirmed findings fixed).
    • Menu TV — MT-SIG "Signature Templates": 6 new 1:1 board designs (static + animated), per-dish design control, Magic Fit, multi-display balance. The owner supplied 6 professional menu-board reference designs; each is recreated 1:1 as a first-class TV template with a FIXED authored palette + its own art-directed backdrop: Fast Lane (dark fast-food combo board — photo columns, white COMBO hero card with a red price burst, 3 promo banners), Rustic Bistro (whitewashed wood + procedural SVG grain/doodles, Pacifico script "Menu", brush-stroke section headers, dotted leaders), Summer Breeze (airy three-panel — round plates, orange price bubbles, SVG produce garnish on the edges), Color Stripes (full-bleed colour panels, one hero dish per stripe in a white-ring circle medallion, script+caps lockups in Bebas/Pacifico), Premium Smoke (yellow brand sidebar with an SVG burger-pattern + giant poster price, smoky dark 4×2 medallion grid), Salad Bar (script lockup + packed bowl-card grid with price badges + a "Today's pick" deal tile; sparse slides drop columns and scale the bowls up). Every template ships static + animated variants — one shared, reduced-motion-safe keyframe vocabulary (templates/motion.tsx: float/drift/ken/pulse/sway/spin/smoke/rise/wipe) gated by a new wire-safe menuBoardConfig.motion flag (absent = animated; never an emitted key when unset, so pre-MT-SIG feeds keep byte-identical version hashes). Architecture: TvTemplateMeta.art (palette/animated/demo) in THE registry; TV_TEMPLATE_IDS is now DERIVED from TV_TEMPLATES and imported by board-data + tv-devices (kills two hand-copied lists that could drift); signature palettes override brand tokens at resolution (owner heading/price picks still win) for the board AND deck screens. Per-dish design control (the "separate one by one" ask): menu blocks gain itemIds (dish-scoped blocks), itemStyles (per-dish colour/size/weight/hide) and feed-time itemOrder honouring — all resolved SERVER-side (filter/order/hidden are content-identical on native; style fields web-only, XS-9 precedent) with conditional version-hash wiring; the Screen Designer inspector gains a "Style one dish" panel and a "Separate dishes" action that explodes a block into one live-bound, freely placeable block per dish (verified end-to-end: 9-dish block → 9 independent elements, one undo step). Magic Fit (deterministic, zero AI cost — the designer stays a no-AI-credit surface): lib/tv-menu/magic-fit.ts scores every template against the LIVE menu (photo/description coverage, category roles via multilingual keywords, size) → "✨ Great fit" chips in the template picker; applying a template now one-clicks the best menu photos into its EMPTY image slots (owner-filled slots never touched) + flips on the content toggles the design leans on; "Auto-split across screens" is now balanced by ITEM COUNT (LPT partition) instead of round-robin. Bundled demo boards: demo-board.ts + 24 curated CC-licensed photos (public/demo/menu-tv/ with CREDITS.md) + 2 self-hosted OFL fonts (Pacifico, Bebas Neue in public/fonts/) make every photo-forward template preview 1:1 even on a photo-less menu — the picker falls back to the sample menu (labelled "Sample menu") until the tenant has 4 photographed dishes, and the new public `/tv/demo` player (?template=&motion=&slide=&cats=) doubles as the QA harness + a sales demo. Gates: project tsc clean (only pre-existing Phase-74 designer errors remain), scripts/menu-tv-qa.mjs 28/28 (all 16 templates × variants × category slices, zero page errors), scripts/menu-tv-designer-uat.mjs ALL PASS (real login → designer → per-dish panel → Separate, deck snapshot/restored), visual QA vs the 6 references via Playwright screenshots. Native TV apps render the new ids via the ledger fallback until Compose ports land (content parity server-side; documented divergence).
    • Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 4: the moonshots Vendion cannot match (all fail-safe OFF) — PROGRAM COMPLETE. ~14 tickets, 7 workstreams, closing the 5-wave / 98-ticket program. Hands-off Autopilot + growth plan (MOON-2/9, AI-6, UX-7): a pure AutopilotPolicy evaluator enforced at the orchestrator tick lets the engine auto-run ONLY zero-cost, sub-threshold, consented items — anything with spend or reach DOWNGRADES to "Needs your OK" (never drops, never over-spends); a deterministic "3 Money Moves" / daily best-move selector (composes the existing next-best-action/forecast/Pareto — each move fires only on a positive real count, one-tap to an existing rail), a weekly brief cron (de-duped against the ANA-6 recap), and a Weekly Growth Plan surface with a deterministic Autopilot Score. Predictive win-back + Regulars Radar (MOON-3, CRM-8): a deterministic churn-risk score (recency vs the guest's OWN cadence) added to the predictor, a once-per-lapse customer.churn_risk lifecycle event (rides the existing tick — no new cron) driving an approved-or-auto win-back, a churn-risk segment, and the Regulars Radar at the POS — recognizing an at-risk regular prompts a one-tap "gesture" that writes a REAL loyalty/gift ledger entry (idempotent). Dynamic offer & reward tuner (MOON-5, LOY-8): deterministic, margin-defended discount/reward suggestions (never below cost; honest fallback when cost data is absent), one-tap apply, AI only phrases the number. Peer benchmark (MOON-6): a k-anonymized "how do I compare to restaurants like me" — a weekly rollup writes each tenant ONLY their own values + bucketed peer medians/percentiles for a cohort above the minimum size (no PII or single-tenant value ever crosses tenants; honest "—" below the cohort floor). Gift portals (GFT-3/5): a self-serve corporate/HR gifting portal (reuses the Phase-65 bulk-invoice issuance) and recurring subscription gift cards (a due-based tick mints each occurrence via the gift engine, idempotent). Marketing concierge (MOON-8): a conversational marketing concierge over the EXISTING WhatsApp/IG/SMS Channels rail (opt-in/opt-out consent fail-closed, honesty-fenced replies via brain-call, allergen/regulated deterministic, sends through the consented broadcast rail with tag attribution — no parallel messaging engine; live send is keys-only). Reputation & Reach Cockpit (SOC-8): one cockpit fusing the reputation score, review-reply queue, referral performance, broadcast reach and social ROI into a deterministic Reach Score, plus a weekly Reputation Autopilot that PROPOSES the top reach actions into the existing approvals (never auto-sends). Seven new master flags; four new crons registered (scheduler + vercel.json). Gates: 6927-test suite (0 fail), full production build, project-wide tsc clean. The whole program is on prod behind fail-safe-OFF flags — go-live is flag flips + keys/certs only.
    • Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 3: unify, automate & close the loop (all fail-safe OFF). ~20 tickets, 7 workstreams, all extending the ONE existing engine. Journeys & loyalty triggers (AUT-4/5/6/7, LOY-4): the automation engine now emits loyalty.joined / loyalty.tier_up / loyalty.reward_ready from both paid-order earn sites (customerRef-tagged, Guardian-capped) with ready-made loyalty recipes; multi-step journeys run on the SAME idempotent dispatcher via additive MarketingActionStep.delayMs (a WAIT pushes the plan-item's scheduledAt forward) + guard (a deterministic BRANCH, failing guard = visible skip); a pure suggestAutomations proposes the owner's next un-adopted recipe (one-tap adopt); the recipe wall shows honest per-recipe "sent N, opened M" (null→"—"). One message everywhere (CMP-5/8/9): a pure omnichannel.ts router reaches each consented guest on their CHEAPEST channel (push > email > SMS) with cross-channel dedupe, folded into the broadcast engine — the stored result records routedAway (the measurable SMS saving) and a unified per-channel card. Loyalty reach + manual tags (LOY-5/7, SEG-5): loyalty tier + owner-applied tags become first-class CustomerView fields with dedicated segment resolvers + tier built-ins; the public loyalty page & Apple/Google wallet pass take the program's brandColorHex/logoUrl; the guest drawer gains a manual-tag editor (bounded, PATCH-persisted) and a "has tag" / "loyalty tier is" audience chip. Gift as acquisition (GFT-2/6/7): a gift recipient who becomes a new paying guest attributes to the buyer via the existing referral rails (self-gift blocked, idempotent); one-tap seasonal gift campaigns; plain-language breakage/liability aging from the BAS-2421 float. Offline→online attribution (MOON-4): a deterministic read-time join surfaces in-venue conversions (POS orders, gift redemptions, Phase-69 QR scans) on the SAME growth/ROI spine with an honest offline coverage % — in-venue revenue that was always "—" can finally appear (real or "—", never fabricated). Money loop (ANA-5/6/8): a deterministic next-best-action strip (lapsed regulars → one-tap win-back; birthday-soon big-spenders → offer), an honest trailing-trend revenue forecaster with a confidence band (never an LLM number), and a weekly "Money Recap" digest cron to email + push. Reach loop (SOC-6/7): a one-tap "Post to Everywhere" over the existing publish seam (7 socials + Google Posts + FSN; unconnected = honest skip) and scan-to-rate gate & route — happy guests to "Share on Google" + invite-a-friend (referral link), unhappy guests to a private recovery on the goodwill rail. Two new master flags (post_everywhere, scan_route); the money-recap cron registered in both scheduler + vercel.json. Gates: 6795-test suite (0 fail), full production build, project-wide tsc clean.
    • Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 2: the conversational + predictive marketing brain (all fail-safe OFF). ~16 tickets, 5 workstreams, built on one foundation: lib/marketing/ai/brain-call.ts — the SINGLE gated/RIC-routed/honesty-fenced/spend-metered entry point every new AI surface calls (triple-gate → RIC cutover → meter-even-on-fence-rejection at the one appendLedgerEntry wallet choke → assertNoFabrication → deterministic fallback; never dark, never fabricates) — and lib/marketing/crm/predict.ts, a PURE deterministic predictor (next-visit window + lifetime value, honest null under 3 visits) and per-guest tagger (regular / at-risk / new / birthday-soon), NO LLM. Conversational tools (AI-2/3/4, CRM-7, ANA-7): a new tools-marketing.ts adds four Ask-AI tools behind a 2-line registry edit — compile_segment (describe an audience in a sentence → an INSPECTABLE deterministic predicate over the 17-field whitelist → a confirm-first "create audience" card; the model physically can't emit a non-existent field), prepare_campaign (locate a plan → fenced draft → confirm-first Approve card via the existing approvals route; never sends directly), draft_variants (3 honesty-fenced copy options via brainCall), get_marketing_money (read-only Pareto + campaign funnel, real-or-"—"). Predictive guest brain (CRM-4/5, AI-7): the Guest 360 payload now carries the deterministic next-visit/LTV prediction + tags; the profile drawer gains a "What to expect" card, tag chips, and an on-demand honesty-fenced "Summarize this guest" (RIC-metered, cached by fact-hash in crmGuestMeta.aiSummary); tags also flow into the POS Guest Card and become three new targetable segment built-ins (at-risk / birthday-soon / high-basket). AI audiences (SEG-4/7/9): a plain-English "describe your audience" box compiles to inspectable chips via a pure deterministic nl-segment.ts (no LLM in the typed-box path), AI-suggested tag chips, and an Audience Radar that auto-discovers high-value under-served cohorts (deterministic scan, one-tap create, honest-empty). Waiter copilot (WI-6/7): one honest deterministic add-on suggestion at the POS (a real in-stock, allergen-safe dish — never a fabricated item/price; allergen banner stays dominant) plus a compact at-the-table guest Q&A. Copilot Dock (UX-4/AI-8): an always-there "Ask your marketing" dock on Marketing Home that commands the existing Ask-AI widget via a window event (no second chat, no new model/metering surface), with marketing starter chips + voice, gated on the agent being enabled for the tenant. Every AI output is fenced (numbers/prices/allergen facts are pre-computed and whitelisted; the model only phrases them), every guest email is masked before it reaches the model, and all prediction/segment math is deterministic. Gates: 6703-test suite (0 fail), full production build, project-wide tsc clean.
    • Experience Studio — both AI studios rebuilt to the owner's mockup (design 1a/1b/1d, 2026-07-17), light + dark. After the Photo/Video split, the owner supplied a design sheet and asked to take the UI/UX inspiration without losing any functionality. Both studios are now a split layout: the guided controls on the left, a big Live-preview hero on the right that updates instantly. Photo studio: step 1 collapses a chosen photo to a compact "Your dish · Detected: …" card (Replace reopens the picker); step 2 "The look" is an accented card with compact style chips + "+N styles" expander and a direction swatch row whose thumbnails are real result previews; step 3 "Your words" is a slim card. The hero shows a before/after slider with the owner's headline overlaid live ("edit in step 3"), and once generated it becomes the result (renderer + WOW + use/ad panels). A "What you'll get for N SEK" value row (Animated scene / Landing page / WOW score) sits under the hero, and the priced Generate button carries "everything above is free — you only pay when you generate" + the included-image quota. Direction lightbox (design 1d): clicking any swatch opens a large real example with ←/→ browsing and a "Use <direction>" commit (Esc/Close, full dialog semantics). "Create | My generations (N)" header toggle (design 1a): the saved-generations gallery is its own view instead of an endless scroll; its handoffs jump back into the right studio. The Video studio gets the same language: source-photo card, motion chips + swatches, and a hero pane that plays the motion sample and then the finished clip big, with its own value row (8-second clip / AI voiceover / ad exports). Every money path preserved (adversarial review: no money/functionality defects) — gates, the 428 billing ack, prices-from-config, refund messaging, the VideoAdPanel machinery, batch remove-bg top-up, FSN /uploads gating. Money fix uncovered by the review: the video-first ack path used to run the image generate() after accepting the disclosure — a Video-studio owner accepting their first-time ack would have been charged for an image they never asked for; now acceptAck auto-continues only the image flow (ackFor), and the video owner simply presses Create again past the ack. Verified: tsc + full build exit 0 (gated in a clean worktree — the shared main tree currently carries another session's half-written Phase-73 files); light + dark render checks of both studios and the lightbox; scripts/es-split-qa.mjs 15/15 ON PROD (both studios, tab switch + URL sync, deep-link, the new hero + value-row assertions, zero page errors).
    • Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 1: Vendion parity, in the owner's hands (all fail-safe OFF). ~23 tickets across 12 parallel workstreams, every new surface behind a strict-binary master flag (5 new: sms_campaigns / money_insights / customer_referrals / reputation_hub / consumer_broadcast) so today's behavior is byte-identical until flipped. Loyalty (LOY-1/2/3/6): klippkort/stamp-card mode on the ONE loyalty engine (per-order / per-item / per-category / per-amount rules, free-item or %-off reward, deterministic two-seam reward redemption mirroring points), a public no-login guest page at /tenant/[id]/loyalty (HMAC magic-link, points/stamps/tier/reward progress, join-by-email) that fixes the long-dead /loyalty CTA, a one-screen guided setup with a live phone preview, and Swedish alcohol-law-correct earning (excludes vatCategory:'alcohol' lines from the earn base, deterministic — never inferred). Self-serve SMS (CMP-2/3/6): a /dashboard/sms composer reusing the email-builder shell with a live GSM-7/UCS-2 segment + honest cost preview (owner-entered price only, "—" when unknown), the auto-appended Reply-STOP hint, a shared AudiencePicker, and the email builder's segment recipient mode wired end-to-end. Recipe wall + safety (AUT-3/8): /dashboard/marketing/recipes — every automation template as a one-tap light-switch (plain-language "what will happen", cooldown in words, themed confirm), a master "pause all", visible Guardian status, and "test on me". Waiter Intel (WI-2/3): the POS Guest Card (name · "regular, N visits" · favourite dish · loyalty reward-ready · VIP) on the order/sell screens, fed by the Wave-0 resolver (404 → renders nothing, byte-identical when off), plus a deterministic Allergen Shield cross-checking cart items against the guest's saved allergens (inline warning chip, never blocks the sale). Money Insights (ANA-1/2/3): a plain-language /dashboard/marketing/money "what made money" screen (a Pareto "top-N regulars = X% of revenue" over real spend, per-campaign honest returns with the coverage caveat, a deterministic "what to do next" strip) + SMS delivery-receipt & click tracking (provider status webhooks + an HMAC short-redirect, real-or-"—"). Gift parity (GFT-1): a one-click "expire after 36 months" preset (SE default stays no-expiry) and a named Björn Lundén SIE4 export. Simplicity (UX-3/5): a 3-minute Marketing Setup wizard (writes only manual-approval mode — never auto) and a reusable Confidence Card ("see it before it happens": reach · consent-excluded · cost · preview · undo). Day-part audiences (SEG-3): deterministic Fredagsgäster / Lunchgäster / Helggäster / Allergiker built-ins from real order timestamps + dietary data (friendly chips in the Audience Builder). Refer-a-friend (SOC-1): a give-X/get-X loop on the EXISTING coupon/loyalty/gift rails (unique codes, first-touch cookie attribution, an idempotent order.paid conversion cron, self-referral + min-order guards) — reward codes filtered out of the public offers list so they can never leak. Reputation + broadcasts (SOC-2/4): a /dashboard/marketing/reputation hub with ONE deterministic weighted score across Google + internal reviews (TripAdvisor/Yelp/Meta honest "—" until keys) and a /dashboard/marketing/broadcast "Tell your customers" studio (push/email/SMS to opted-in guests, live per-channel consent-fail-closed reach, Guardian-capped). Social live-publish (SOC-5): every platform behind ONE publish() seam with encrypted per-platform token vaults — FSN live now, the rest activating as OAuth keys land (honest "not connected", never a faked post), trust levels preserved. Guest 360 profile (CRM-2): the /dashboard/customers drawer over the Wave-0 spine — one visit+order+booking+review+loyalty+gift timeline, per-currency gift balances (never summed), owner notes + VIP toggle, deep-linkable by ?guest=<ref>. Three new crons registered in the platform scheduler + vercel.json (sms-campaigns, customer-referrals, marketing-broadcasts). Gates: 6634-test suite green (0 fail), full production build, project-wide tsc clean, dark-vocabulary + no-native-dialogs guards.
    • Phase 73 — Marketing Supremacy ("Stamgäst"), Wave 0: the load-bearing seams + the one door (all fail-safe OFF). Nine tickets across 8 parallel workstreams, every surface behind strict-binary master flags (guest_360 / marketing_home / audience_builder, new multi-key lib/marketing/supremacy-flag.ts cloning the gift-cards flag shape; flag OFF = byte-identical behavior). Base-plan de-gating (SEG-2/ANA-4): segment list/create/edit/delete/preview and the honest marketing-analytics READ dropped their marketing-autopilot add-on gate — insight is free, every send/spend path keeps its gate. Guest 360 spine (CRM-1): lib/marketing/crm/guest360.ts — buildGuest360(db, email, nowIso) composes the unified customer view and joins order line-items (top dishes + "the usual", combo header lines only), loyalty points + derived tier, per-currency gift balances (GDPR-erased sides excluded), reservations, exact-email reviews, and the new bounded TenantDB.crmGuestMeta (owner notes + persistent VIP, cap-evicted, normalized-email keys); customers route gained flag-gated GET ?email= detail + PATCH notes/VIP (audited). Per-visit trigger (AUT-1): order.paid joined MARKETING_RULE_TRIGGERS with additive customerRef (sha256, never raw email) enrichment across all 10 emit sites (POS settle, born-paid, unified seam, table-QR pay, payment-intent, channels, Adyen, gift engine), plus the previously-deferred post-visit review-request rule template. Anti-spam Guardian (AUT-2): global per-customer frequency cap (default 3 sends/7 days, protecting even unconfigured tenants) + quiet hours as owner-configurable MarketingState fields (brain-route zod + clamped merge), enforced at plan time as VISIBLE skips (never silent drops), per-customer fire history bounded in the automation store, stamped on dispatch success; cap-unevaluable (no customerRef) fails closed for customer-directed sends. SMS STOP → consent (CMP-1): new public inbound webhook receivers for Twilio (HMAC-validated) + Vonage with STOP/STOPP/SLUTA keyword classification, pure applySmsStop flipping smsNotifications consent by loose phone match (over-revoke beats texting an opted-out guest); the SMS-ordering webhook now honors STOP before its channel gate; marketing SMS bodies gain the "Reply STOP" hint (EN/SV). Ask-AI guest tools (AI-1): get_guest_insights (per-segment counts + masked samples) and get_guest_detail (exact-email, masked, birthday month-day only) — the live agent can finally read the CRM, guest_360-gated, raw emails never reach the model. Audience Builder (SEG-1): /dashboard/audiences — plain-language chip composer over the existing deterministic segments API (live reach counts, consent-eligible counts, masked samples, If-Match concurrency, honest "(advanced)" rendering for API-created shapes). Marketing Home (UX-1/2): /dashboard/marketing became a server component that renders the classic hub byte-identically while marketing_home is OFF and, when ON, a plain-language front door (outcome tiles → guests/audiences/email/loyalty/gift cards/reviews/week/what-made-money, real-or-"—" stats via the new KpiOrDash primitive, classic hub always reachable via ?view=all and every ?tab= deep link). Approve my week (MOON-1): /dashboard/marketing/week — the already-computed weekly orchestrator plan as approve/skip cards + honest approve-all over the existing per-plan approvals endpoints (no new engine; 409/404 refresh, never fake success). POS guest resolver (WI-1): pure lib/pos/guest-intel.ts + flag-gated GET /pos/guest-intel — exact-match-only guest projection (visits, spend, favorite dish, strictly-deterministic allergens, loyalty reward-ready, VIP/notes) ready for the Wave-1 Guest Card; never fuzzy-matches (wrong-guest allergen info is a safety failure). Gates: 654 cross-cluster unit tests green (incl. accounting + intelligence event-contract consumers), full production build, dark-vocabulary + no-native-dialogs guards.
    • Experience Studio — AI Photo and AI Video are now two dedicated studios with an enterprise redesign (owner ask 2026-07-17: "separate AI image and AI video; redesign both to a more professional enterprise-level UI"). The single merged page that served both from one long form is now a mode-aware workspace: AI Photo Studio at /dashboard/experience-studio (URL unchanged — every existing link/bookmark still lands on photos) and AI Video Studio at ?mode=video, chosen with a real segmented switcher (role=tablist, per-tab aria-selected, "Styled marketing images" / "Cinematic video ads"). The Quick Create cards on /studio, the project wizard, and the Studio hub now deep-link the two modes separately (StudioShell shows two tiles: AI Photo Studio + AI Video Studio). One shared shell still owns entitlement (trial DEMO vs paid), the one-time 428 billing disclosure, pricing, the brand kit and the reference examples — zero engine drift, one set of API contracts — so every money path is byte-preserved: paidGranted gates both the image Generate and the video Create (a locked, priced card in demo), the ack modal, 402 AI-budget handling, the heroPhotoFailed refund message, charge-on-delivery, the remove-bg batch top-up flow, and FSN publishing gated on a hosted /uploads URL. Enterprise layout: a two-pane grid (the guided flow on the left, a sticky summary rail on the right showing a live preview, the exact SEK for the next action and the included quota) with numbered step cards, a filterable Your generations gallery (All / Photos / Videos tabs + "Show more" paging replacing the endless grid), and a calmer, consistent card system. AI Video Studio is now first-class: it opens on "Choose your source photo" (pick a saved Studio image, upload, or jump to the Photo studio to make one) instead of only appearing after a paid image — then style/direction motion pickers, Instant vs Narrated, and the create → poll → export → market machinery (unchanged). Cross-studio handoffs: an image result offers "Make a video from this image" (sets the source explicitly so re-use never animates the wrong photo), and the gallery's "New from this" / "Make video" switch modes with the right source. Video-first creators who hit a first-time 428 now get the billing disclosure, not a generic error. Verified: full tsc + production build exit 0, dark-vocabulary + no-native-dialogs guards green, a 3-lens adversarial money/gate review (1 regression found + fixed: the image→video source handoff; 1 hardening: the video ack gate), and a Playwright smoke (scripts/es-split-qa.mjs: both routes, tab switch + URL sync, deep-link, zero page errors).
    • Phase 72 — Experience Studio Enterprise (Creative Suite), waves XS-0…XS-10. Experience Studio grew from a launcher + one-click AI generator into the platform's single creative hub, Canva-class but restaurant-simple — with zero parallel engines. Home 2.0 (`/studio`): "What would you like to create today?" prompt box (deterministic intent→preset matcher, never an LLM call), Quick Create cards (~20 project types with honest time estimates; AI Image/AI Video cards deep-link the existing paid Studio AI with its full gate chain — the new surfaces themselves never charge), a Continue Working grid (thumbnails, Draft/Published/Archived/Template chips, favorite/duplicate/archive/delete, relative edited times, skeletons + premium empty state), a starter-templates rail, and a 4-step project wizard (Type → Canvas size incl. custom 200–4096 → Starter template/Blank → Name). Projects: new StudioProject model on the tenant doc (cap 100, never silently evicted; multi-page; draft→published→archived + template lifecycle; publishedTo history) whose pages reuse the ONE shared CanvasElement model, sanitized by the ONE shared sanitizeCanvasElements (now stage-size-parameterized — portrait/social/print canvases keep 2× full-bleed headroom; defaults byte-identical for Menu TV/Kiosk). Studio-only layer extras (name/lock/hide) live in a side meta-map keyed by element id — the wire type native TVs/kiosks decode is untouched, and publishing strips hidden layers structurally. Studio Designer (`/dashboard/studio/designer`) — the THIRD host of the shared canvas engine (CanvasStage/CanvasInspector, additive stageW/stageH + lockedIds props): variable canvas sizes, dotted workspace with zoom-to-cursor (10–200%) + fit, pages rail (add/duplicate/rename/reorder ≤12), Layers panel (rename/eye/lock/reorder with dense integer z — native Int decode preserved), per-page gesture-coalescing undo/redo incl. layer meta, 1.5s debounced autosave + Ctrl+S, inert Play overlay, insert bar (text/image/video/shape/live-menu blocks + QR codes — server-rendered deterministic PNGs inserted as normal image elements, free). Export: WYSIWYG DOM raster of the real renderer (html-to-image; no parallel painter) → PNG 1×/2×, JPG, multi-page PDF (pdf-lib), JSON; auto-thumbnails on save (20s throttle); honest 413→1× retry and 503 storage messaging. Publish pipeline — one design fans out through each destination's EXISTING sanitizer + store, never a bypass: Menu TV (pages become layout:'canvas' deck screens with stable sps_* ids — re-publish replaces, never duplicates; uniform-scaled + letterboxed to 1920×1080), Kiosk welcome screen (attract canvas, kind-filtered honestly), Customer Display ad reel, Social draft post (drafts only — can never auto-publish), Media Library; per-target feature gates mirror each target's own routes (kiosk 402 + upgrade prompt). Brand kit: additive logoUrl on the ES BrandKit + one-click Apply-brand (single undoable commit: surface→background, primary→shape fills, luminance-contrast text ink) and Add-logo, with save-back to the kit. Entrance animations (fade/rise/drop/pop — the compositor's MotionStep vocabulary verbatim) as an additive CanvasElement.anim field: whitelisted in the shared sanitizer, rendered on web boards' incoming rotation layer + the designer's Play overlay (reduced-motion aware), byte-identical feeds when unset (no key emitted, conditional version-hash append — no fleet-wide TV re-ship), native TVs render static until the Compose port (documented divergence). Also: /studio registered hub-linked "Studio Designer" nav (all 20 languages), shared dialogs now detect the studio's scoped dark theme, and the /studio shell's ?section=/theme seeding moved to a mount effect (recurring React-#418 hydration class). Gates: 6340-test suite green, full production build exit 0, 5-lens adversarial review workflow, and a 33-assert Playwright smoke (scripts/studio-qa.mjs: lifecycle → export/QR/publish → Home light/dark/deep-link → designer/Play; self-cleaning). Plan: docs/Phase-72-Experience-Studio-Enterprise.md.
    • Kiosk Designer — "+ New template" now opens the empty page canvas directly (owner UAT 2026-07-17: "it is confusing, it should open the empty canvas so user can fully design kiosk, all empty pages"). Previously the button only blanked the design tokens — the preview stayed fully populated, so it looked like nothing happened. Now it lands the owner straight in the fullscreen canvas editor on an empty Start screen, and the editor grew page tabs (Start screen · Eat here/Take away · Menu · Order number, with per-tab element counts) so every page can be designed without leaving the editor — plus an explained chip for Item options · Order review (live ordering screens with no freeform canvas by design: the working controls can never be covered, they take the theme's colors/fonts). An empty-page hint overlay ("This page is empty — design it") shows on any canvas with no elements. Hardened after adversarial review (2 confirmed defects fixed): switching page tabs now remounts the studio's preview engine (the engine reads initialScreen only at mount, so after Done the Pages rail/inspector showed "Menu" while the preview was still on the Start screen — in-preview block editing was unreachable); and the empty-page hint is target-aware — on the Menu tab it leads with the 🍔 Menu block and states that a designed menu page replaces the standard grid (the generic "add Text/Image/Box" advice would have produced a menu page guests can't order from), plus a persistent amber "No Menu block yet — guests can't order dishes on this page" warning whenever the menu canvas has elements but no Menu block. Verified with a 13-assert Playwright smoke (tablist, tab switching, menu-only inserts, target-aware hint, amber warning, per-canvas hint clearing, Done, API-cleaned dev tenant).
    • Menu TV — template picker with live thumbnails + all-slides preview popup (owner ask 2026-07-17: "thumbnails for Templates; click = popup with all screens, apply/cancel/reset"). The per-screen Template grid on /dashboard/menu-tv (previously text-only buttons that applied instantly) is now TvTemplatePicker: each of the 10 board templates renders as a live mini-preview (the real MenuBoardScreen on the page's already-fetched preview board, re-skinned per template exactly like the TV feed — config.template + that template's slot images; inert, non-interactive). Clicking a template opens a popup showing every slide of the rotation (buildPages, the same paginator the TV runs; slide count in the header; capped at 12 shown) with a light/dark toggle (the other mode's board is fetched once, lazily) and Apply (keeps the screen's custom colors + display options — this preview already shows them) / Reset colors (returns custom heading/price colors to Auto) / Cancel — house dialog semantics (role=dialog, aria-modal, Escape, initial focus, aria-pressed toggle). Hardened after an adversarial review (confirmed finding + review-flagged issues fixed): the other-mode board is cached keyed by the exact cfg + mode — fixing both a staleness bug (the picker outlives many cfg edits, so an unkeyed cache served a stale/wrong-mode preview) and an infinite refetch-on-failure loop (a failed fetch is now recorded, not retried); the 10 thumbnail boards are memoized so opening the popup doesn't re-render all of them; and the footer copy now states honestly that the preview matches Apply (Reset was reduced to a colors-only reset since display toggles are per-screen content, not template styling). Verified with a 14-assert Playwright smoke (incl. a request-count guard proving the alt-mode board fetches exactly once across repeated toggles) and a self-cleaning paired test TV.
    • Navigation fixes (owner UAT 2026-07-17: "Kiosk Designer hard to find; back-to-Kiosks lands on Devices & Apps overview"). Root cause: /dashboard/devices read ?tab=… in its useState initializer, which (a) races client-side navigations — deep-links like the designer's back link silently landed on Overview — and (b) mismatched the SSR HTML (hydration error). The tab now resolves in a mount effect and tab clicks sync the URL via replaceState, so deep-links, refresh and back all land on the right tab. Also: the Devices overview kiosks card gained a direct 🎨 Kiosk Designer link, the Studio hub's "Kiosk templates" tile now opens the Kiosk Designer itself (where the new template popup lives), the designer's back link is labeled "Kiosk fleet", and the Menu TV page no longer renders window.location.origin during hydration (second hydration error fixed).
    • Kiosk Designer — template thumbnails + all-screens preview popup (KD-12). The "Base template" rail now shows each of the 5 luxury templates as a live thumbnail (the REAL KioskEngine scaled down on the attract screen, pure template look, non-interactive) instead of a text list, with an "In use" badge on the active one. Clicking a template opens a preview popup rendering all 6 kiosk screens (Start · Eat here/Take away · Menu · Item options · Order review · Order number) with the tenant's live menu, a light/dark toggle, and three actions: Apply template (switches the base, keeps the owner's colors/texts/photos/canvases), Reset to this template (also clears customizations so the kiosk matches the preview exactly; disabled when there's nothing to clear), and Cancel — a KD-4 version snapshot is taken before either change. KioskEngine gained an additive staticPreview prop (skips the window idle-guard listeners and the done-screen auto-reset) so pinned mini previews never drift back to the attract screen — devices never pass it, runtime behavior unchanged. Hardened after a 25-agent adversarial review (5 confirmed findings, all fixed): thumbnails are inert (pointer-events alone left ~40 invisible engine buttons Tab-reachable — Enter could mutate a pinned screen; also an aria-hidden-focus violation), rail cards are role="button" divs (no button-in-button DOM nesting), the popup follows the house dialog idiom (role="dialog" + aria-modal + Escape + initial focus, like confirm-dialog.tsx), the staticPreview idle-effect branch still clears timers on unmount, and — the real data bug — sanitizeKioskDesign now persists a baseTemplate-only design instead of deleting the record ("Reset to this template" and a fresh template pick used to silently forget the chosen base on reload; design.test.ts updated to lock the new rule, invalid baseTemplates still drop).
    • Ask AI 2.0 — a full ChatGPT-style assistant over the whole system (owner ask 2026-07-17). The Ask AI widget grew into a big, centered chat window (expand/collapse, remembered per user; tenants open large by default) whose agent now knows the platform end-to-end and answers in 2–3 lines with rich blocks under the message: KPI stat cards, tables and mini charts (line/bar/donut — self-contained SVG, AskAiBlocks.tsx). 5 new KPI read tools build every block server-side from live engines (never the model): get_business_kpis (revenue/orders/AOV + revenue-by-day chart + top sellers, vs-previous-period deltas), get_costs_and_profit (Restora Profit core: monthly costs by category donut, labor %, food cost %, net margin + a per-staff true hourly cost table incl. employer load), get_staff_schedule (who works the next 1–6 weeks + est. labor cost), get_payment_status (unpaid supplier orders, platform invoices due, received-by-method 30d), get_channel_kpis (FSN followers/engagement, Food Bazar orders/rating/listing views, GA4/Search-Console website stats when connected, campaign ROI). 5 new confirm-first action tools — same hard contract as the supplier flow (the agent only PREPARES a card; the user's click fires an existing auth-guarded API): prepare_price_change (menu item price, PATCH re-validated server-side), prepare_offer (goes live on confirm), prepare_setting_change (safe whitelist: per-day opening hours, accept-orders-while-closed), prepare_social_post (publishes to connected socials), get_marketing_suggestions (autopilot's pending plans + one-click approve card through the audited approvals route). Every agent answer now carries an honest AI-usage strip (this answer ≈ $, % of lifetime credit used) computed from the wallet choke point. ActionButton gained PATCH; ActionCard gained a change kind with before→after summary. scripts/askai-qa.mts = 35-assert deterministic QA (tools never write, cards always tenant-scoped, invalid inputs rejected) — all green on the dev seed. Agent stays flag-gated (askAiAgent / SUPPORT_AGENT_ENABLED). Owner UAT fixes (same day): new get_menu_items tool (current prices lookup — "what's the current price?" was unanswerable and the price-change flow now states the old price before asking for the new one), and the "Yes, go ahead" chip only appears on genuine yes/no offers ("Want me to…?"), never when the assistant needs a value like a price. UAT round 2 — "it confirmed but the menu still shows 18 kr": the change had never been applied (the card's Apply button was never fired; the chat text read like a confirmation). Three-layer fix: (1) cards now carry an unmissable amber "Not applied yet — press the button below" banner until actioned, and the prompt bans "done/confirmed/updated" wording for prepared cards; (2) verify-after-apply — after a successful click the widget re-reads the changed record and proves it ("Diavola is now 100 kr on the live menu") via a new ActionButton.verify contract + a new GET on the single-product route; (3) the product PATCH/DELETE routes now revalidatePath the public storefront (/tenant/<id>, /tenant/<id>/menu) so an applied price shows on the live menu instantly — previously even a successful change kept serving the cached old price. UAT round 3 — "Food Bazar shows a different price": that page was another restaurant's same-named dish (marketplace, per-restaurant pricing — not a bug); the real find was that the owner's own dish was held off Food Bazar by the photo-quality gate with nothing telling them — get_channel_kpis (bazar) now reports dishes-on-Bazar vs held, plus a "Held off Food Bazar — fix to list them" table with per-dish reasons (no photo / photo flagged / no price / no description), so the agent answers "why isn't my dish on Food Bazar?" honestly. Human review loop over the AI photo gate (owner ask — one system, no parallel build): the EXISTING owner held-banner on /dashboard/food-bazar now shows the AI's issue tags and a "Photo looks fine? Request a human review" button (new POST …/food-bazar/request-photo-review stamps photoQuality.reviewRequested); the EXISTING super-admin Food Bazar hub gained a Photo review tab (badge-counted queue across all tenants, image preview + AI issues + score, owner-requested first) with Approve — overrides the AI verdict to `pass` for that exact image (audited, re-lists via ensureBazarListing; a later photo change re-scores normally) or Keep held (clears the request). photoQuality was previously written by the scorer and read only by the feed gate — no human had any view of it.
    • Menu page — direct "Screen Designer" link in the toolbar. /dashboard/menu now has a Screen Designer button next to History/Import that opens the Menu TV signage designer (/dashboard/menu-tv/designer) — the same freeform canvas Experience Studio links to — so owners editing the menu can jump straight to designing their TV screens.
    • Every image-URL field on the platform now has an Upload button. A full-platform scan (6 UI slices + endpoint audit) found every text input where a user was expected to paste an image URL; each one now also uploads from mobile/computer via a new shared ImageUrlField primitive (components/ui/image-url-field.tsx: URL paste kept, Upload button with spinner/error/thumb-preview, per-surface styling, onUploaded kind callback). Wired (19 fields): tenant offers Banner Image, blog Cover + OG image, Settings → Branding logo, SEO hub OG image, Mason Style-tab background image (upgraded to the full MediaPicker), the email/blog rich-editor Insert-image dialog (per-tenant), admin platform-SEO Default OG + Organization logo, admin TV-ads Creative (auto-sets image/video kind), admin Food-DNA set-image, admin Experience-Studio DNA-detect, admin Ingredient drawer, designer-portal avatar/logo/portfolio images, chain (group) logo/hero/white-label favicon. New upload endpoints (shared lib/media/save-upload.ts writer, mirrors tenant-media validation): POST /api/admin/media (super-admin, /uploads/admin/, image+mp4/webm), POST /api/designer/[designerId]/media (own-designer only, /uploads/designers/<id>/), POST /api/group/[orgId]/media (requireGroupAccess, /uploads/groups/<orgId>/). The three new namespaces are registered in RESERVED_UPLOAD_DIRS so the retention sweep can never mistake them for orphaned tenant dirs. Dead components (SeoSettingsTab, BlogEditorModal, legacy StyleEditor — zero importers) deliberately skipped; YouTube/Vimeo/embed link inputs excluded (page links, not media files).
    • QR generator — Logo Overlay now has a Browse button to upload the logo from your device. Next to the existing URL field, a Browse button uploads the picked image through the tenant media library (POST /api/tenant/[tenantId]/media — so it also appears in Media), with inline validation errors (type/size mirrored client-side) and an uploading state; the returned /uploads/... URL drops straight into the logo field. Server-side, /api/tenant/[tenantId]/qr now inlines local /uploads/... logos into the SVG as data: URIs, so downloaded SVGs are self-contained — a relative href would 404 outside the site and prod doesn't serve /uploads statically. Hardened (3-lens adversarial review): the server-side /uploads read is scoped to the calling tenant's own prefix and reuses the public-media isPrivateMediaSubtree guard, so the QR route (gated only by requireTenantAccess, any staff role) can't read another tenant's uploads or the HR/ATS PII vault (hr-docs/, fsn-resumes/); the inlined content-type is taken from the media record's real mimeType (falling back to an extension map that now includes the .jfif/.jpe/.jif JPEG aliases) so a valid JPEG saved under one of those no longer silently drops from the SVG; a logo over 512 KB (or of undeterminable type) is referenced by its same-origin href instead of base64-inlined, bounding memory/ZIP size across the up-to-100 bulk-table SVGs and the per-request heap. The injected <image href> is XML-attribute-escaped.
    • Personalliggare — the statutory Swedish staff register (SFL 39 kap. 11–12 §§, SKVFS 2015:6), end-to-end. The DB-8 terminal punch ledger now doubles as a compliant electronic personalliggare. Personnummer on staff: new encrypted StaffMember.personnummerEnc (+ display mask YYYYMMDD-••••) via the proven AES-256-GCM PII seam — validated/normalized to the canonical 12-digit form (Luhn + real-date + samordningsnummer support, lib/workforce/personnummer.ts), edited on the HR profile tab (audited reveal, hr.manage-gated), wiped by GDPR staff-erase, masked-only in the worker DSAR export, and wired into AGI filing as the real BetalningsmottagareId (fältkod 215, decrypted at build time, employeeNumber fallback). Register surfaces: an on-premises inspection view ON the venue terminal (new PersonalliggareScreen, reachable from the clock-in gate and Settings → Compliance; the register's structure — names + times — shows without a PIN since inspections can precede opening, but the full personnummer column needs a staff clock-in code, is brute-force-throttled and audited per view; auto-closes on idle), a dashboard register at /dashboard/personalliggare (Team nav, day browsing, live today view; personnummer + exports are hr.manage-only — lower tiers see coverage, never the numbers), and print/CSV exports (audited, spreadsheet-injection-safe) stamped with venue name + organisationsnummer, timestamps in the venue's timezone, and the statutory 2-year retention statement. GDPR-erase now honours the register's legal retention (art. 17(3)(b)): punch rows inside the 2-year window keep the name + an encrypted personnummer snapshot; older rows stay fully pseudonymised. Manual after-the-fact insertions (deviceId 'manual-entry') and corrected punches are honestly flagged on every surface with their immutable PunchEdit trail. Missing personnummer / missing orgnr are surfaced as fix-it compliance gaps (orgnr saveable inline). New feature-registry entry personalliggare (basic plan, per-tenant toggle, depends on staff-shifts); endpoints: device-token POST /api/public/android-terminal/personalliggare, tenant GET /api/tenant/[tenantId]/personalliggare (json/csv/print). Timesheets corrections UI: the previously API-only editHours / insertMissedPunch actions now have a themed correction modal in /dashboard/timesheets (mandatory audited reason, client mirrors the server's 24h/future/locked-period rules).

    Fixed

    • Studio Home — project-card overflow menu rendered UNDER the templates rail (owner UAT 2026-07-17: "Delete goes behind the Bold offer card, takes seconds to come above"). Root cause: the card's hover lift (transform: translateY(-2px)) creates a CSS stacking context, so the popup's z-index only competed inside the card — the rows extending past the card painted beneath the later-DOM templates section until the pointer left the card and the hover transition dissolved the stacking context (hence the delayed "pop above"). Fix: while a card's menu is open (data-menu-open) the card itself gets z-index: 50 with the lift frozen, so the whole stacking context sits above sibling cards/sections and the menu never shifts when the pointer crosses the card edge. Verified with an elementFromPoint hit-test on the Delete row over the templates rail + screenshot.
    • Cron drift: `/api/cron/bazar-visit-reminders` and `/api/cron/tv-ads-settle` were born dead on prod. Both were declared in vercel.json (inert under pm2) but never registered in PLATFORM_CRON_JOBS, so visit-reminder emails and scheduled TV-ad settlement never fired on the box. Registered with their documented cadences (daily / hourly); the MK-3 drift guard now passes again.
    • Public web shop — full consumer redesign + accounts (`/shop`). The Restora 360 Shop is now a complete public e-commerce storefront anyone can buy from: sticky nav with live search + account + cart, an admin-managed hero banner slider (new Banners tab on /admin/dashboard/webshop, banners on the webshop doc, optional per-banner coupon chip), a "Today's offers" deals rail driven by a new display-only compareAtPrice (strike-through + −% badge; charging still uses basePrice/priceTiers only) plus a featured flag, a left categories + filters rail (price range, in-stock, on-offer, sort — slide-in sheet on mobile), product cards with deterministic per-category art for photo-less items, product detail pages (/shop/p/[id], SEO metadata, bulk-tier table, qty-aware pricing), a slide-in cart drawer (localStorage cart, server re-prices everything), and a real checkout page (contact + delivery address + EU VAT preview + coupon + invoice/card choice; Stripe Checkout returns land on /shop/checkout which confirms server-side). Customer accounts: the shop reuses the platform's ONE consumer identity (BazarUser + bazar_user_session) — register/sign-in at /shop/login (shop-branded verify email via source:'shop'), account hub at /shop/account; restaurant people get their shop account automatically — the same platform-bridge as Food Bazar accepts their dashboard email+password, AND an existing platform_session is recognized directly. Order/subscription attribution goes through a new getShopCustomer() seam (ui/src/lib/webshop/shop-customer.ts): platform users → their platform userId (bridged consumer logins map to linkedUserId, so it's one history either way), pure consumers → bazar:<id>; guests can buy with just contact+address. Orders now persist contact + shippingAddress and send a deterministic confirmation email (invoice/dev-sim at checkout; live card on first confirm). Orders + subscriptions pages re-styled into the shop shell; invoice page accepts both identity kinds (+ super-admin cookie). Light/dark scoped theme re-tinted to brand azure-on-navy; EN/NO/SV dictionaries cover the whole storefront.
    • TV Ad Network — external advertiser self-serve platform (Phase 70). Anyone outside the platform can now advertise on opted-in restaurant TVs end-to-end: a public `/advertise` landing + magic-link portal (single-use login nonce — an emailed link dies on first use), a guided campaign wizard (hardened creative upload incl. video with magic-byte sniffing → TV-accurate live preview via the real AdSlideView → all/geo targeting → budget vs a prepaid wallet), deposits via Stripe Checkout (webhook ref-idempotent; fully functional keyless in mock), and honest ROI stats (estimated plays, spend, budget bar, per-day series, QR scan-throughs via a new counted /a/[campaignId] redirect). Super-admin moderation: a review queue on /admin/dashboard/tv-ads — nothing external serves unapproved (enforced in the engine's unit-tested serving predicate, not UI), reject-with-reason emails the advertiser, advertisers can be suspended; all decisions audit-logged. Money/isolation invariants (engine, lib/tv-ads): a restaurant's house ad is free, never settled, earns no self-credit and is hard-locked to its own screens (widened targeting is refused at write AND serve time); external spend debits the wallet atomically with budget accrual + play settlement (one row-locked mutate), clamps to both budget and wallet, auto-pauses on empty wallet. Settlement now also runs hourly via /api/cron/tv-ads-settle. Offline buffering (AD-5): the native Menu TV app gained a media3 SimpleCache read-through + board-arrival prefetch AND last-good-board persistence (an offline restart now renders the board instead of the pairing screen); the web menuboard persists its last-good board and caches ad media via a scoped service worker — a network drop keeps ads playing, and buffered offline playback is never billed (the existing cursor-gap guard).

    Changed

    • Food Bazar — combos are now orderable in the consumer marketplace + a real card-rail money fix. The Bazar restaurant page renders a Combos section with the full slot-picker (swap items, deal-mode "+15 kr / −5 kr" deltas, live total); the bazar cart carries combo lines and checkout sends comboSelections to the (already combo-ready) storefront order route. Money fix (also fixes the storefront): the shared Stripe `/payment-intent` route validated comboSelections but never priced them — a card order with a combo would drop the combo (undercharge / 0-total combos-only). It now expands combos + applies auto-bundles exactly like orders/route.ts (member lines excluded from bundle/coupon, signed combo net into the total, subtotal − discountAmount + fee == total), so card + combo works on storefront AND bazar — the temporary "combos = cash only" bazar gate is removed. tsc 0, full build green, 114 menu tests.
    • Menu — "Deal" combos: a meal-deal price with swap-and-adjust, + AI one-click swappable combos with a generated photo (Phase 67b). A new `deal` price mode (now the recommended one) lets an owner set one price for the default combo (e.g. "Burger + fries + soda = 99") where the guest can swap an item and the price adjusts — by the item's real cost difference (auto) OR a fixed upcharge the owner sets per option (priceOverrides), their choice per alternative. The customer picker on all three surfaces (storefront, kiosk, table) shows the live deal total and a "+15 kr" / "−5 kr" delta next to each swap. The combos editor gained the deal price + a per-slot default picker + per-option upcharge inputs. The existing AI Combo Suggestion now publishes a genuinely swappable deal combo in one click (each add-on becomes a slot the guest can swap among its category siblings, server-side) via a new /combos/from-suggestion seam. The combo editor gained a photo picker (the same MediaPicker products use — upload / stock search / an image made in Experience Studio), so the owner sets the combo photo the same way as any dish — no surprise AI charge on publish. Priced by the SAME server engine as the register (expandCombo); client prices are display-only. Verified by a 3-lens adversarial money review — 3 fixes: the deal net is now floored at 0 (a negative override can't leak a discount onto the cart), expandCombo now rejects an out-of-slot swap product (was pricable), and the AI publish now writes atomically (can't clobber a concurrent order/wallet write). tsc 0 errors, full build green, 114 menu tests.
    • Menu — Combos & bundles UNIFIED into one "Combos" concept, orderable everywhere (Phase 67). The platform had two overlapping, confusing entities: an explicit slot-picker ComboGroup (build-your-meal, POS-only) and an automatic MenuBundle price rule (silent cart discount). They are now one thing — a Combo has fixed and/or "pick-one" items, a fixed / amount-off / percent-off price, and an optional auto-apply toggle that reproduces the old bundle behaviour (discount a cart that already holds the items). One editor (/dashboard/menu/combos) replaces both; legacy MenuBundles migrate into combos on read (idempotent, nothing lost); the pricing engine feeds the existing, tested applyBundles from auto-apply combos so auto-apply now fires on every surface (storefront, kiosk, table/QR — previously table applied no discounts — and register). Explicit combos are now customer-orderable on all three front-ends (storefront MenuClient, KioskEngine, table OrderingScreens) via a new synthetic "Combos" section + slot-picker; the five public order routes (storefront + PayPal + Adyen + kiosk + table) accept comboSelections and re-price them server-side through the SAME engine as the POS (client prices never trusted), with combo members excluded from bundle/coupon inputs and the signed combo net applied to the total (a fixed-price combo charges its fixed price even when cheaper items are picked). Verified by a 3-lens adversarial money review (2 fixes: a kiosk cash-path discountAmount that dropped the combo saving, and a fixed-price-combo undercharge) — full next build green, 257 tests. comboName is stamped on member lines so receipts/tickets can group them.
    • Menu — Product editor redesigned into a roomier, better-structured dialog. The "Edit / New Product" editor moved from a cramped 580px right-side drawer with four squeezed top tabs to a spacious centered modal (max-w-5xl, up to 800px tall) with a left section rail — each of the four sections (Basic Info, Dietary & Allergens, Options, Cost & Profit) now shows an icon, a one-line description and a live count badge, so the owner always knows where they are and what still needs input. The header gained a live product summary (thumbnail + name + price pill), each section shows a heading, and the Basic tab is grouped into titled sub-sections (Details / Pricing & category / Selling model / Visibility) with the weigh-and-pay + pre-order options as distinct cards. The wider content pane lets the allergen chips flow ~6 per row (was ~3). Fully responsive — on mobile it becomes a full-screen sheet with the rail as a horizontal scroll strip — and correct in both light and navy dark themes. Adds ESC-to-close, initial focus, focus-return and body scroll-lock. Pure presentational change: every field, handler, validation rule and save payload is identical; new copy keys fall back to English in all languages.

    Fixed

    • Food Bazar — browse-only restaurants no longer read as "Closed" while their card says "Open Now". Two distinct states were conflated: closed by hours vs doesn't take online orders (owner toggle / entitlement). An open restaurant with ordering off showed a "Closed" pill on every menu item — contradicting the honest "Open Now" hours badge. Now browse-only mode shows NO per-item pill (the page-top "doesn't take online orders" notice explains it) and the combo picker's disabled button says "Ordering unavailable" (sv: "Beställning ej tillgänglig") instead of "Closed" — the "Closed" pill is reserved for genuinely closed-by-hours. Also fixed the item detail page ignoring the ordering toggle entirely: it computed canOrder from hours only, so on a browse-only restaurant it still offered a live "Add to cart" (server would refuse at checkout) — it now mirrors the profile exactly (entitlement/owner-toggle first, then hours with the acceptOrdersWhenClosed pre-order override, which it also previously ignored) and shows the honest browse-only notice. Verified (Playwright): browse-only → profile has 0 "Closed" pills + notice + "Open Now" intact, item page shows notice + disabled CTA; toggle on → ordering fully restored on both.
    • Online ordering — the owner's on/off toggle now stays in sync between the restaurant website and Food Bazar. The online-ordering capability is one owner switch (settings.capabilities['online-ordering'], Control Center) whose live value = entitled AND owner-enabled via the shared liveCapabilitySet. The tenant storefront already read it, but the Food Bazar surfaces read the entitlement only — so an owner who turned ordering off saw it disappear on their website while Food Bazar still showed "order now" (and the reverse). Now every surface reads the SAME live capability: the Bazar restaurant profile (onlineOrderingEnabled, fail-open to match the storefront), the Bazar discovery feed's acceptingOrders badge, and — via onlineOrderingEntitlement — all five public order-creation routes (website cash/card/PayPal/Adyen + table QR), so "off" actually refuses orders everywhere, not just hides the button. Flip the toggle anywhere and the website + Food Bazar follow together. Verified end-to-end (Playwright): toggling capabilities['online-ordering'] off makes both the storefront and the Bazar profile go browse-only (0 add buttons, "Closed" pills, browse notice), and on restores ordering on both.
    • Delivery platforms — incoming orders now actually reach the restaurant (were silently dropped/mis-booked). The Wolt / Uber Eats / Foodora / Deliveroo order webhooks were hand-rolling orders via db.orders.push — the exact anti-pattern the order seam warns against — so a delivery order appeared on the board but bypassed the ledger (no revenue for accounting/commission/reconciliation, breaking the cross-truth invariant), item→product mapping (kitchen station routing, recipes, stock, COGS all broke), kitchen-ticket/receipt printing, and the orderNumber lock. All 5 receivers (incl. a new GloriaFood order webhook) now route every accepted order through the locked createTenantOrder seam (born-paid settle → one paid ledger row + order.paid event, idempotent redelivery, hardware dispatch on a fresh create), map every line to a real product, and are HMAC/token fail-closed (was optional), with an indexed tenant lookup (was an O(n) all-tenant scan) and encrypted platform creds (were plaintext).

    Added

    • One restaurant login now works on Food Bazar and Food Stories automatically (platform-credential bridge). A restaurant person's Restora account (master.users) previously failed with "Invalid email or password" on the Food Bazar and FSN sign-in forms — those surfaces each have their own consumer account store. Now both login routes fall back to verifying the email + password against the PLATFORM user store; on first success they provision (or adopt-by-email) a SEPARATE linked surface account — BazarUser.linkedUserId (new field) / FsnAccount.linkedUserId — per the existing ADR-FSN-identity pattern (bridge, never a merge: the marketplace session grants zero platform privileges, and the platform password is re-verified on every login, so a platform password change applies immediately). The FSN dashboard bridge (FSN-8) and the new login fallback now share ONE provisioning implementation (lib/fsn/platform-bridge.ts) with identical suspension/lifecycle semantics. Security posture: generic 401s (no platform-account-existence oracle; decoy hash equalizes timing), 2FA-enabled platform accounts are excluded (a bridge must never skip a second factor; 2FA is only mandatory for superadmins), and an FSN account protected by its OWN 2FA is never adopted via platform password. Verified end-to-end: platform creds sign into both surfaces (provision + idempotent reuse, 1 account), wrong password 401, pure consumer register/login untouched.
    • Menu — category SECTION BANNERS (owner-uploadable per-category images). A category can now carry its own image (Category.imageUrl), rendered as a banner behind the section heading — the category name (+ food glyph) overlaid on the photo with a legibility scrim — on both the tenant storefront `/menu` and the Food Bazar profile (classic text mode = centered title; photo-card mode = left title + item count). Categories without an image keep the clean text header, so a menu can mix banner and plain sections. Owners set it in the menu editor: each category row in the sidebar gains an image button that opens the same MediaPicker products use (upload / stock search / an Experience-Studio image) in a modal, with a live thumbnail on the row and a one-click Remove. New shared CategoryBanner component (theme-agnostic — white overlay text works on both the tenant theme and the marketplace theme). Threaded end-to-end: Category.imageUrl in the model, GET/PATCH on /menu/categories/[id] (image validated with isSafeImageUrl; '' clears), and the shared Bazar payload. This closes the "some restaurants have category images" case. Full next build green; Playwright-verified the banner renders in both classic and card modes with plain fallback headers for image-less sections.
    • Menu — photo-less menus now auto-present as an elegant CLASSIC text menu, everywhere (Food Bazar + storefront). Most real-world imported menus arrive with few or no item photos, and a photo-card grid then degrades into a wall of blank/placeholder tiles (the Food Bazar profile showed a gray fork-and-knife tile per dish). One shared decision — resolveMenuPresentation(items, menuLayout) in lib/menu/menu-style.ts (11 unit tests) — now drives every consumer surface: the owner's saved `menuLayout` always wins (photos on = cards, off = text), and with no saved layout it auto-detects from real photo coverage (below 50% of items with a usable photo → the classic printed-menu look). The classic presentation is numbered rows (auto-numbered per category even when menu-numbering is off), bold name, right-aligned/leader prices, description + ingredients, hairline dividers, and centered section headers with a food glyph (🍔 Burgers, 🥤 Drinks — via a new categoryEmoji() reusing the marketplace taxonomy). Wired into: the Food Bazar restaurant profile (RestaurantClient — new ClassicMenuRow, photo carousels/offers suppressed in text mode since offer prices show inline; ordering add/qty controls preserved via an extracted shared BazarCartControl), the Bazar item-detail imageless hero (branded "carte" panel with the dish glyph + name instead of a bare placeholder), and the tenant storefront `/menu` (MenuClient — same engine, tenant's own brand/theme). Each surface keeps its own design language (marketplace tokens vs the tenant theme) — one decision engine, per-surface rendering. The owner "items missing photos" nudge now softens to reassurance when the menu is already presented as a clean text menu. menuLayout is threaded through the shared Bazar payload for the owner-override. tsc 0 errors; verified end-to-end (Playwright) on a photo-less tenant → classic on both Bazar and storefront, and photos-restored → photo cards return (no regression), desktop + mobile.
    • Swedish kassaregister — certified control-unit adapter + fiscal receipt wired into settlement (R360-C01 + R360-C02, keys-only). A REAL certified CLOUD control-unit (kontrollenhet) adapter (CloudControlUnit, e.g. fiskaly SIGN SE) now implements the existing ControlUnit interface — bearer-auth HTTPS signing of each receipt over the mandated fields, registered via the same adapter/registry pattern as the accounting providers (registerControlUnitAdapter / resolveControlUnit). It fails safe to the deterministic local sandbox stub (certified:false) whenever creds / liveReady are absent, so a receipt can never masquerade as a legal registration; go-live = the certified unit's creds + liveReady. recordFiscalSale is now wired into the order settlement path (createTenantOrder born-paid + finalizeSeamCharge capture): every paid Swedish sale is recorded exactly once (idempotent per order, POST-COMMIT / best-effort — never blocks or throws into the order mutation), the monotonic per-register counter (receipt number + grand total) is advanced under the row lock (gapless numbering), and the immutable journal is DERIVED from the orders (no unbounded array). Adds X-report (running) + Z-report (daily close, monotonic Z-number + period boundary persisted under lock), a fiscal status panel on the compliance dashboard (mode mock/live, control-unit serial, counter, last Z, X/Z buttons), and a GET/POST /api/tenant/[tenantId]/fiscal route. ALL fiscal math is deterministic — never an LLM. XML journal export reused as-is. 18 injected-dep unit tests (control-code determinism, counter monotonicity, idempotent record-once, X/Z aggregation + period boundary). ⚠ The cloud unit's exact API envelope + XSD are UNTESTED until certification/creds land (like a keys-only rail); sandbox receipts are compliant drafts, not legally valid until a certified unit signs.
    • B2B supplier payout reconciliation sweep (R360-H70, keys-only). The supplier charge + delivery-time payout halves already move real money, but a payout stuck pending (supplier not yet Connect-onboarded at delivery) or failed (a transient transfer error) had no way to complete. New daily supplier-payout-reconcile cron scans delivered orders and safely RETRIES pending/failed payouts through the same idempotent settleDeliveryPayout path (Stripe idempotencyKey: payout_<orderId> → a retry racing a real transfer returns the original, never double-pays). Terminal states (paid, simulated) are never retried; failed retries are capped (MAX_PAYOUT_RETRY_ATTEMPTS). It also reports charge↔payout mismatches (a supplier paid out with no real restaurant charge behind it, or a really-charged order whose supplier is still unpaid) for operator attention — never inventing or reversing a money movement. With no Stripe rail configured it is an honest no-op. Registered in PLATFORM_CRON_JOBS + vercel.json (fail-closed CRON_SECRET gate); 10 injected-dep unit tests.
    • Delivery platforms — outbound to the aggregators (real, keys-only): real menu / item-availability (86) / store open-closed push OUT and order status callbacks (accept / reject / ready) — Uber Eats fully real (OAuth + Menu/Store/Order APIs), Deliveroo real order-side, others real where their partner docs permit, all with honest mock-fallback when creds are absent. GloriaFood is now a first-class tenant-bound connectable platform (per-tenant encrypted push token — the routing key is no longer spoofable across tenants). Verified by a 3-lens adversarial review (4 HIGH findings — GloriaFood dead-routing + a cross-tenant auth spoof + two reject-enum 400s — all fixed + pinned). Go-live = each aggregator's partner-program credentials + certification (GloriaFood needs none — just paste the webhook URL + token).
    • Accounting Integration Platform — finished the Phase-50 engine to production-real, keys-only. The universal provider-agnostic engine already shipped (Phase 50, BK-0…11); this closes it to a genuine Sweden-first go-live where the only remaining gates are credentials + sandbox verification + the flag flip — no more code.

    - Real expense + journal-entry push (BK-9) on Fortnox + Visma (previously unsupported): supplier-invoices + vouchers via their real APIs, supplier ensured search-first, balanced double-entry validated before any POST, idempotent via the external-id ledger + a deterministic externalRef marker. - Two new complete real adapters — Xero + QuickBooks Online (2 → 4 real providers): the full AccountingProviderAdapter (company/accounts/VAT-codes/fiscal-years, customer upsert, invoice, credit-note, payment, journal, expense) against each provider's Accounting API, with their native idempotency headers (Xero Idempotency-Key, QBO RequestId), honest error classification, OAuth-bearer + tenant/realm scoping — mock-fallback preserved when creds are absent (isProviderMockMode). - Inbound provider webhooks: real HMAC signature verification (fail-closed, timing-safe, raw-body), the Xero intent-validation handshake, idempotent by event id, their own isolated inbound store; an unconfigured provider honestly 503s rather than process an unverified body. - Reconciliation report (BK-11): provider-posted (external-id ledger) vs local books per document type — drift / orphan / dead-letter classification, honest — where a provider figure can't be read, surfaced on the accounting sync page. - Mixed-rate VAT: per-line 12% + 25% on one bill via an additive optional Product.vatCategory (menu-editor set) — resolving to dated Phase-19 rates per channel. Strictly additive: an uncategorized order renders byte-identical to before; a review-caught tax bug (categorized additive catering/table-bills mis-taxed) was fixed so mixed-rate applies only to VAT-inclusive orders and nets order-level discounts. - Verified by a 3-lens adversarial money review (5 findings — a HIGH VAT-compliance bug + a customer-mislink idempotency collision — all fixed + regression-pinned). 402 accounting/bookkeeping/VAT tests green, full next build green. Go-live gates (sandbox verification against real Fortnox/Visma/Xero/QBO test companies, per-country legal consent, the flag flip) are documented, keys-only.

    • Growth Command Center BI — finished to 100% (real data or an honest "—", never a fabricated number). The super-admin /admin/growth dashboard was already mostly real; this wires every remaining panel to a real source and retires what has none.

    - Killed the one null-honesty violation: the "Direct Joins" module rendered seed panels (fake restaurant names / "199 kr") under live:true, suppressing the sample flag — a super-admin saw fabricated data as real. Now built from real direct-channel tenants (joins-by-source from acquisition.utmSource/referrerHost, plan mix from subscriptions, recent joins with real first-invoice amounts), or honestly SampleNote when there are none. - Now real from existing sources: channel CAC/ROI from real sales-agent + web-designer commission ledgers (per-channel; every channel without a real platform-acquisition cost stays — — tenant sponsored-ad spend is deliberately excluded because it isn't platform acquisition cost); leaderboards (top agents by attributed MRR/deals, top designers by sites-live); the industry drill (grouped by businessType); the country drill (from explicit billing/registrant country or an unambiguous currency→country map — EUR/USD never inferred, self-retires to sample below coverage); the executive briefing + advisor cards (deterministic, every claim citing a real number — no LLM); the MRR forecast (from the real trailing-trend slope, replacing hardcoded projection %). - Retired the fake: the Waste & Recycling module had no data source and none planned → removed from the nav rather than ship a permanently-representative module. The decorative "Last 30 days" pill (nothing was date-windowed) was removed rather than fake a filter. - CSV export now covers the funnel, forecast, leaderboards and module panels, each tagged with a source (live | sample) column so an export can never present seed as real. Phase-62 Marketing module + all its hooks preserved.

    • Swedish receipt & invoice set — closed to 1:1 with the design handoff. The Kvitto, Faktura and Leveranskvitto were already ~85–90% implemented in prior sessions; this closes the remaining gaps against the reference, verified by a headless screenshot-compare per the 1:1 rule (which also caught two real correctness bugs — see Fixed).

    - Kvitto (thermal receipt): the hosted receipt + PDF + email now render itemized lines (name, á-pris sub-line when qty>1, option sub-lines), the Kassa/Kassör/Bong register row, and the Terminal/AID/Ref/TVR card-acquirer trace + website footer — all additive and honesty-guarded: fields render only when the terminal actually reports them (items persist only when they sum exactly to the recorded total), never invented server-side; older terminals get byte-identical lump-sum receipts. - Faktura (A4 invoice): the shared InvoiceModel + both renderers (screen + PDF, used by sale/catering/canteen/webshop) gained the "Avser:" subject line, Kundnummer, a seller tagline, the "Sida 1 (1)" footer, and the Swish payment rail — a green Swish label + Swish-nummer row + a fixed-amount locked Swish QR (the design's C-format payload), preferred over the bank-app QR when the tenant has Swish, coexisting with the Bankgiro/OCR rows. - Leveranskvitto (delivery slip): it rendered 1:1 but nothing linked to it — added a "Leveranskvitto" link on the dashboard orders history (delivery/pickup only) and a real 32-col ESC/POS delivery-slip print job (delivery-slip device-job kind), preserving the name-only privacy projection (no address/phone/portkod on the slip).

    Fixed

    • Invoice VAT-inclusive rounding (surfaced by the 1:1 compare): storefront orders whose total already includes moms rendered Netto = gross and an impossible Öresavrundning −50,00; buildSalesReceipt now detects additive-vs-inclusive VAT by which yields a plausible öresavrundning (±0,50), so inclusive orders show the correct Netto/Moms/rounding and every existing golden fixture stays byte-identical.
    • Swish number formatting on receipts + invoices — bare 10-digit numbers now display design-grouped (123 456 78 90), owner-typed formatting preserved, WinAnsi-safe.
    • Adyen rail — the money core completed (Group A): refund confirmation, double-refund fix, webhook hardening, webhook-miss reconcile.

    - G1 — refund confirmation: /api/adyen/webhook now handles REFUND / REFUND_FAILED (it previously dropped everything but AUTHORISATION). The pending refund row the refund route posts is matched by documented precedence (refund pspReference → ledger idempotency key from our minted refund:{tenantId}:{key} merchantReference → originalReference+amount fallback) and flipped by the new finalizeRefundRow (once-only, never downgrades — mirrors finalizeLedgerCharge). A late REFUND_FAILED after a confirmed refund is audited, never auto-reversed. - A2 — credit-note timing: the BK-7 credit note is now issued when the refund CONFIRMS. Split by row status, not provider: sync rails ('refunded' rows — mock/gift/immediate) keep today's same-lock behavior byte-identical; async rails ('pending' — Adyen) defer to the confirmation flip via the new confirmAsyncRefund chokepoint (lib/payments/refund-confirm.ts), reusing the exact same recordCreditNoteForRefund path (idempotent by ledger-entry id). - C1 — double-refund guard (real money bug): the refund route's remaining-math now counts PENDING refunds conservatively (refundAvailability); a second click while one is in flight gets an honest 409, and a REFUND_FAILED flip frees the amount again. The refund idempotency key now defaults DETERMINISTIC (refund:{chargeId}:{amountMinor}, :rN past failed attempts) instead of a fresh UUID per request — identical retries dedupe in the ledger AND share the Adyen Idempotency-Key, so a double-click can never mint a second real refund. Explicit client refs still win. - Webhook hardening: merchantAccountCode must match the configured account (C4, fail-closed behind the HMAC); the order branch books what Adyen actually captured but flips the order paid ONLY when the total is covered (isFullySettled — C2, partials are logged + left pending, never over-credited); CAPTURE finalizes identically to AUTHORISATION keyed by the payment's pspReference (delayed-capture accounts confirm; auto-capture redelivery = no-op; failed captures audited); CANCELLATION releases pending seam rows honestly (paid-then-cancelled is audited for a human); CHARGEBACK flags the charge row (disputed — additive field) + audit log so disputes are VISIBLE (full dispute/defense flow = documented future item). Per-item try/catch + the [accepted] contract preserved. - G6 — webhook-miss reconcile for orders: the adyen-order route now stamps Order.adyenPaymentLinkId, and the customer's post-payment status polls (/api/bazar/track/[orderId] — the Adyen returnUrl landing — and the session-gated /api/bazar/orders/[orderId]) re-query the payment link once when the order is still pending, finalizing through the SAME creditAdyenOrderPayment path the webhook uses (one finalizer; a later webhook upgrades the row's providerRef to the refund-capable pspReference, never a second row). Mirrors the shipped gift-card fallback. - Verified: 29 new unit tests (ledger.test.ts, refund-confirm.test.ts, adyen/notifications.test.ts, adyen/order-credit.test.ts) with Adyen-doc-shaped fixtures; full payments suite 81/81; tsc --noEmit clean.

    • Payments — tenant Adyen toggle in Setup Center (Adyen rail, group C). The owner's Settings → Payments tab gains an Adyen card matching the Stripe/PayPal/Swish idiom: it toggles paymentSettings.adyenEnabled (the per-tenant opt-in the checkout + gift-card buy pages already gate on) via the existing settings PATCH. The switch is honest about platform state: a new tenant-scoped GET /api/tenant/[tenantId]/payments/adyen-availability returns a secret-free { available, environment } projection of the platform rail (projectAdyenAvailability, no-leak contract enforced by availability.test.ts), and when the platform rail is off the toggle renders locked with "Not yet enabled on the platform" instead of a dead switch that would silently no-op at checkout. Test-mode rails surface an amber "Test mode" badge + sandbox-only copy; live shows a green "Live" badge. Fail-closed on fetch errors; both dashboard themes; 320px-safe.
    • Adyen go-live console (super-admin, Phase 65 PAY-RAILS Group B). New RestoraPayAdyen panel on /admin/restora-pay (the existing rails console, already in the sidebar): honest rail-state banner surfacing resolveAdyenRailMode's mode + reason, redacted credential display (API-key last-4 / HMAC-set flag — secrets are write-only, never echoed), rotate forms for API key + HMAC key, merchant-account + live-URL-prefix inputs, a Test/Live environment selector with an unmissable "LIVE = real money" warning and a confirmDialog(danger) guard on any save that sets environment:'live' or enables the rail in live, the effective Checkout API base for operator sanity-checks, and a 6-step go-live checklist. GET /api/admin/adyen/settings extended additively with railMode / railReason / checkoutBase. Money-guard + checklist logic extracted pure and unit-tested (adyen-console.test.ts, 8 tests).
    • Phase 62 — AI Marketing Autopilot (MK-0 … MK-12): configure once → AI drafts, owner approves, honest ROI. A new per-tenant (and platform) marketing pillar that generalizes the shipped social autopilot rather than forking it: a deterministic planner ranks what to do across channels from the tenant's own data, an LLM only narrates copy (RIC-routed, one-wallet-metered, honesty-fenced), and everything runs under an owner-set approval mode (manual drafts · semi-auto low-risk auto + an approvals inbox · automatic within guardrails). Ships flag-OFF, mock-first, go-live = keys only (no second dev round).

    - The engine + Hub: a Brain (TenantDB.marketing) + a sense→plan→generate→gate→execute→learn orchestrator, surfaced as tabs on /dashboard/marketing (Command · Plan · Approvals · Learning · Analytics · Budget). An event→rule automation bus (lifecycle / weather / low-sales triggers, cloned from the accounting-sync dispatcher) and a unified CRM view + deterministic segments over the 5 fragmented customer stores. - Channels: organic social, email (with real open/click/unsubscribe tracking — fills the previously-empty CampaignAnalytics), consent-gated SMS + push, Google Business posts + AI review replies, VSO/VEO/EEO structured data. - Measurement: the order→campaign attribution link (a signed, consent-gated first-party carry → Order.campaignTag → the growth spine) makes ROAS/CAC real instead of —, and closes the learning loop back into the scorer. - Real go-live rails (built 100%, gated): complete Meta Marketing API v25.0 / Google Ads v24 / TikTok Business v1.3 ad clients + GA4 / Search Console ingestion, real per-provider OAuth, an encrypted connections vault (reuses the existing token key), a real budget engine (ad money kept strictly off the AI wallet) and a real social-publish bridge to the live FSN publisher — each serves an honest in-memory mock until its credentials + a live connection resolve. Owner-gated behind marketing_paid_ads / marketing_live_publish. - Verified: ~700 unit tests, full next build, and a 5-lens adversarial review (10 findings, all fixed). Docs: docs/products/marketing-autopilot/go-live-runbook.md.

    • Reliability — cron-drift remediation (shipped alongside Phase 62). The self-hosted prod box only ever fired ~15 of 52 cron routes (vercel.json is inert under pm2); PLATFORM_CRON_JOBS is now the single source of truth (15 → 45), reviving ~24 silently-dead jobs (incl. the Phase-66 workforce crons + order-rollup that ran nowhere), with a cron-registry.test.ts drift guard so a cron can never merge unregistered again, heartbeats + a Guardian staleness alarm, and two real billing bugs fixed (restora-pay minimum-fee / swish-fees day-1 under-billing).

    Fixed

    • RIC consolidation: the blog/assistant, citation-hub AI-draft, and SEO copy generators now route through the RIC gateway (the SEO "Generate with AI" buttons are genuinely AI now, with the deterministic templates as the honest fallback) — zero behaviour change while RIC is off.
    • Seller mode — a real tool for a person on a bike, not a trimmed restaurant dashboard. The lite shell now stops handing a solo mobile seller (cart / cycle / on-foot vendor who IS the cook, cashier and server) surfaces built for a multi-person venue. Kitchen Display and Customer Display became opt-in features (kitchen-display / customer-display, basic-tier so every existing multi-station tenant keeps them — zero regression — and add-on-purchasable so a food truck with a real second screen can switch them on); the Orders-page display tabs and the /dashboard/kds route are now gated on them, so a seller with no second screen sees a clean orders list and no "Connect a screen". The seller nav is rebuilt around the actual loop — Sell · Orders · Menu · Money · Where am I today · Get discovered · QR code · Gift Cards · Reviews · Billing · Setup · Help — with seller framing (no "Take Order (Waiter)", no tables/seats/courses), landing on the new Sell counter instead of a restaurant overview.
    • Services directory redesign — imagery + a platform-aware app-download showcase (`/services`). The flat icon grid becomes a full marketing surface: an editorial hero with a live laptop + POS-phone dashboard mock, a new "Download the apps" showcase (flagship Owner card + a per-role app grid), an enriched searchable directory, and an ambient "every device" CTA band over real food photography.

    - App→platform accuracy: every app shows App Store / Google Play / Windows badges only for the platforms it actually ships on, driven by a NATIVE_APPS registry cross-checked against android/settings.gradle.kts, the iOS/ apps and the Tauri Restora360 Station (e.g. FSN = iOS + Android, no Windows; Kitchen Display = Android + Windows; Menu TV / Atmosphere / Staff = Android-only). Windows apps are labelled as running inside the Station shell. - Mockup download links: no public store listings exist yet, so badges preview each app and open its live web surface today — centralised so go-live is a one-line swap to the real App Store / Play / Station installer URLs. - Directory: services carry per-app platform glyphs inline and a platform-correct "Get app" reveal; the auth-gated "Worker shift client" entry corrected to login-required. - Scoped to the .r360-landing azure-on-navy theme (no new tokens); a11y-reviewed (filter aria-pressed, polite results live-region, AA-contrast tag fix). Verified: TypeScript clean; live render 200 with zero horizontal overflow at 390 px and 1280 px.

    • Wave 0 — the unified payment layer: go-live is now KEYS ONLY. The lib/payments seam (the choke point every POS settle, deposit, top-up, table-pay and bar flow already calls) finally has real rails behind it — the registry that sat empty since Phase 33 now resolves per-rail bridges built on the existing production-grade code:

    - Per-rail three-state mode (disabled | test | live, lib/payments/provider-mode.ts) derived purely from credential presence + the explicit switches: a Stripe sk_test_ key or the Swish MSS cert or Adyen test env = test (real sandbox round-trips, mock:true rows, exercisable end-to-end with zero risk); live keys + the restora_pay flag + the mode switches = live. Matrix-tested; a super-admin entering keys flips a rail with no deploy. GET /api/admin/payments/rails is the live readout with honest per-rail reasons. - Three provider bridges (lib/payments/providers/): Stripe Connect (direct charges on the tenant's Express account with the clean application_fee cut, pending+clientSecret results), Swish (mTLS client, deterministic retry-safe 32-hex instruction ids, e-commerce push via payerPhone or swish:// app link, brief settle-poll), Adyen (hosted Pay-by-Link + HMAC webhook). Plus the honest external-tender recorder: cash / external-terminal card / gift rows become real money rows (mock:false) once restora_pay is ON. - Real holds: authorizePayment / captureHeldPayment / voidHeldPayment (Stripe manual-capture PaymentIntents) with a mock fallback so every flow works in every mode. - Pending→final plumbing: finalizeSeamCharge (split-bill-aware, idempotent, BK-5 events) driven by all three confirmation channels — the Swish callback (ledgerKey pending records), the Stripe connect-webhook (metadata.seam='payments', incl. amount_capturable_updated hold confirmation), and the Adyen webhook (ledger: references). POS settle now answers 202 awaiting approval with the Swish app link / Stripe client secret instead of pretending an instant settle. - PAYMENTS_MODE=mock stays the global kill switch; =live = strict fail-loud (never silently mock-settle). Ledger rows carry mode: 'mock' | 'test' | 'live'.

    • Wave 1 — deposits ride the real rail. Reservation deposits and pickup-slot deposits (previously mark-only: no charge, no ledger row, no refund) now capture through the unified seam (reservation-deposit: / slot-deposit: idempotency-key families with never-double-charge retry minting), post honest ledger rows, and refund on cancel/refund actions against the recorded paid charge (over-refund capped). Catering deposit/balance and bar settle were verified already-wired. Legacy mark-only records keep their exact old behavior.
    • Wave 1 — bar tab pre-auth is a real card hold. Tab open places a genuine hold (authorize → guest confirms card → webhook flips pending → held), card settle captures the hold (remainder auto-releases), void/close releases it at the provider. Mock holds keep today's flow byte-for-byte; replaced/orphaned holds are voided, never stranded.
    • Wave 1 — production ESC/POS receipts: the ` MOCK PRINT ` stamp is dead. lib/devices/render.ts now emits the byte-accurate Swedish Kvitto layout (styled ESC/POS: emphasized store header, org/moms numbers, wrapped item lines with options, gift-tender rows, back-derived VAT table, ATT BETALA double-height, sv payment labels, CP865 åäöÅÄÖ) through the EXISTING Windows Station transport — no station rebuild needed. Preview mode (*** FÖRHANDSVISNING ***) renders the same layout whenever no agent has claimed the printer; preview defaults ON so an un-updated caller can never accidentally print a clean receipt.
    • Wave 2 — canteen seams closed. (1) The daypart productIds allow-list now actually switches the menu (kiosk feed filters products + drops emptied categories within one poll; the POS and kiosk order writes 409 out-of-window items server-side) with a per-daypart product picker in the canteen admin. (2) The self-order kiosk accepts the canteen wallet/subsidy tender (badge-code keypad in the cart, same one-lock debit path as POS — factored into shared lib/canteen/tender.ts, default ON with canteen, explicit kioskWalletTender:false opt-out). (3) Canteen card/Swish top-ups ride the unified seam: with real rails the wallet credits ONLY when the money is confirmed (a source-keyed finalize hook), never on initiation.
    • Wave 2 — Seller plan + true LITE dashboard. The 99 SEK street-food-starter plan gets a curated street-food-sized feature set (was: the full basic cockpit), Stripe price wiring (STRIPE_PRICE_SELLER), billing-page bullets, and a one-click Restore & publish in the Pricing Studio (ships archived/CLOSED until then). Plans can declare uiMode:'lite' → tenants on such a plan get a genuinely simplified shell: flat 11-entry sidebar, no group headers, ~52px touch rows, /dashboard lands on order-taking. Same routes, deep links intact, absent signal = byte-identical full shell.
    • Wave 2 — Guest at-table QR ordering (scan → browse → cart → KDS → pay from the table). The table QR (/t/[tenantId]/[tableSlug]) grows a full guest ordering flow, default OFF per tenant (settings.tableOrdering.enabled, editable via the standard settings PATCH) and additionally gated by the master ita_enabled flag, the per-tenant ita.enabled opt-out and the online-ordering entitlement — with the gate off the QR page is byte-identical to the pay-only surface it was.

    - Order create: new public POST /api/public/[tenantId]/tables/[tableSlug]/order — HMAC guest token pinned to tenant AND tableSlug (a token minted for one table can never fire to another), rate-limited, demo-blocked, entitlement- and closed-hours-guarded, server-side re-priced (verbatim port of the online-order loop incl. multi-locale option resolution + stock/86 gates, shared as pure lib/tables/table-ordering.ts), created through the locked seam (createTenantOrder) as type:'dine-in' + tableId/tableNumber + new source:'qr', born pending, idempotent by clientRequestId. Lands on the KDS/orders board exactly like a waiter dine-in ticket (the table badge already renders). - Pay from the table: new public POST …/order/[orderId]/pay through the unified payment seam (capturePayment → postChargeToLedger, source:'table-qr') — mock settles instantly; Stripe rails return clientSecret (confirmed on the guest's phone, webhook finalizes); Swish returns the swish:// deep link (callback finalizes). Stable idempotency key with deterministic retry-after-recorded-decline. Pay-later remains first-class via the existing staff-built bill QR flow. - Guest UI: Menu / Cart / Your-orders screens inside the existing ITA PWA (320px-first, azure, en+sv via the ITA i18n table), option bottom-sheet honoring radio/checkbox + min/max, sessionStorage cart per table, live status via the existing token-gated /api/bazar/track/[orderId] at the 5s cadence. - Menu projection: GET /api/public/[tenantId]/ita/menu additively gains price/description/imageUrl/available/localized optionGroups + ordering:true/currency — ONLY when the tenant gate is on; otherwise byte-identical (still unpriced). - QA: 10 new node:test units (binding, gate, re-pricing, schemas); end-to-end mock run on the dev server (order fired → dine-in ticket with table binding on disk → track → mock pay → paid flip + ledger row + order.paid outbox; idempotent replay / cross-table rejection / gate-off regression / closed-hours guard all verified) + 320px Playwright screenshots of every screen.

    • Phase 60 — KDS 2.0, the world-class kitchen display (waves KDS-0…10 + TV parity, delivered in one go). The kitchen board grows enterprise-grade workflow while staying exactly as simple as before by default — every new capability is opt-in and OFF at factory settings, and the order engine is untouched (bumps still ride the existing order PATCH + offline outbox; course-fire unchanged).

    - Settings (KDS-0): gear icon → a touch-first Kitchen Display Settings modal (Appearance / Workflow / Order display / Behavior / Sorting & station), persisted per device (kds-settings:v1:{tenant}, migrates the old kds-sound key). Reset = today's board, exactly. - Themes (KDS-1): Dark (default — the familiar look, polished) / Light / Auto + a high-contrast option, implemented as a scoped CSS-variable layer so the KDS keeps its own switch independent of the dashboard theme. Font size S/M/L/XL scales the whole board; compact mode for busy screens. - Card redesign (KDS-2): bigger numbers and timers (readable at 2–5 m), 4-step priority (On time · Waiting · Late · Critical — always icon + label + color, color-blind safe), order-type + delivery-channel badges, table numbers, every line toggleable. - Cooks & claims (KDS-3): tap-your-name cook bar (Phase-38 roster + ad-hoc names) and advisory, non-blocking "I'm on it" claims with avatar chips. - Item progress (KDS-4): large per-item check targets, quantity-aware (3× counts 1/3 → 2/3 → ✓); Auto Complete bumps the order through the REAL lane transition when the last item is checked; held course items can't be checked (pacing wins). - Assignment (KDS-5) + vocabulary (KDS-6): per-item cook assignment with initials dots, and one shared icon+label status chip set (Waiting/Working/Ready/Served/Done/Cancelled/Hold/Fired) used across web and TV. - Undo & Recall (KDS-7/9): every bump/check/claim/assignment gets a 15 s themed Undo toast (offline-safe — inverse ops share the same outbox dedupe keys); Recall brings back the last bumped tickets; bulk Complete-all-ready and Clear-done; Print ticket re-enqueues through the RW-3 kitchen-printer pipeline (hidden when no printer). - Filters, sorting & stations (KDS-8): one-tap filter chips (All/Mine/Pending/Working/Delivery/Takeaway/Dine-in), six sort modes with an Auto Sort toggle (positions freeze when off), and per-device station pinning ("grill screen") with an n more items at other stations context hint. - Performance & a11y (KDS-10): the old whole-board 1 Hz re-render is gone (leaf <TicketAge> timers + bucket-only priority updates + memoized cards), the feed short-circuits on a contentHash, long lanes window their rendering behind an IntersectionObserver sentinel, number keys 1-9 bump the Nth Ready ticket (bump-bar friendly), U = undo, aria-live new-order announcements, reduced-motion honored. - TV parity (KDS-11): the read-only /tv/screen kitchen board shows claims, item check marks and assignee dots — additive feed fields, rendered only when the tenant uses them. - Data & API: additive-only OrderItem.kdsState/kdsDoneCount/kdsCompletedAt/kdsAssignee + Order.kdsClaims; new idempotent POST /kds/actions op channel (absolute values, outbox-replay safe). POS, waiter, storefront, Kanban and tracking are untouched. - QA (all gates green): 8 new unit tests (settings merge, quantity cycle, hold blocking, undo stack, recall pool); a 44-assertion live Playwright gate — zero-regression at factory settings (real bump PATCH, real course fire), the full feature matrix (claims/checks/assign/undo/auto-complete/filters/station/bulk/recall/print-gating) against the live board, theme × contrast × font screenshot pass, and scale 5/40/200×6 (board interactive ~3 s, windowed DOM 120→180 on scroll, p95 scroll frame 18 ms, zero page errors).

    Changed

    • No more native browser dialogs — anywhere (repeated owner request, now a guarded platform rule). Every window.confirm / alert / prompt on every surface (owner dashboard, super-admin, agent/designer/supplier portals, order tracker, Food Bazar, Mason, FSN, invoice pages) — 85 call sites across 73 files — now uses the themed in-app dialogs, which follow Light/Dark mode and never show the browser's "restora-server… says" chrome. The shared component grew alertDialog (single-OK notices) and promptDialog (text input, same string | null contract as window.prompt) beside the existing confirmDialog; destructive confirmations get a red action button and a proper verb ("Delete", "Revoke", "Unpair", …). A new guard test (no-native-dialogs.test.ts) scans the whole source tree and fails the suite on any future native dialog call — the rule can't silently regress again.
    • Test suite fully green again — both long-standing red tests fixed. intelligence/import-guard: the Learning Ledger no longer imports the raw Intelligence Store accessor; it reads its one aggregate through a new sanctioned seam (lib/intelligence/ledger-stats.ts → rieEventCount()), keeping RIE isolation mechanical. dark-vocabulary: the three flagged pale hexes are genuinely fixed surfaces (TV-canvas selection handles + default TV text color, the TV style-tile mini-preview, and the transparency checkerboard behind background-removed PNGs) — allow-listed with documented reasons per the test's own convention. Also fixed 4 pre-existing lint errors in touched files (a local helper named useImage tripping rules-of-hooks, unescaped JSX entities).

    Added

    • Phase 61 follow-ups IA-2b · IA-3b · IA-3c · IA-5b — the four parked Navigation-2.0 follow-ups, all delivered.

    - IA-3b — Mason Menu tab reaches parity with the retired Navigation Builder, and `showCartButton` finally has a renderer. Every top-level navbar link in the Navbar editor's Menu tab now has three per-surface visibility toggles (Desktop/Mobile/Footer) and a badge picker (None/New/Hot/Promo) — the exact fields sanitizeNavItems always round-tripped but no UI exposed. A "Cart icon" card toggles navConfig.showCartButton, which was dead config end-to-end: there is now a real header cart icon (HeaderCartLink, live item count from the layout-wide cart, links to the menu page) rendered by BOTH the legacy SiteHeader and the themed SiteNavbar, gated on the Online-ordering capability like every other ordering surface. SiteNavbar also renders link badges now (they were mobile-only before), the Mason header preview mirrors desktop visibility + badges + the cart icon (WYSIWYG), and reconcileNavItems no longer strips badge/visibility from nested child links on every save. - IA-2b — new QR screen pairings create per-device records; fielded TVs untouched. "Connect a screen" codes are now minted as PRE-AUTHORIZED TvDevice links (POST tv-devices/preauth), and both TV-side flows — typing/scanning the dashboard code (/api/public/tv/pair) and scan-to-connect from the TV (live-orders/tv-link + tv/link/[deviceId]) — create a real TvDevice (customer-display mode) at pairing time, so every NEW screen shows up in the Hardware Center fleet with name, online heartbeat, theme override, remote commands and per-device revoke. The /tv page stores the device identity and boots the full /tv/screen experience. Transition-safe by design: the shared liveOrdersDisplayToken, its rotate/disable card, the /display page and every fielded TV's stored token are untouched (NEVER auto-rotated); the live-orders display feed additionally accepts active customer-mode device tokens (with heartbeat), so even a TV still running cached pre-deploy /tv JS pairs into a working, revocable screen. Owner UAT left: one real-TV re-pair (documented in TODO). - IA-3c — approved WoW-clone theme snapshots feed the Mason theme gallery. Approved snapshots (reviewStatus === 'approved', super-admin review is the curation gate) surface as skin-only standard packages in the unified catalog — colors/font/header/menu-style only, tenant content untouched — riding the normal install pipeline (rollback snapshot, undo, themePackageFlags disable). ThemeVariant gained menuStyle so the snapshot's menu presentation applies like the retired /dashboard/themes page did. Pipeline is dormant until the WoW funnel goes live (zero approved snapshots exist), so the gallery is unchanged today. - IA-5b — Help Center "Learn more" tab (the gate finally passed). The Academy (/learn/academy, Phase 15 role tracks + certifications + progress API) and Community forum (/community) turn out to be REAL, working surfaces — the IA-5 "don't exist yet" premise was stale. New 5th Help Center tab with outbound cards (Academy, Certifications, Certified directory, Community; new-tab, since they're public pages), tabLearn label translated in all 20 languages, "Academy/Community/Learn" added to sidebar-search keywords. Bundled fixes: /learn + /community (+ their APIs) added to the proxy BYPASS_PREFIXES so they resolve from dashboards on TENANT hosts (same class as the IA-3 /studio 404), the /learn header is session-aware ("Open dashboard →" for signed-in owners instead of the pre-launch launch-list pitch), and the Academy CTA's stray non-azure hover was re-azured. - QA: tsc 0 · eslint 0 errors · nav guards 10/10 · theme-package tests 35/35 · pairing tests 62/62 · full suite 4498 pass (2 pre-existing known reds only) · live E2E on dev: full IA-2b pairing lifecycle (mint → exchange → fleet online → revoke kills both auth paths), builder GET/save showCartButton round-trip, storefront cart icon SSR, Learn tab + Academy/Community 200 on tenant hosts.

    Changed

    • Phase 61 IA-7 — Navigation 2.0 is now the default and only owner nav (the `nav_v2` flag is retired). After the full IA-0…IA-6 build + owner UAT, the job-based sidebar (5 daily primaries + workspaces + utility zones) is promoted to the one canonical catalog. The nav_v2 feature flag, its super-admin master-switch API, the per-request flag read in the dashboard layout, the navV2 thread through TenantProvider/sidebar/search/Quick-Links, and the one-time "navigation got simpler" intro card are all removed — no behavior is gated any more, so there is nothing to flip. Route resolution is preserved: the 19 hub-linked tools (the /studio designers + the Marketing Hub toolset) that used to resolve only via the legacy catalog now carry their own metadata in a HUB_LINKED_ITEMS list folded into ALL_NAV_ITEMS, so Quick-Links stars, bookmark validation and search keep working — including the exact-beats-prefix case (/dashboard/settings/google is not shadowed by the Setup Center /dashboard/settings prefix). Guards reworked to single-catalog invariants (10/10); the nav sweep no longer flips a flag. tsc 0, lint 0 errors, suite green. Everything still lives at the same URLs; absorbed routes still redirect.
    • Nav polish (owner makeover) — Marketing Hub & Experience Studio are single sidebar links, and the Marketing Hub cards now describe themselves. In the Navigation 2.0 sidebar, a group with exactly one destination (Marketing Hub, Experience Studio — and Help Center, same shape) now renders as a flat link instead of a dropdown: click it and you land straight on the hub. The Marketing Hub's individual tools (Offers, Loyalty, Email, Social, SEO, Reviews, …) are pulled OUT of the sidebar — they live as cards on /dashboard/marketing, which is the one place that "says everything." Those routes stay fully live and searchable (generalized HUB_LINKED_TOOLS registry + keywords; findNavItem now prefers an exact match in either catalog over a broad prefix, so /dashboard/settings/google still resolves). The Marketing Hub cards gained a one-line description each (matching the Experience Studio tiles) and translated labels. tsc 0; nav guards 11/11 (the "nothing removed" + "hub-linked tools stay live and linked" invariants now cover both hubs); suite green.

    Added

    • Help Center + Overview 2.0 (Phase 61 IA-5 + IA-6) — one help home, and a dashboard that answers "what needs me now". IA-5: /dashboard/help is the Help Center with four tabs — Guides (the 32-article browser, default; also fixes the latent ?q= deep-link bug), Ask Restora (the Phase-18 Support Brain; escalating now lands you on your new ticket), Tickets (the full ticket system, re-housed verbatim), and What's New (recent release notes rendered in-dashboard by the same parseChangelog() the public /docs/changelog uses — [Unreleased] filtered out, QA footers stripped, English-only noted). /dashboard/support is a redirect stub into the Tickets tab; the attention-bar "Support team replied" CTA and the header help dropdown point straight at it. The spec's "Learn more (Academy/Community)" tab is deliberately deferred (IA-5b) — those products don't exist yet, and dead links help nobody. IA-6: the Overview's Operations Center gets a curated default of FOUR widgets — Live Orders · Upcoming Bookings · Needs Attention (new: merged, severity-ranked, one hard-capped row per source — pending orders / unread messages / low or out of stock / offline devices — sourced from the existing overview payload + the existing devices summary; clears by state change, never by dismissal) · AI Recommendations. Everything else moved to a new add-widget gallery (per-widget hide ✕ + "Add widget" picker; zero data loss) — and owners with a pre-IA-6 saved layout are grandfathered (nothing hidden). Quick-Links stars now render as a shortcuts row on the Overview; the static Recent Activity card folded away (the Live Orders widget streams the live subset); the onboarding checklist auto-hides at 100%. i18n: help namespace (5 keys) + 10 new ops keys in all 20 non-en languages. tsc 0; guards 11/11; suite green (2 pre-existing fails).
    • Marketing Hub (Phase 61 IA-4) — marketing tools grouped by job, one site-scoring surface. New thin landing page at /dashboard/marketing: tools grouped Promote / Reach / Reputation / Insights (incl. the Marketing ROI cross-link into Restora Profit), each tile respecting the same per-item featureId visibility as the sidebar, plus a light status strip (active offers · avg rating · loyalty members · scheduled posts) read from FOUR EXISTING tenant GETs — live number or "—", no time series, deliberately NOT a fourth analytics view. Site-scoring dedupe: the AI Design Report is now the SEO page's "Site Health" tab (the SEO page gained ?tab= deep-linking); /dashboard/design-report is a redirect stub into it. The V2 marketing group is ordered by the four jobs with the hub on top; the earlier IA-4 moves (Messages → Customers, Voice/Table Assistant → In-Venue Ops, AI Spend → Plan & Features) had already shipped in IA-0's catalog. i18n: new marketing dict namespace (15 keys) + seo.tabSiteHealth in all 20 non-en languages. tsc 0; guards 11/11; suite green (2 pre-existing fails).
    • Experience Studio workspace + the nav-clobber data-loss fix (Phase 61 IA-3). New /studio workspace on the Atmosphere shell pattern (own scoped theme, session guard, navy sidebar) plus the back-to-dashboard link Atmosphere lacks; the V2 sidebar entry opens it in a new tab (existing non-/dashboard convention). It is a LAUNCHER — eight sections (Home · Website · Menu Screens · Kiosk · Displays · Create · Assets · Templates) of deep-link tiles; no tool route moved: Mason, the Menu TV designer, the Kiosk designer, Studio AI and the Media Library stay the single owners of their jobs (guard-tested via the new STUDIO_LINKED_TOOLS registry — each must remain a live page linked from the shell). The verified nav-clobber bug is closed: /dashboard/nav and Mason's NavbarEditor both wrote db.navConfig with incompatible item vocabularies — every save on one silently deleted the other's links. The stand-alone page is now a redirect stub into Mason opened directly on the Navbar editor's Menu tab (new additive deep links: /dashboard/site?panel=navbar|theme, &gallery=1); ONE write path remains. /dashboard/themes (the WoW-clone marketplace, zero approved snapshots in dev) also retires into the Mason theme gallery. The two buried pickers are surfaced as Displays tiles: customer-display themes (orders?tab=customer) and the order-tracking style (Mason theme panel). findNavItem gains a V1-pool fallback under nav_v2 so Quick-Links stars and bookmark validation keep working on tool pages that left the V2 sidebar. Bonus fix: /studio AND /atmosphere were 404 on tenant subdomains (missing from the proxy's BYPASS_PREFIXES) — both whitelisted; the nav sweep now fails on 404s so this class of bug can't slip again. Follow-ups tracked: IA-3b (surface badge/per-surface visibility/cart toggle in Mason's Menu tab — the API already preserves them), IA-3c (fold approved clone snapshots into the packages catalog). tsc 0; guards 11/11; suite green (2 pre-existing fails); sweep incl. /studio sections × both flag states passed.
    • Hardware Center (Phase 61 IA-2) — every physical device pairable and manageable from ONE page, and the invisible-screens pairing fork surfaced. /dashboard/devices is now a tabbed manager: Overview (summary grid + the universal pair form) · Screens & TVs (embeds the same ScreensManager the Orders hub uses — one TV fleet, two doors) · Payment terminals (absorbs /dashboard/terminals) · Self-order kiosks (absorbs /dashboard/kiosks; the Kiosk Designer route is untouched) · Printers & peripherals (absorbs /dashboard/hardware) · Reliability (absorbs /dashboard/reliability). The four absorbed fleet routes are redirect stubs into their tabs (ABSORBED_ROUTES grew; guard-tested). ONE shared `PairDeviceCard` replaces the copy-pasted 6-char-code forms (universal form, terminals, screens; the kiosk form keeps its pick-the-look wizard flow but shares the code cleaner). Legacy TV-pairing fork fixed by surfacing: screens paired via the Orders-hub QR path share one tenant-wide token and never got a TvDevice record — they now appear as a pinned "Shared display link" card in the Screens tab with Rotate/Disable actions (existing display-token endpoints; honest all-screens-go-dark warnings). Folding NEW QR pairings into per-device tv-devices records is a tracked follow-up (needs real-TV re-pair testing). Menu TV keeps its own screens list plus an "All screens & devices" link into the hub; the orphaned TvPairingButton.tsx duplicate was deleted. V2 nav slims (fleet items → search keywords); V1 untouched. i18n: a new devices dict namespace (9 keys) in all 20 non-en languages. tsc 0; nav guards 10/10; live sweep incl. all 6 device tabs × both flag states passed.
    • Setup Center + Plan & Features consolidation (Phase 61 IA-1) — 11 setup surfaces become ONE sectioned page, entitlement pages go 4 → 2. /dashboard/settings is now the Setup Center: every tab deep-linkable via ?tab= — Setup guide (absorbs the /dashboard/onboarding checklist, same localStorage+server-detected progress), General, Opening hours (absorbs /dashboard/business-hours; its viewport-fixed save bar became a tab-scoped sticky bar), Branding, Languages, Payments, Ordering & Delivery (winner-links per the dedupe table — each duplicated editor now has ONE home), Integrations (absorbs the /dashboard/connections hub grid; the old 4-platform delivery-toggle tab is retired — its strict superset lives on the Delivery page's Platforms tab), Website & Domains (connect/verify + the /dashboard/domains marketplace as one surface), Notifications, Security, plus a Devices pointer. Features page gains 3 URL-driven tabs: Manage (the Phase 56 capability switchboard, byte-identical PATCH), Your features (the catalog), Get more (the add-ons marketplace). All six absorbed routes (/onboarding, /business-hours, /connections, /domains, /capabilities, /addons) are redirect() stubs — the addons stub forwards query strings so Stripe checkout returns still finalise; AddonsMarketplace's post-purchase router.replace and 8 in-app links retargeted. Dedupe winners enforced: the delivery-zones map editor now lists legacy postcode zones (editable pricing + delete) and hosts the delivery-radius cap; the old postcode-zones tab is a signpost to it; /dashboard/translations keeps ONLY the AI translate editor (the duplicate enable/disable + default-language chips retired into a Setup Center link). V2 nav slims to match (absorbed items → search keywords; ABSORBED_ROUTES exported + guard-tested: every absorbed route must BE a redirect stub to its documented tab); V1 nav untouched. i18n: 19 new dict keys (Setup Center shell + Ordering section + Features tabs) ship in all 20 non-en languages. tsc 0; nav guards 10/10; suite green (2 pre-existing fails unrelated).
    • Navigation 2.0 (Phase 61 IA-0) — the owner sidebar regrouped by job, behind the `nav_v2` flag. The ~112-entry owner nav is reorganized into the approved tree — daily primaries (Home · Quick Links · Orders) + compact daily groups (Reservations · Menu · Customers+Messages) + job workspaces (Marketing Hub · Experience Studio · Money · Restora Profit · Team · In-Venue Ops · Inventory & Suppliers · Devices) + conditional (Chains & Groups · Food Bazar) + utility (Plan & Features · Setup Center · Help Center) — as a pure regroup: every existing href appears exactly once, zero routes moved/added/removed, every item keeps its exact featureId/badge/icon so plan-gating is untouched (all enforced by a new nav-catalog.test.ts guard: href-set parity, per-href gating parity, registry/icon validity, i18n coverage — the catalog previously had zero tests). Rollout: platform flag nav_v2 (fail-safe OFF = the legacy nav byte-identical), flipped at runtime via the new super-admin GET/POST /api/admin/nav-v2/master-switch (audit-logged, ITA-pattern; listed in the flags console once created). Flag ON extras: sidebar search (and the Quick-Links picker) answer the old vocabulary — retired group names ("Marketing & AI", "Account & Settings", "Operations (in-venue)"…) and "Overview" match via catalog keywords, in English and translated — and a one-time dismissible "Navigation just got simpler" card explains the change. i18n: all 9 new labels ship in all 20 non-en languages, plus back-fill for 9 labels that had been silently English-only since 2026-07-05 (Capabilities, Menu TV, Take Order (Waiter), Self-order Kiosks, Kiosk Designer, Demand & Weather, Network Reliability, Devices & Apps, Delivery Area (map)); the guard test now enforces full label coverage forever. Quick-Links stars are unaffected (stored by href). New scripts/nav-sweep.mjs renders every catalog href in BOTH flag states as a real owner (login → sidebar-tree assertions → 112-href sweep → flag flip via the real API → restore). tsc 0; my-files lint 0; nav guards 9/9.
    • Fixed: Network Reliability sidebar icon. Wifi was missing from NAV_ICONS, so the item silently rendered the Circle fallback since it shipped — now registered (and the new guard test fails on any future unmapped icon key).
    • Real printing via the Windows Station (RW-3 slice 1) — Phase-38 hardware goes live. New public print-agent API (/api/public/hardware/claim|jobs|jobs/ack): an agent exchanges a hardware device's one-time pairing code (from /dashboard/hardware) for its capability token, then polls for work and acks each job. Agent-claimed devices get a real job lifecycle (queued → printing → done/failed, 3 delivery attempts, stuck-printing requeue so an agent dying mid-print never silently loses a receipt); unclaimed devices keep the mock behaviour byte-for-byte. The job poll doubles as the heartbeat (finally populates lastSeenAt → online dot), failures surface in the dashboard job log with the agent's reason ("out of paper"), and toPublicDevice now reports agent/agentInfo. Windows Station side: ESC/POS encoder (CP865 Nordic — åäöÅÄÖ correct on Swedish receipts), TCP:9100 + Windows-spooler RAW transports, cash-drawer kick. E2E-verified byte-exact against a wire-level fake printer. tsc 0; 9 new unit tests + 18 existing device tests green.
    • Automatic burn-in protection on every connected screen. The TV surfaces (/tv/screen — all four modes — and the legacy /display queue) now drift the whole rendered frame through a ±2px 8-point orbit (one step per minute, 2s glide) via a shared useBurnInShift hook — imperceptible from viewing distance, but no static edge (logo, headers, column rules) ever parks on the same physical pixels, which is what permanently marks TVs left on all day. Because it lives in the page, every platform is covered: plain browsers, the Android TV shell, Samsung Tizen, LG webOS and the new Windows Station (native shells that ship their own orbiter coordinate via <html data-r360-burnin> so exactly one guard owns the transform, and display surfaces get overflow:hidden so the drift never spawns scrollbars). The "Connect a screen" card (Dashboard → Orders → TVs & Displays, and /dashboard/tvs) now tells owners: Automatic burn-in protection — every connected screen imperceptibly drifts the image so static content never marks your TV. tsc 0.
    • Creator earnings — tenants earn 15% every time their image is resold. When a tenant creates + pays for an image and it's approved into the shared Inspiration pool, every time another restaurant buys it the original creator keeps 15% of the sale — carved from the platform's margin, so the buyer's price never changes (a resale has ~zero cost). Earnings land in a separate "Creator earnings" balance on the AI-spend page (shown in SEK so it feels real), which the owner can move into AI credit instantly, or — once a bank rail is wired — cash out above 300 SEK. Paid as AI credit today (zero payout infrastructure); the cash-out is a rail-agnostic PayoutRail adapter so a Nordic provider (Bankgiro/SEPA, Swish, Trustly, Adyen) plugs in later with no ledger changes. Anti-farming: royalties only on cross-tenant, admin-approved resales (originTenantId ≠ buyer), idempotent per sale. tsc 0, lint 0; royalty + FX-round-trip unit-verified.
    • Batch background removal + a dedicated transparent-image price (25 SEK). A "Remove background · 25 SEK/ea" button above Your generations opens a picker of your generated images (videos and already-made cutouts are hidden), where you multi-select and see a running total. The key rule: if your AI credit can't cover the whole batch, nothing runs — you're shown the exact shortfall and a one-tap top-up by card (reusing the AI-credit top-up flow), then it proceeds; you're only charged for images that actually succeed (N idempotent ledger lines, and re-running an already-cut image is free). Background removal is now priced at 25 SEK (it costs us more than a generation, and a ready-to-composite cutout is worth more) — generation stays 10 SEK. Buying a transparent cutout from the Inspiration pool is likewise 25 SEK (regular pooled images stay 10); cutouts show over a checkerboard with a "transparent" badge so buyers see what they're getting. The single- and batch-removal paths share one server helper, so dedupe/charge/idempotency/Media-library-save/pool-banking behave identically. tsc 0, lint 0, pricing + idempotency unit-verified.
    • Background-removed cutouts now flow everywhere — the owner's Media library AND the shared Inspiration pool. A cutout used to land only in Experience Studio's "Your generations", so it couldn't be picked in the Menu-TV designer / website builder / menu image pickers. It now also saves to the owner's Media library ("My Library" in every picker) as a permanent, reusable transparent PNG. And, exactly like a generated image, it is banked to the system-wide media pool as PENDING (originTenantId attributed): a super-admin approves it in the "System-wide Media Pool", after which other tenants can buy it for 25 SEK (the transparent-cutout price) into their own library (the buyer's copy is transparent too — no re-encode). Deduped per source image so distinct cutouts never collapse and the identical cutout can't be banked twice. Reuses the existing pool + approval + buy pipeline and the generated-image banking pattern verbatim — no new admin UI. tsc 0, lint 0.
    • Experience Studio — Background removal (any image → a fully-transparent PNG), 10 SEK. A "Remove background" action turns any image into a transparent PNG cutout — on the AI-generated result and on every saved image in Your generations (the priority), so a dish/logo/product shot can be dropped onto a menu, signage, or the Menu-TV designer with no background. The cutout is saved back to the gallery and previewed over a checkerboard so the transparency is visible. Charged a flat 10 SEK (super-admin-editable) through the one AI-wallet ledger choke point — only on a delivered cutout (a failure is never billed), in the tenant's own currency for non-SEK wallets, itemized on the AI-spend statement as "Background removal". Paid-gated + budget-gated like image generation. Powered by a swappable hosted vendor (Photoroom / Clipdrop / remove.bg — whichever key is set); ships mock-first — with no key it shows an honest "not switched on yet" and charges nothing, going live the instant a key is provisioned. Security: the input-image fetch now goes through the DNS-pinned safeFetch (blocks private hosts), hardening this route and the existing generate route against SSRF. tsc 0, lint 0, 4 tests; adversarially reviewed.
    • Exact menu import from Lumit (embedded on the restaurant's own site). Lumit (lumit.se) is embedded as an iframe (embed.lumit.se?id=<compId>) on restaurants' own domains (e.g. backagardskrogen.se) — the page URL carries no id, so it's detected from the embed in the page HTML (like GloriaFood/OrderYOYO). The whole menu is a world-readable static data.json on Lumit's S3/CloudFront (a fixed host — no SSRF, no AI), keyed by compId; items carry the restaurant's own photos when present. Verified live: Bäckagårdskrogen → 91 items / 11 categories with correct SEK prices (was 2). Auto-detects on /try and the website-link importer, and the import-learning loop records it as platform coverage (and resolves any prior "undiscovered platform" gap for it). tsc 0, lint 0, +2 tests.
    • Import learning now recognises the PLATFORM pattern — so undiscovered ordering platforms surface themselves. The hardcoded adapters (Qopla/Wolt/GloriaFood/OrderYOYO) solve the known platforms; the learning loop now learns the shape so new systems sharing the same logic get caught. Each import's site signature records the known platform (if any) and, when a site LOOKS platform-backed (an ordering SPA with an ordering widget or an external-order link) but no adapter matched and the generic ladder read only a thin menu, it logs a "platform gap" grouped by shared structural markers. The super-admin Import Learning dashboard gains two panels: Platform coverage (exact, no-AI imports per adapter) and Undiscovered platforms — adapters worth building (the gap patterns, ranked by demand). A gap auto-resolves once an adapter later serves that pattern. GraphQL alone never triggers a gap (too common), and a fully-readable site is never flagged. All capture is anonymous + best-effort (never blocks an import). 13 new tests.
    • Exact menu import from OrderYOYO (Cloudflare-fronted, read via the real browser). OrderYOYO ordering sites ({slug}.orderyoyo.{tld}) block every server fetch at the Cloudflare/JA3 layer, but the full menu is server-rendered into the page's __NEXT_DATA__. So we read it out of the RENDERED HTML obtained via real Chromium (whose TLS gets through) — exact menu, zero AI (verified live: 86 and 151 items on two real restaurants, correct prices). On the /try clone path the WoW capture already loads the page, so the menu is parsed from that SAME capture — no second browser. For the standalone importer a new SSRF-hardened fetchRenderedHtml fetches it (mirrors captureSiteForClone's IP-pinning). Registry-driven like the others; falls back to the ladder on any failure. tsc 0, lint 0, +6 tests (incl. malformed-payload safety).
    • Exact menu import from Wolt and eatsmart/GloriaFood (auto-detected, like Qopla). The platform-API registry gains two more Nordic ordering platforms, each read deterministically from its public API — no AI, no truncation. Wolt: one unauthenticated call to the consumer assortment API keyed by the URL slug (verified live: 258 items / 17 categories with photos + prices for a real Stockholm venue). eatsmart (and any GloriaFood/Foodbooking-hosted ordering page — foodbooking.com, restaurantlogin.com, restaurants' own domains — they share one backend): a single POST /api/cart/init keyed by the restaurant_uid in the URL (verified live: 114 items / 14 categories). Both auto-apply to the WoW /try clone engine and the "website link" importer; a pasted Wolt/eatsmart URL upgrades to the exact API path, and any failure falls back to the generic ladder. A shared lib/menu-import/platform-common.ts keeps each new platform to "parse URL + fetch + map". safeFetch now supports POST so eatsmart's user-origin request is DNS-pinned (closes a rebind SSRF flagged in review). tsc 0, lint 0, 17 tests.

    Changed

    • Background removal is now 10 SEK (down from 25) — flat 10 SEK across the board. Since the vendor cost is a fraction of a krona, background removal is priced the same as a generation: generate, remove-bg, batch, and buying any image from the Inspiration pool are all 10 SEK. One config change (removeBgPriceSek 25 → 10) cascades to every surface. Simpler and cheaper.
    • Menu-from-photo import is now on the Import hub too. A "Menu photo or PDF" card on /dashboard/import opens the existing full-featured photo importer (snap or upload a menu / PDF — or paste a URL — and AI reads the categories, dishes and prices, with review + photo-matching + budget-gated top-up). It's the same component the Menu editor already uses, surfaced here so every import method lives in one place. Always available (independent of the platform-importer flag); on success it drops the owner into the menu editor.
    • Import hub is now one uniform page — no GloriaFood banner, and Wolt / OrderYOYO / Lumit are live (were "coming soon"). The /dashboard/import page dropped the big GloriaFood spotlight; every source is treated the same. A single Website / menu link card is the fast path (it auto-detects Qopla, Wolt, OrderYOYO, Lumit, GloriaFood and more), with the recognised platforms shown as chips (live first) and the file imports (CSV / Excel / WordPress) below. After yesterday's exact-import adapters shipped, Wolt, OrderYOYO and Lumit are flipped to live (thin website-link delegates registered as real providers, so their chips open a working wizard); Square / Toast / foodora stay "coming soon". The Website-link engine now also exact-parses the RENDERED page HTML from the browser capture (OrderYOYO's __NEXT_DATA__ behind Cloudflare, a lazily-injected GloriaFood/Lumit widget) before ever falling back to a vision read — so the import hub gets the same exact-import quality as the /try clone engine. tsc 0, lint 0, 18 import tests green.

    Fixed

    • Pool safety — a tenant's OWN photos never enter the shared pool. Only generic AI art (a pure text-to-image scene, or a cutout of one) can be banked to the system-wide Inspiration pool for other tenants to buy. Previously every generated hero — including an img2img restaging of the owner's uploaded product photo — and every cutout were auto-banked (pending admin approval), which risked a restaurant's private/brand imagery being offered to competitors (and, now that creators earn 15% on resales, being over-banked to farm royalties). Generations now carry a generic provenance flag (true only when no owner photo was used); generate-core and background removal bank to the pool only when generic, and a cutout inherits its source's provenance. Fails safe: unknown/owner-sourced ⇒ private. tsc 0, lint 0, 142 studio/media tests green.
    • Batch background removal — hardening from an adversarial review (before any user hit it). (1) An all-failed batch (e.g. the vendor is out of credits) now returns the honest 503 the single path would, so the modal never shows a green "done" for cutouts that were never made. (2) The top-up shortfall is sized off the signed wallet balance, so if the wallet was already overdrawn a single top-up clears the overdraft and the batch (was: a second payment needed). (3) The pre-charge gate is dedup-aware — images that already have a cutout are a free no-op and no longer trigger a spurious "add credit" ask. (4) Duplicate URLs in one batch are de-duplicated (no double-count). (5) Images that can't be read are reported as skipped instead of silently dropped from the tally. (Two concurrent-charge TOCTOU findings were left as-is — a pre-existing, bounded, self-healing property of the lifetime-wallet across all AI paths, not specific to this feature.) tsc 0, lint 0; signed-balance + shortfall unit-verified.
    • Background removal can no longer double-charge on a retry (was: 10 SEK billed twice). Background removal is deterministic (same input image → identical cutout), yet the charge had no server-side idempotency — a lost/slow response + "Try again", two tabs, or the panel + gallery buttons firing on the same image each ran end-to-end and billed again. Now the charge carries a stable ref derived from the input image and the AI-wallet choke point (appendLedgerEntry) commits a ref-tagged charge at most once (serialized under the tenant row lock — mirrors the Swish accrual dedup); ref-less charges (top-ups, per-token usage) are unaffected. An upfront lookup also returns the already-made cutout free on a repeat, saving a wasted vendor credit. Unit-verified exactly-once. Found by an adversarial multi-agent review of the feature.
    • A background-removal provider rate-limit (HTTP 429) is no longer mislabeled "out of credits". 429 (rate-limited, but the account still has credits) was collapsed with 402 (genuinely out of credits), telling the operator to "top up" an account that didn't need it. 429 now maps to its own RATE_LIMITED state → "busy right now, try again in a moment" (still charges nothing); only a real 402 says top up. Also found by the adversarial review.
    • Platform ordering menus embedded on a restaurant's OWN website now import (were: no menu items). GloriaFood and OrderYOYO are usually embedded on the restaurant's own domain (e.g. sukisushi.se, inter-grill.se) whose URL carries no restaurant id — so the URL-only detector missed them and the generic ladder found "info but no menu." Detection now also reads the PAGE HTML: a GloriaFood widget's data-glf-ruid, or the OrderYOYO app's __NEXT_DATA__/oyyservices marker (its parser is now host-agnostic). The registry gained an optional matchesHtml, the WoW /try clone passes the captured HTML to it, and the website-link importer adds a "tier 0.5" that checks the fetched HTML. Verified live: Suki Sushi (GloriaFood) 127 items / 15 cats, Inter Grill (OrderYOYO custom domain) 111 items / 22 cats. GloriaFood also now always POSTs the fixed foodbooking.com backend (it serves every tenant), removing the earlier user-origin SSRF surface entirely.
    • Imported dishes now use the restaurant's OWN photos, not a wrong stock guess. A URL import attaches no scraped photos, so the stock/demo matcher guessed one per dish — and on a box with no live stock keys (built-in demo library only) some guesses were visibly wrong (a poké bowl getting a generic sushi photo). Qopla's API actually returns each dish's real photo (refProduct.imageUrl, ~76% of items on the test restaurant — the owner's own licensed gallery), so the importer now carries those through as the dish image; the stock matcher only fills the remainder and leaves it blank rather than attach a mismatch. Also hardened that matcher: a stock photo now has to share a meaningful token (the protein/dish), not a generic form-word, so "Deluxe Rolls" can no longer match a cinnamon-rolls photo. ExtractedItem.imageDataUrl now carries a usable image URL from any structured source through normalizeExtractedMenu. Verified: 75/99 Saigo dishes get their real photo; 13 unit tests.
    • Menu import from Qopla now reads the FULL menu (was 2-3 bogus items / hallucinated dishes). Qopla is a client-rendered GraphQL SPA, so a URL import saw an empty app-shell (the vision fallback turned a near-blank page into 2-3 invented dishes) and a full-page-image import hallucinated a different restaurant entirely. Qopla's ordering backend is a public GraphQL API, so we now read the exact menu from it — deterministically, no AI, no truncation: for the "Saigo — Sushi, Thai & Poke" test link that's 99 real items across 13 categories with correct Swedish names, prices, and descriptions (was 2-3). New shared lib/menu-import/qopla.ts (URL→publicId→getShopByPublicIdForOnlineOrder→getMenusByIds, deduping the duplicate day-menu + upsell repeats by product id) is wired into both entry points: the WoW /try clone engine (a structured platform-API menu now supersedes every heuristic read, and the real shop name flows into the rebuild) and the onboarding importer (a live qopla provider adapter + a Qopla short-circuit in the generic "website link" adapter, so a pasted Qopla URL auto-upgrades to the exact API path). Any API failure falls back to the previous ladder. Detection is registry-driven (lib/menu-import/platform-apis.ts) so teaching the importer the next platform-with-an-API (OrderYOYO, Wolt, …) is a one-line entry that auto-applies across every URL import path. tsc 0, lint 0, 6 unit tests (parse/dedupe/skip-rules/registry-detect); validated live end-to-end through the module, the registry, and the adapter.
    • Menu TV — designed decks flashed to the background between screens (regression). MenuBoardScreen gated the outgoing crossfade layer on pages[prev], but pages is empty whenever a Screen Designer deck is playing — so the intended deck[prev] crossfade branch was dead code and each transition faded the incoming screen in over the bare host background (a ~700ms flash to base colour, worst on photo-forward signage decks — exactly the "professional signage" surface). The layer now resolves from whichever source is active (deck ? deck[prev] : pages[prev]), restoring the smooth crossfade for decks. Verified: DOM shows both layers coexisting mid-transition + a mid-fade screenshot shows the true colour blend (was: flash to base).
    • Menu TV pairing screen used a retired orange glow. The 96px pairing code carried textShadow: rgba(194,98,42,.5) — a warm/clay colour on the customer-facing in-store setup screen — violating the azure-on-navy brand rule. Now azure (rgba(21,112,239,.5)), matching the Restora logo directly above it.
    • Canvas editor — a shadowed headline lost its shadow while inline-editing. The contentEditable overlay shown on double-click didn't carry the text drop-shadow, so a shadowed headline flickered (shadow → none → shadow) the instant you started typing. The overlay now mirrors the same shadow, keeping the edit WYSIWYG.
    • Menu TV — stale "6 templates" copy. The plan/entitlement feature description and the page header comment said "6 templates" and led with the Android app; 10 designed templates now ship (plus the freeform Screen Designer), and the browser path works today while the native app is not yet distributed — copy corrected to match.

    Changed

    • Phase 57 (ES-3) — Experience Studio style tiles now show real examples. Each creative-style tile previewed only an abstract colour swatch, so owners couldn't tell what "cinematic" or "moody" actually produces. The picker now pulls one real approved example image per style from the shared library (keyed by style, image-only, deduped) and shows it on the tile — falling back to the swatch for any style without an approved example yet. Purely additive + fail-open (a missing/blocked library just shows swatches as before).
    • Phase 57 (ES-0) — Experience Studio, first declutter pass for non-technical owners. The generation form asked owners to make decisions they shouldn't have to: it defaulted cuisine to a hardcoded "burger", price level to "mid", and put two hex colour pickers front-and-centre. Now: cuisine defaults to empty and is auto-detected from the photo (moved under an "Advanced" section as an editable fallback, never "burger"); price level defaults to Premium (still changeable); brand + accent colours are tucked under "Advanced" since they auto-detect from the storefront; the headline is AI-written and editable (fills in after generation instead of a hardcoded "Built to order." placeholder); the white-label toggle is removed in favour of one flat per-image price with the Restora mark; and the confusing "Save as brand kit" button is hidden for single-location owners (colours auto-apply anyway) and, for franchises only, reworded to "Use these colours for all my restaurants." Help text updated to match. Groundwork for the fuller guided-flow redesign (ES-1…6, docs/Phase-57-Experience-Studio-Redesign.md).

    Added

    • Menu TV canvas — gradients on shapes & the stage background + a text drop-shadow (owner ask 2026-07-08). Bringing richer fills to the freeform designer. (Shapes) a shape can be filled with a 2-colour gradient (From / To) instead of a flat colour, in one of four preset directions — Right (→), Down (↓), ↘, ↗ — chosen from a "Solid / Gradient" toggle. (Background) the canvas stage background gets the same Solid / Gradient choice. (Text) a Shadow toggle adds a soft drop shadow behind text for legibility over busy photos. Rendered identically on web and native (Compose Brush.linearGradient / Shadow); the two corner directions trace the element's box diagonal on both renderers (the web emits an aspect-dependent angle rather than the CSS to bottom right "magic corner", which diverges on non-square boxes — most visibly the 16:9 background), and the shadow uses the same offset/blur (0 2px 10px @55%) in both. Whole text/shape/background objects are in the board version hash, so any gradient/shadow edit re-ships within one device poll. NOT an AI feature — no credit is ever charged from the Menu TV designer. tsc 0, lint 0; web screenshot-verified (shape + background gradients in all 4 directions incl. wide/16:9 boxes, text with/without shadow) and native build clean; adversarial-reviewed (fixed the corner-angle + shadow-weight parity breaks before ship).
    • Menu TV canvas — dish descriptions on a menu block (owner ask 2026-07-08). A menu block can now show each dish's description under its name (opt-in "Descriptions" toggle), like a printed menu — the small grey line, e.g. "San Marzano tomato, fior di latte, fresh basil…". Descriptions come from a per-item lookup built regardless of the device's board config, so a block shows them independently; the resolver copies items (never mutates the shared board data the auto-templates use), a shown description is in the version hash so an edit re-ships, and the interactive editor preview carries descriptions so the stage matches the TV. Web + native (Compose) render it the same (small, faded, indented under a photo). tsc 0, 17 tests; web + native screenshot-verified.
    • Menu TV canvas — centred section heading with rules on both sides (owner ask 2026-07-08). A menu block's section heading can now be styled like a classic printed menu: ———— Pizze Rosse ———— — centred with a rule flanking each side. The heading rule gains a position (Under the heading, or Both sides) and a new Dotted style (alongside Line / Dashed), so you can pair it with the dotted name→price leader for the printed-menu look. Renders identically on web and native (Compose); existing headings (under-rule / bar) are unchanged. tsc 0, 18 tests; web + native screenshot-verified.
    • Menu TV canvas — full menu-block styling for BOTH dishes and headings + full-bleed elements (owner asks 2026-07-08). Bringing the website's Menu Appearance controls onto the canvas menu block. (Dish rows) name size, price size, row gap, bold, italic, font (Sans/Serif/Mono), how the price sits (Right-aligned / Dotted leader / Inline), and a divider between rows (None / Dotted / Dashed / Solid + colour). (Section headings) everything from before plus a "Bar" header style (a filled colour bar behind the heading + its own colour). All keep the existing Text/Prices colours. (Full bleed) an image or shape can now be sized larger than the 1920×1080 stage (up to 2×) so it covers corner-to-corner without a margin on the opposite edge — the previous cap silently shrank bleed elements to the stage size. Rendered identically on web and native (Compose); one shared resolveMenuItem/resolveMenuHeading clamp for feed + save; all in the version hash. tsc 0, 17 tests; web + native screenshot-verified.
    • Menu TV canvas — full section-heading styling (owner ask 2026-07-08). A live menu block only let you set the section-heading colour; now each heading (e.g. "Pizze Rosse") has a full "Section heading style" panel: colour, size, bold / italic / underline, UPPERCASE, left / centre / right alignment, font (Sans / Serif / Mono), and a divider rule beneath each heading (None / Line / Dashed) with its own colour. Rendered identically on web and native (Compose dashPathEffect for the dashed rule); existing blocks with no heading style are unchanged (bold, 34px, left, no divider). One shared resolveMenuHeading clamp for the feed AND save, in the board version hash so a style change re-ships. tsc 0, lint 0; web + native screenshot-verified.
    • Menu TV Screen Designer — day-part scheduling (auto lunch → dinner boards). Any screen in a deck can be limited to certain weekdays and a local-time window ("When to show": Always, or pick days + from/to). The live TV feed shows only the screens active now — evaluated in the restaurant's timezone — and switches automatically as the day-part changes (the board's version hash already covers the visible screens, so the TV re-ships within one poll at a boundary). Overnight windows (e.g. 22:00–02:00) are supported; a screen with no schedule is always on; if every screen is out-of-window the board falls back to the normal auto-built menu. The designer always shows every screen (with a clock badge on scheduled ones). New lib/tv-menu/schedule.ts (pure: isScheduleActive / localDowMin / scheduleLabel / sanitizeSchedule), a schedule filter threaded through buildMenuBoardData (feed-only; the designer/preview never filters), and nowMs passed from the device-feed route. tsc 0, lint 0, 26 tests (weekday+window, overnight, filter-drops, timezone wall-clock); web screenshot-verified. Native needs no change — it just receives fewer screens.
    • Menu TV canvas editor — multi-select, group move & alignment. Select several elements at once: shift-click to add/remove, drag an empty area to marquee (rubber-band) select, or Ctrl+A for all. Dragging any selected element moves the whole group together (with the group's bounding box snapping to the stage and other elements), arrow keys nudge the group, and Delete removes them all. When 2+ are selected the inspector becomes a group panel with Align (left / centre / right / top / middle / bottom), Distribute (horizontal / vertical, 3+), group opacity, and duplicate/delete. Ctrl+D / Ctrl+C / Ctrl+V now operate on the whole selection. Editor-only (no data/engine/native change — it just repositions elements); resize/rotate handles still appear for a single selection. Verified with Playwright (shift-select, align-top, group move, marquee-select-all); tsc 0, lint 0.
    • Menu TV canvas editor — a menu block can now show MULTIPLE sections (owner ask 2026-07-08). A live menu element was limited to one section; it now holds several. The inspector's single dropdown became multi-select section chips, and the element renders each chosen section stacked (heading + its items, honouring prices/photos/2-columns) on both web and native. Under the hood the menu element stores categoryIds[] (the legacy single categoryId is still read, so existing blocks keep working); resolved server-side to live categories[] in menu order, ≤12 sections, deduped, in the board version hash. Verified web + native screenshots (three sections stacked, three chips) and a shaper test covering multi + legacy; tsc 0, lint 0.
    • Menu TV canvas editor — pro-editor batch: undo/redo, snapping guides, rotation, keyboard shortcuts. The freeform canvas now behaves like a real design tool: undo/redo (toolbar buttons + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) with per-screen history that coalesces a single gesture (a slider drag, a move) into one step; alignment/snapping guides — dragging an element snaps its edges/centre to the stage edges, stage centre, and other elements' edges/centres, drawing pink guide lines (hold Alt to disable); a rotation handle (drag the knob above the selection; Shift snaps to 15°); and shortcuts Ctrl+D duplicate, Ctrl+C/Ctrl+V copy-paste an element, Esc deselect (Delete + arrow-nudge already existed). Fixed a subtle history bug in the process — the stage no longer mutates shared element objects mid-gesture (it uses a local live-override), so an undo snapshot always captures the true pre-gesture state and reliably reverts a move/resize/rotate. Web-only (the native renderer already applies rotation). Verified with Playwright (snap guide mid-drag, rotate, undo-restores-upright); tsc 0, lint 0, 16 tests.
    • Menu TV Screen Designer — a freeform, PowerPoint/Mason-style canvas editor (owner ask 2026-07-08). A new "Free canvas" screen type (and a "Start from a blank canvas" option) lets non-technical owners design a signage screen from scratch: an Insert bar drops text, image, video, box/shape, and live menu-block elements onto a 1920×1080 stage; every element drags to move, resizes with 8 handles, nudges with arrow keys, and deletes with Delete; double-click a text element to edit it in place (contentEditable, commits on Enter/blur). The right inspector gives per-element properties — text (size, colour, bold/italic/align), image/video (pick asset + fill/fit + rounding), shape (fill + rounding), menu block (bound section + prices/photos/2-columns + colours) — plus opacity/transparency, bring-to-front/send-to-back, duplicate, and a canvas background (colour, photo, dark overlay). Menu blocks stay LIVE (prices/86/numbering follow the real menu). Everything is stored as CanvasElement[] in design px and resolved server-side into the existing device feed (in the board version hash → TVs live-sync); a new native Compose renderer (CanvasScreen.kt, media3 for video) draws it 1:1 on the TV app. The editor renders through the SAME CanvasScreen component the TV uses, so Play shows exactly what the screen will display. Bounded/sanitised on save AND at render (≤40 elements, clamped geometry, HEX colours, safe URLs). Verified with browser screenshot QA (render / select / inline-edit / insert / freeform-drag / Play) and installed on the test tablet; tsc 0, lint 0, 16 shaper tests.
    • Menu TV Screen Designer — drag-to-reorder screens. The rail's screens can now be dragged by a grip handle to reorder them (the arrow buttons stay as a keyboard/click alternative), reusing the menu page's proven @dnd-kit sortable pattern. Selection follows the dragged screen by id, and thumbnails look up their preview by screen id so a reorder repaints instantly without waiting for the board to re-resolve. Verified with a Playwright drag test.
    • Menu TV — the Screen Designer gets its own page (owner ask 2026-07-08: "dedicated designer… its own space… so we can add advanced functionality later"). The designer squeezed into the Menu TV page's left column overflowed off-screen and had no room to grow. New /dashboard/menu-tv/designer: a three-pane studio — screens rail (live thumbnails + per-screen dwell badges) · big 16:9 canvas with a Play mode that runs the real rotation ("exactly what the TV shows") · a full inspector (screen type as visual buttons, sections, photos, structured offer editor, video, light/dark, colors, logo). Destructive actions now confirm (remove screen, restart with a pack); the canvas previews with a REPRESENTATIVE TV config (a deck-enabled TV's own numbering/prices/images settings) so what you design is what the TV renders. The Menu TV page keeps a compact entry card: style packs one-tap into the studio (?start=<pack> builds the deck on arrival), plus the per-TV "Show designed screens on" switches. All logic extracted to components/dashboard/menu-tv/designer-studio.tsx. Hardened by a 14-agent adversarial review before ship: a failed deck load can no longer lead to overwriting a saved design (error state + ?start= consumed via router.replace + auto-build only when the server verifiably has no deck), saves report honestly ("saving… / saved / couldn't save" incl. a no-permission message instead of a false "saves automatically"), screen ids can't collide (add→remove→add), the 24-screen server cap is enforced client-side, the dwell input commits on blur (10–39 s was untypeable with per-keystroke clamping), Play starts at the screen being edited, and finishing a design with no TV in Menu-board mode shows guidance instead of a dead end. Verified with browser screenshot QA (hero / menu-with-numbers / offer inspector / denied-save / no-TV states).
    • Menu TV Screen Designer — professional digital signage for non-technical owners (owner spec 2026-07-08). Owners can now design their own signage deck instead of only picking a template: pick a starter pack (🍣 sushi / 🍔 burger / 🍕 pizzeria) and one tap builds a full deck from their REAL menu — a hero image screen, menu screens, and an offer screen. The engine (lib/tv-menu/deck.ts) suggests screens by bin-packing WHOLE categories against the chosen template's capacity, so the screen count follows the menu and a section never splits mid-screen; sanitizeDeck clamps everything (≤24 screens, ≤4 callouts, HEX colours, safe URLs). Four new screen primitives — Image Full, Image Duo, Offer with callouts, Video (uploaded MP4 or Experience Studio video gallery ONLY — no YouTube) — built twice, web (components/tv-menu/primitives/) and native Compose (android/tvmenu, media3/ExoPlayer). Per-screen everything: layout (12 templates + 4 primitives), sections, hide-category-names, images via MediaPicker (free sources only — nothing on this page can charge AI credit), callouts, light/dark, heading/price colours (WCAG auto-contrast per screen), logo show/position (default hidden), and dwell seconds. Decks resolve server-side onto the existing device feed (ResolvedDeckScreen[], included in the board version hash → TVs live-sync on any designer edit) and each TV opts in with a per-device "Designed screens" toggle (useDeck) — classic auto-generated boards untouched. New route /api/tenant/[tenantId]/menu-tv/deck (GET / suggest / save, settings.edit-gated). tsc 0; tests green; native :tvmenu builds and verified on the test tablet.
    • FSN restaurant brand accounts — auto-provisioned, quality-gated, self-cleaning (owner spec 2026-07-08). Every restaurant now gets a Food Stories Network presence automatically, but the network stays high-quality by construction. (Auto-create) createTenantDatabase provisions a brand FSN account (fsnacct_rest_<tenantId>, un-loginnable) on tenant creation — best-effort, never blocks signup. (Quality gate) the account starts provisional and is HIDDEN from every feed/search/profile (folded into hiddenAuthorIds) until it publishes a real image + text post; a text-only post never reveals it. The first qualifying post (via Compose/autopilot → the FSN adapter) clears the flag and the restaurant appears on the network. (Disable on remove) deleteTenantDB disables the brand account (hidden immediately). (60-day cleanup) the existing fsn-account-purge cron now also erases brand accounts that are provisional-or-disabled with no activity for BRAND_INACTIVITY_DAYS (60) — empties and orphans self-clean; an active, posting brand account is never touched. New lib/fsn/brand-account.ts (pure: provision/disable/activate/purge/postQualifies) with 5 tests; the FSN social adapter refactored onto it. tsc 0; 61 FSN/social tests green.
    • Owner evening batch (2026-07-07, screenshots): ITA live + measured-holiday forecasts + worker load + Ask-AI live data + FSN connector. (F — Table Assistant) it was BUILT all along (Phase 49) behind ita_enabled OFF; flipped ON with owner approval, created Luigi's first table (qrSlug tavolo-1), guest page serves 200. (A1) the MEASURED holiday lift now REPLACES the calendar's fixed +12%/+25% guess inside stock forecasts (per-named-holiday first, else overall; clamped ×0.3–3; signal detail says "measured from your own sales"; fixed defaults stay when nothing measured). (A2) worker load: DB-8 clock-ins × orders per LOCAL hour → orders per staffed person-hour at the heaviest slot, joined into the Plan-ahead card (≥20 staffed hours guard; honest empty state). (B — Ask AI) the owner's screenshot showed "show me low stock items" answered with a menu-management ARTICLE; new L0 live-data layer answers data questions with the restaurant's OWN numbers (low stock w/ item names → Review & reorder link, open orders, today's orders/revenue, unanswered reviews; EN+SV patterns) before any doc retrieval; new 'live' layer badge ("From your live data"). (C — Social) FSN (our own network!) is now a first-class connector — no OAuth: connecting creates a brand FSN author account per restaurant (unusable password, stable id) and publishing writes REAL FsnPosts into the real feed (engagement read live from the feed store); + fixed the owner-reported contradictory state ("Connected." under a "Not connected" card) with an honest-state guard. 7+ new tests; tsc 0. REMAINING from the batch: bilingual (EN+SV) SEO generation, campaign real-world scenarios.
    • S1-H Holidays × peak hours × staffing — the owner's missing pieces, found and built (2026-07-07: "I told the system to use holidays/religious/cultural days to learn behaviour, staffing, peak hours — I don't see it"). He was right: the date-holidays calendar (~200 countries incl. Ramadan/Eid/Easter/Diwali) had been wired into stock forecasts since Phase 2 but only applied a FIXED guess (+12%/+25%) — its own comment admitted "future learning tunes it from history" and that learning never existed; staffing/peak-hours existed nowhere. (demand-calendar.ts) holiday impact MEASURED from the restaurant's own orders against its same-weekday non-holiday baseline (≥4 baseline days; overall claim needs ≥2 measured holidays; per-named-holiday entries honest at n=1 — "measured once, will sharpen"). (peak-hours.ts) items-per-LOCAL-hour histograms (settings.timezone IANA — a US restaurant's 6pm rush never reads as UTC midnight) → busiest 2-hour window per weekday (≥8 orders guard) + staffingOutlook: upcoming holidays (observances excluded) joined with the measured lift and that weekday's peak window into plain sentences ("Midsommarafton is in 2 days — last time it ran 80% above a normal day; busiest hours 17:00–19:00"); zero history → honest "your first X on record — we'll measure it". Surfaces: /dashboard/demand gains Plan ahead (staffing), Holidays — how they actually hit you, and Your peak hours (24h histogram + per-weekday windows); the daily briefing's measured section now leads with a time-sensitive holiday heads-up (≤4 days out); Ledger demand counter + S1 gap updated (honest remainder: measured lift doesn't replace the calendar's fixed guess inside stock forecasts yet; worker-load needs the Shifts clock-in data joined). 8 new tests (calendar verified against real Swedish holidays incl. Midsummer); tsc 0.
    • DS-6 Support per-answer accountability (gap-list "continue" 2026-07-07) — plus an honest Ledger correction. Investigating the "no signal on which answers resolved the question" gap revealed it was STALE: thumbs feedback, global+per-tenant CSAT, daily trend buckets and the down-vote-rejects-pending-draft guard existed all along. What was genuinely missing: votes never landed on the SPECIFIC answer served — a repeatedly-downvoted APPROVED answer stayed approved invisibly. Now: the widget sends the served answer's matchedId with each vote; SupportBrainStore.answerFeedback tallies per answer (bounded, lowest-traffic evicted); flaggedAnswers() surfaces answers with ≥3 downs and downs>ups; the super-admin Support Brain console gains a "Flagged answers" queue (worst net-negative first, one-click "Stop serving this" reject for approved learned answers; Help-Center entries show their source for a docs edit). Learning Ledger: new "Support answers — did they actually help?" counter (CSAT + votes + flagged count) and the gap entry CORRECTED (it understated reality) — narrowed to escalation outcomes + docs follow-through. 3 new tests (support suite 11); tsc 0.
    • Import density check — the "3 of 28 looked perfect" blind spot, closed at import time (gap-list item, owner "continue" 2026-07-07). A JavaScript-rendered menu whose HTML text carried only 3 of 28 dishes produced a spotless-looking import; the page itself betrays the truth — it still SHOWS ~28 price tokens. New lib/menu-import/density.ts counts currency-MARKED prices only ("129 kr", "85:-", "€12,50" — phone numbers/years never inflate the expectation; guards: ≥8 visible prices AND under 40% extracted before it may speak, so option-price-heavy menus don't false-alarm). Wired into the universal importer's URL adapter twice: (1) a suspicious density now TRIGGERS the real-browser capture tier even when the item count passed the old ≥3 threshold — and the capture result is adopted only when it found MORE (a retry can never replace a decent text read with a worse screenshot read); (2) if the final result is still short, the import report's "Worth a look" panel says honestly: "The page shows about N prices but we only found M dishes — part of the menu may be hidden from us." Learning Ledger completeness-trend method + gap updated (remaining: photo/PDF imports have no page text to compare; density not yet recorded on the learning signal). 5 new tests; import-providers suite 23 green; tsc 0.
    • VL-B Voice transcript mining — the drive-thru now learns (owner "continue" 2026-07-07). The lane transcripts recorded since DS-4 finally feed back into the product. (Mining) lib/voice-concierge/mining.ts — template-driven: stored replies are matched back against the engine's OWN message templates (vcTemplateRegexes, every language), so the 'unresolved' reply's {text} placeholder yields the exact chunk the guest said that failed to match — no LLM, no re-parsing of raw audio text; scrub artifacts ([number]/[email]) and owner-ignored phrases are dropped. (The loop) new Product.voiceAliases (owner-CONFIRMED only — mining never writes aliases itself) is indexed as EXTRA NAMES in buildProductIndex, so a mapped phrase resolves through resolveDish on every surface (lane, phone concierge, POS search) from the next request; proven by a loop-closure test ("cheese pizza" unresolvable → mapped → resolves). (Owner surface) the Voice & Concierge page gains a "Guests said this — the menu couldn't match it" card (phrase + said N× + "They meant…" dish picker + Teach it / Ignore); the list is SELF-CLEANING — each mined phrase is re-run through the current index, so mapped phrases (or dishes added since) drop out by themselves; POST /api/tenant/[id]/voice-concierge/aliases (map/ignore, capped 12 aliases/dish, 100 ignores). (Measured trends) the Learning Ledger gains two lane trends measured from the transcripts — "turns understood first try" (reply-vs-template misses) and "conversations that ended in an order" — and the voice gap narrows to what's genuinely left (lane volume; phone/web surfaces don't record transcripts yet). 6 new tests (63 voice+search suite green); tsc 0.
    • S1/S2 → the daily briefing email ("Measured this month", owner go 2026-07-07). The demand and reliability intelligence no longer waits for a dashboard visit: the daily executive briefing now carries up to 3 MEASURED lines — the 2 strongest demand↔weather patterns plus the reliability root-cause line when there were outages (zero-outage days stay silent: a daily "all fine" is noise). Design honors the honesty contract end-to-end: lib/ai/briefing/intelligence-lines.ts computes the lines deterministically and the service attaches them to a NEW Briefing.measured field AFTER the AI step — the LLM never sees them, so a measured number can never come back paraphrased. Rendered as a "📊 Measured this month" section in the email (html+text) and on /dashboard/ai-briefings (with links to the two detail pages). Best-effort everywhere: any failure just means fewer lines, never a failed briefing. 4 new tests; tsc 0.
    • S2 Operations Intelligence — the second flagship RIC scenario, live (2026-07-07, same day as S1). "Is this my equipment, or an area-wide network problem?" is now answered from real device history, not a guess. Builds on the already-live DS-1 reliability history (deviceReliability: offline gaps + Wi-Fi↔cellular flips, consent-gated). New: lib/devices/network-identity.ts — best-effort ISP detection from a heartbeat's client IP (free ip-api.com lookup, 4s timeout, never blocks the heartbeat), caching only the ISP name against a MASKED ip (never the raw address) with a 30-day TTL; explicitly skips private/CGNAT ranges (10.x/172.16-31.x/192.168.x/100.64.0.0/10 — this box's own Tailscale range) rather than reporting a wrong ISP; wired fire-and-forget into both heartbeat routes (POS + Android terminal). New: lib/devices/reliability-area.ts — the anonymous cross-tenant counterpart of demand-area: per-cell gap TIME-WINDOWS (no tenant/device ids), k-anonymous (≥3 contributors), rebuilt in the same weather-cron tenant walk. New: lib/devices/reliability-insights.ts — the root-cause narrator: correlates a tenant's own outages against the area's anonymous gap windows by TIME OVERLAP (not ISP matching — simpler and works without identity) to split "N of your M outages lined up with restaurants nearby → shared network/power problem" from "isolated → your own Wi-Fi/router", plus an own-vs-area-average rate comparison (≥25% relative guard, ≥3 area gaps to compare against) and honest "still watching" lines. Surfaces: owner page `/dashboard/reliability` ("Network Reliability", sidebar NEW badge + a card on the Devices & Apps hub), GET /api/tenant/[id]/reliability-insights; Learning Ledger gains a reliability-patterns counter (contributors, cells, ISPs detected) and the Scenario-2 gap narrows to what's genuinely left (≥3 sharing restaurants per area; the ISP detection is flagged UNVERIFIED behind this deployment's own reverse-proxy chain until confirmed live). 16 new tests (dish-taxonomy/demand suite unchanged at 32 + reliability-area 5 + reliability-insights 8 + network-identity 3); tsc 0.
    • S1 Demand Intelligence — the founding RIC scenario, live (owner go 2026-07-07: "the big vision"). "Pizza down, burgers up in this area — because it's sunny" is now measured, not imagined. (S1-0) lib/intelligence/dish-taxonomy.ts — a deterministic multilingual (EN/SV/IT/TR) dish→category classifier (18 global categories, priority-ordered so "chicken burger"→burger, "kycklingsallad"→salad, "kebabpizza"→pizza; substring for Swedish compounds, word-start for short keywords; no AI, fully explainable). (S1-1) demand-rollup.ts — a tenant's orders → daily category buckets (canceled/rejected excluded, combo headers not double-counted; classifier reads product name in every language + the tenant's menu-category name, falling back to the order-line snapshot for deleted dishes). (S1-2) measured weather history: new WeatherHistoryDB app-doc backfilled from the free Open-Meteo ARCHIVE API (real past tMax+precipitation per ~11 km cell, ≤1 fetch per cell per day, pruned at 400 days) — the existing cache only ever knew today's forecast. (S1-3) demand-insights.ts — deterministic correlation with honest significance guards (≥8 days per bucket, ≥5% share + ≥30 units per category, ≥20% relative move) voicing plain-language sentences that always carry their sample counts ("On rainy days, Pizza rises to 34% of everything you sell (vs 26% on clear days) — measured over 26 rainy and 41 clear days."); below-guard patterns surface as "still watching for", never dressed up as findings. (S1-4 area layer) the weather cron's existing tenant walk now also rebuilds an ANONYMOUS per-cell area rollup (consenting tenants only — ricLearningAccepted, fail-closed; contributor COUNT + category totals only, no tenant ids; k-anonymity ≥3 before anything shows; contribute-to-see: non-sharing restaurants see no area numbers). Surfaces: owner page `/dashboard/demand` ("Demand & Weather", sidebar NEW badge) with insights, watching-list, measured category mix, area comparison and a coverage footer; GET /api/tenant/[id]/demand-insights; Learning Ledger gains a demand-patterns counter and the Scenario-1 gap narrows to what's genuinely left (≥3 sharing restaurants per cell; feeding briefings/stock forecasts). 16/16 new tests (incl. the k-anonymity gate); tsc 0; archive API response shape verified live.
    • Supplier "all three" batch (owner go 2026-07-07): crons live + profile unfrozen + approve-from-email. (1) Crons: daily-briefings (04:10 UTC) and inventory-reports (hourly, delivering at each tenant's chosen local hour) are finally scheduled on the box — the daily "N products low" emails the owner designed now actually fire for opted-in tenants. (2) Supplier profile: the write-once half is editable — /api/supplier/[id]/profile PATCH now covers contactName/phone/address/businessType/vatNumber/fulfillmentMethods/productCategories/minimumOrderValue/leadTimeHours (registration vocabularies enforced), the page (/supplier/storefront, nav renamed "Profile") gains a Business-details section with chip pickers, and the logo finally uses the real image uploader instead of paste-a-URL. (3) Approve-from-email (the flagship): the daily inventory email now proposes a CONCRETE supplier pick per reorder line (previous supplier first, else cheapest) with a signed one-tap "Approve & order" button → GET /api/public/inventory/approve-reorder verifies the HMAC token (48h, tamper-tested), REPRICES server-side, applies the same daily spend-limit gate as the dashboard flow, places via the shared B2B order engine with payment honestly on-account/invoice (Phase-40 pending), notifies the supplier at placement (previously only fired on payment-confirm, i.e. never in prod), emails the owner a confirmation, and renders a human confirmation page; idempotent per ingredient+day ("Already ordered"). tsc 0.
    • Multi-role identity + email-only verification (owner decisions 2026-07-07). (1) One login, many hats: a person can now be restaurant owner AND food supplier AND designer AND agent under one email — additive UserRecord.roles[]; GET/POST /api/auth/portals lists the portals a signed-in user genuinely holds (re-resolving each role's record and re-applying the SAME suspension checks login uses) and re-mints the single-role session cookie on switch, so every existing guard is untouched; POST /api/auth/add-role attaches a Supplier identity to the signed-in account (same taxonomy enums as registration; email treated as verified — the session proves account control); PortalSwitcher renders in the supplier rail only for multi-hat users. Also fixed: /supplier was a 404 — now forwards to the portal. (2) SMS verification off until the sender paperwork is done: phone verification is REQUIRED only when SMS_PROVIDER is configured (PHONE_VERIFICATION_REQUIRED) — in prod the OTP only ever reached the server log, so the mandatory phone step silently blocked every real partner at 'email-verified' forever; email alone now activates, the verify page hides the phone card (smsRequired on the snapshot), and getVerificationSnapshot self-heals stuck statuses on the next visit. Flips back automatically when SMS is configured. tsc 0.
    • Phase DS — the missing data streams, closed in one pass (owner go 2026-07-07; Ledger = source of truth). DS-2: order.status_changed finally EMITTED (transactional outbox — same save as the status change) from the staff status PATCH + the restaurant cancel route, with a canceled marker and no free-text reason (PII); the RIE order-lifecycle projection ingests it unchanged; Ledger gains a 30-day cancellation-rate row. DS-7: guest allergen/dietary asks counted per dish (bounded itaAllergenAsks map, real product ids only, count+lastAt only, fire-and-forget so the safety answer never waits); the allergen-coverage list now ranks by REAL asks → popular → name and the card shows "guests asked N×" — the guest-question gap closes. DS-4 (VL-A): lane conversations record their full transcript — one session row per car (surfaceSessionId), every turn heard/replied/outcome, scrubbed (emails + digit-runs masked) and capped (40 turns × 500 chars); the lane suppresses advanceTurn's terminal row (recordSession:false) so owner KPIs never double-count; Ledger voice row upgrades to live. DS-1: device reliability HISTORY — heartbeat transitions become bounded per-tenant records (deviceReliability: offline gaps >10 min + wifi↔cellular flips; rolling caps; consent-gated by the existing device-telemetry consent; honest limit documented: terminals ping on use, so a gap reads "unused OR offline" until clocked POS heartbeats land). DS-3 (ops, live-verified): the ADR-17 pipe to RIC is OPEN — ric_event_export flag on, RIC_EXPORT_TOKEN set, RIC worker rebooted "r360 pull ON" and stored 60 events on its first pull. Deferred with Ledger gaps: DS-5 generation keep/edit signals, DS-6 support-answer thumbs, DS-8 marketing engagement, clocked POS heartbeats + ISP identity. All slices tsc 0 + tests green.
    • Allergen declaration coverage (AL slice) — the "ingredient truth ladder" made real (owner go 2026-07-07). Safety invariant unchanged: guests are answered ONLY from what THIS kitchen confirmed — never inference, never cross-restaurant statistics. What's new is making the kitchen's confirmation effortless and measurable. (1) New Product.allergensConfirmedAt (additive) distinguishes "declared allergen-free" from "never looked at" (a bare empty allergens array reads as nothing-recorded in the ITA). (2) lib/menu/allergen-coverage.ts — coverage math + deterministic per-dish suggestions drawn from the dish's OWN multilingual name/description/ingredients text via the existing EN/SV/TR/AR/FA keyword map (86'd dishes excluded; popular-first ordering; 4 unit tests). (3) GET/POST /api/tenant/[id]/menu/allergen-coverage — coverage + one-tap kitchen confirmation (registry-validated ids; empty set = confirmed none; writes the human-gated allergens + the stamp). (4) Menu-page coverage card: "N dishes have no allergen info — guests asking about them can only be told 'ask our staff'" → expandable list, suggestion chips (tap to untick), Confirm / "None apply" per dish; goes green when the menu is fully covered. (5) Learning Ledger gains an "Allergen declarations (safety coverage)" counter (X dishes covered, % of active dishes across tenants) and the gap entry narrows to what's genuinely left: ranking nudges by REAL guest questions (not recorded per dish yet). E2E-verified on dev: Pad Thai created → suggestions gluten/eggs/peanuts/soy from its own description → one-tap confirm → covered, stamped. 13/13 menu tests; tsc 0; dark-vocabulary green.
    • Learning Ledger follow-up (owner feedback on first prod look): the Ledger moved INTO the Intelligence command center (/admin/intelligence/learning, old URL redirects) — it had been rendering its dark idiom inside the standard admin chrome and broke in Light mode; inside the always-dark IntelligenceShell (with a new "Learning Ledger" nav item) both modes are correct by construction. Added measured accuracy-over-time: first-pass import accuracy per week (share of AI-extracted items the owner kept unedited — the exact formula the learning store scores scenarios with), rendered as a bar trend with "first week X% → this week Y%", the sample count per bucket, and the method spelled out under the chart so the number is never a black box; the same LedgerTrend shape is ready for voice/generation accuracy when those loops land. Learned-knowledge rows now lead with the plain-language lesson (rule-kind label) with the free-text note as context. And rie_enabled was switched ON (owner request) — the Intelligence Engine now genuinely collects platform-usage signals, and the Ledger's RIE row reads "Collecting".
    • Learning Ledger + Data & Learning framework (LL-0 / DL-1 / DL-2) — the first slice of the RIC intelligence program (owner go 2026-07-07). Ledger: new super-admin page /admin/learning (sidebar: "Learning Ledger") — the honest window into platform learning: consent coverage (owners sharing learning signals / device diagnostics, capped scan), live counters over what's genuinely collected (import-correction signals, Studio approved examples, weather area-cells, voice sessions [partial — transcripts are a declared gap], RIE platform-usage events incl. its flag-off state), the distilled learned knowledge list (import rules with plain-language lessons + how often each was used), and a permanent "Not learning yet" gap list (S1 demand, S2 ops, voice, generation, onboarding, marketing, support, RIE-off) so missing loops are visible instead of silently absent. Read-only aggregation (lib/learning/ledger.ts + GET /api/admin/learning). Framework: the legal design is tiered — anonymous/aggregated learning (outside GDPR), business telemetry (terms-disclosed legitimate interest + opt-out), personal data (real unticked opt-in; NOT covered by signup). Restaurant signup now captures an affirmative terms acceptance (checkbox, required by RegisterSchema.acceptTerms, on both /register and the onboarding wizard) whose wording contains the anonymized-learning disclosure; registration records TenantSettings.termsAcceptance (v2026-07) and seeds the same `ricLearningAcceptance` record the Settings switch writes (wording v3-signup-li, revocable in Settings → Data sharing as before). Pre-framework owners get a slim one-click dashboard invite banner (writes the same record via the existing data-sharing API; "Not now" dismisses per device; never auto-flips). Verified live on dev: register rejects without the box (friendly message), accepts with it, both records written; ledger builder returns real counts. Also fixed a pre-existing dark-mode bug the vocabulary test caught: the delivery-zone map's hardcoded pale backdrop → var(--color-card). tsc 0; vocabulary + dark-vocabulary + consent + import-learning tests green.
    • Drive-thru lane: human-feel batch (owner test feedback). (1) Natural voice — the native kiosk now picks the most natural installed TTS voice per language (highest quality, exact-country preferred, offline preferred on ties) instead of the robotic engine default. (2) Patient listening — customers think out loud ("mmm… take number 3… aaah… two of number 2"): the recognizer no longer endpoints at the first pause; finalized segments are buffered and listening auto-restarts, submitting the WHOLE utterance as one order sentence only after a full quiet round, a "tap when you're done", or a ~60s cap — plus long-silence endpointing extras, and pure thinking-noises (um/uhh/hmm/mmm/aah/öh/äh, en+sv) are stripped before the text reaches the engine so they can't misread as dish words. (3) Explicit confirm → order number — the lane read-back now speaks like a human at the window and asks for the word: "So that's 1 Marinara and 2 Diavola. That'll be 49.00 SEK all together. Say \"confirm\" — or tap the green button — and I'll send your order to the kitchen." (sv equivalent with "bekräfta", now accepted by the engine's confirm matcher: bekrafta folded, +test); a placed order speaks and shows its short orderNumber ("Your order number is 7 — please pull forward!"), which advanceTurn now returns (additive) and the lane route exposes. Confirmed orders were already born-PENDING source:'drive-thru' → they land in Live Orders/KDS as before. Lane-only dressing lives in the VC-8 route — the shared engine copy stays phone-neutral. 16/16 voice-concierge engine+turn tests; tsc 0; :pos:assembleDebug green.
    • Native drive-thru voice kiosk — a third mode on the POS tablet (`android/:pos` + `:core`, no server change). The web VC-8 lane kiosk ported 1:1 into the POS app's boot picker (Register / Terminal / Drive-thru): the AI greets each car out loud (time-aware, en/sv), the customer orders by voice (native SpeechRecognizer, push-to-talk with live partial transcript) or by tapping the always-numbered menu ("number 3" resolved client-side so the number heard can never drift from the number shown), every turn runs through the SAME deterministic tenant-guarded voice-concierge/lane engine, replies are spoken via native TextToSpeech, and the running order/board, read-back → Confirm order, "Order placed — pull forward" (9s auto-advance to the next car), escalation and flag-off states all mirror the web lane. Always-dark kiosk chrome (KDS navy theme, system bars hidden), RECORD_AUDIO + Android-11 speech-service visibility in the manifest, and the same restaurant login gate as the register. Multi-agent reviewed (4 lenses); 10 fixes applied incl.: never speak the greeting into an open mic, one-turn-at-a-time guard (a tap mid-voice-turn can't fork the conversation), full car+blocked reset on exit (the activity-scoped VM otherwise resumes the previous car on re-entry), HTTP 403-token match (a bare "403" also matched ports like :4030), honoring the on-screen partial transcript when an OEM ends a forced stop with NO_MATCH, web-parity dimmed disabled buttons (the M3 default swallowed the busy spinner on navy), ≥48dp customer touch targets, and non-clipping boot-picker cards. :pos:assembleDebug green. Remaining for go-live: on-device UAT (mic quality at a real lane) + the per-lane device token the VC-8 route notes for unattended lanes.
    • Phase 56 (CC-9) — Swedish fiscal recording core (the sale→kassakvitto engine). Building toward wiring the fiscal engine into live sales: a pure, synchronous recordFiscalSale that assigns the next unbroken receipt number, signs it (sandbox control code — sync so it can run inside the row-locked order mutation), advances a small per-register counter (last number + grand total), and returns the kassakvitto + journal entry — plus an order→receipt-line mapper (single VAT rate, matching the platform's existing order VAT model; delivery + negative-discount lines so the receipt total equals the order total). Designed so the journal is DERIVED from orders (no unbounded array in any document). 5/5 unit tests. Still isolated — the point-of-sale payment-seam hook, tenant fiscal config, and dashboard are the next slices; and it stays sandbox (certified: false) until a certified control unit is connected.
    • Social Media — "Connect" gave no feedback and swallowed failures. On the restaurant Social Media page, clicking Connect fired a request with no success/error handling: if it failed (or the platform's OAuth creds weren't wired), nothing happened and the owner couldn't tell why. Now each card shows a clear "Connected." or the actual error under the button, an early "no restaurant selected" case is surfaced instead of silently returning, and the accounts API returns a useful message (e.g. an adapter's "OAuth not configured") with a 400 instead of an unhandled 500. (Platforms still connect in demo mode until their real OAuth app credentials are configured.)
    • Food Stories (FSN) — sign in / create your account with Microsoft & Facebook (alongside the existing Google, Apple and email). Same env-driven design as before: each provider lights up the moment its own credentials are configured and shows a "Soon" badge until then, so all four appear on the join screen. Microsoft is OIDC (idtoken, PKCE — like Google); Facebook reads email + name from the Graph API after the code exchange (it isn't OIDC). New env: `MICROSOFTOAUTHCLIENTID/SECRET (+ optional MICROSOFTOAUTHTENANT, default common) and FACEBOOKOAUTHCLIENT_ID/SECRET, with the callback {origin}/api/fsn/oauth/{provider}/callback`. 3/3 provider tests. (Web; the native FSN app stays email-only for now — in-app social sign-in is a follow-up.)

    Fixed

    • Menu TV — numbers STILL didn't show after the 3-choice control: the per-dish `showNumber:false` veto (owner report 2026-07-08, second round). Premium starters and demo packs seed every dish with showNumber: false — a field NO dashboard UI exposes — so computeNumbers skipped every item and an explicit "1…N" still rendered zero numbers. Now an owner's explicit board numbersMode ignores that invisible starter baggage and numbers every dish; the legacy follow-the-storefront path still respects it, so a board mirroring the website stays byte-identical to the website. New shaper test locks both.
    • Menu TV — the "Numbers" toggle silently did nothing on an un-numbered menu (owner report 2026-07-08). The board's numbers only revealed the storefront's Menu > Appearance numbering; with that set to "None" (the default), toggling Numbers on any TV template had zero effect. The toggle is now the SAME 3-choice control as the Menu page — None / 1…N / Per section — and an explicit pick numbers the board itself via a new MenuBoardConfig.numbersMode, independent of the website menu (absent = legacy follow-the-storefront, so existing boards are unchanged). The shaper normalizes showNumbers to what actually renders (templates and the native apps gate on it), numbering still matches the storefront ordering rules (showNumber per-dish opt-out respected, slice-stable global numbers), and the mode is in the board version hash so TVs re-sync instantly. No native app change needed — numbers were already on the wire per item. New shaper test locks override + legacy + hash behaviour.
    • Food Stories — the "Import from LinkedIn" CV import wasn't discoverable. A change earlier today hid the whole CV & job-history section (which holds the LinkedIn import) behind the "I'm looking for work" toggle, so a member who hadn't switched that on couldn't see the import at all. The CV section staying tied to "looking for work" is intended; this note records that the LinkedIn import lives there and appears once the toggle is on.
    • Admin "View Platform" link (and the Google OAuth redirect) pointed at localhost. The super-admin top bar's "View Platform" button was hardcoded to http://localhost:3001, so in production it opened localhost instead of the live site — now a relative / that resolves to whatever origin the admin is served from. Separately, when GOOGLE_OAUTH_REDIRECT_URI is unset the Google-connect redirect no longer falls back to localhost; it derives from NEXT_PUBLIC_SITE_URL (must still match a URI registered in the Google console). The super-admin dev-credentials card (which also shows a localhost URL + demo password) was already correctly tree-shaken out of production — verified.
    • Some admin pages returned "Internal Server Error" after a bad build. A production build whose static-generation worker was OOM-killed left a corrupt .next with an incomplete React Client Manifest (missing Next's metadata/viewport boundary modules), which 500s authenticated pages on render (e.g. /admin/dashboard/staff). A clean rebuild regenerates a complete manifest and clears it; the experimental.cpus: 2 build-worker cap already in next.config is the standing guard against the OOM that causes it. Public routes crawled clean (no 500s); /pricing and /blog return 404 but nothing links to them.
    • Phase 56 (CC-9) — Swedish kassaregister (cash-register-law) fiscal engine, foundation. A new server-side lib/fiscal/se/ module implementing the software side of Sweden's cash-register law (Skatteförfarandelag ch. 39 + Skatteverket SKVFS 2014:9/2014:10, updated by SKVFS 2021:16/17 effective 2027-01-01). Builds a compliant kassakvitto with every mandated field (seller/org-number/address, unbroken sequential receipt number, per-rate VAT extracted from VAT-inclusive prices, payment method, control-unit serial + control code, copy/pro-forma/training marking), appends an immutable journal (journalminne), produces X- and Z-reports (net-of-returns, VAT-per-rate, per payment method, grand total), and exports the journal as SKVFS 2021:16-style XML (the 2027 requirement). A pluggable control-unit adapter (kontrollenhet — hardware "black box" or approved cloud control system) signs each sale; auto-applies when the tenant's country is Sweden. 13/13 unit tests. ⚠️ Foundation only, not yet wired to the sale path or UI, and — critically — not a legal certification: real use requires a RISE-certified control unit/cloud control system, a manufacturer declaration, registration with Skatteverket, and a tax-professional's sign-off; until a certified unit is connected the engine runs in a clearly-flagged sandbox mode (certified: false on every artefact).
    • Phase 56 (CC-6) — bookkeeping charts of accounts for the US, Canada, Denmark & Norway. The per-country chart-of-accounts registry previously only held Sweden, so every non-SE tenant's books fell back to Swedish BAS accounts. Added four national charts, wired automatically by the tenant's countryCode (the mapping resolver already keyed off it): US models sales tax correctly — collected as a Sales Tax Payable liability, with no input-tax recovery (sales tax on purchases isn't recoverable in the US); Canada models GST/HST as recoverable Input Tax Credits (VAT-style); Denmark (25% moms) and Norway (25% / 15% food MVA) use their national standard-chart numbers, with Norway keeping a distinct 15%-food revenue/VAT account. Per-tenant overrides and a provider's imported chart still refine these. Tax rates for the US/Canada remain deliberately non-auto-computed (they vary sub-nationally — defer to a tax engine/accountant); live provider sync (QuickBooks/Xero, e-conomic/Tripletex) stays gated on credentials. 10/10 registry tests (SE drift-pins intact).

    Fixed

    • Delivery map & checkout followed a stale address. A restaurant's map coordinates were geocoded once and never refreshed, so after an owner changed their address the Delivery Area map kept centring on the old city (a Swedish restaurant showing Portland, OR) and — more seriously — the checkout delivery-radius gate measured customers against the wrong location and could reject genuinely in-area orders. Saving a new address now re-geocodes and updates the coordinates (remembering which address they came from), and the delivery-center endpoint self-heals coordinates that don't match the current address. Uses keyless Nominatim; appends the country as a disambiguation hint so a street-only address still lands in the right country.
    • Settings — visible save confirmation on the General tab. Saving (e.g. after changing the address) now shows a "Saving… → Saved ✓ / Couldn't save" state right next to the Save button at the bottom of the form — previously the only confirmation was an easy-to-miss banner at the top of the page, so an owner scrolling at the Save button couldn't tell if their address change stuck.
    • Delivery Area — free Google Maps + restaurant pin + address + Settings link. The delivery-zone editor now renders the free, no-key Google Maps embed keyed off the restaurant's saved address (reliably shows the restaurant + its pin — fixes the "map didn't pick up my address" case, which the OSM-tile map could leave blank), shows the address text, links to Settings → Address, and offers "Open in Google Maps". (Drawing editable/draggable circle/rectangle zones on the map needs the paid Maps JS API — a free embed can't host overlays — so rings are still sized from the restaurant via the controls; the checkout in/out-of-zone gate is unchanged.)
    • Marketing logo — force the azure mark (cache-bust). The logo mark + "360" are already azure (#1570ef) in code and on the box (verified: the served /landing/restora-mark.svg is fill="#1570ef" and "360" uses --clay-500 = azure), but a browser can hold a stale ORANGE cached copy of the static SVG. Appended a ?v=azure version to the mark URL across the landing nav + footer + product demo so every client refetches the azure asset. (If the orange persists it's a separate deploy — e.g. restora360.com's standalone host — not the box.)
    • Mason builder — double navbar in the page preview (builder-only; live was already correct). The editor stacked two navbars: the site-chrome mirror (MasonHeaderPreview) plus, when present, a redundant navbar/footer page-block rendered in the canvas — and the chrome mirror rendered on every page even where the live SiteHeader hides it per per-page assignment. Two builder-only fixes, live untouched: (1) MasonShell now gates MasonHeaderPreview by navbarShowsOnPage(navbar, activePage), mirroring the live SiteHeader's early-return so a page with the navbar hidden shows no chrome bar in the editor either; (2) MasonCanvas skips navbar/footer block nodes in its render (they're site chrome, already shown by the header + footer previews) while keeping their ids in children so drag-reorder indices stay aligned. tsc + eslint clean.
    • Phase 56 (CC-0 cascade) — the storefront nav + sections now fully OBEY the capability switches (both ways). CC-0 shipped hiding placed blocks when a capability was off, but three gaps surfaced in testing: (1) with Table Booking off the storefront still showed the Reservations nav link + Book button and served the /reservations page; (2) with Blog on the nav still hid the Blog link when the site had no posts (the old empty-blog heuristic didn't know about the switch); (3) with Newsletter on, nothing appeared because the section was never placed — the toggle only gated existing blocks, it didn't surface a missing one. Fixes: SiteHeader now gates the Reservations + Blog nav items and the Book button by the live capability (covers legacy, redesigned, and premium templates — all use SiteHeader), with an explicit-ON override so a switched-on Blog shows even with no posts yet; /reservations returns 404 when booking is off; and the home page auto-injects a capability's section (newsletter / loyalty / gift-card) when it's ON but unplaced — mirroring the existing auto-promo-banner pattern — so ON surfaces the section and OFF hides it. Premium (art-directed) templates aren't auto-injected; owners add those sections in Mason and the switch still gates them. tsc + eslint clean.

    Added

    • Phase 56 (CC-2b) — delivery zones you DRAG on an interactive map, as circle OR rectangle. Upgrades the delivery-area editor from restaurant-centred rings to fully draggable zones on a real interactive map — DeliveryZoneEditorMap, a dependency-free slippy map (raster OSM tiles + pointer-drag + zoom, no map library, so no npm/deploy/CSP risk). Each zone can be a circle (radius) or a rectangle (width×height), has its own centre (drag it anywhere), and a fee. DeliveryZone gains shape / centerLat / centerLng / widthKm / heightKm; the checkout picks the smallest zone containing the customer via feeForCustomer (rewritten pure helper, circle = haversine, rectangle = box test; test 6/6), refusing addresses outside every zone. The map also shows the restaurant pin + address + a Settings→Address link + "Open in Google Maps". (This is the free interactive map the owner asked for; Google Maps' own drawing would need a billing-enabled JS-API key, which the free embed can't do.) tsc + eslint clean.
    • Phase 56 (CC-2) — geo delivery zones: draw priced rings on a map + charge by distance. A new /dashboard/delivery-zones ("Delivery Area (map)") lets the owner size concentric delivery rings on a real map — a dependency-free OpenStreetMap-tile map (DeliveryZoneMap, no map library → no SSR/CSP/dep churn) with SVG ring overlays — and set a fee per ring. At checkout the smallest ring covering the customer's address sets the surcharge, and an address beyond every ring is refused (OUTSIDE_RADIUS), reusing the coords the order already carries (customerCoords) + the restaurant's location (auto-geocoded once via a new /delivery/center endpoint). DeliveryZone gains radiusKm; the public order route now picks the fee via a pure, unit-tested geo-zone helper (5/5), replacing the old "first zone's flat fee" bug — legacy postcode zones + the single deliveryRadiusKm gate still work. tsc + eslint clean.
    • Phase 56 (CC-4) — waiter table → seat → dish order entry. A new /dashboard/order screen (Operations → "Take Order (Waiter)") lets staff pick a table, choose the seat + course for each dish, and send it to the kitchen — so the right plate reaches the right person. It's pure UI over the already-complete POS stack: fetches GET /pos/menu (dishes + options + tables), builds per-line seat (0-based) + course + fireState, and submits via submitPosOrder → POST /pos/orders (source:'waiter', type:'dine-in', unpaid — settled later on the table bill), offline-safe through the outbox. Course 1 fires immediately; later courses ship as `hold` so the KDS fires them course-by-course for correct pacing — and KDS already renders "Seat N" + course, so it works end-to-end with no kitchen changes. Required dish options auto-default (a full per-option picker is a fast-follow). tsc + eslint clean.
    • Phase 56 (CC-3 polish) — autopilot one-click easy-setup + "Enhance with AI". The Capabilities panel's Marketing-autopilot row now shows a Publishing mode control (Review each · Publish automatically) that sets the autopilot trust level in one click via its own config route — consent-first (stays on "Review each" until the owner chooses auto-publish), so "set it once → publishes on schedule" is a single click. And a new "Enhance with AI" button on the menu-item description field rewrites the copy via a focused /api/tenant/[id]/ai/enhance endpoint (Haiku, wallet-metered, never destructive — returns the original text if the model gives nothing), gated by the Typing-help capability. The dashboard now feeds the client the FULL live-capability set (storefront + AI), so the button only appears when Typing help is live. tsc + eslint clean.
    • Phase 56 (CC-3) — the three AI features join the capability switchboard. The Capabilities panel gains an "AI & automation" group with real on/off switches for Typing help (Writing Assistant), AI content enhancement (Experience Studio) and Marketing autopilot (Social Autopilot). No parallel flags — each toggle reads/writes the feature's OWN native store via a per-capability binding: Typing help ↔ settings.writingAssistant.aiEnabled (consent-stamped like the settings route), Autopilot ↔ db.autopilot.config.enabled (via mergeAutopilotConfig), and content enhancement ↔ settings.capabilities['experience-studio'] gated into the single shared runTenantExperience core so OFF blocks AI generation across BOTH the dashboard one-click flow and the public Experience API. Availability is binding-aware (platform Writing-Assistant flag / PAID experience-studio add-on / social-autopilot feature), so the panel shows "Get it" when a tenant isn't entitled. The storefront gate (liveCapabilitySet) is deliberately scoped to the storefront group only, so page blocks never gate an AI feature. Guard test extended (16/16, incl. "blocks never gate an AI feature"); tsc + eslint clean.
    • Phase 56 (CC-1 slice B) — structured per-guest pre-orders, so the kitchen prepares ahead. The /book floor flow already collected each guest's diet / drink / starter, but only flattened them into free-text on specialRequests — invisible as data. CC-1 now also persists them structurally (Reservation.guestPreferences, new ReservationGuestPreference type) and surfaces them in the owner Reservations dashboard as a per-person "Guest pre-orders" panel, so staff can have the right thing ready for the right person before the party arrives. Fully backward-compatible (the free-text summary is unchanged); tsc + eslint clean. This completes the two user-facing halves of Phase 56 request #8 (booker/notify in slice A + advance preferences here); consolidating the three create paths into one remains a deferred internal cleanup.
    • Phase 56 (CC-1 slice A) — reservation confirmations to the booker + multi-recipient notify. Before CC-1 no booking path emailed the BOOKER at booking time (only the owner, and only on one of three create paths) — a guest was left unsure their request even landed. A new lib/reservations/notify.ts centralises it: on CREATE → owner email + booker "received" (pending) or "confirmed" (autoConfirm) email + staff SMS; on a status change → the booker gets the update. Guests can now add extra recipients ("Also email updates to…") — stored on Reservation.notifyEmails, server-sanitized (valid / deduped / capped at 5) and CC'd on the confirmation AND every time/place/party-size change, so a partner / organiser / PA stays in the loop. Wired into all three create paths (public/[id]/reservation, public/book/[slug], tenant/[id]/reservations) + the status PATCH, replacing the old single-recipient inline sends; new reservationReceivedEmail template. Guest form gains an optional multi-email field. Unit test 6/6 (recipient list + sanitizer); tsc + eslint clean. i18n: the new storefront field ships English-first (fast-follow). Also delivers Phase 56 request #2 (offer-table-booking toggle) — already shipped in CC-0's reservations capability gate.
    • Phase 56 (CC-0) — Restaurant Control Center: the owner capability switchboard. A new owner surface (/dashboard/capabilities, sidebar "Capabilities") lets the restaurant turn features on/off, and the public storefront templates + the Mason page builder follow automatically. Architecture is reuse-first over the existing feature-gate engine and adds NO MasterDB array: a capability is LIVE only when the tenant is entitled (lib/features plan/grant/add-on) AND the owner hasn't switched it off (TenantSettings.capabilities, a tenant-scoped map; ABSENT key = ON, so every existing site is unchanged). New lib/features/block-features.ts is the single, client-safe source mapping blocks→capabilities + the owner-facing capability catalog (CC-0 set: online-ordering, table booking, loyalty, gift cards, blog, newsletter); lib/features/capabilities.ts resolves entitlement×owner-switch server-side. The cascade has one choke point each side: the SSR renderBlock hides a placed block whose capability isn't live (covers home + every data page + nested container children; fails OPEN so a DB blip never blanks a site), and the Mason palette locks the block's tile (via useTenant().capabilityLive, fed by the dashboard layout). online-ordering keeps its Phase-51 semantics (menu stays a browsable showcase; only the cart is gated — menu-full is deliberately never hidden). Toggling PATCHes /api/tenant/[id]/capabilities (entitlement-guarded: you can't switch on what you haven't bought → 402 to the Add-ons store; owner-only settings.edit) and revalidates the tenant's pages (layout mode) so the change is live immediately. Guard test (block-features.test.ts, 8/8) asserts every block→feature id and capability id is real + perTenantToggle, that every gated capability is owner-visible in the panel, and that menu-full is never gated. Brand (azure) + dashboard dark-mode guards stay green. tsc 0 across the new files. First wave of the Phase 56 plan (docs/Phase-56-Restaurant-Control-Center.md).

    Fixed

    • Phase D (PD-5 batch 4a) — post-hoc adversarial review + hardening (the gate-context conversion made byte-parity at flip). A four-lens multi-agent adversarial review of batch 4a (9 candidates, 5 refuted with reasons — notably: multi-sub-per-tenant is NOT producible by any current write path, and the two-connection gate read is faithful parity, not a torn-read bug) surfaced three real parity gaps in the feature-gate conversion, all hardened. (1) `plan-definitions` had no pre-cutover backfill. 4a added it as a third platform_config key read by loadGateContext, but never extended backfill-platform-config-addons.mjs (still ['ai-settings','platform-settings']), so the "REQUIRED before flip" backfill left the row empty — a custom Pricing-Studio (plan_*) or DB-overridden-plan tenant would lose EVERY plan-included feature (402s / collapsed sidebar) in the window between flipping the read flags and the first live reconcile, and re-running the backfill after a live reconcile tripped its own count !== 2 verify (false "broken cutover"). The backfill KEYS list now includes plan-definitions in lockstep with PLATFORM_CONFIG_KEYS, with a banner making the lockstep a rule. (2) NULL-doc rows silently failed OPEN, not to the doc. pgGetGateContext filtered doc IS NULL subscription rows and returned subs=[] without throwing, so loadGateContext (which only falls back on a thrown error) served sub=undefined — silently stripping paid features AND flipping tenantIsReadOnly to false (bypassing requireWritable, the write guard on every route) for a tenant whose row simply wasn't backfilled yet (migration 013 adds doc nullable). (3) Multi-sub tenants diverged. doc-mode picks the array-FIRST sub via .find(); registry ordered by started_at ASC, which cannot reproduce master-array append order for a legacy/imported/hand-edited tenant with >1 row — feeding a wrong readOnly/plan decision into the write guard. Both (2) and (3) fixed at the source: pgGetGateContext now returns null (→ authoritative master-doc scan, fail-SAFE) for either anomaly, so single-sub populated-doc tenants (100% of code-produced data) serve from the registry while the rare/transitional cases defer to the doc — no new migration, no hot-path reconcile change (a lighter, lower-risk fix than an ordinal column for a data-only edge). The subscriptions backfill self-check now also asserts zero NULL docs so a partial state can't read green. Proven on scratch Postgres 16 (001→013): a new gate-context-guards test drives BOTH modes — a multi-sub tenant's readOnly matches the doc (registry deferred, did NOT pick the started_at-min stale sub), a NULL-doc row returns null, and the plan-definitions mirror is read-parity — all six suite tests green, plus the sibling registry + plans suites unchanged. tsc 0, eslint 0.
    • Cross-tenant custom-domain takeover via case/port variants + PD-3 review hardening. The adversarial review of the PD-3 cut-over surfaced a pre-existing tenant-isolation bug: the owner-facing custom-domain PATCH (/api/tenant/[id]/domain) checked conflicts with strict ===, so claiming Pizza.SE passed the uniqueness check while another tenant held pizza.se — and the two normalize onto the SAME domains row in registry mode (last-write takeover). The route now canonicalizes (lowercase + port-strip, ''→null) BEFORE both the conflict check and storage, compares case-insensitively against existing docs, and runs the check+write atomically under mutateMasterDB (two concurrent claims serialize instead of both passing the read-then-write race). Two hardening fixes from the same review: (1) the /api/domains on-mode fallback now catches its own failure (both stores down → clean 503 the proxy treats as "no tenant" for its 60s cache, instead of an unhandled 500); (2) reconcileTenantRegistry defensively dedupes case-variant subdomains/custom domains — bad doc data now degrades to a loudly-logged row skip instead of a unique-index violation aborting the ENTIRE master-save transaction (the mirror must never brick platform writes). Proven on scratch PG16: 11/11 including the new bad-data test (save survives, first claimant in doc order wins, both warn lines observed). tsc 0, eslint 0.
    • Onboarding wizard — exhaustive 1:1 pass against the handoff (every state, desktop + mobile). Drove both the design mockup and the live wizard through all five states — step-1 logo empty + filled, step-2 hours, provisioning, activated — at 1440px and 390px, and ran a 9-pair vision comparison. Real deviations fixed: (1) the left-rail brand lockup was hand-reconstructed (an orange app-icon nested inside a cream square + a clay-coloured "360"), reading as a doubled square — replaced with the handoff's actual restora-wordmark-light.svg asset (white rounded square + orange mark directly on it + gold #ecc049 "360" with correct spacing), a pixel-exact lockup; (2) the rail watermark now matches the handoff's placement (right/bottom, 248px, 5%); (3) the provisioning spinner span span the Restora brand mark instead of a generic storefront glyph (the handoff's restora-mark.svg at 42px); (4) the step-2 opening-hours rows overflowed the viewport on mobile (fixed 100px day label + two 104px time selects summed past 390px, clipping the closing-time select so it couldn't be tapped) — the two time selects now wrap to a full-width second line below the day + toggle at ≤560px, both fully usable; (5) a modest top-padding rebalance so the form isn't top-heavy after the (intentionally removed) sign-in header row. Verified live after the fixes (desktop lockup + provisioning mark + mobile hours all correct). Confirmed as correct-not-bugs and left unchanged: the post-register copy ("Let's finish setting up…", "a couple of quick steps"), the omitted "Sign in" link and step-1 "Back" button (account is pre-done), the extra "Finish setting up" card + "AI during trial" row + 149 kr pricing (intentional live enhancements), and the adapted provisioning task labels. tsc 0, eslint 0, 4,163 tests green.

    Added

    • Nordic Table — a new light Scandinavian premium website template (7 colour combinations + demo images), built 1:1 into the one Mason/premium system. Reproduced from the design handoff as bespoke, fully-editable Mason blocks — NOT a parallel engine. Five bespoke sections (nordic-hero centred airy hero + dot-grid + wide image card; nordic-principles 3 cards; nordic-seasonal image+tag-chips split; nordic-fika image+copy split; nordic-visit info-list + booking-CTA panel) render live via the shared registry path and inline-edit in the canvas (isPreview-gated data-mason-field markers — the marker-invariant guard covers all five). Colours flow from a shared nordic-shared.ts that maps the source's 5 base tokens + color-mix derivations onto the platform 9-token contract, so any of the 7 palettes (Warm Stone · Cotton Candy · Lavender Dream · Buttercream · Powder Blue · Pistachio Cream · Peach Sorbet) re-skins the whole site; fonts are Syne (display) + Manrope (body). Ships as a code-template (nordic-table.ts, 7 palettes → 9-token themes with WCAG-luminance accentInk) + a functional starter (nordic-table-starter.ts — home = the 5 sections, menu = the real orderable menu-full as a text-only 2-column card, reservations = the real booking form, 11 seeded SEK dishes, high-quality Unsplash demo images), registered in the premium registry + catalog (Premium badge). Applying it yields a complete, orderable, bookable Scandinavian site editable in Mason. Verified: headless 1:1 render at desktop + mobile vs the reference, catalog adaptation (7 variants, correct card DTO), 577 template/mason/section/theme tests green (block counts bumped 99→104 / 82→87 static), tsc 0 for all new files.
    • Phase D (PD-5 batch 4a) — the feature-gate family leaves the master doc. loadGateContext (subscription + feature grants + plan definition) backs tenantHasFeature, tenantNavFeatureSet, tenantIsReadOnly and requireWritable — the last of which runs on EVERY tenant write route — and each call parsed the whole master doc. Migration 013 converts it with the zero-drift orders-mirror pattern: subscriptions_registry gains a doc jsonb column carrying the FULL SubscriptionRecord (the content hash now covers the whole doc — any field change rewrites the row, the right trade for small low-churn subs, and the gate can never miss a field a projection forgot — proven by the trial-engine readOnly flag round-tripping), a new feature_grants_registry mirrors FULL FeatureGrant docs tenant-indexed, and the Pricing-Studio planDefinitions array becomes a third platform_config key. With SUBSCRIPTIONS_REGISTRY_READ + FEATURE_GRANTS_READ + PLATFORM_CONFIG_READ on, the whole gate context is three indexed reads; errors fall back to the doc scan; sub selection orders by started_at ASC as the append-order proxy (identical for the normal one-sub-per-tenant case). Backfill extended in lockstep (db:backfill:subscriptions-registry now fills the doc column + grants). Proven on scratch Postgres 16 (001→013): the REAL gate consumers in registry AND doc modes with parity asserted, revoked-grant delete propagation, and all suites green (12/12 + 12/12 + 5/5 + 4/4). tsc 0, eslint 0.
    • Phase D (PD-5 batch 3) — users projection widened; `requireLocalizationManage` leaves the master doc. Migration 012 adds admin_role, assigned_countries (jsonb) and language to users_registry — the hash formula is bumped in lockstep in the reconciler AND the backfill, so every user row self-heals exactly once on the next reconcile with no manual step. requireLocalizationManage (the GL-9 country-manager write gate) now answers from one PK read: a HIT is trustworthy under the current-or-absent invariant, a MISS or error falls back to the doc scan. language is projected ahead of need for the dashboard-layout conversion (next batch, which still requires plan-definition + featureGrants mirrors). Proven on scratch Postgres 16 (001→012): projection round-trip including the jsonb country array + a 6-case canManageCountry parity block in the matrix suite; 12/12 + 12/12 + 4/4 regressions. Deploy note honored on the box: migrate BEFORE restart — the new reconciler writing to an unmigrated schema would drop user rows (never-brick) and fail-close logins. tsc 0, eslint 0.
    • Phase D (PD-5 batch 2) — the AI-call path and the public site gate leave the master doc. Three of the highest-frequency remaining whole-doc parses converted, behind new expand mirrors (migration 011): (1) platform_config — the aiSettings and platformSettings singletons (~1KB each) mirrored out of the doc with hash-skip; an absent doc field DELETES the row, so a MISS always means "not configured", never stale — consumed by getAISettings() (one PK read) and /api/public/site-status (the proxy's 15s-cached site-gate poll); (2) addon_subscriptions_registry — the projection resolveTrialHold needs on EVERY AI budget check for a never-paid trial tenant, now two indexed EXISTS checks instead of a full doc parse, with a deliberate, documented semantic tightening: "never paid" asks whether ANY subscription has a paid status (order-independent, intent-true) instead of whichever sub the doc's .find() happened to hit first — the only divergence direction releases the hold, matching the function's own fail-open contract. All flag-gated (PLATFORM_CONFIG_TABLE/_READ, ADDON_SUBSCRIPTIONS_TABLE/_READ, SUBSCRIPTIONS_REGISTRY_READ, default OFF = byte-identical), wired into the one-transaction control-plane reconcile + the hourly self-heal cron, errors fail open to the doc scan. Proven on scratch Postgres 16 (001→011): 4/4 suite driving the REAL consumers — the site-status route handler, getAISettings, and resolveTrialHold across a 5-case billing matrix in BOTH modes with parity asserted — plus config hash-skip/delete-propagation and 12/12 + 12/12 + 4/4 regressions. Review follow-up: self-verifying db:backfill:platform-config added (named in the assertReady guards) and the REQUIRED flip ordering documented — _TABLE flags + restart → backfill (verify counts) → only then the _READ flags; a MISS means "not configured" only after the first reconcile. tsc 0, eslint 0.
    • Phase D (PD-5 batch 1) — the per-request auth lookups leave the master doc. requireTenantAccess loaded and scanned the whole master doc on every tenant-staff API call (the Phase 38 revocation + sessionEpoch check — KDS/POS terminals poll this constantly) and on every group-manager cross-tenant call. With the registry read flags on (USERS_REGISTRY_READ=1, TENANT_REGISTRY_READ=1 — live on the box since the PD-3b cut-over), both branches now answer from indexed PK lookups — usersRegistryRepo.findById for the staff check, plus tenantRegistryRepo.findById in parallel for the group-manager org check — with the original doc scan kept as the fail-open fallback on registry errors and as the byte-identical flag-off path. New seam surface: pgFindUserById, RegistryTenant + pgFindTenantById, and findById on both repos. Predicate parity proven on scratch Postgres 16 across an 11-case matrix (valid staff / stale epoch / wrong staffId / wrong tenant / revoked login; manager reaching every org branch / non-org tenant / no-groupRole user / unknown user) including instant revocation propagation — an epoch bump through the real mutateMasterDB path kills the old session on the very next check. A 5-agent adversarial security review then confirmed the one deep risk — never-brick skip rows freeze stale values, and this batch made stale rows answer authorization (a frozen sessionEpoch would survive revocation; a stale organizationId could keep granting org access) — hardened before the box saw traffic on it: (1) users_registry now upholds a current-or-absent invariant — rows the reconciler can no longer keep current (malformed, duplicate email, unresolvable swap) are dropped in the same transaction, and dropping a swap party's row frees its email so the other party's retry converges (proven: one drop + one converge, then full self-heal on the next clean save); (2) the staff branch treats a registry MISS as "fall back to the doc" (ambiguous: revoked or dropped — the doc answers both correctly) while a HIT stays trustworthy; (3) the group-manager branch is deny-fast, confirm-grant: the registry short-circuits the common denial, but an org-wide GRANT is re-verified against the doc before being honored (grants are rare; the doc load lands only where it did pre-PD-5); (4) a new registry-self-heal platform cron (hourly, flag-gated, FOR UPDATE reconcile, hash-skip makes clean passes ~free) backstops swallowed mutate-path reconciles, whose log level rose to error. New matrix case proves staleness can never grant: a demote-under-duplicate leaves the row absent, never stale. 12/12 + 12/12 + 4/4 suites. tsc 0, eslint 0.
    • Phase 55 (ML-2…ML-6 + guards) — the multilingual platform lands end-to-end. Continuing the two ML-0/ML-1 waves: imports prompt, never spend silently — every provider/wizard import stamps an honest translation offer (targets + pending fields + kr estimate, pure math) onto its report, rendered in the wizard (tenant-language copy), mentioned in the completion email, and one click fires the bulk engine; new tenants’ languages seed from their signup country; WoW claims persist the preview translations the prospect already paid for. Writers stopped lying about language — imports write ONE slot keyed by the DETECTED menu language (engine understands sourceLang; the tenant default became a valid machine-fill target, so a Swedish restaurant importing an English menu gets real Swedish); every editor writes the default slot and spreads the record (the product editor used to WIPE translations on every save; the SEO editor dropped locales; the Mason builder’s round-trip DESTROYED manual translations — all fixed, regression-tested). Surfaces expanded — option groups/option names, combos, blog titles/excerpts joined the translate registry + manager. The guest funnel speaks 21 languages, free — 216-key dictionary rebuilt from live strings (en source of truth, sv hand-authored, 19 generated by a native-translator agent workflow, completeness + placeholder parity validated; 199 KB committed, zero runtime AI); cart→checkout→Swish/Stripe/PayPal→confirmation→reservations→customizer wired via a layout-provided language context (SSR-safe); html lang/dir per tenant (Arabic flips RTL), Accept-Language first-visit detection, hreflang on every page, storefront chrome ×21. The dashboard nav speaks 21 languages — per-user language (profile field + PATCH /api/auth/me + header picker), all 115 sidebar labels localized at render (English labels stay stable ids), Quick-Links + search included, tenant-default fallback. Guard — a static-scan test bans new {en:X, sv:X} duplication (it immediately caught the last un-swept option-group writer, and surfaced + repaired 3 NUL-corrupted separator bytes a scripted edit had introduced in commit.ts). Verified throughout on live dev: Swedish checkout (Kassa/Din varukorg är tom), German dashboard (Übersicht/Speisekarte & Combos), dir=rtl for Arabic; tsc 0; all i18n+importer suites green.
    • Phase 55 (ML-0 + ML-1) — Fully Multilingual Platform: the canonical language registry + owner-confirmed bulk content translation. First two waves of the multilingual program (owner decisions locked 2026-07-04: 21 languages = the marketing-pages 18 ∪ {es, tr} ∪ Arabic; platform CHROME will ship built-in free in every language; tenant CONTENT translation is paid from AI credit behind an estimate→confirm). ML-0: ONE registry (lib/i18n/languages.ts) replaces five divergent language lists — the AI translator's 6-language ceiling is gone (enabling Finnish/Italian/Danish… previously silently translated NOTHING), TRANSLATE_LANGS/LANGUAGE_META/the tenant Languages tab/the Translations-page names all derive from it, and the WoW preview switcher offers all 21 (six pills + a "More…" dropdown). ML-1: the auto-translate engine gained a shared runTranslationPass core (now CHUNKED — a 200-dish menu can't blow the model's output ceiling; the on-save path is behaviorally unchanged: cap-enforced, cap-counted) and a new bulk module (lib/i18n/bulk-translate.ts) covering every content surface (menu, categories, restaurant texts, Mason blocks): GET/POST /api/tenant/[tenantId]/translations/bulk returns an HONEST estimate (only fields that would actually be translated — owner-edited translations stay locked; priced per language at the real model rate + FX/markup, hold-aware wallet remaining) and runs only on explicit confirm (402 AI_BUDGET_EXCEEDED when credit can't cover it; confirmed runs bypass the small on-save monthly cap by design and are excluded from it). /dashboard/translations gained the per-language "Translate everything into …" estimate→confirm panel. Verified on live dev: demo-import tenant with de+it enabled → estimate 50 fields / 0.08 kr (hold-aware wallet remaining), confirm panel renders, and the engine fail-safes to a zero-cost no-op without an AI key (this box has none — the model call itself is the same prod-proven WoW-preview translator). 23/23 i18n tests, tsc 0. Remaining waves (import hooks, write-path correctness, chrome+dashboard dictionaries, RTL) tracked in the Phase 55 plan.
    • Phase D (PD-4c) — the audit sink moves onto the append-only `audit_logs` table. Every audit event — including every failed login, which audits unauthenticated — used to rewrite the ENTIRE master doc (master.auditLogs push + full-doc save). Behind AUDIT_LOGS_TABLE (default OFF = byte-identical behavior), an audit event is now a plain INSERT into the partitioned audit_logs table (migration 007 + new 010 for doc_id + action indexes) and the master doc is not touched at all. One seam in lib/audit/log.ts carries both modes: writers (writeAuditLog, and appendAuditEntry for call sites inside an existing master mutation) and readers (queryAuditLogs paged+filtered, listRecentAuditLogs, countAuditLogs, listTenantAuditLogs, hasAuditAction) — all 12 inline master.auditLogs.push sites across 11 admin routes and all 6 readers converted (/api/admin/logs, monitoring counts, overview recent-activity, the tax-audit trail, guardian's security window, and the addon-billing cron whose reminder dedup now needs NO master write in table mode). Idempotent backfill db:backfill:audit-logs copies doc history in via doc_id WHERE NOT EXISTS (proven +2 then +0 on re-run). A 5-agent adversarial review confirmed three real gaps, all fixed before landing: (1+2) the backfill's doc_id idempotency broke on legacy cron entries whose ids were minted as audit_${Date.now()} — two same-millisecond reminders share ONE id, silently dropping history and hard-failing the reconciliation, while the post-flip re-run reconciled against ALL doc_id IS NOT NULL rows (table-born rows also carry docids → false pass). The backfill now re-mints unique docids for duplicates (no history dropped) and reconciles against the exact attempted id set (proven: a seeded duplicate-id pair lands as ..._dup1, both runs reconcile, re-run +0), and the cron's doc-mode id is now collision-free; (3) guardian's audit fetch was awaited outside its security try/catch, so a transient PG error in table mode would have broken the whole snapshot instead of degrading to seed — moved inside. Plus a NaN-page guard on /admin/logs (garbage ?page= would have become OFFSET NaN → 500 in table mode). Proven on scratch Postgres 16 (migrations 001→010): 4/4 audit suite — including the headline proof that writeAuditLog in table mode leaves the master doc's updated_at untouched, LIKE-wildcard escaping in the admin filter, and doc-mode parity with the flag off — plus the duplicate-id backfill proof and 12/12 tenant + 11/11 users/subs regressions. tsc 0, eslint 0.
    • GloriaFood `demo` — professional studio dish photos + premium-template dress-up. The funnel's try-it restaurant (Demo Trattoria, mock adapter) now shows the finished product instead of a photo-less default site. (1) Photos: all 8 demo dishes carry professional studio photography — the two existing renders (margherita, pepperoni→Diavola) plus six new Imagen/Gemini renders in the same overhead wood-and-linen editorial style (quattro formaggi, carbonara, lasagne al forno, penne arrabbiata, unlabeled sparkling water, house lemonade), committed as first-party WebP under public/demo/gloriafood/ with a documented generator (scripts/demo/gloriafood-demo-images.mjs — every render passes a Gemini vision gate, and the retry re-verifies; a human visual check caught the one garbage render the first pass produced). The image pipeline gained a first-party branch: a root-relative ProviderImageRef.url is read from public/ (path-confined) instead of fetched, then localized per-tenant exactly like a provider-CDN photo — so the demo exercises the REAL photo path (sharp → WebP → dedup → /uploads/<tenant>/) with zero network. (2) Premium template: the demo snapshot is now isDemo-flagged, and the register claim path dresses the fresh tenant in the osteria-lume premium template (theme-only install, Terracotta variant — keeps the imported menu, which the template's dynamic menu page renders); fail-open and demo-only, live imports are untouched. (3) Funnel preview: /api/public/import/gloriafood returns sampleImages (first-party URLs only — provider CDN links are never hotlinked pre-auth) and the preview card leads with the 8-photo strip; the count tiles now show "8 Photos". Self-QA'd end-to-end on live dev: demo preview → register with token → 8/8 products with localized /uploads/ photos, tenantSite=osteria-lume/terracotta, storefront + menu page + funnel screenshotted and visually verified (desktop + 320px); QA tenant fully removed, importer flag restored OFF in dev. tsc 0, importer tests 18/18.
    • Phase D (PD-4b) — the login read cut-over: `/api/auth/login` staged onto the users registry. The user lookup — today a whole-master-doc load + linear scan on EVERY login attempt — is staged behind USERS_REGISTRY_READ via the new UsersRegistryRepo seam: unset → the original doc scan, unchanged; shadow → the doc authenticates while the registry is compared and any disagreement logs SHADOW DIVERGENCE; 1 → the indexed registry (functional index on lower(email)) answers the lookup, so a failed login skips the per-attempt doc parse + O(n) scan for the lookup. Security posture: the registry is only the index — on a hit + password match the master doc is loaded (the route needs it anyway for the 2FA secret and tenant resolution) and RE-verified before any session is minted; registry-shaped errors fail open to the doc scan; reads require the dual-write flag. A 4-agent adversarial security review confirmed two real gaps, both fixed before landing: (1) the "failed logins never touch the doc" framing was false — writeAuditLog (fired on every failed attempt, unauthenticated) did a getMasterDB→saveMasterDB read-modify-write, i.e. a full doc parse + last-writer-wins rewrite per attempt that a flood could use to clobber concurrent legitimate master mutations; it now runs under mutateMasterDB (row-locked, serialized), the route/repo comments state the honest scope, and PD-4c (audit sink → the append-only audit_logs table from migration 007, which is what actually makes per-attempt audits cheap) is queued; (2) the fail-closed guard compared password but not email, so a stale registry email→id mapping could authenticate an email the doc no longer holds — the doc re-verify now requires password AND email to agree. Proven through the REAL login handler on scratch Postgres 16: all three modes, a source-of-truth proof (registry row deleted → 401 despite valid doc credentials; reconcile restores), fail-closed stale-hash AND stale-email proofs with repair (both LOGIN DIVERGENCE errors observed firing), case-insensitive email — 11/11 suite, 12/12 tenant regression. Scope decision recorded in TODO/design doc: SUBSCRIPTIONS_REGISTRY_READ is deferred to PD-5 because every subscription read site already holds the master doc — a lone canary there removes zero doc loads. tsc 0, eslint 0.
    • Phase D (PD-4) — users + subscriptions registries: the control-plane expand goes multi-entity. master.users[] and master.subscriptions[] now dual-write into the indexed users_registry/subscriptions_registry tables (migration 007) under their own flags (USERS_REGISTRY_TABLE/SUBSCRIPTIONS_REGISTRY_TABLE, default OFF = inert), following the proven tenant-registry pattern with every hardening built in from day one: per-row content_hash skip (migration 009 — a master write that touched one user upserts one row, not all N; doc-only fields like the 2FA secret, password token and trial-engine ledger are NOT projected so they never churn rows), never-brick guards (malformed rows and case-duplicate emails — the registry enforces lower(email) uniqueness — skip with a loud warn instead of aborting the master save; garbage timestamps degrade to NULL via tsOrNull, also applied to the tenant reconcile), and delete-propagation. The write seam is generalized: saveMasterDB/mutateMasterDB now gate on anyControlPlaneRegistryEnabled() and writeMasterWithRegistry reconciles every ENABLED entity inside the one transaction — per-entity flag independence proven (users flag on + tenant flag off leaves tenant_registry untouched). New indexed reads for the future cut-overs: pgFindUserByEmail (case-insensitive via the functional index; auth-core projection incl. sessionEpoch for the Phase 38 session-kill check) and pgListSubscriptionsByTenant. Self-verifying backfills db:backfill:users-registry + db:backfill:subscriptions-registry compute the identical hashes (proven: a round-trip save after backfill re-writes 0 rows). A 5-agent adversarial review (2 lenses → refute-by-default verification) confirmed three deeper failure classes, all fixed before landing: (1) cross-row unique violations (an email/subdomain swap or reassignment across two rows in one save) could still abort the whole master-save transaction despite the batch dedupe — all three reconcilers now run delete-removed FIRST (a vacated value is free before upserts), wrap every row upsert in its own savepoint (a collision skips that row loudly, never the save), and give first-pass collisions one retry (reassignment orderings converge; a pure A↔B swap stays skipped-with-warn while the doc still commits); (2) the mutate-path reconcile had a stale-read race (doc read unlocked outside its txn could overwrite a fresher concurrent write's registry rows) — it now rides INSIDE the row-locked doc transaction via mutateDoc's existing onPersist hook, savepoint-guarded so a registry failure never fails the doc mutation, and the standalone repair tool reads the doc FOR UPDATE; (3) tsOrNull now emits the canonical ISO (JS accepts strings Postgres rejects) and pgListSubscriptionsByTenant returns ISO-8601, not Postgres text format. Proven on scratch Postgres 16 (migrations 001→009): 7/7 users/subs suite (incl. delete+re-add, within-save reassignment resolved by the retry pass, and the pure-swap degradation with both warn lines observed) + 12/12 tenant-suite regression (incl. a subdomain-reassignment case) + backfill↔dual-write hash parity for both entities with the normalized timestamps. tsc 0, eslint 0.
    • Phase D (PD-3) — the first read cut-over: `/api/domains` staged onto the tenant registry. The platform's hottest read (the proxy resolves EVERY request's host through this route, 60s per-host cache) can now be served from the indexed tenant_registry/domains tables instead of loading and scanning the whole master doc, staged behind TENANT_REGISTRY_READ: unset → the original doc scan, unchanged; shadow → the doc still serves every request while the registry is read alongside and any disagreement is logged as SHADOW DIVERGENCE (the zero-divergence soak); 1 → the registry serves through the TenantRegistryRepo seam — no master-doc load at all — with the doc scan kept as a fail-open fallback on a thrown registry error. Parity with the doc scan was made exact: the registry's subdomain lookup is gated on the shared SUBDOMAIN_BASE_HOSTS allowlist (so pizzahut.evil.com does NOT resolve — the doc scan never matched foreign base hosts), subdomains are stored lowercased to match the scan's case-insensitive compare, and the {tenantId, subdomain, redirects} response contract is unchanged. Proven through the REAL route handler on scratch Postgres 16 (migrations 001→008): 9/9 integration tests covering all three modes with a source-of-truth proof per mode (registry emptied → doc still serves in off/shadow; registry row deleted → 404 in on-mode while the doc still holds the tenant), the response contract, base-host + mixed-case parity, and the shadow divergence warning observed firing live. Also captured docs/architecture/launch-cost-ladder.md — the web-verified launch-cost analysis (Stage A = Cloud SQL ~$57/mo with PITR; every migration confirmed plain PG16 SQL so stage-up to the unchanged AlloyDB end-state is a DATABASE_URL flip). tsc 0, eslint 0.
    • Phase 54 — Trial Cost Shield: paid-gated Experience Studio, staged AI credit, 30-day GloriaFood trial. Three cost-protection changes for never-paid trials, owner-decided 2026-07-04. (1) Staged AI credit: the 100 kr lifetime grant is now split for NEW signups — 20 kr usable during the trial (keeps AI menu photo import + the assistant alive), the remaining 80 kr unlocks automatically on the first payment. Implemented as a trialHoldUsd stamped once with the grant (same immutability) whose application is derived at read time from billing state (lib/ai/trial-hold.ts) — any payment (Stripe webhook, DEV checkout, an add-on purchase) releases it with zero unlock hooks and zero wallet writes. Enforced at the single getBudgetStatus 402 choke every AI route already uses; top-ups are never held; existing wallets are grandfathered (no stamped hold → nothing changes). Both amounts are super-admin knobs (Admin → AI: grant + "usable during trial"). All copy surfaces updated honestly (register note now dynamic from signup-plan, both onboarding wizards, billing banner, ai-spend hero with an amber "unlocks on your first payment" pill, hold-aware 402 messages). (2) Experience Studio paid gate: during a never-paid trial the Studio stays visible — same layout, interactive style grid, a read-only "examples from the shared library" panel, prices on every (locked) button — but all six spend paths (generate, video, video-export, pool import, overage purchase, and the public API-key path) return 402 STUDIO_REQUIRES_PAID until real entitlement exists. "Paid" is a new pure rule (paidFeatureGranted in gate.ts): resolved without the trial-all-access shortcut AND plan-included features count only once the plan itself is paid (Online Starter deliberately includes the Studio per OP-14 — a trialing subscription is previewing that plan, not owning it); explicit add-on grants count immediately. The features route exposes paidGranted so the page's demo mode can never disagree with the server gate. (3) First-use billing acknowledgment: before the FIRST paid generation the owner accepts a versioned disclosure ("generations are paid from your AI credit — images from X kr, videos Y kr; delivered results are charged, true non-deliveries auto-refund"), server-enforced as 428 STUDIO_ACK_REQUIRED on generate/video-start/pool-import (tax-consent pattern clone; public API-key path exempt by design). (4) GloriaFood 30-day trial: signups carrying a live staged-import token get platformSettings.trial.importFunnelDays (default 30, admin-editable) instead of the 7-day plan trial, and the cohort is frozen as a new gloriafood acquisition channel (ranked above UTM, below partner attribution) for conversion tracking. Verified end-to-end on live dev: fresh trial stamps the 80 kr hold and 402s at exactly 20 kr spent; all six Studio endpoints 402 in trial while reads stay 200; flipping to active unlocks +80 kr and the Studio with no writes; first generate 428 → ack → real generation charged 10 SEK to the ledger; GloriaFood-funnel register → 30-day trial + gloriafood channel + menu imported + hold stamped; garbage token → 7 days, direct, signup unbroken. tsc 0; wallet/channels/importer/theme-vocabulary suites 38/38 green; QA tenants and knobs fully cleaned.
    • Phase D (enterprise-scale) — first vertical slice: the tenant registry, retiring the master JSON doc. The platform control plane lives in one JSONB row (app_documents/'master', a MasterDB with tenants[] + ~35 arrays); at ~14.4 KB/tenant it OOMs Node's heap around ~40k tenants and hits Postgres's 1 GB single-field limit around ~70k, and every hostname resolution does a whole-doc load + linear master.tenants.find(). This slice moves the hottest read off the doc onto indexed relational tables without touching the ~367 doc readers, using the exact expand-phase pattern the orders/stock mirrors already ship: flag-gated, reconcile-in-the-write-transaction, doc stays source of truth, fully reversible. Shipped: migration 008_tenant_registry_content_hash.sql (adds content_hash); reconcileTenantRegistry/writeMasterWithRegistry/reconcileRegistryFromMasterDoc/pgResolveTenantByHost in pg.ts, wired into saveMasterDB/mutateMasterDB under TENANT_REGISTRY_TABLE=1 (default OFF = fully inert); an assertReady() fail-loud guard (flag on but table missing); the TenantRegistryRepo read seam (src/lib/db/repos/tenant-registry.ts, findByHost only, behind TENANT_REGISTRY_READ, not yet wired into /api/domains); and db:backfill:tenant-registry (reconciles the tables from the doc, computes the identical hash, self-verifies count). Critical at scale: the reconcile hashes only the projected tuple (routing/identity/lifecycle — a theme change does not churn the row) and skips unchanged tenants, so a master write that touched one tenant upserts one row, not all N. Proven on a throwaway Postgres 16 (migrations 001→008): the integration suite (src/lib/db/tenant-registry.it.test.ts) passes 5/5 against the REAL code paths — dual-write, subdomain + custom-domain resolution, delete-propagation, mutate-path reconcile, and the content-hash skip (identical re-save touches 0 rows) — plus a backfill↔dual-write hash-parity check. Design doc: docs/architecture/phase-d-repository-migration.md. tsc 0, eslint 0.
    • Phase 53b — the public GloriaFood migration front door (pre-signup funnel). A homepage announcement band ("Import your GloriaFood restaurant" — with the April-2027 sunset urgency chip) links to the new public page /import-gloriafood: a visitor pastes their GloriaFood connection code, we fetch their menu through the official Fetch Menu API (the code is never stored — one read, then discarded), run the deterministic clean/validate pipeline, and show a live preview (count tiles + sample dishes + "worth a look" warnings). "Create my restaurant & import" carries a one-time 256-bit staged-import token into /register (restaurant name prefilled); right after the tenant is provisioned, the register route consumes the token (atomic, once) and commits the staged menu through the normal Phase 53 pipeline — completed ImportJob with report, undo snapshot, currency applied, allergens suggested-only. Strictly fail-open: an expired/consumed token never breaks signup (the owner can re-import from Menu → Import Restaurant). Staging lives in an ephemeral own-doc store (60-min TTL, consume-once, pruned, capped); the public endpoint is rate-limited (10/h/IP + global ceiling) and gated by the same importer_enabled master flag, so the band, the page and the API go live together with one flag flip. Self-QA'd on live dev: band + page render, a bad code round-trips the REAL GloriaFood API into a friendly 422, register-with-token imported the fixture menu (4 dishes/2 categories, job + report + snapshot, live allergens [] with provider allergens as suggestions), a second register with the same token imported nothing while signup still succeeded; QA tenants/users fully removed, flag restored OFF. tsc 0, importer tests 18/18.
    • Phase 53 — Universal Restaurant Importer ("Import your restaurant"): one-click migration with a provider-adapter framework, GloriaFood first. A non-technical owner moves their whole menu, photos, options and details into Restora 360 in a few minutes. New nav entry Menu → Import Restaurant (with a NEW badge) opens a wizard: connect → animated scan → preview (counts + warnings + "also bring over" settings checkboxes) → Import everything → background-safe import (leave the page; the bell + an email tell you when it's done) → done + plain-language report, with one-click Undo and re-import from history. Built as a thin-adapter framework (ui/src/lib/import-providers/) cloned from the accounting-platform seam: every adapter emits the existing canonical ExtractedMenu, so the whole pipeline downstream of the snapshot reuses the proven menu-import machinery (no parallel engine). Adapters shipped: GloriaFood (official keyed Fetch Menu API — clean, consent-based; urgent because Oracle sunsets GloriaFood 2027-04-30 with no data retention), a generic website-link adapter (JSON-LD → page-text → real-browser capture, budget-gated, RIC-routed, with server-enforced ownership attestation), and the pre-existing CSV/Excel file import folded into the hub; Toast/Square/Wolt/Foodora/OrderYOYO/Qopla/Lumit ship as "coming soon" stubs that point owners at the file/website fallback (never a dead end). Pipeline: deterministic text cleanup (spacing/caps/HTML/mojibake/hidden-chars) → optional RIC-only AI description polish (digit-invariant, budget-gated, silently skipped when off) → validation + auto-repair (recover prices from price text, collapse duplicates) → never-abort image pipeline (SSRF-safe download → sharp → WebP → sha256 dedup → uploadDir(); a failed photo becomes a report line, never stops the import) → one atomic commit (snapshot-first for undo, Merge / Replace / Skip, sizes → required "Size" radio, i18n {en,sv}, fingerprint ledger for re-import) → deterministic report. Safety: live allergens/dietary tags are NEVER auto-asserted — even GloriaFood's declared allergens land only as suggestedAllergens/suggestedDietaryTags for the owner to confirm; credentials are AES-256-GCM encrypted with a GDPR TTL (24h) purged by a daily cron; no menu write outside the single row-locked mutateTenantDB commit. Master kill-switch importer_enabled (fail-safe OFF). Full en+sv wizard copy (the word "API" never shown to the owner). Verified end-to-end on the live dev app (authenticated flow connect→scan→preview→run→completed→undo restoring the menu byte-count exactly) and headlessly against a seeded tenant across all three duplicate modes + re-import-keep-local-edits + allergen-safety, all with hard assertions and the tenant restored to baseline; routes compile + enforce auth, docs article renders, dark-vocabulary guard green, tsc 0, importer tests 16/16.
    • Writing Assistant — owner consent toggle for AI prediction. New always-visible consent card on Dashboard → Settings (pattern: the data-sharing card): "AI writing suggestions in text fields" writes settings.writingAssistant.aiEnabled via the settings PATCH (zod + whitelist extended). Copy is explicit that local menu/dictionary suggestions never leave the restaurant and that the AI layer sends typed field content to the model and spends AI credit. Default OFF. Live-verified: toggle persists across reload, the assistant's vocabulary endpoint flips ai:true, restored OFF after QA. This closes the "DB field with no UI" gap from the writing-assistant review batch.
    • Onboarding-handoff fidelity pass (13-task program verified end-to-end + deviations fixed). Full e2e verification against the docs/Restora360 onboarding wizard handoff on the live dev app: register → /welcome/onboarding (prefilled steps, hours presets) → provisioning → completion screen (admin/public-site cards, claim-domain banner with 7-days-left chip) all work; public /book produces a real booking (reference RB-####, slot then shows Booked for the next guest); /products, /services, /plan, footer + Org.nr legal bar on every public page; KeepAccountBanner (day-4 nudge) + trial-day4 email cron present; register has no payment step. A 6-surface screenshot comparison (design vs live) confirmed the build is faithful; confirmed deviations fixed: (1) last stray 199 SEK pricing (18 languages, public /learn coming-soon FAQ) → 299; (2) SiteFooter brand column gained the spec'd App Store / Google Play badge pills (→ /services until the store listings are live); (3) onboarding rail wordmark is now the two-tone mark with white logo chip + ghosted watermark per the design; (4) table-designer top bar shows the spec'd segmented booking-context chip (date · time · covers, clock set post-mount to avoid hydration mismatch); (5) /book guests step gained the design's empty-state helper ("…we'll have them ready the moment you arrive") and the reassurance line under Confirm ("We'll confirm by email or SMS. No payment needed to reserve." — hidden for deposit venues); (6) /book floor map no longer clips owner-placed tables — the design canvas now extends to the tables' extents (was fixed 540×392). Intentional non-changes (newer owner decisions supersede the June handoff): 149 kr starter pricing on register/completion, /try funnel instead of /demo on the closing CTA, marketing nav on /plan+/services, "Your account is live!" heading.

    Fixed

    • Premium templates (Carte/Volt) — source-fidelity polish. Carte's menu now shows the source design's per-dish indices: new menuNumbering plumbing through the starter→package→installer pipeline (TemplateStarter.menuNumbering → DemoContentBundle → applyPremiumDesign, demo-mode only like menuLayout), Carte seeds per-category, and buildStarter demo products' showNumber now follows the template's numbering intent (a hardcoded false was vetoing numbering per item). Volt's closing "Hungry?" band corrected from a full accent band to the source's surface-bg band with two-tone headline (ink + accent <em>) and accent button. Verified through the true-preview pipeline (theme-and-demo installs of carte + volt rendered via /tenant/preview_*): Carte menu = 2-col elegant + roman category numerals + "1./2." indices; Volt band matches source. (Mercato checked against its source — it has no numerals by design; Stack's band was already 1:1.)
    • Platform-wide AI Writing Assistant — inline ghost-text autocomplete in every text field (Tab accepts, Esc dismisses). One client component (GhostTextAssistant, mounted once in the root layout) attaches via document-level event delegation, so every eligible <input>/<textarea> across the whole platform — menu editors, Mason inspector, marketing/email subjects, notes, admin, portals — gets suggestions with zero per-field wiring (data-assist="off" opts a field/subtree out; data-assist-context forces a context). Typing Ky shows a dim ckling after the caret → Tab (or tap, mobile) accepts → Kyckling; Esc dismisses and stays quiet for that word; arrows/Enter/IME never disturbed; ghost inherits the field's font/color so it's correct in light + tenant-dark; aria-live announces suggestions. Suggestions come from 3 layered sources: (1) a static Swedish-first dictionary (lib/writing-assistant/dictionaries — sv derived from the shared SV_EN_FOOD map + curated menu staples/allergens/marketing phrases like "beställ nu"/"gratis leverans", en equivalents; per-language files, trivially extendable), (2) the tenant's own vocabulary via GET /api/dashboard/writing-assistant/vocabulary (product/category/option/ingredient/inventory names, ≤800 ranked entries, session-scoped, 5-min client cache) which outranks the dictionary, and (3) POST /api/dashboard/writing-assistant/predict — a capped Haiku completion (RIC cutover, max_tokens 24, wallet-metered under a new writing-assistant AI feature, 402 on empty wallet, requireRateLimit 30/60s) used ONLY when local sources miss in description/marketing contexts (never notes — PII posture), tenant-opt-in via settings.writingAssistant.aiEnabled (default OFF; typed content goes to the model, so consent is explicit), debounced + session-capped + cached. Local suggestions are pure in-memory prefix search (<1 ms; the engine, field-context classifier and vocabulary extractor are DOM-free with 30 unit tests). Field-context heuristics boost the right vocabulary (ingredients vs marketing …) and a sensitivity filter hard-skips passwords/emails/phones/addresses/names/codes/prices; accepted words are learned per-browser (localStorage frequency boost). Zero cost until a text field is focused (dictionaries dynamic-import + one cached fetch on first focus). Gated by the writing-assistant feature flag (dev = on, prod = off until the super-admin flips it; WRITING_ASSISTANT_ENABLED env override). Both routes live-re-verify the session via requireTenantAccess (revoked staff/designer cookies get the anonymous payload / no AI). Hardened by a 27-agent adversarial review (14 confirmed findings fixed): AI completions clamp to maxLength (+ the native-setter insert path enforces the cap browsers skip on programmatic sets), AI ghost respects caret-at-end (no mid-edit Tab hijack), one-shot boot failures self-heal after a 30 s cooldown, no persistent vocab cache (cross-account residue on shared devices), per-tenant learning keys, aria-live debounced + cleared on hide, typeahead-owning fields excluded (role=combobox/aria-autocomplete + data-assist="off" on the ingredients editor & nutrition search), owner ai-spend page labels the new feature. Verified vs the live dev app (Playwright/Edge): ghost render + Tab-accept in the product editor name input, phrase completion in the description textarea ("gratis lev" → "gratis leverans"), Esc swallowed (modal stays open) + same-word suppression, dark-mode color parity, mobile (Pixel 7) tap-accept — re-run green after the review fixes; tsc 0, eslint 0, 4,163 tests green.
    • FSN reposts — members can repost posts and reels to their followers (owner request), web + native. Model/server: FsnPost.repostOfId always points at the ROOT original (reposting a repost resolves to it — rows never chain) + a denormalised repostCount; pure rules in lib/fsn/repost.ts (6 tests): PUBLIC+active originals only (a followers-only/private post can never be re-broadcast), one active repost per member per original (idempotent toggle), and repostVisible filters every listing so a repost row vanishes the moment its original is deleted/removed/hidden or its author is blocked — no ghost cards. POST/DELETE /api/fsn/posts/[id]/repost (rate-limited, block-aware with the same 404 anti-oracle as like/save, repost notification to the original author — new verb+icon on web and native notification lists). Hydration embeds the fully-hydrated original as PublicPost.repostOf (depth 1 guaranteed) + viewer.reposted; author-deleting a repost row returns the count via cascadePostDelete. Web: feed/post-list cards render a "↻ X reposted · when" banner over the ORIGINAL author's full card, with like/comment/save/repost bound to the original's id (updatePostInList keeps optimistic state in sync when the same original appears twice); Repeat2 toggle (pine when active) with live count; profile tiles label reposts ("Repost · original author") and tile-delete deletes the repost ROW (un-repost); /fsn/post/<repostId> redirects to the original. Native (`:fsn`): the same banner + Repeat action on Home feed cards (FeedPost.interactionId binds all interactions to the original), a Repeat rail button on Reels with optimistic count (repost rows never enter the reels pager — video-only originals), and profile grids show the original's media for repost tiles. Verified: 19 lib tests, tsc 0, live e2e vs the dev server (repost 201 → idempotent 200 → repost-of-the-repost resolves to root → un-repost → 404 on bogus id), Playwright feed screenshot (banner + count sync between the repost row and the original), :fsn:assembleDebug green. On-device visual pass queued for the next phone unlock.
    • FSN CV system — a real job-seeker CV on the talent profile (owner request), web + native + LinkedIn import. Self-declared CV on TalentProfile: introduction (≤800), birth year (stored privately, only the derived AGE is ever exposed publicly), gender, up to 6 social/portfolio links (http(s)-only), and up to 20 job experiences with YYYY-MM date ranges (open end = "now"; sorted current-first then newest-first) — all validated/normalised in pure lib/fsn/cv.ts (8 tests). PATCH /api/fsn/me/profile extended (empty/null clears a field); LinkedIn import via POST /api/fsn/me/cv-import parses the official member-data-export Positions.csv and MERGES (dedup by company+title+start) — honest scope: LinkedIn offers no profile-read API without a partner app, and the UI copy says exactly that. Web: a "CV & job history" editor card on the account page (FsnCvEditor: links/experiences CRUD + CSV upload) and the public talent page renders About / Experience timeline / Details (Age · Gender · Location) / Links (nofollow). Native (`:fsn`): a "CV & job history" entry card on the own profile opens a full CvEditorSheet (drag-dismissable, 0.94 height, keyboard-aware; same fields + CSV import via the system file picker), and OtherProfileScreen renders the same About/Experience/Details/Links blocks. Verified e2e vs the dev server (save all fields → current-job-first ordering → CSV import "parsed 2 / added 1" → public endpoint exposes age 31, never the birth year) and on-device UAT on the S24 (editor save → DB inspection → prefill on reopen → another member's CV renders). tsc 0; 123 FSN lib tests; :fsn:assembleDebug green.
    • FSN account lifecycle — deactivate + 90-day-grace deletion (owner request), web + native. lib/fsn/account-lifecycle.ts (6 tests): Deactivate = reversible status:'disabled' (profile/posts/comments vanish everywhere via the existing hidden-author machinery, sessions killed); signing back in reactivates. Delete = disabled + deletionRequestedAt + a 90-day grace window — sign in within 90 days and the deletion is CANCELLED (both web login and the app bridge reactivate + toast "welcome back / deletion cancelled"); after 90 days the account is fully erased on next touch (the hardened Wave-1 eraseAccount cascade + uploads directory removal) — enforced by a lazy purge at login (the guarantee on a box with no scheduler) plus a vercel.json cron GET /api/cron/fsn-account-purge (CRON_SECRET-gated) as the scheduled sweep. POST /api/fsn/me/deactivate + DELETE /api/fsn/me now schedules instead of instant-erasing ({scheduled:true, graceDays:90}). Settings UIs on web + native gained a Deactivate section and the delete flow states the 90-day terms. Verified: 8/8 lifecycle e2e checks vs the dev server (deactivate → hidden + 401 → login reactivates with fresh status; delete → schedule → login cancels; a past-due account purges at login with uploads gone). One cosmetic fix found by e2e: the reactivation login used to echo the stale disabled status in its response.
    • FSN dark mode → admin-panel slate (owner request: "black and grey like the admin panel, not brownish"), web + native. The FSN dark palette (web r360-theme.css dark block + native DarkScheme in Color.kt) moves from the warm clay-brown family to the super-admin midnight-slate family: #0a0f1a canvas / #121a2a cards / #0e1522 insets / slate borders + cool text ramps; reels/tab/scrim constants and the toast/plan-card surfaces follow (InkSurface/InkSurfaceDeep). The clay brand accent (buttons, active states, badges) is kept on purpose — surfaces are neutral, brand stays warm. Light mode untouched, pinned by the light-values-unchanged check. On-device visual pass done on the S24 (home feed, reels, profile all slate; no brown surfaces). The "Order · X kr" button on the recipe sheet and the "Order on Food Bazar" CTA on a sponsored reel used to call app.order(dishId), which stuffed a SampleData bazar-hero into state and navigated to the Kitchen tab — which no longer consumes it, so the dish was silently dropped (and the BazarScreen / HandoffSheet it once fed are unreachable dead code). Both CTAs now open the real web Food Bazar searched for the dish (/bazar?q=<dish> via an ACTION_VIEW intent), the exact pattern the dish-detail sheet's "Browse Food Bazar" already uses — verified the route resolves (200). New shared openBazarSearch(context, query) helper next to shareFsnPost. :fsn:assembleDebug green. (The dead order()/openHandoff()/BazarScreen/HandoffSheet chain is now fully orphaned — left in place, safe to delete in a later cleanup.)
    • FSN native app Wave 4 — design-conformance polish (audit theme D). Android-only (`:fsn`), no server change. Dark mode fixed on the feed & profile: every feed card, header, comment sheet, and profile screen used raw static Stone constants (Stone700/600/500/400) as icon/text colors on theme-reactive surfaces (SurfaceCard/HeaderBg go dark in dark mode) — so in dark mode the icons and secondary text were near-invisible dark-on-dark. All of them now use the reactive tokens (TextDefault / TextMuted / TextSubtle) the rest of the app already uses, so they adapt to the theme (light-mode values verified unchanged). Nav-bar inset: the shared ModalSheet now applies navigationBarsPadding() to its content, so every sheet's footer CTAs and the comments composer clear the system nav bar on edge-to-edge devices (the sheet surface still paints to the edge); removed 3 now-redundant inner paddings. Status bar over dark overlays: the video editor, publish step, and full-screen post viewer now get light system-bar icons (the clock was invisible-dark over them in light mode). Emoji removed from live UI (design system bans them): the Global Kitchen globe + country-flag emoji → plain country names, the dish-detail ←/→ ASCII arrows → Chevron/Arrow Lucide icons, "Applied ✓" → a check-circle icon + "Applied", and the redundant "✓" in the "Posted" toasts (the toast already renders a check icon). Accessibility: the pine/clay toggle now carries Role.Switch so TalkBack announces on/off instead of a generic button. :fsn:assembleDebug green. Left intentionally: the region/language picker flags (a standard picker pattern that was in the 1:1 handoff), an orphaned rating-star glyph in the dead BazarScreen, and the nice-to-have Geist-Mono-number / off-token-color touches on the always-dark day-plan card. On-device dark-mode visual pass pending the S24 reconnecting (the token remap targets the same reactive tokens the app's dark mode already uses successfully everywhere else).
    • FSN native app Wave 2.1 + 3.1 + bottom-sheet UX — honest list states, profile follow-ons, and two owner-reported sheet fixes. All Android-side (:fsn) plus two small additive server fields; on-device verified on the Galaxy S24. Wave 2.1 (honest error/retry on the remaining lists): Notifications, the DM inbox, the Jobs board and Search now show a "Couldn't load — Retry" card when the server is unreachable instead of a misleading empty state (a new generic FsnApi.Loaded<T> result — the feed's old FeedLoad generalised — distinguishes empty from unreachable). Also fixed the DM inbox 30s poll silently wiping a populated inbox to "No messages yet" on a transient failure, and the dead DM-compose "tap to retry" path (suggested-people failures now actually surface it). Wave 3.1 (profile follow-ons): comments now carry the author's account id (additive PublicComment.author.accountId), so tapping a commenter opens their profile; the follower / following counts are tappable on any profile (own + others) → a list overlay backed by a new GET /api/fsn/accounts/[id]/follows?type= (each row → that person's profile + inline Follow); and the profile Message button opens straight into the DM thread instead of the inbox. Owner-reported sheet UX (fixed in the shared `ModalSheet`): every bottom sheet now has a drag handle and swipe-down-to-dismiss (drag past ~120dp releases, else springs back), and the Edit-profile sheet is tall + scrollable + keyboard-aware (imePadding) so the whole form and Save button stay reachable — it was cut off before. Also fixed an overlay z-order bug so the ⋯ report/block sheet and a follow-list-row tap render on top of the profile instead of underneath it. Verified: the two new endpoints e2e (comment carries accountId; follows returns the right people + viewer follow-state + 404 on unknown); on-device on the S24 (edit-profile full window + Save reachable with keyboard, swipe-down closes a sheet, tap author → profile, tap following count → list → tap row → that profile). tsc 0; 109/109 FSN tests; :fsn:assembleDebug green. Deferred: a direct comment-handle tap target (the avatar is the tap target today), and pull-to-refresh.
    • FSN native app Wave 3 — flows: fixed the one-shot video creator + a view-other-user profile screen (audit theme B + E). Video creator second-run bug fixed: VideoEditorViewModel and PublishViewModel are Activity-scoped and SURVIVED across creator opens, so a second video kept the first clip's trim/overlays/music and its already-consumed (deleted) export/file, re-firing onExported/onPosted with a dangling path — and reopening the publish step with a leftover posted=true instantly fired a fake "Posted". Both VMs now rebind when a different clip arrives (full reset) and no-op when the same clip is re-shown (edits preserved); exported/posted are consumed as one-shots so a re-entered editor/publish can't replay them. View another member's profile (the audit's dead-ended core social loop): new GET /api/fsn/accounts/[accountId] returns a public profile (name / handle / avatar / verified / real post·follower·following counts + the viewer's follow state; blocked-either-way → 404 with no oracle, matching the feed) and a native OtherProfileScreen overlay renders it with the post grid, Follow / Message, and a ⋯ report/block. Tapping an author now opens it from the feed (author row), Reels (creator row), and search results — before, tapping a name did nothing anywhere in the app. Message starts-or-opens the DM thread; Follow is optimistic (reverts on failure). Verified: the endpoint e2e vs the dev server (view → follow flips followers 0→1 + viewer.following, self → isSelf:true, unknown → 404); on-device on the S24 (tap feed author → profile with real counts → Follow flips to Following → Back returns to feed). tsc 0; :fsn:assembleDebug green. Deferred: comment-author taps (the comments API doesn't return an account id yet) and tappable follower/following LIST overlays.
    • FSN native app Wave 2 — trust: no fake data on signed-in surfaces + honest error/retry states (audit themes A + C). All Android-side (:fsn), no server change; verified on the Galaxy S24. Honest load states: the Home feed and Reels no longer start on SampleData — they show a loading spinner, then real posts, a genuine empty state, or (new) a "Couldn't load — Retry" card when the server is unreachable, instead of silently sticking fake "Bella Forno / Green Bowl" posts on screen forever (a new FsnApi.discoverFeed(): FeedLoad.Ok|Failed result distinguishes empty from unreachable; a shared ErrorRetry composable + FeedPhase drive it). Sample data purged from the signed-in profile: "Share profile" now shares the member's REAL account URL (/fsn/talent/{accountId}, matching web FsnShell) instead of the sample @elin.eats link that 404'd — and prompts sign-up when signed out rather than sharing a stranger's profile; the "Open to restaurant work" card shows the member's REAL city (or an "Add area" CTA) and hides the "Roles & availability" row entirely (no server field yet), instead of presenting sample "Stockholm/Uppsala · Barista/Line cook" as the user's own hiring profile; the previously dead ✕ / "Add area" chips now act (✕ only shows when removal is wired; "Add area" opens edit); the Saved tab is real (GET /api/fsn/saved) with a proper "No saved posts yet" empty state instead of being permanently blank; and a transient /me failure keeps the last-known real identity instead of flipping the whole profile to the sample persona. Create sheet is honest: the fake pre-filled attachments ("Margherita", "640 kcal · 25 min", "Stockholm") are gone — the recipe/nutrition/location rows read "Optional" and answer taps with "Coming soon"; the "Let viewers order this" toggle (which never actually attached to the post) is removed. Interaction polish: system Back now dismisses any ModalSheet and the Photo Studio (was falling through to the hidden tab / exiting the app); edit-profile and avatar-upload failures keep the sheet open and warn instead of silently discarding edits; and error toasts show an amber alert icon, never a green success check (ToastPill(isError)). Verified on-device (S24, via `adb reverse` to the dev server): feed loads with no sample flash; kill the connection → error+retry card (not fake posts) → Retry recovers; Create sheet shows "Optional" + no order toggle; Back closes the Create sheet; signed-in open-to-work shows only "Add area" (no sample cities/roles); Saved tab shows the real empty state. :fsn:assembleDebug green. Deferred to a follow-up: the DM/notifications/jobs/search list surfaces still swallow failures into friendly empty states (nice-to-have Result refactor), and the orphaned recipe→Bazar order chain.
    • FSN native app Wave 1 — account + policy foundation (from the 69-finding polish audit, `docs/products/food-stories-network/native-app-polish-audit-2026-07-03.md`). The :fsn Android app finally uses the auth/moderation backend the web already had. Sign-in: the onboarding overlay gains a "Already have an account? Sign in" mode over POST /api/fsn/login incl. the two-step TOTP/backup-code branch — before this the app could only register, so a logged-out user was locked out of their account forever; the wizard VM is now epoch-keyed per open so it never resumes a stale step. Dead-session detection: one OkHttp interceptor on a new process-wide shared client (replaces ~13 per-ViewModel clients) — any 401 riding a session cookie flips the app to signed-out with one honest "session expired" toast (login/register excluded; wrong current-password is a 400 so it can't trip it). Log out now actually revokes the device's session server-side (POST /api/fsn/logout) — proven by replaying the raw cookie → 401. Report + block (Google Play UGC requirements): the dead ⋯ kebab on feed cards and a new More rail button on Reels open a ModerationSheet — report post/account (6 reasons + optional details → the existing Super-Admin moderation queue) and block (mutual invisibility; blocked authors vanish from feed/reels instantly via a new reactive ModerationStore seeded from GET /api/fsn/block, and the server excludes them from every later load). The dead Reels search icon now opens Search. Settings → Privacy & security is real: change password, blocked-accounts list with unblock, and two-step-confirmed permanent account deletion (Play account-deletion requirement); the fake "Connected accounts · 3 linked" badge is gone. Server: `DELETE /api/fsn/me` erasure hardened — extracted to pure lib/fsn/account-erase.ts (7 tests): owned posts now get the full cascadePostDelete (video/recipe/article scrub, count zeroing, edge drops, media-ledger reclaim, report auto-resolution) instead of the old light scrub, plus the previously-missed collections: media-ledger rows (avatar/unattached), saved meals, the subject's support tickets, connected social accounts (encrypted OAuth token blobs destroyed), cross-post records, DM bodies + read receipts (the other participant's own messages are kept — their correspondence), open reports about the account (resolution account-deleted; reports by the subject stay for moderation accountability), BOTH talent-profile link directions (incl. avatarUrl), and 2FA secrets + the platform bridge link on the account record. Verified vs real conditions: 18/18 live e2e checks on the dev app (register → logout-revoke replay 401 → login → wrong-password 401 → block/list/unblock → report account+post → delete → dead cookie + deleted-account login both 401) plus a DB-level erasure inspection (account pseudonymised, sessions/blocks/media rows 0, report-about auto-resolved, other users untouched); :fsn:assembleDebug green; tsc 0; 109/109 FSN lib tests. On-device UAT of the new sheets still pending (S24).
    • True theme preview — "Preview with demo content" now opens a live preview in a new tab WITHOUT touching the owner's site (owner report: the old button actually installed). New POST /api/tenant/[id]/themes/preview: clones the tenant DB in memory, runs the exact same applyInstall a real install runs (demo or my-content mode), and stashes the clone in an ephemeral store (lib/themes/preview-store.ts, unguessable preview_<hex> ids, 30-min TTL, per-tenant eviction + 200-entry cap, globalThis-anchored so the API and page bundles share one store). getTenantDB intercepts preview_ ids and returns the clone, so the preview renders through the REAL storefront path — what you see is exactly what installing would produce, with the theme's 9-token palette, demo menu, chrome, everything. Preview pages carry a sticky "nothing here is saved" banner and are noindex; unknown/expired ids 404. Catalog card buttons reworked: primary Preview (opens the new tab; demo content when the pack has it), secondary Use with my content / Use demo content installs (both still snapshot-undoable). Two real defects found by the E2E and fixed: (1) plain-string product names (what AI-authored demo menus naturally contain) rendered as their FIRST CHARACTER everywhere — translate() fell through to text[Object.keys(text)[0]]; demo menu names/descriptions are now normalized to the canonical {en:…} shape at package parse AND translate() accepts strings (defense-in-depth); (2) a hung dev-server compile had poisoned .next, masking the new route (cache cleared). Verified vs the live dev app — 13/13 E2E checks: preview renders themed demo content + banner + noindex; the REAL site's blocks/HTML byte-stable throughout; theme-only preview themed without demo data; bogus preview id 404s; unauthenticated preview 401s. tsc 0; eslint 0; package+i18n suites green.
    • Theme packages carry real images now — ZIP binary assets with sharp re-encode (Phase 32 P1 follow-up), plus demo media seeding. A theme ZIP can bundle images under assets/ and reference them from blocks/demo content as asset:<path> — the missing piece for fully self-contained (incl. AI-authored) template packages. Import: bounded raw reads (3MB×40 caps) → sharp decode + re-encode (auto-rotate, ≤2400px, WebP) so polyglot files, embedded payloads and EXIF never survive to storage — a non-decodable image becomes a warning, never a package failure; the integrity contentHash now covers asset bytes (a tampered image fails the check, pinned by test); safeImportUrl accepts the strict internal asset: shape (it silently blanked such refs before). At the storage boundary (admin catalog upload + tenant one-off upload) new persistPackageAssets writes each image to /uploads/theme-packages/<slug>/<name>-<sha10>.webp (content-addressed, so a changed image never overwrites a file an installed site is serving) and deep-rewrites every asset: reference to the public URL — catalog, installer and storefront deal in plain URLs and stored packages stay small. Demo media: Install+Demo now registers the pack's gallery + dish photos in the tenant's media library (db.media, source demo, idempotent by URL) so the media picker isn't empty after installing a demo restaurant. E2E-proven against the live dev app — the full "AI-designed theme" loop: a hand-assembled ZIP (manifest + template referencing asset:hero.png + a real PNG) → admin upload (re-encoded, persisted, refs rewritten, 0 warnings) → owner installs from the catalog → the live site serves the persisted webp (200, image/webp) with {{restaurant.name}} resolved and no raw asset: refs leaked → snapshot revert restores the site → catalog cleanup. 41/41 theme-package tests (6 new); tsc 0; eslint 0 errors. Plus the AI Theme Designer Kit: npm run theme:catalog (generates docs/theme-kit/BLOCK-CATALOG.md from the live SectionRegistry — 99 block types with their exact content keys) and npm run theme:validate <zip> (the real parsePackageZip + authoring lint: unknown types/keys with did-you-mean, unresolved asset: refs, unknown {{tags}}, partial palettes; verified on seeded-broken and correct ZIPs), tied together by the new theme-designer skill (ui/.claude/skills/theme-designer/SKILL.md) so "design me a theme" requests in Claude Code author validated, admin-uploadable packages.
    • Mason builder Stage-2 container nesting (MNEST-2/3/4a) — the 5 layout containers (Columns / Grid / Stack / Container / Section) hold real nested child elements, end to end. The data layer (adapter content.children round-trip + cycle-guarded tree ops) already existed; this ships render + editing. Render (MNEST-2): the container components take a childrenSlot — with children, the children become the grid cells / stacked items (fixed demo slots + preview placeholders suppressed); without, they render exactly as before, so every existing page stays byte-identical (pinned by a new render test). Children render recursively through the SHARED SectionRegistry in BOTH the live BlockRenderer and the editor MasonCanvas (no editor/live drift; a nested data-bound block still gets live data), gated on a non-empty array + MAX_NESTED_DEPTH=5. Editing (MNEST-3): a "+ Add inside" chip on any selected container opens the palette in add-into mode (amber banner, cancellable) and inserts via the tested insertNodeInto; nested nodes are click-selectable, inline-editable and inspector-edited on the canvas; the Layers panel shows the subtree indented with child counts (tree-safe hide/duplicate/delete). duplicateNode is now tree-aware and deep-clones subtrees with fresh ids — the old root-only version would have placed a nested duplicate at page root and aliased a duplicated container's children to the original's. Drag (MNEST-4a): children drag-reorder within their container (nested SortableContext → moveNodeTo; over-item original-index semantics pinned by test); cross-container drag stays deferred (MNEST-4b). Load-bearing fix found by the live UAT: REGISTRY_RENDERED_BLOCKS was a stale hand-kept allowlist, so every newer palette element (card, badge, table, avatar, accordion, forms, mobile, commerce, the containers themselves…) rendered as NOTHING on the live site — visible in the editor, invisible after publish. It's now derived from the SectionRegistry minus the 18 switch-rendered data types, so all 86 palette elements are publish-real (registry components are publish-safe by design; the marker test enforces editor-only attributes never reach live output). Verified against the live dev app: full UI loop (add Columns → Add inside Card + Badge → nested selection → Save → live page renders the nested layout themed → no editor markers in live HTML → delete → live back to baseline) plus an API-level persist/render/restore probe. tsc 0 · eslint 0 errors · 437 mason/section tests.

    Changed

    • Cookie banner upgraded from binary to a small per-category CMP (GDPR/ePrivacy). The consent cookie (cookie_consent) now stores per-category choices — new v2 format v2:analytics,marketing / v2: (percent-encoded on write; RFC 6265-safe) — with legacy values kept readable (all → both granted, essential → both declined), so existing visitors keep their choice. lib/consent/consent.ts gains ConsentState (necessary always on), hasAnalyticsConsent() alongside hasMarketingConsent() (name + semantics preserved), serializeConsent/consentAll/consentNone; parseConsent/readConsentFromCookieString return the category object and never throw on malformed cookies. Banner (ConsentBanner): three equally-prominent actions — Reject non-essential, Accept all (identical size/weight — no dark-pattern nudge) and Choose, which expands inline Analytics/Marketing toggles with Save choices; DNT/GPC still hides the banner and declines everything non-essential; kiosk/TV/table-assistant exclusions unchanged; CONSENT_EVENT still fires so capture components re-init. Withdrawal path: new reopenConsentBanner() + CookieSettingsLink — "Cookie settings" links in the landing footers (landing/Footer, landing-v2/LandingFooter, whose dead # Terms/Privacy/Cookies links now point at /legal/*) and the tenant storefront footer (all 3 SiteFooterView variants) re-open the banner pre-filled even when a choice cookie exists. Consumers re-gated per category: ANALYTICS → RIE (RieAnalytics, sdk.analyticsConsented) and /api/track (which now also checks the consent cookie server-side + honors GPC, not just the client flag); MARKETING → AcquisitionCapture, /api/public/track-acquisition, /g/[id] tracked-link redirect, referral capture (all unchanged via the preserved hasMarketingConsent). Verified vs real conditions: 19-check Playwright E2E on the live dev app (fresh visitor, per-category save, cookie values, reopen pre-fill, legacy-cookie compat, tenant footer) + live curl checks of both API gates (analytics-only cookie stores on /api/track but not track-acquisition, marketing-only the reverse, GPC blocks both) + tsc 0 + 4,057/4,057 tests.

    Fixed

    • Admin Staff: creating an admin actually works now — invite email + choose-your-own-password + forgot-password (owner report: "not able to create super admin"). Root cause: POST /api/admin/staff stored the new admin with an EMPTY passwordHash and sent nothing, so the person could never log in — and the staff page swallowed every API error silently (a 409 duplicate-email looked like a dead Create button). Rebuilt as a proper invite flow: creating an admin issues a one-time set-password token (only the SHA-256 hash is stored — new UserRecord.passwordToken, additive; invite 7 days / reset 1 hour, single-use, re-issue replaces) and sends a branded email linking to the new public `/admin/set-password` page (fixed-dark, styled like /admin/login; added to the vocabulary-test FILEALLOWLIST as the same documented exception class as the login page). **Forgot password:** "Forgot your password?" on `/admin/login` → `POST /api/admin/auth/forgot-password` (rate-limited 3/15 min, always-200 anti-enumeration, matches superadmin accounts only; dev returns `devSetupUrl mirroring the customer flow). **Resend:** per-row mail button → POST /api/admin/staff/[userId]/invite (fresh invite while unactivated; admin-issued reset once active). Create/resend return the setup link to the calling super-admin as a deliberate copy-link escape hatch when SMTP isn't configured; emailed links resolve from APP_URL in production (request origin honoured only outside prod so a spoofed Host header can't poison reset links). Staff UI: inline create errors, invite-sent banner with Copy link, new Status column (Active / Invite pending / Invite expired), legacy no-adminRole admins now display as **Super Admin** (they were mislabeled "Support" — they authenticate as full superadmins), and the page's native confirm()/alert() were converted to the themed confirmDialog()/inline errors. API responses expose passwordSet/invitePending and strip passwordToken`/2FA material. Verified vs the live dev app: 28/28 API checks (create→invite link→set password→real login; token replay rejected; weak password 400; wrong password 401; reset rotates the password and kills the old one; unknown/tenant-owner emails leak nothing; unauth calls 401) plus a Playwright visual pass — staff page in BOTH admin modes, set-password form/mismatch/success/expired states, login forgot flow, and a real UI sign-in by the invited admin. tsc 0; vocabulary test green.

    Changed

    • Landing nav restructured around "Try Free" (owner request). "Experience Center" is renamed Try Free and now also absorbs the former top-level "Before & After" link. Dropdown items renamed to say what you get: Import your menu (WoW experience), Import your website (Before & After), Table designer, Check your site free (Site health check), Free 7-day trial (Onboarding); Free demo unchanged. Top-level "Services" renamed Products (still /services — note a separate /products marketing page also exists, only linked from /plan; pointing the nav there is an open owner call). "Help" and "About us" dropped from the nav — they live in the footer, whose dead # links are now wired for real: Help center → /help, About Restora → /about, Contact us → /contact; footer "Before & After" renamed to match ("Import your website"). All hrefs unchanged otherwise; mobile accordion uses the same NAV array so it picks the change up automatically.

    Added

    • RIC Phase 1.5 W8 (buildable half) — manage RIC's config from r360 instead of the CLI. RIC's model-routing/pricing config was previously editable only via pnpm mrr + raw SQL. New server-side client lib/ric/admin.ts (ricAdminGetConfig/ricAdminPutRouting, using a separate platform-admin RIC_ADMIN_KEY kept server-side) + a super-admin proxy GET/POST /api/admin/ric-config let the operator READ RIC's live providers/price-cards/routing-rules (credentials never returned — only hasCredential) and edit a routing rule (which model chain serves a taskClass) without a deploy — the edit hot-reloads on RIC within ~10s. The r360 session gates access; the RIC platform key never reaches the browser. Verified end-to-end against a live RIC: the client read the full config (no credential ciphertext) and upserted a routing rule that landed on read-back. Deliberately deferred (documented in the RIC repo's docs/phase-1.5): the admin UI page, provider-credential/price-card writes (credentials don't hot-reload — need a restart story), and the per-capability↔per-task-class config-model reconciliation. Not touched: retiring the local lib/ai provider registry + inline-prompt fallbacks stays gated on 30 clean production shadow-days — those are the safety net until RIC is proven live.
    • RIC Phase 1.5 W7 — RIC gateway alerts become Guardian incidents. New inbound webhook POST /api/admin/guardian/ric-alert: RIC's worker posts health alerts (error-rate, DLQ) and a new per-tenant AI-spend anomaly (a tenant spending >3× its trailing-7-day daily baseline, with absolute + baseline noise floors so tiny tenants never page) to this endpoint, HMAC-signed (x-ric-signature: sha256=…, shared RIC_ALERT_SECRET/ALERT_WEBHOOK_SECRET). The receiver verifies the signature timing-safely (fail-closed: no secret or bad signature falls through to a super-admin session, so a leaked URL can't forge incidents), maps the alert to a Guardian incident candidate (lib/guardian/ric-alert.ts: RIC warning→P3, critical→P2, source integration, a stable per-rule + per-tenant fingerprint so a sustained condition folds into ONE incident via reconcileIncidents instead of one per RIC tick), and persists it into guardianIncidents; Guardian's existing alerting/paging then escalates on the next probe cycle. The RIC→r360 direction is a push webhook because the existing ADR-17 channel only flows r360→RIC (RIC pulls r360's events, never the reverse). Verified end-to-end against real conditions: the actual route handler with a genuinely RIC-signed spend-anomaly body created a P2 incident, a repeat folded (deduped, no duplicate), and a forged signature was refused (scripts/w7-alert-demo.ts); plus unit tests for the HMAC round-trip, severity mapping, per-tenant dedup, and payload validation. Alerting stays OFF until the shared secret + webhook URL are set on both sides.
    • RIC Phase 1.5 W5 (started) — prompts move into RIC's versioned registry (edit + roll back without an r360 deploy). New harness helper runPromptRefCutover (lib/ric/narrate.ts, the 6th cutover shape): instead of sending the prompt text inline, a site sends promptRef: { key, variables, attachments? } and RIC renders the registered template server-side, runs it, and returns the text/forced-tool object — same off/shadow/live semantics and the site's inline prompt stays as the OFF/fallback path (retired only in W8). RIC-side, promptRef was extended to carry image/PDF attachments so vision prompts (menu extraction, Food-DNA) can be registry-managed too, not just text. First cutover: WoW clone-copy (lib/wow/clone-copy.ts) — the inline copyPrompt is refactored so a single copyVars(dna) feeds both the registered template's {{variables}} and the legacy fallback string (one source of truth, no drift); generateCloneCopy now calls runPromptRefCutover({ promptKey: 'r360.wow.clone-copy', variables, tool, legacy }), taskClass and model unchanged (r360.narrate / Sonnet), and validateCopyPack + the CLAIM_PATTERNS no-verifiable-claims fence downstream are untouched. Verified against real conditions — a Postgres-backed live RIC with the prompt registered + published (r360.wow.clone-copy@1.0.0): generateCloneCopy round-tripped through the registry to real Sonnet and returned a valid, policy-compliant pack (atmosphere-only, "Reserve your table"; 1587/581 tokens metered as before). The template is version-controlled in the RIC repo (prompts/r360.wow.clone-copy.json) and re-seedable via pnpm prompts. tsc 0 · 16/16 clone-copy tests. Second cutover: menu-import Stage-1 VISION extraction (lib/menu-import/run-extraction.ts) — the churniest prompt. EXTRACT_PROMPT is exported and the registry template r360.menu-import.extract@2.0.0 is generated FROM it (byte-fidelity: an empty {{learnedHintsBlock}} renders exactly EXTRACT_PROMPT, so off/live differ only by source); the vision path now sends promptRef with the page images as attachments (the W5-1 extension) while the HTML-text path stays inline (its menu text is embedded in the prompt, a different shape). The save_menu forced tool, normalizeExtractedMenu, prices-as-read and deterministic fences are all unchanged; the learned-hints block is derived by slicing EXTRACT_PROMPT off the assembled prompt (one source of truth). Verified vs real conditions: a synthetic 4-dish menu image through a pg-backed live RIC resolved @2.0.0, ran real Sonnet, and returned all four items with prices exactly as printed (95/119/89/65 kr). Note: the pre-existing eval suite for this key is a stale RIC-5 text demo that can't validate a vision prompt, so @2.0.0 was published with --skip-gate (audited) — a vision-capable eval suite needs the RIC-9 harness to support attachment cases (documented follow-up); the deterministic fences + shadow-days are the net meanwhile. Behind the fail-safe-OFF ric_enabled flag. Third cutover: Experience Studio Food-DNA dish detection (lib/experience-studio/food-dna-runner.ts) — the base64-image path now sends promptRef for the registered r360.food-dna.detect@1.0.0 (system+user template generated byte-faithfully from buildFoodDnaMessages, r360.vision taskClass) with the dish photo as an attachment; the external-URL path stays legacy (RIC takes base64 only) and parseFoodDna/vetFoodDna are unchanged. Verified vs real conditions: a synthetic pizza image through a pg-backed live RIC resolved the template and returned pizza/italian at 0.88 confidence with only visible ingredients (the never-invent invariant held). W5 code cutovers COMPLETE (clone-copy text + menu-import vision + Food-DNA vision, all three high-churn prompts now registry-managed, editable/rollbackable without a deploy); the inline prompts remain as OFF-path fallbacks until they're retired in W8.

    Fixed

    • RIC W4b video cutover — three bugs caught by an adversarial review of the media batch (all behind the fail-safe-OFF media flag, so none were live-exploitable). (1) Cross-tenant forgery (major): the RIC video jobId was plain base64url JSON whose tenant field was self-asserted, so a caller who learned another tenant's Veo op could re-wrap it under their own tenant and poll the victim's clip. Fixed RIC-side by sealing the jobId with AES-256-GCM (unforgeable); r360 just round-trips the opaque token. (2) Transient blip treated as terminal (major): pollExperienceVideo's RIC branch caught every error — including retryable 5xx/timeouts — and permanently refunded + retracted the owner charge while Veo kept generating and RIC's submit charge stood, orphaning an already-billed clip. It now honours RicError.retryable: a transient error returns processing (keep polling, no refund), only a definite failure refunds and ends the job. (3) Refund/reserve divergence (minor): the reserve used RIC's dynamic usage.costUsd but the failure refund used a hardcoded $3.20 — identical today but corrupts the platform-day video cap the moment RIC's Veo price card changes. The jobId now carries the exact reserved amount (ric:<reservedUsd>:<jobId>) so the refund always mirrors the reserve. Also documented a known bounded residual: the 45s RIC timeout can't cancel RIC's server-side work, so a generate that succeeds just after we abort falls through to legacy and makes a second asset — the airtight fix (an idempotency key RIC dedups on) is deferred to a dedicated pass. r360 tsc 0 · RIC 179/179 (new forgery test) · HTTP e2e (sealed jobId + forged-token 404) · r360 demo branches green.

    Added

    • Owner-panel dark: last brown wash eliminated (hover-collision in the tenant dark sheet). The tenant dark override sheet (lib/tenant-theme/dark-css.ts) had the same substring-selector flaw fixed earlier in the admin engine: [class*="bg-amber-50/"]-style rules also matched hover:bg-amber-50/40 classes and painted the warm tint PERMANENTLY — the brown shortcut cards on quick-links, and every card with a warm hover accent. All substring selectors are now word-boundary anchored and hover variants get real :hover-scoped rules (all 17 color families). Verified live + on prod: card surfaces compute #121a2a navy. (c8b7b0f2)
    • Owner-panel theme overhaul — dark mode goes NAVY (owner request: "no brownish look, like super admin dark"), plus a full 121-route deep scan. LIGHT keeps the warm clay brand; DARK now uses the super-admin navy palette (#0a0f1a canvas / #121a2a cards / #1b2538 insets / #1f2a3d hairlines / #e5e9f0 ink, primary #ea580c). Root cause of the brown: a second warm .dark token block (clay #1a1712/#26221c) later in globals.css silently overrode the neutral one — BOTH now navy, along with the neutral safety net (gray/slate/zinc/neutral/stone ramps), warm copilot-hero gradient, --warm-ink, and new dark coverage for divide-gray-50 + neutral from/to gradient stops. Hex hygiene: ~50 dashboard files still hardcoded the clay brand (bg-[#bf601d], gradients to [#d4762f]) — all converted to primary/accent-ink tokens so dark mode re-colors everything at once; broken clay→pastel gradients (SEO/AI/briefings/branches heroes, standing-reorders chip, supplier-quotes sky tile) rewritten to token tints. Element fixes: orange-on-orange text (AI assistant header/Pro-Tip, help setup banner, briefings labels), FSN bridge CTA (undefined --clay-500 → invisible button), dark-vanishing active pills (waitlist, Google audit/insights → bg-text text-background), hardcoded pine "Service live" label, features/labels/support hexes, Restora-Profit accent tokens now mode-aware CSS vars, features plan banner navy in dark, SmartRefill locked card matches its sibling. Systemic: .dashboard-shell scope class + normalized disabled buttons (washed-out white-on-peach in light → solid neutral pill; navy pill in dark) + themed native file-input buttons. QA: probe sweep of ALL 121 static owner routes × both modes (242 probes) + 16-agent screenshot review, fix, re-capture (79 shots) + 8-agent re-review → 0 broken; tsc 0; 4,024/4,024 tests. By design: KDS stays always-dark; website-builder previews show the public site's own theme; the red "N Issues" pill bottom-left is the Next.js dev-mode indicator (never in prod).
    • Themed in-app confirmations everywhere — native `confirm()` retired. The browser's native confirm dialog can never inherit the app theme (the owner flagged it on the Image Bank purchase popup). New self-contained confirmDialog() (src/components/ui/confirm-dialog.tsx): renders into document.body outside every theme engine's scope, detects the surrounding surface (admin data-admin-theme / tenant .dark) at open time and styles itself accordingly — title, message, ghost Cancel, accent confirm button (red for destructive, brand orange otherwise), Escape/scrim to cancel, focus on confirm. All 16 remaining window.confirm call sites across 14 files converted with per-action labels: owner dashboard (orders decline, accounting disconnect, hiring unverify, quick-links group delete, supplier-marketplace cart switch, inventory order/order-all ×3), FSN (post delete ×3, block, mark-hired, disconnect platform, sign-out device), admin FSN maintenance (orphan purge). Verified live on the accounting Disconnect flow in BOTH tenant modes (dialog renders themed, Cancel leaves the connection untouched); tsc 0; 4,024/4,024 tests.
    • Admin theme hardening round 2 — full-panel sweep (104 routes × both modes), chrome + islands + native widgets. Owner kept finding one-mode-broken spots after round 1 (sidebar search box dark on light, Domains page white-on-dark, preset swatches invisible, un-themed native confirm popup) — root causes: (1) scope — the generated vocabulary only themed .admin-content descendants, so the chrome (sidebar/topbar) was never remapped; the engine now themes the ENTIRE [data-admin-theme] subtree. (2) tenant tokens — pages built with the tenant design system (bg-card/text-text/border-border…: domains, support-brain, and the re-exported global-media) render one-mode-only under the admin engine; those pages were rewritten onto the standard vocabulary, the engine gained a tenant-token SAFETY NET mapping the core tokens per mode (so a re-export can never break again), and the vocabulary test now BANS tenant tokens on admin sources. (3) native widgets — the app root pins color-scheme:dark, leaking dark range-tracks/date-pickers into light admin; the themed root now sets its own color-scheme and range sliders get an explicitly themed track+thumb. Also fixed: light-mode disabled buttons normalized to a readable neutral pill (washed-out white-on-peach Save buttons), bare border-b/t/l/r + divide-y/x currentColor hairlines, translucent bg-black/ scrims in light (solid before — hid hover overlays), RAOS status pills off inline dark hex onto palette classes, escrow overdue rows visible amber in both modes, monetization/modules neutral chips, themes Premium badge ink, color-preset swatch circles (self-contained double shadow-ring, visible on any strip), and the Experience Studio Image Bank's native confirm() replaced with a themed in-app purchase dialog. Verified: probe sweep of ALL 104 static admin routes × both modes (whole-document luminance + text-contrast) + a 13-agent screenshot review of all 208 captures; tsc 0; 4,024/4,024 tests. Known by-design: Guardian/Growth command-center rails stay dark in light mode (1:1 ported prototypes — flag if unwanted); localization preview renders fixed receipt paper.
    • RIC Phase 1.5 W4b — Experience Studio VIDEO generation through the RIC gateway (W4 media batch COMPLETE). RIC gained an async Veo-shaped LRO surface (POST /v1/ai/videos submit → GET /v1/ai/videos/:jobId poll; the jobId is opaque and TENANT-BOUND — a leaked ref polled by another tenant 404s exactly like an unknown one; cost commits at submit as RIC's aicalls ledger row; on done RIC downloads the clip immediately — Veo links expire in ~2 days — and returns `mp4Base64` in-band with `synthetic`/`disclosure`; file-level marking rides Google's embedded SynthID watermark since mp4 has no lossless iTXt equivalent). r360 side: `ricVideoStart`/`ricVideoPoll` client + a seam in `startExperienceVideo` (media-gate LIVE only — same no-shadow rule, a shadowed clip is a real ~$3 second generation) that submits through RIC with the SAME negative-prompt rails and reserve-on-submit metering; jobs route back via a `ric:`-prefixed jobId so `pollExperienceVideo` (now tenant-threaded from the video route, since RIC's jobId is tenant-bound) polls RIC under the submitting tenant's scope, refunds the video day-lane on terminal failure exactly like the google branch, and returns the same `data:video/mp4` URL — the whole downstream (owner ×2.5 charge with poll-failure retraction, auto-bank gallery pipeline) is untouched. The mock-poisoning guard applies at submit: a mock-served job falls through to legacy Veo. RIC-side verified 178/178 (4 new gateway tests incl. tenant-binding + terminal-failure row) + HTTP e2e on a real gateway (submit / poll-ready-mp4 / foreign-tenant-404 / garbage-404); r360-side live-verified via `scripts/w4-video-demo.ts` (fake-live → `ric:` jobId + real mp4 data URL, exactly 2 gateway hits · flag-off → 0 RIC calls · mock-guard · dead-RIC → legacy). tsc 0. Go-live: RIC needs a Google key with paid Veo access (`GOOGLEAPI_KEY or mrr.providers`); until then live mode falls back to legacy loudly.
    • RIC Phase 1.5 W4a — Experience Studio TEXT→IMAGE generation through the RIC gateway. New ricImage() client (lib/ric/client.ts, sharing the completions transport/error contract) calls RIC's existing POST /v1/ai/images, whose PNGs arrive Art-50-marked (machine-readable iTXt AI-Disclosure chunk — the EU AI Act synthetic-content marking r360's legacy images never had). The seam sits inside generateExperienceImage so ALL its callers (Studio hero generation, Food-DNA admin generation, Flow-B component assets) cut over at once, with three deliberate guards: (1) media never shadows — new getRicMediaMode() collapses shadow to off because shadowing an image is a real second generation (double spend on both ledgers), so media cuts over only at an explicit live; (2) the img2img/owner-photo path stays legacy — RIC images take a prompt only, and the photo-must-drive-the-result guard is untouched; (3) a mock-served RIC image (provider `mock`) is rejected and falls through to the legacy provider, so a RIC-side outage can never persist or bill a 1×1 placeholder as a hero (RIC-side, the mock tail was also removed from the LIVE image/embedding chains — same fail-loud decision as the text chain). RIC's in-band usage.costUsd feeds the SAME metering as legacy (platform per-day lane + per-tenant attribution); any RIC failure falls back to the legacy registry path unchanged. Verified live via scripts/w4-image-demo.ts against a real running gateway: fake-live → r360 returns the marked PNG + meters $0.04 (1 gateway hit) · flag-off → 0 RIC calls · RIC-in-mock-mode → guard rejects the placeholder, legacy resolution runs · RIC-dead → RicError → legacy. tsc 0 · lib/ric+studio tests green. Go-live note: RIC needs an OpenAI key (mrr.providers or OPENAI_API_KEY) for gpt-image-1; until then live mode falls back to legacy loudly. Video (Veo LRO) is W4b — it needs a new async RIC surface, not /v1/ai/images.
    • RIC Phase 1.5 W3 (started) — vision cutover pattern proven on the FSN food-gate. Extends the RIC harness (lib/ric/narrate.ts) with runVisionCutover: sends a base64 image part + instruction (and an optional forced tool) through RIC, returning the model text/object so each site's existing parser/vetter and fences run unchanged. Two new fenced RIC vision taskClasses back W3: r360.vision (Haiku-first, cheap classifiers) and r360.vision-hi (Sonnet-first, accuracy-critical regulated extraction). Cut over `fsn/food-gate` (classifyImageBytes → r360.vision): the inline Haiku vision call is the legacy fallback, and the conservative decideVerdict/vetGateResult/fail-open behaviour is untouched. Live-verified against a running RIC gateway: a real PNG round-trips → {"isFood":false,"confidence":0.99,"label":"…"}, the exact shape parseGateJson expects, metered at $0.0002. Behind the fail-safe-OFF ric_enabled flag (shadow default). Also cut over `profit/invoice-vision` (the Operating-Cost-Ledger feed — a misread digit is a real cost error) via a new runDocVisionCutover that takes a pre-built content array (multiple images / a PDF document part) plus the forced save_invoice tool, on the Sonnet-tier r360.vision-hi. The legacy Sonnet vision call is the fallback; normalizeInvoice and the ai-estimate/owner-confirm mapping are unchanged, so amounts stay "as read, never computed". Live-verified: an image + forced tool round-trips through RIC → a save_invoice object with an honestly-low confidence on unreadable fields (Sonnet, $0.0027). Also cut over the regulated extraction cluster — nutrition/calculate (text forced-tool, r360.generate), nutrition/from-image (vision forced-tool, r360.vision-hi) and menu/detect-ingredients (text forced-tool, r360.narrate). In every case RIC only extracts (ingredient names / visible foods + portions); the nutrition numbers stay deterministic via the food catalog and the allergens stay deterministic via detectAllergens (owner-confirmed suggestions) — the never-invent-numbers / allergens-as-suggestions invariants are preserved exactly, with the deterministic engines and disclaimers unchanged. W3 is now COMPLETE — every remaining extraction/vision site is cut over on the same helpers: bazar/photo-quality (vision, r360.vision) · WoW design-dna (both generateDesignDna + detectMenuLayout doc-vision forced-tool, r360.vision-hi since design-DNA runs on Sonnet) and ai-theme (vision forced-tool, r360.vision-hi) · wow/translate-menu (narration, r360.narrate, positional element-count invariant preserved) · fsn/recipe-card (structured save_recipe, r360.generate) · inventory/copilot (narration, r360.narrate — the fenced taskClass ADDS a number fence this RAG site lacked) · launchpad/research-runner (extraction, r360.generate, extract-from-source-only + citation-pinning fence unchanged) · admin/support-brain curator + dashboard/support/ask (narration, r360.narrate, PII gates unchanged) · experience-studio/food-dna-runner (vision, r360.vision; an external-URL image stays on the legacy provider since RIC takes base64 only) · and menu-import Stage-2 photo detection (r360.vision-hi), the one menu call RIC-6 left behind, now reusing the same ricTenantOf/ricMode Stage-1 uses. In every case the site's downstream parser/vetter/deterministic-fallback and wallet metering are untouched — RIC only replaces the model call, with the legacy provider call as the fallback. All behind the fail-safe-OFF ric_enabled flag (shadow default). One site is explicitly deferred: detectDishGemini uses the Gemini provider directly (not Anthropic), so consolidating it to RIC is a later provider-routing optimization, out of scope for the Anthropic cutover pattern. tsc 0 across all touched files. Next: W4 (image/video generation).
    • Admin theme hardening — "no hardcoded styles" is now an ENFORCED rule (owner request: fix light/dark for good). Root cause of the recurring "page looks broken in one mode" reports was three systemic bug classes in the override engine (src/lib/admin-theme/theme-css.ts): (1) hover-collision — the [class*="bg-x/"] substring rules also matched hover:bg-x/5 classes and painted the hover tint PERMANENTLY (the "dark buttons on a light page" breakage on Food DNA); all substring rules are now word-boundary anchored ([class^=] / [class*=" ..."]) and every bg/border/text utility additionally gets a :hover-scoped remap for its hover: variants. (2) colored-text lift gaps — text-{color}-600/950 (and hover//alpha variants) authored for light pages were near-invisible on the dark canvas; dark mode now lifts all 600–950 colored text to readable tints. (3) vocabulary holes — neutral 300/400 surfaces+borders, border-white/nn/border-black/nn hairlines, text-white/nn inks, gray alpha hovers and bare border (Tailwind v4 currentColor default) are now themed in both modes. Coverage went from 319 unthemed tokens across 91 admin sources to 0 neutral gaps. Enforcement: new src/lib/admin-theme/vocabulary.test.ts scans every admin surface (app/admin/dashboard, components/admin, (dashboard)/dashboard/admin) and FAILS the suite on any unthemed color class or arbitrary hex class (bg-[#bf601d]) — rule documented in AGENTS.md. Cleanups to pass it: Food DNA page rewritten onto the covered vocabulary (no bg-transparent inputs / hover:bg-black/5), and all hex classes in feature-flags, revenue-share, docs-analytics, admin addons + monetization replaced with palette equivalents. Verified: tsc 0 · 4,023/4,023 tests · Playwright screenshots of 12 admin pages × BOTH modes, automated luminance probe + eyeball review all clean.
    • RIC Phase 1.5 W2 — narration cutover to the Restora Intelligence Cloud gateway (social captions first). Extends the RIC anti-corruption layer (ui/src/lib/ric/, the ONLY RIC-aware code in r360) from menu-import (W1/RIC-6) to the narration surfaces. New lib/ric/narrate.ts = the generalized narration cutover harness: a RIC-backed transport matching the existing pluggable AiGenerate seam exactly, so a call site cuts over by swapping ONE line — its validator, fence and deterministic fallback untouched. Three modes via the fail-safe-OFF ric_enabled flag (getRicMode): off = legacy Anthropic path byte-identical · shadow = legacy serves, RIC runs alongside and a structural number-drift diff is recorded to databases/ric-narration-shadow.json (gitignored, capped) · live = RIC serves with the legacy transport as the fallback on any RIC error. The RIC narration taskClass is FENCED, so RIC's own output filter flags number drift / invented allergen claims — a second wall behind each r360 site's validator. Two cutover shapes are supported so any narration site fits: (a) the transport-seam pattern (a site that already injects an AiGenerate) and (b) runNarrationCutover for inline routes (system+user → text). Cut over EIGHT surfaces so far: social captions (draft-caption route, cron/social-autopilot, live-instagram — transport swap), Guardian RCA, Growth advisor chat, and the four platform/founder exec narrations that share the per-field number/date guard — RACM governance briefing, Mission Control founder briefing, Intelligence recommendations, Profit advisor — plus the inventory daily summary (inventory/narrate). For the exec-narration sites the LLM call is swapped for runNarrationCutover while the downstream guardField number-guard is left completely intact — it re-validates whatever RIC returns, so a RIC-served rephrase that drifted a figure still reverts to the deterministic line. Every site keeps its deterministic fallback and its exact wallet metering; the tenant sites (profit, inventory) attribute cost to the tenant, the platform-ops sites use a dedicated platform-ops RIC scope. Live-verified against a running RIC gateway: a RIC-served Haiku caption PASSES the existing validateSocialCaption with zero violations, the inline path returned a real RCA, and a governance-briefing JSON round-tripped through the fenced taskClass with every figure (45200 / 12% / 3) preserved; shadow-compare recorded; the drift fence caught an injected "25% off + 100 kr". tsc 0 on all touched files. ALL 16 narration sites now cut over — captions, guardian RCA + ops-chat, growth chat + outreach, the four number-guarded exec narrations (RACM / Mission Control / Intelligence / Profit), inventory summary, voice-concierge, exec briefing, email campaigns, SEO landing, and site-template copy — plus WoW clone-copy via a structured (forced-tool) variant runStructuredCutover. Two RIC taskClasses back them: r360.narrate (Haiku-first, light rephrases) and r360.generate (Sonnet-first, heavier content) so model quality is preserved per site — both FENCED. Live-verified: r360.generate serves Sonnet SEO copy; the forced-tool path returns a validated clone-copy object; a governance-briefing JSON round-trips with every figure preserved. Everything stays behind the fail-safe-OFF ric_enabled flag (default shadow) until an explicit live flip. Next: W3 (extraction & vision — the fence-heavy batch).
    • Phase 52 — Food DNA: ONE curated dish→image registry for every import (owner request: "system learns it by time"). The two import paths gave different images because they never shared a source: the dashboard import ran its own ladder (tenant media → community → stock) while the WoW clone ran a separate stock chain and left unmatched dishes blank. Now both consult the same super-admin-gated registry. Store + engine: databases/food-dna.json (FoodDnaEntry: multilingual canonical names + aliases + status + license-tracked image; own app-doc, PG-ready) with a deterministic conservative matcher (exact=100 / token-set=95 / Jaccard≥0.8=85, below → NO match — a wrong dish image misrepresents meat/allergen content) and resolveDishImage that serves approved entries only. Wire-ins: the dashboard import's image-match ladder gains a DNA lane (near-exact = 96, the only non-owner source allowed to AUTO-assign — a human approved it); the WoW claim now backfills imageless products from near-exact DNA matches (marked imageSource:'dna') — the two paths finally agree. Demand loop: dishes that finish an import imageless (both paths) are captured as demand — unmatched names become pending name-only entries and the admin queue sorts demand-first, so the registry grows toward what restaurants actually import. Human gate: /admin/dashboard/food-dna — seed the 700-dish FSN Global Kitchen library as PENDING (FSN presentation ≠ restaurant presentation; 650 seeded, each needs explicit approval), search/status filters, per-entry set-image-by-URL (SSRF-guarded, rehosted under /global-media, credit required before approval), REJECT/DELETE, and AI generation: one click generates a menu-style photo from name+cuisine via the existing image-provider registry (platform $5/day lane), sharp-resized to the library convention (1024×768 + thumb) — and still lands PENDING. Honest marketing: DNA-assigned images carry Product.imageSource:'dna' and the public menu shows a subtle "Illustrative" marker; the owner replacing an image flips it to 'owner' and the marker disappears. Verified: tsc 0 · 4,022/4,022 (5 new engine tests) · live end-to-end on dev — seed 650 → approve "Kanelbulle" → tenant match-images returns the DNA image at 96/auto from /global-media · admin page renders with thumbnails/credits/counts.

    Changed

    • Plan catalog overhaul (owner decision 2026-07-02): FOUR live plans, all others WIPED; flat plans billed YEARLY; AI-paid-during-trial stated everywhere. The live catalog is now exactly: Online Starter 149 kr/mo (1 788 kr/yr) · Basic 299 kr/mo (3 588 kr/yr) · Enterprise 2 999 kr/mo (35 988 kr/yr, EVERYTHING included — every feature and add-on) · Pay As You Go 0 kr/mo + 7% per transaction (everything included; rides the existing revenue-share commission engine). Flat plans show a monthly price but charge ONE yearly payment (resolveSignupPlan.chargeSEK, 365-day periods end-to-end: register provisioning, /register/payment dev-sim + Stripe yearly line item, billing-page upgrades). Wiped (archived + non-public + non-assignable, still resolvable so existing subscriptions keep entitlements): basic-annual 999, premium-annual, growth-partner 8%, street-food-starter 99+2%, free-growth-7pct, franchise, seasonal, and the old usage-metered PAYG (2 kr/order · 0.10/SMS · 0.50/AI-req). The tenant billing page now renders the live catalog dynamically (4-card grid, "billed yearly · X kr/yr" / "7% per transaction"), switching to PAYG charges nothing (works even without Stripe keys), and the revenue-share banner shows for any revenue-share plan. PAYG is exempt from the retired Free-Growth signup flag (first-class plan). Trials: always 7 days on every plan — and AI usage is PAID during the trial (draws from the one-time 100 kr AI credit), stated on: the landing pricing band, /register, /register/payment, the public onboarding wizard, the /welcome/onboarding trial summary (new "AI during trial" row), the dashboard trial banner, the billing trial banner and the welcome email. Landing pricing band redesigned as Starter card + a Basic/Enterprise/PAYG ladder. 9 catalog-shape tests updated to codify the new reality (engines unchanged); PAYG-shape, offerable-set and eligibility tests now assert the four live plans. Verified: tsc 0 · 4,017/4,017 · live QA (signup-plan API 149/1788/yearly+AI-note; billing grid = exactly the 4 plans, legacy hidden; PAYG switch → active at 0 kr with the full entitlement nav appearing; landing band renders). NOTE: /plan and /about marketing pages still carry older copy (OP-7b, owner).

    Added

    • Phase 51 OP-14 — menu as a digital SHOWCASE; "Online Ordering" becomes an add-on (owner decision). The Online Starter plan no longer includes online-ordering: the public website and Food Bazar page render the FULL menu (photos, prices, allergens) but no cart or add/order buttons; buying the add-on (29 kr/mo billed yearly — now listed in the store) or being on Basic+ lights ordering up on both surfaces. Enforced in FOUR layers so it's never just hidden buttons: (1) all three public order-creation routes (cash/card/PayPal) refuse with a polite 403 ONLINE_ORDERING_NOT_ENABLED via a new server-only entitlement gate (fail-OPEN on transient read errors — a DB hiccup must never block an entitled restaurant's money); (2) the tenant storefront threads orderingEnabled through BlockContext → MenuClient (cart drawer/button, quick-add, customize all gated; default true so every other caller is unchanged); (3) the Food Bazar detail payload carries onlineOrderingEnabled and folds it into acceptingOrders, with a dedicated "This restaurant doesn't take online orders — browse the menu, then visit or call" notice; (4) the marketplace LIST badge uses a new batched tenantsWithFeature gate helper (ONE master read for the whole list) so cards never advertise a checkout the restaurant can't fulfil. Trial semantics unchanged: the 7-day all-access trial DOES unlock ordering; the showcase mode begins when the trial converts to a plan without the add-on. Landing/payment/FAQ copy now says "digital menu on your own website" for the starter and moves "Online ordering & payments" to the Basic card. Live-verified on dev: post-trial starter = menu renders, 0 order buttons, API 403, store lists the add-on at 29/mo · 348/yr; premium tenant byte-identical (27 add buttons, ordering APIs pass, bazar acceptingOrders true). tsc 0 · 4,017/4,017 tests.

    Changed

    • Phase 51 v2 — owner re-cut of the Online Starter package + unified add-on pricing (29 kr/mo, billed yearly). Owner decisions applied end-to-end: (1) Starter recomposed — OUT: Orders board, Customers CRM (and Messages, which rides the same feature), Tables & QR + Floor Designer, Delivery zones, and the whole Reports & Money set (analytics, bookkeeping, payouts, payments dashboard) — each now activates from the Add-ons store; IN: Experience Studio (access included; generation spends the one-time 100 kr lifetime AI credit every signup already gets — GRANT_SEK_PROD, stamped once, never resets). Guest-facing ordering + payment processing (stripe/paypal/swish) deliberately stay in the plan — guests can always order and pay; it's the owner-side management surfaces that are add-ons. (2) Account & Settings slimmed — Restaurants (→ multi-restaurant) and Go Mobile (→ new go-mobile feature) are now add-on-gated; new payments-dashboard feature gates the Payments page. (3) One add-on price — ALL single add-ons are now 29 SEK/mo billed yearly (one 348 SEK charge for a 365-day period): 37 registry entries repriced (from 9–199 kr; incl. fsn-recruiter/food-catalog-api 199→29, inventory 99→29) — suites keep their bundle pricing per 30 days, and Experience Studio keeps its premium price (it's in the 149 plan instead). Billing engine now period-aware: resolvePurchasable carries periodDays/chargeAmount/periodLabel, activation + renewal sweep use them, Stripe checkout charges 12× monthly with a "— 12 months" line item, the confirm modal previews the real yearly total, and the store/active cards say "billed yearly". Also fixed: the marketplace guessed tier from the planId prefix, so Online Starter tenants (unknown prefix → 'basic') wrongly saw NO basic-tier add-ons as buyable — it now trusts the per-feature resolved entitlement (isActive) from the API. Landing pricing card, FAQ and /register/payment copy updated to the new composition. Verified: tsc 0 · 4,012/4,012 tests · live QA on a fresh signup (compact 9-section sidebar exactly as envisioned; store lists the moved-out features at "29 SEK/mo · billed yearly · 348 SEK/yr").

    Fixed

    • Menu import from URL now accepts bare domains ("kebabtime.se") — same normaliser as the WoW funnel, one engine. The WoW clone flow got normalizeUrl (bare domains, www-forms, loose schemes → https default) but the menu-import guard (lib/menu-import/url-safety.ts checkUrl, used by both the URL-capture and direct-download import routes) still parsed the raw string and rejected anything without an explicit https://. It now normalises with the SAME WoW helper before validating; the SSRF guard is unchanged and verified to still block loopback/private/metadata hosts after normalisation. 5 new tests; live-verified: kebabtime.se starts a capture job instead of "Enter a valid URL (including https://)".
    • Logged-out `/dashboard` now redirects to `/login` instead of rendering a ghost panel. The dashboard layout never required a session: an anonymous or expired-cookie visit rendered the FULL unfiltered nav catalog (119 rows) with every link disabled. Pre-existing, but once the sidebar became entitlement-aware (Phase 51 OP-2, deliberately fail-open) this ghost state masqueraded as "plan filtering is broken" — found when the owner's stale tab showed "the long navigation" for a fresh 149 kr Online Starter tenant whose server-side filtering was verified correct. One-line guard: no valid platform_session → redirect("/login"). Verified: anonymous /dashboard → /login; starter login still gets the compact plan-filtered sidebar.

    Added

    • RIC-6 — Restora360 becomes the first consumer of the Restora Intelligence Cloud (separate repo `restora360_restaurant_intelligence_system`). New anti-corruption module lib/ric/ — the ONLY RIC-aware code in this repo: mode.ts (pure fail-closed gate pickRicMode: MasterDB flag ric_enabled fail-safe OFF ∧ RIC_BASE_URL ∧ RIC_API_KEY; with the flag on, RIC_SERVE_MODE=live opts in, anything else = the safer shadow), client.ts (RIC gateway client: explicit restora360:<tenantId> scope per call, RFC 9457 problem mapping, timeout), shadow.ts (structural menu diff + capped databases/ric-shadow.json store, best-effort by contract), types.ts. Stage-1 menu-import extraction (run-extraction.ts, both the vision and text paths) can now run through the RIC AI Gateway: off = byte-identical legacy; shadow = legacy serves, RIC compared (first live shadow: 100% name overlap, 0 price mismatches); live = RIC serves with the SAME wallet metering (recordUsage gets RIC's in-band token usage → appendLedgerEntry unchanged) and automatic legacy fallback on any RIC failure (proven by killing RIC mid-demo). Stage-2 photo detection stays legacy (cutover with the P1.5 migration wave). Demo/verification harness: scripts/ric6-demo.ts <off|live|shadow|reset>. Verified: tsc 0 · 8/8 new lib/ric tests · live cross-repo demos for all four behaviors with real Anthropic answers on both paths.

    Added

    • Phase 51 — Owner Panel Reorganization & the 149 SEK Online Starter (OP-1…OP-6, buildable scope COMPLETE). The owner dashboard had grown to 89 top-level pages with a ~95-entry sidebar shown to EVERY tenant and zero plan filtering — while the entitlement engine (67-feature registry, resolver, requireFeature in ~85 API routes) was fully built but never wired to navigation. This phase wires it, reorganizes the IA, and ships the new signup package (plan: docs/owner-panel/PHASE-51-OWNER-PANEL-PLAN.md; grounded in a 4-agent recon + 10-agent orphan-route triage). (OP-1) Welcome email — the first one the platform ever sends: idempotent POST /api/tenant/[tenantId]/onboarding/complete stamps onboardingCompletedAt server-side and sends ONE branded email with the two links every owner needs — the admin panel and their resolving live site (tenantLiveSiteUrl, never the unwired vanity subdomain) + a plan-driven trial line; called by BOTH wizards so /register, the landing CTA and the WOW magic-link claim all converge; the public wizard's fake "verify email" phase (no email was ever sent; resend was cosmetic) is REMOVED and its dead <subdomain>.restora360.com link replaced with the resolving URL. (OP-2) Entitlement-aware sidebar: every gateable nav item carries a featureId; resolveNavFeatureSet resolves the tenant's PLAN set without the trial-all-access window (a trialing owner sees their plan's nav — deep links to trial extras still work; day-8 no longer looks like the product broke), fails OPEN on errors; sidebar, sidebar search, and Quick Links (add-grid + stored bookmarks, rows preserved) all filter identically; superadmin unfiltered. ~20 nav-coverage features minted at basic/0 SEK (zero regression for existing tenants; re-tierable in Pricing Studio without code); also fixes a pre-existing leak where sidebar SEARCH exposed admin-only entries to every tenant. (OP-3) New IA: compact primary (Overview · Quick Links · Orders (renamed from "TV & Displays") · Customers · Add-ons) + 12 job-shaped groups (Reservations / Menu & Combos / Food Bazar / Marketing & AI / Website & Content / Operations (in-venue) / Inventory & Suppliers / Reports & Money / Restora Profit / Chains & Groups / Account & Settings / Help & Support); 10 off-catalog routes triaged (3 already-correct redirects kept, 6 deliberate sub-tools of Receiving/inbox documented, /dashboard/features "Your Features" added to the catalog); the missing Landmark nav icon registered. (OP-4) Connections hub (/dashboard/connections + aggregation API): Google Business, socials, accounting provider, Stripe Connect payouts, paired terminals, delivery platforms, commerce channels and the custom domain in one place with Connect/Manage CTAs — connect-state is NEVER hidden by plan (unlike entitlements), an owner can always find where to connect. (OP-5) Add-ons front door: the marketplace machinery MOVED (cut, not copied) out of /dashboard/billing into components/dashboard/addons/AddonsMarketplace mounted at the always-visible /dashboard/addons (suites + à-la-carte + active add-on management + the Stripe-return finaliser; checkout success/cancel URLs updated); Billing keeps plan + invoices + a link card; requireFeature's 402 upgradeUrl and the upsell CTAs now point at the store. (OP-6) Online Starter 149 SEK/mo: new built-in online-starter-monthly plan (7-day trial, explicit includedFeatureIds = the online set: website/menu/online orders/reservations/Food Bazar/basic marketing/analytics/payments + the owner's own books & payouts; NO in-venue ops — that's the upgrade story to Basic 299); platformSettings.signupPlanId + resolveSignupPlan now drive register, WOW claim, Launchpad, /register/payment (charges the plan's REAL price), the register-page copy, the onboarding "plan after trial" line, the day-4 trial email and the KeepAccountBanner — killing four hardcoded "299 kr" copies and the old register-trials-on-basic-annual vs WOW-trials-on-basic-monthly mismatch (trial LENGTH now also comes from the same plan); SubscriptionRecord.planId loosened to string (any resolvable PlanDefinition id). Verified: tsc 0 · 4,004/4,004 tests · next build clean · 43/43 Playwright checks against the running dev app (premium+grants / fresh-starter / superadmin-cookie sidebars, addons + connections + billing pages, search-leak fix, register→starter provisioning, onboarding-complete idempotency) · welcome email observed end-to-end in the dev mailer with resolving links and "149 kr/mo (Online Starter)". NOTE (deliberate): landing-site marketing copy still advertises Basic 299 (true — the plan exists); repositioning the public pricing story around the 149 starter is an owner copy decision.

    Fixed

    • Super-admin dark mode — closed the override-sheet gaps that left faint text + light cards on the dark canvas. The unified admin theme (theme-css.ts) re-skins Tailwind classes per mode, but three common classes weren't in its vocabulary, so any page using them broke in dark (e.g. the new Import-Learning pages, and other sections): (1) text-gray-400 / text-gray-500 weren't remapped in dark → muted labels rendered near-invisible (now mapped to readable inks #9aa6ba / #8c98ab); (2) colored -50/-100 opacity tints (bg-indigo-50/50, bg-emerald-50/…) only matched the exact class, not the /opacity variant → a light card sat on the dark page (now the [class*="bg-…-50/"] variants map to the dark tint); (3) bg-white/opacity glass panels weren't remapped (now → the dark card surface). One systemic change themes every admin island at once, no per-page edits; original dashboard rules untouched. Verified the generated dark CSS now emits all three.

    Added

    • Phase 50 hardening — plan gating, Guardian alerting, group (HQ-connects-once) scope, Explain button (the four airtightness gaps). (1) Plan gating: new accounting-sync feature (Premium+ by default, 49/mo add-on for Basic, super-admin grant/retier without code — plan §13 tier decision stays open) wired into every accounting write route (requireFeature → 402 with upgrade metadata), the status feed (featureAvailable → the page renders an upgrade panel) and the dispatcher (an un-entitled connection neither plans nor dispatches — nothing accumulates, everything resumes on upgrade). (2) Guardian: new integration incident source + an accounting-sync service row; the probe cycle now reads the LIVE sync stores and opens stably-fingerprinted incidents for dead-letter backlogs (P3, P2 at ≥10 stuck documents) and reauth-required connections — folding into one incident instead of one per tick, riding the existing anti-fatigue alerting; a store read failure yields no candidates (monitoring never takes the monitor down). (3) Group scope: a group HQ can tick "Connect for the whole group" — one org-scope connection covers every location (the planner now takes a tenant-id SET and each task carries the event's tenant; branch documents build from the branch's own doc/currency; gates = the deterministically-resolved HQ's mapping+consent — one legal company, one chart, one consent; the 0-or->1-HQ anomaly resolves deterministically). Branches see a "managed by your headquarters" card; every resolver is org-aware (own connection wins, else the group's). (4) Explain button on stuck tasks calls the BK-11 narration (deterministic fallback inline). Live-verified: org context correct for standalone tenants, premium tenant passes the gate, tick green, Guardian probe runs the accounting feed with a clean healthy-queue negative. tsc 0; 10 new tests; full suite 4,004/4,004.
    • Phase 50 BK-11 — the accounting assistant: DETERMINISTIC hints always, AI narrates only (closes the buildable scope BK-0…BK-11). The docs/bookkeeping.md "Fortnox rejected invoice — AI explains why, suggests fix" built the Restora-Profit way: deterministicHint (lib/accounting/assist.ts) is a PURE rule table over the queue's error taxonomy — mapping-unapproved / consent-missing / unknown-account (carries the exact account number into the fix) / unbalanced / ordering / reauth / rate-limited / wave-scope / generic — every failure gets a summary AND a concrete next click, no AI key required. Hints ride inline on stuck tasks in BOTH the tenant sync panel and the admin dead-letter list; POST /api/accounting/sync { action:'explain' } adds an optional warm AI narration (voice-concierge narrate pattern: budget-gated via the AI registry, metered as assistant usage, system-prompt forbids new numbers/accounts, ANY failure falls back to the deterministic text). Nothing auto-repairs — fixes stay the owner's click. Live-verified: explain returned the correct wave-scope rule with usedLlm:false on this box's invalid AI key — the fail-safe demonstrating itself. tsc 0; 3 new tests; full suite 3995/3995.
    • Phase 50 BK-10 — the Accounting Integration Center (super-admin). /admin/dashboard/accounting (new sidebar entry under Platform & System, dashboard chrome = shared admin theme for free) + GET/POST /api/admin/accounting/overview: the master kill-switch as a one-click card, the provider catalogue with per-provider auth kind / markets / verified rate budgets / app-credential presence (not set = mock mode), every tenant connection with its gate state at a glance (mapping approved + consent current, demo badge), per-connection queue health + posted-document counts + last sync, cross-tenant totals, and a dead-letter list with admin REPLAY + a run-tick-now button — the cross-tenant connect/health/retry controls the old read-only Integration Monitoring page never had. Super-admin only, audit-logged, token material never crosses the boundary; tenant gate reads fail closed. Live-verified: overview feed correct for the luigi demo connection (both gates green, 4 posted), page renders 200, admin run-tick executes. tsc 0.
    • Phase 50 BK-9 — expense sync (wave 2): one-off owner-entered costs become expense documents. A NEWLY-created, fixed-amount, one-off cost-ledger entry (a real purchase — oven repair, equipment) now emits expense.recorded from the profit costs route in the same mutate, plans an exp_<id> task, and posts to the provider on the mapped purchases account. Honest boundaries kept: recurring/pct-of-revenue entries stay P&L ESTIMATES and are deliberately never posted as documents; updates never re-emit; input VAT is never invented (the owner-entered amount posts gross, vatAmount: 0 — receipt-level VAT splitting belongs to a future receipt-scanning wave); and a provider capability that genuinely doesn't exist (unsupported, e.g. expense push on Fortnox/Visma pending their sandbox wave) now becomes a visible skip instead of a dead-letter — nothing a retry or human can fix there. Live-verified: a 1,250 SEK one-off cost added through the real profit API flowed tick → demo provider expense #24935. tsc 0; 2 new tests; full suite 3992/3992.
    • Phase 50 BK-8 — real Fortnox + Visma eEkonomi adapters (fixture-tested, LIVE-GATED). The first two real provider implementations behind the universal contract, built on an injected-transport pattern so every behavior is unit-tested against fixtures and no test touches a real API: shared HTTP plumbing (providers/http.ts) maps provider semantics onto the queue's error taxonomy (401/403 → reauth-required — Visma's silent password-change death becomes a Reconnect prompt, never a retry loop; 429 → rate-limited with Retry-After honoured; 404 → retriable ordering not-found; 400/422 → validation dead-letter; 5xx → provider-unavailable). Both adapters use SEARCH-FIRST idempotency (neither API has idempotency keys — our deterministic externalRef rides the document reference fields and is looked up before create; a hit returns duplicate:true, matching the mock's contract exactly). Fortnox: v3 wrapper objects, customer ensure-then-invoice, invoice payments resolved by reference; Visma: eAccounting v2 $filter search, credit-invoice flag, per-invoice payment posting. Wiring is a side-effect register module (providers/register.ts) imported by the dispatcher/bootstrap — which changes NOTHING until a connection actually reaches sandbox/live mode, a path that stays quadruple-gated (kill-switch → provider app creds → BK-7 consent → approved mapping). ⚠ Exact FIELD mappings are explicitly marked SANDBOX-VERIFICATION-PENDING — validating them against real Fortnox/Visma test companies is the BK-12 go-live gate by design. tsc 0; 6 new fixture tests; full suite 3990/3990; live mock tick unaffected after wiring.
    • Phase 50 BK-7 — auto-posting consent (the deliberate TC-0 extension) + first-class CREDIT NOTES + document fidelity. (1) Consent (lib/accounting/consent.ts + GET/POST /api/accounting/consent + a consent card on /dashboard/accounting): TC-0's "Restora prepares, never files" is extended with its OWN versioned acceptance — the owner explicitly approves that Restora creates DRAFT documents in their connected accounting system while they remain responsible for review/filing; a terms-version bump invalidates old consents. canAutoPost is now THE pure dispatch gate: mapping approval always; a CURRENT consent + a non-demo tenant before anything non-mock (demo restaurants can NEVER post to a real system); mock stays consent-free. (2) Credit notes become a first-class books document (lib/bookkeeping/credit-note.ts + TenantDB.creditNotes — the object the recon confirmed existed NOWHERE): every refund now records a numbered kreditnota (per-tenant yearly series KN-<year>-<seq>, amounts split at the ORDER's dated VAT rate with the recompute's honesty flags) in the SAME lock as the refund's ledger row, emits the first creditnote.issued events, rides the bookkeeping API response, and correlates the sync push (docNumber = KN number; invoices carry the order number the same way). (3) Robustness fix caught by live QA: payments/credit-notes now ensure their referenced invoice provider-side before pushing (idempotent by externalRef — a duplicate returns the existing doc), and not-found is classified as a retriable ORDERING condition instead of fatal — this heals both real-provider edge cases (doc deleted provider-side) and the demo provider's restart amnesia. Live-verified end-to-end: consent recorded (version-stamped, audited) → 5 SEK refund created KN-2026-0001 (6% dated split) + queued its event → tick initially dead-lettered on the pre-fix amnesia → REPLAY + ensure-invoice landed it as provider doc #25202 — the replay facility proving itself on a real failure. tsc 0; 12 new tests; full suite 3984/3984.
    • Phase 50 BK-6 — the sync engine: durable queue + dispatcher + external-id ledger + REPLAY + the platform cron scheduler (the keystone). The "sync forever" half of connect-once, and the fix for the confirmed 37-of-42-crons-never-fire prod gap. (1) Queue core (lib/accounting/sync/core.ts, pure, RAOS-bus-shaped): per-connection FIFO with at most one running task (invoice-before-payment ordering is an accounting invariant), retry backoff [1,5,30] min, fatal validation errors dead-letter immediately, replay requeues dead-letters with a fresh budget (a facility that existed nowhere in the platform), deterministic task refs (inv_<orderId>/pay_<ledgerId>/cn_<ledgerId>) so replans and overlapping emissions collapse; non-sale money (canteen top-ups/B2B) and order-less charges become VISIBLE skipped tasks, never silent drops. (2) Isolated store (databases/accounting-sync.json — tasks + the external-id ledger ("our doc X = provider doc Y") + per-connection cursors into the append-only domain-events store; pg-migrated for free). (3) Dispatcher (dispatcher.ts): tick = inline drainOutbox() nudge (the zero-caller relay parameter) → plan from events since cursor → claim/execute/outcome outside the store lock; hard fail-closed gates all surfaced in the result — kill-switch, connection status, owner-approved mapping required, non-mock modes blocked until the BK-7 consent ships, mock money never posted to real books; per-connection sync history on the connection card. (4) Platform self-hosted cron scheduler (lib/cron/platform-scheduler.ts + instrumentation switch): the RIE-only scheduler generalized into a neutral, data-only job registry — 5 intel jobs carried 1:1 (no double-fire) PLUS the set that never ran on the box before (domain-events relay, guardian probe, email campaigns, social autopilot, accounting sync); legacy env honoured. (5) Wave-1 document builders (order→invoice, charge→payment, refund→credit-note; BK-4 recompute + approved mapping; BK-7 upgrades fidelity). Routes: GET/POST /api/accounting/sync (status/run-now/replay, audit-logged) + GET /api/cron/accounting-sync (vercel.json */10); a Sync-activity panel with per-task Replay on /dashboard/accounting. Live-verified end-to-end on dev through the real POS route: cash-settled a seed order → outbox event → tick planned invoice+payment in FIFO → both pushed to the demo provider (invoice #87222, payment #34228) → external-id ledger recorded; re-tick planned/pushed ZERO (cursor + dedupe); an order-less refund surfaced as a visible skipped task. tsc 0; 15 new tests; full suite 3976/3976.
    • Phase 50 BK-5 — financial event-spine completion: EVERY money movement now emits a transactional outbox event. Before this, only 4 of ~15 payment-ledger call-sites emitted domain events — Stripe/Swish webhook confirmations, PayPal captures, the tap-to-pay terminal, table-pay, catering deposits/balances, bar-tab settles, canteen top-ups/B2B and ALL refund paths wrote money rows the event spine never saw (and order.refunded was declared but emitted nowhere). One new helper (lib/events/ledger-events.ts emitLedgerOutboxEvent) now rides INSIDE the same row-locked mutate as every ledger write across all 13 production sites: a newly-created paid charge → payment.captured, a refund → payment.refunded, each anchored on the ledger row id (ref/meta.ledgerEntryId — the deterministic task key the BK-6 sync queue will dedupe on, which also makes historical overlapping emissions harmless), with created:false (webhook redelivery / retried confirm) never re-emitting — the emission inherits the ledger's idempotency for free. The main refund route additionally emits the first-ever `order.refunded` exactly once, on the row that flips the order fully refunded. DomainEventType gained expense.recorded + creditnote.issued (declared for BK-9/BK-7). PII posture preserved (ids + amounts + scalars only). Live-verified on dev: a real API refund landed payment.refunded on the tenant doc's outbox transactionally (anchored + charge-linked), and a retried identical refund produced NO second row and NO second event. tsc 0; full suite 3961/3961 (one POS test updated to the new intended two-event contract + a new no-re-emit test).
    • Phase 50 BK-4 — mapping engine + sync-time VAT recompute + the unified CoA registry (also FIXES the latent zero-VAT books defect). Three load-bearing pieces: (1) ONE per-country chart-of-accounts registry (lib/accounting/mapping/coa-registry.ts) from which BOTH historical SE BAS maps now derive — DEFAULT_SE_ACCOUNTS (SIE export) and SE_BAS_ACCOUNTS (revenue-share journal) can never drift apart again; drift-pin tests assert the pre-migration literals verbatim (incl. the deliberate 2641-purchases vs 2640-fees input-VAT nuance, preserved not "fixed"); the full 3955-test suite passes untouched. (2) Sync-time whole-order VAT recompute (vat-recompute.ts): Order.taxAmount is 0 on every real sales channel (VAT-inclusive pricing — verified in the Phase 50 recon), so the Phase 19 books reported ZERO VAT for real orders; the bookkeeping route now recomputes per order (Order.type → VatCategory → dated resolveVat, counter=dine-in, stored positive tax trusted, unsupported jurisdictions flagged never guess-stripped, purchases NEVER recomputed — input VAT is not invented) and CSV/SIE/P&L/VAT-return all inherit the fix through the one wire-in. Live-verified on seed: luigi's books went from 0 to 3,353.93 SEK output VAT across 77 sales, recomputed rows landing on the dated 12%/6% SE rates. (3) Mapping engine + owner approval (engine.ts + GET/PUT /api/accounting/mapping + a mapping panel on /dashboard/accounting): registry defaults layered under per-tenant overrides, DETERMINISTIC suggestions against the BK-3 imported chart (exact/missing — no AI decides anything), one-click Approve, and an append-only old+new-value MappingHistoryEntry trail where a change after approval re-opens review. Live-verified end-to-end: 14/14 exact matches after refresh, approve → override (reopens + history before/after) → reset + reapprove. New TenantDB additive field accountingMapping. tsc 0; 20 new tests; full suite 3955/3955.
    • Phase 50 BK-3 — post-connect company bootstrap ("the system configures itself"). Every connect now auto-pulls (fire-and-forget, guarded — a bootstrap hiccup can never fail a connect) the provider's company identity, REAL chart of accounts, VAT codes and fiscal years into an AccountingBootstrapCache on the connection — the snapshot the BK-4 mapping engine will suggest against, so the owner only ever clicks Approve. POST /api/accounting/bootstrap = "Refresh company data" (also a button on the connected card, which now shows N accounts · M VAT codes · K fiscal years + org number). Failure posture: company-info failure fails the refresh but KEEPS the previous good snapshot with the error recorded beside it; unsupported list-reads degrade to empty lists. Public views expose counts + fiscal years, never raw dumps. Live-verified on dev: bootstrap over the demo connection returns 13 BAS accounts / 5 VAT codes / 2 fiscal years and the status API serves the summarized view. tsc 0; 4 new tests (53 accounting total).
    • Phase 50 BK-2 — `/dashboard/accounting` one-click connect UI + the OAuth route quartet (audited). The tenant-facing face of the accounting platform: a provider grid for the tenant's market (SE sees Fortnox/Visma + globals; NO sees Tripletex — driven by the def-table country map) with three connect shapes — Google-style OAuth redirect (POST /api/accounting/connect → authorize URL → GET /api/accounting/oauth/[provider]/callback → ?connected=1 bounce), paste-credential form for token providers (POST /api/accounting/credentials, secrets encrypted, never echoed), and a one-click Restora Books demo connect — plus a connected status card (mode badge Demo/Sandbox/Live, status pill incl. an explicit Reconnect-needed state with a reconnect CTA — the expired-token UX the Google integration never had), sync-history list and disconnect. Flag-gated by the master switch ({ skipped:true } → not-available panel; nav stays visible per repo convention). New nav entry beside Bookkeeping. ActivityType gained an additive integration member; connect AND disconnect write both audit tiers (master audit log with IP + tenant activity feed) — fixing the documented Google-integration anti-pattern. Verified END-TO-END against the running dev app: flag OFF → {skipped}; admin flip → provider grid; demo connect → record created; Fortnox mock OAuth → 302 ?connected=1&mode=mock, connection REPLACED in place (one-per-legal-entity invariant held live), refresh token AES-GCM-encrypted on disk, zero token fields in any API response; disconnect → revoked + reconnect works; both audit trails carry all four events. tsc 0; page renders 200.
    • Phase 50 BK-1 — accounting connections + token vault + rotation-safe refresh (all 4 authKinds). The connect layer under the universal accounting platform: a new isolated databases/accounting-connections.json store (lib/accounting/connections-db.ts, Google-store clone with a scope key — tenant:<id> default, org:<id> for chains — one ACTIVE connection per LEGAL ENTITY; pure mutation core unit-tested, reconnect preserves id+sync-history, resolver = own-connection-wins-then-org, toPublicConnection never leaks token material). Provider-parameterized OAuth (connect/oauth.ts: declarative endpoint/scope/env config table for Fortnox/Visma/Xero/QBO, HMAC stateless state + PKCE, mock-mode-first with RELATIVE mock callbacks; mock refresh deliberately ROTATES the refresh token every call so Fortnox semantics are exercised on seed). The rotation-safe token service (connect/token-service.ts) fixes what Google's pattern gets wrong for single-use tokens: cached access tokens (ordinary calls never touch rotation), per-connection single-flight refresh, compare-and-set persistence of the rotated refresh token (CAS mismatch adopts the other writer's state instead of clobbering — never bricks a Fortnox connection), grant-level rejections → formal reauth_required status that short-circuits instead of retry-looping, session-minted (Tripletex) mint-and-cache with an injectable minter. Connection lifecycle facade (connect/service.ts): redirect / paste-credentials / one-click-demo connect shapes, fail-closed sandbox unless ACCOUNTING_LIVE_MODE=live, e-conomic demo/demo → mock-mode connection, disconnect wipes token material and keeps a revoked record for history. Verified: tsc 0; 26 new tests (49 accounting total) incl. a concurrency test proving exactly one refresh under parallel callers.
    • Phase 50 BK-0 — Universal Accounting Integration Platform: provider contract + registry + mock provider + master kill-switch. The keystone of the connect-once accounting platform (plan: docs/products/accounting-integrations/PHASE-50-UNIVERSAL-ACCOUNTING-INTEGRATION-PLATFORM-PLAN.md): ONE provider-agnostic AccountingProviderAdapter contract (lib/accounting/types.ts — uniform CallResult envelope with a typed retriable-vs-fatal error taxonomy the sync queue will key on) + a declarative provider def table (provider-defs.ts — Fortnox/Visma eEkonomi/e-conomic/Tripletex/Xero/QBO with verified authKinds and rate budgets: Fortnox oauth-rotating 25 req/5s, Visma oauth-stable 600/min, e-conomic static-grant, Tripletex session-minted, Xero 5k/day cap) + a fail-loud resolution seam (registry.ts, mirrors payments/registry.ts: mock serves everything on seed, sandbox/live throws until a real adapter registers in BK-8 — never silently mock-posts real books) + the full "Restora Books" demo provider (mock-provider.ts: BAS chart aligned with the two existing SE account maps, Swedish VAT codes, strict idempotency — same externalRef → same remoteId + duplicate:true — balanced-legs/consistent-totals validation, payments reject unknown invoices to exercise BK-6 FIFO ordering; hard-blocked from ever resolving outside mock mode) + the accounting_sync_enabled master kill-switch (flag.ts clone of the ITA pattern, fail-safe OFF, strictly binary) with GET/POST /api/admin/accounting/master-switch (super-admin, audit-logged). Verified: tsc 0 project-wide; 23 new node:test cases pass.
    • Import-learning loop (Phase IL) — foundation + capture (shared by menu imports AND WoW web clones; one anonymous, platform-wide store, no parallel engine). Every import now teaches the next one: the system records the delta between what the AI extracted and what the owner approved, so future imports get less defective and cover more scenarios. This phase lands the substrate + capture; distillation → replay → coverage dashboard follow.

    - Shared substrate — new ImportLearningSignal / ImportItemDelta / ImportScenario / ImportCorrectionRule types + MasterDB.importLearningSignals|importScenarios|importCorrectionRules (signals capped to a rolling 500; scenarios are tiny per-bucket rows). CloneRecord.issues now links each clone's deterministic migration defects into the same loop. - Pure signal engine (lib/import-learning/signal.ts) — diffs the AI extraction against the owner-approved menu: pairs items (exact then token-overlap so a renamed dish isn't counted as drop+add), and records structural deltas only — a name edit's kind (cased/cleaned/reworded/other), a price edit (keeping the printed format text like 85:- because that's the biggest parse signal, never restaurant-identifying), dropped items, and per-item confidence. Best-effort currency + language detection feeds a source × language × currency × cuisine coverage key. 8 unit tests. - Anonymous by construction — no raw dish/description text is ever stored; learning is platform-wide but only as patterns, honouring the "never copy one restaurant's data into another / never fabricate" rule. - Wired both sources, fully guarded — the authenticated menu/ai-import route captures the delta fire-and-forget (the review modal now snapshots the raw extraction, crops stripped, and sends it); the WoW clone path (recordClone) links its migration issues + folds a coverage signal into the shared store. Any capture failure is swallowed — it can never affect a real import or clone. - Self-improving core — distil + apply (`distill.ts` + `apply.ts`). Signals are distilled (≥3-agree threshold, mirroring the clone loop) into ImportCorrectionRules: recurring price formats per currency (the Swedish 85:- / comma-decimal / space-thousands patterns), a scenario's dominant currency, and a learned confidence-gate (when owners kept correcting items the model was confident about). Distillation runs in the same guarded mutation as capture; it preserves curated rules + learned hit counts and drops rules that fall back below threshold. Rules are applied as a SAFE, fixed-template hint block appended to the extraction prompt — format/currency guidance only, never tenant content, always carrying an anti-fabrication guardrail — selected by the import's pre-extraction context (source + the tenant's own currency, both known up front) so future imports make fewer of the mistakes owners previously fixed. The menu/ai-extract route fetches + injects the hints and credits a hit per applied rule; runMenuExtraction/extractMenuFromText gained an additive learnedHints seam (empty = byte-identical behaviour). Rules never rewrite an owner's prices/names/allergens — they only change how the model is guided and what gets flagged. - Verified: tsc 0; eslint 0 on changed files; 135 tests (import-learning + menu-import + wow) pass (17 in import-learning). Note: the hint's effect on extraction is inherently probabilistic (LLM-in-the-loop) — the plumbing/selection/rendering is fully unit-tested; real-menu accuracy uplift is a go-live validation. - Strategy EXECUTION (Phase IS) — a taught strategy actually fetches the menu. execute-strategy.ts turns a matched handling strategy into a real menu: resolveSourceUrls finds the taught source on the page (by the generalisable locator), then the existing extractors go get it — fetchPdfDataUrl fetches + (one hop) follows a PDF‑viewer plugin's embedded PDF, incl. dearpdf's JSON‑escaped config URL (findEmbeddedPdf), then runMenuExtraction OCRs it; sub‑pages go through extractMenuFromText. Wired into run-url-clone as a best‑effort job: detect signature → match a learned strategy → execute → the strategy menu leads the richest‑menu pick (a taught PDF is the source‑of‑truth). safeFetch gained a buffer() for binary payloads. Bounded (≤3 sources, PDF size cap) + fully guarded. Live‑verified on 400grader.nu: the clone now resolves the dearpdf link → follows it → extracts the embedded MENY‑…‑.pdf → fetches the real 495KB PDF; the OCR step then returns 401 invalid x-api-key because THIS dev box's Anthropic key is invalid (a known box issue) — on a box with a valid key it OCRs the menu. 6 tests (resolver + escaped‑PDF config). tsc 0, eslint 0, 36 import‑learning tests. - Teach‑the‑importer UI (Phase IS) — point at the menu, don't type. /admin/dashboard/import-learning/teach: paste a URL → strategy/analyze fetches the page (no AI cost), shows what the system sees (CMS, plugins, JS‑vs‑HTML, text density, archetype key) + a plain‑language diagnosis of where the menu likely hides + any already‑learned strategy → and pre‑detects the likely menu sources (extractSourceCandidates: the PDF link, sub‑pages, the third‑party ordering host) as one‑click cards (PDF/external pre‑selected, anchor‑noise filtered). You tick the real source(s) — optionally add one the detector missed — and "Teach the system" saves a generalisable strategy (strategy route → recordStrategy), reinforcing the bucket for that signature. A live "what the system has learned" list. An in‑page "How to teach" guide keeps the input structural — teach stable patterns that recur (​.pdf, weiq.app, lunch), not one‑off URLs or random words — with a live hint showing whether a manual pattern will match by URL‑part or link‑text. Verified end‑to‑end on the real 400grader.nu: analyze → detects wordpress|pdf+ext + the dearpdf PDF + weiq candidates → teach → re‑analyze returns a strong match. tsc 0, eslint 0, 32 tests. Next: the fetch‑execution layer (actually go get the taught PDF/sub‑page and OCR it). - Teachable clone-HANDLING strategies (Phase IS) — the learning brain. The URL/clone import fails on most real sites because the menu isn't homepage HTML — it's a PDF, an image, behind tabs, on a sub-page, on a third-party ordering service, or JS-rendered (diagnosed live on 400grader.nu: the clone got 0/‑menu items because the à-la-carte is a dearpdf PDF + a weiq.app SPA). Rather than hardcode a handler per site, this is the foundation of a system you TEACH once per site-type and it generalises: detectSiteSignature (pure) fingerprints a site structurally (CMS, plugins, SPA-vs-HTML, text density, PDF/menu/ordering links); signatureKey groups by handling archetype (e.g. wordpress|pdf+ext); matchStrategy applies a learned strategy to a new site (strong when the archetype matches, weak on partial overlap); recordStrategy teaches/reinforces a MenuSourceStrategy[] (kinds: pdf / images-ocr / tabs / sub-page / external-service / page-text / homepage-vision) into the dedicated store, reinforcing (taughtCount++) the same bucket so a second dearpdf pizzeria strengthens one strategy instead of fragmenting. Types + strategies added to ImportLearningDB; 4 tests over a 400grader-like fixture; tsc 0, eslint 0. Next: the teaching UI (point it at the real menu per site) + the fetch-execution layer (reuse the existing PDF/screenshot/text extractors to actually go get it). - Export path — trained rules ship as code (`trained-rules.ts`). So a training campaign's knowledge travels to production via git, not a fragile runtime-DB copy. GET /api/admin/import-learning/export (super-admin) renders the exact replacement for src/lib/import-learning/trained-rules.ts (learned rules frozen to curated, hits zeroed, seeds dropped, sorted) — JSON summary or ?format=file download; a dashboard "Export rules ↓" button. The bundled TRAINED_RULES load at read time between the seed defaults and the live store (seed < trained < store), so a fresh deploy starts already-smart. Pure export.ts (normalize + file-render) with 3 tests; tsc 0, eslint 0; live-verified (endpoint returns valid file content). - "Train the importer" surface (super-admin, tenant-less). A pre-launch training tool at /admin/dashboard/import-learning/train: drop in a menu (photos/PDF) or a menu URL → the SAME extractor runs (with learned hints applied) → correct the reading (fix prices/names, untick invented items) → record. Two routes (train/extract + train/record, super-admin-gated) run extraction and capture the extracted-vs-corrected signal into the dedicated store WITHOUT creating a tenant or a menu — pure "analyse → correct → teach, one at a time", so a rich rule set can be built before go-live. Confidence-flag threshold is the learned one; a running signal/rule counter + dashboard link. tsc 0, eslint 0; live-verified (extract responds, record writes a real signal to the store). - Dedicated persistent store (`databases/import-learning.json`). Moved the signals/scenarios/rules OUT of the hot master.json doc into their own store (ImportLearningDB + getImportLearningDB/mutateImportLearningDB, Postgres-doc + fs-file with a write lock), so a long pre-launch training campaign accumulates cleanly, survives deploys, and isn't fighting the master doc's size. The rolling signal cap was raised 500 → 5000 (distilled rules persist beyond it regardless). Store + admin route + distiller rewired to the new DB; tsc 0, eslint 0, 24 import-learning tests pass; live-verified on the dev box — the store file is created + written on a real import, the dashboard reads it, and the cutover from the old master location works. - Day-one usefulness + clone coverage + review flagging. The loop no longer waits for ≥3 organic corrections before it helps: a small set of curated seed rules (seed-rules.ts — SEK 85:-, EUR/NOK/DKK comma-decimals) is merged in at read time so a brand-new tenant gets the right format hint on its first import; a seed materialises into the DB (with hit tracking) the first time it's actually applied, and shows in the dashboard from day one. The WoW clone path now consumes the same hints — run-url-clone infers the currency from the site's country-code TLD (.se→SEK, generic TLDs → source-only) and feeds learned hints into both clone extractors (text pages + vision tiles). And the learned confidence-gate is now deterministic, not just a prompt nudge: ai-extract returns a learned reviewBelow (computed post-extraction once language is known) and the review modal flags items below that threshold instead of a hardcoded 70. Verified: tsc 0; eslint 0; 142 tests (5 new); live smoke — admin/ai-extract 401, wow-from-url 400 (all compile + respond).

    • WoW import — Wave 1 accuracy + "Migrate MY site" mode (reuse-and-advance of the existing clone/menu-import path; behind `wowFunnel`, default behaviour unchanged). Closes the biggest "this isn't my site" gaps the recon flagged ("vibe clone, not a migration"), by wiring already-built machinery into the clone path — no parallel engine.

    - "Migrate MY site" mode (W2.6) — new additive WowPreview.migrateMode (default false = the trademark-safe competitor-clone behaviour, byte-identical). The Health Check rebuild (HealthCheckClient) now sends migrateMode:true because the owner just audited their own site, so the claim imports the real brand name/tagline/contact/hero instead of an invented name + Stockholm demo contact + stock hero: claim.ts skips the demo-contact clobber when migrateMode (real menu.info carried by applyPreviewThemeToTenant survives); from-url accepts the migrateMode body flag. - Full-page Design DNA (W1.1) — generateDesignDna now reads the whole scroll-and-shoot page (new imageDataUrls[]), not just the hero, so sectionOrder reflects below-the-fold design instead of the model guessing. Single-screenshot path (imageDataUrl) unchanged. - Real-pixel palette anchors (W1.2) — the deterministic palette.ts sampler (dominantAccentColor/averageColor, no AI) reads the hero's real pixels and passes them as anchors in the DNA prompt, so the palette is grounded in the brand's actual colours rather than a one-shot mood-guess. - Real hero image (W1.5) — new deriveSiteMeta() extracts the owner's og:image; used as the page hero only in migrate mode (never reuse a competitor's image), with the curated cuisine stock as the fallback. Also imports the real meta-description as the hero tagline in migrate mode. - Allergen suggestions at import (W1.3), safety-gated — the clone import now computes deterministic EU-14 allergen suggestions (suggestItemAllergens, no AI) and stores them as the unconfirmed Product.suggestedAllergens — never written to live allergens. The product editor surfaces them as tap-to-confirm chips; the storefront still reads only allergens, so nothing is asserted without the owner's OK. - Photo quality at import (W1.4) — new scoreProductPhotosInBackground() (bounded concurrency, budget-capped, fail-open) runs the broken-image + quality verdict on freshly imported photos at clone-claim and the authenticated ai-import, so expired/broken stock photos get held instead of shipping live. - Verified: tsc 0; 182 tests (wow/menu-import/menu/bazar) pass; eslint 0 errors on changed files. next build runs on the Linux deploy server (this box OOMs). Allergen never-LLM + flag-for-review-not-invent rules preserved. Remaining Waves 2–3 (JSON-LD free-tier reader, DOM/CSS design reader, image relevance-gate, confidence/migration-report + review gate, full-site crawl/multi-page, SEO redirects, render-compare) tracked separately.

    • AI budget transparency page + custom top-up amount (owner-facing spend breakdown; every top-up itemized on the invoice). Extends the AI budget pill so owners can see exactly where their AI money goes and top up any amount they like — not just the fixed Small/Medium/Large packs.

    - Transparency page `/dashboard/ai-spend` (new "AI Spend" item under Account, plus a "See where your AI budget goes →" link from the budget pill). Shows: this month's remaining/used/limit hero (same figures as the pill); "Where your AI budget goes" — a per-feature breakdown (AI Assistant, Menu Photo Import, Social Posts, CFO Advisor, …) with owner-currency amounts + request counts + share bars, month-selectable; a 12-month usage trend; monthly + yearly top-up statements (each purchase listed with date, amount, allowance, and its AITOP-… invoice number) with lifetime totals; and a plain-language "how AI billing works" explainer. New pure aggregation lib lib/ai/tenant-spend.ts (aggregateTenantAISpend — feature breakdown, trend, statements, lifetime, all currency-converted) + a strictly tenant-isolated DB wrapper, behind GET /api/tenant/[tenantId]/ai-spend (requireTenantAccess). - Custom top-up amount. Alongside the packs, owners can type any amount (e.g. 1000 kr) in the pill and on the page. New usdFromOwnerAmount inverts the FX×1.5 markup so the granted USD allowance round-trips back to the price paid ("every 1 kr you add is 1 kr of AI"); buildCustomTopup validates against a currency-converted band (customTopupBounds). The dev path grants immediately; prod opens Stripe Checkout in the tenant's currency. - Top-ups go directly + show on the invoice. Each top-up is recorded as a paid invoice the moment it's charged (InvoiceRecord.category:'ai-topup' + aiAllowanceUsd, additive) and now appears in Billing → Invoice History correctly currency-formatted and tagged "AI top-up", as well as in the page's monthly/yearly statements. - Fixed a pre-existing PROD gap: the Stripe webhook's checkout.session.completed handler ignored kind:'ai-topup' sessions — a prod top-up charged the card but never granted allowance or wrote an invoice. The webhook now grants + records on payment, idempotently on the Stripe session id (and surfaces a billing.ai_topup.orphan integrity event rather than silently 200-ing if metadata is unfulfillable). Stripe amounts now go through toMinor/fromMinor so zero-decimal currencies (e.g. JPY) aren't overcharged 100×. - Verified: 21 new tests (top-up math/round-trip/bounds + tenant-spend aggregation incl. mixed-currency safety); tsc 0; eslint 0 (changed files). Adversarially reviewed (5 dimensions × verify) — 12 confirmed findings fixed, incl. making recordTopupPurchase's check-then-write atomic via mutateMasterDB (no double-grant/lost-update under concurrent webhook retries; rolls the invoice back if the allowance grant fails), currency-safe statement/lifetime totals (never cross-sum currencies; off-currency entries counted + noted), and a full a11y pass on the new controls (input labels, live-region banner, aria-expanded/aria-pressed, touch-readable trend bars, integer-only custom input).

    • Customer order-tracking ("live order status") screen — 1:1 redesign from the design handoff (3 owner-selectable templates, brand-themed, real data). Rebuilt the customer "waiting for your order" screen (/track/[tenantId]/[orderId]) 1:1 from docs/Order Status Redesign, as three interchangeable visual directions sharing one data interface: A · Wood-Fired (default — circular progress ring + rising steam + clustered dish preview with "+N" overflow + live ETA countdown + itemized list), B · The Journey (a travelling bag/scooter token bobbing along an animated road between status stops + live activity feed), C · Big Moment (dark espresso card, flickering flame, glowing shimmering progress bar). New shared engine lib/order-tracking/ — types.ts (one TrackingViewModel contract), theme.ts (resolveTrackingTheme() maps each restaurant's brand — preset or premium 9-token palette — into concrete colours with the warm "Luigi's" palette as fallback; pure colour utils mix/luminance/contrastRatio/readableOn/accentText/rgba), model.ts (step model, status→copy maps generalized for multi-tenant, live ETA, the brand-accent rule, item/hero/activity derivation). Presentational templates components/order-tracking/TrackingTemplate{A,B,C}.tsx + design-specific bag/scooter icons + scoped keyframes. Wired to the real system, not a parallel demo: the rewritten TrackerClient fetches the real order, derives the model, runs a single live ETA countdown to a real promisedReadyAt ((preparingStartedAt ?? acceptedAt ?? createdAt) + 20min pickup / 35min delivery, or a scheduled slot), adapts pickup vs delivery labels/icons/final-steps from order.type, handles loading / error / canceled-refunded / locked states, keeps the existing refresh/share/copy/cancel actions, and honours prefers-reduced-motion (freezes all loops, transitions stay instant) + an aria-live status region. The public order API now returns type/scheduledTime/preparationTimeMinutes; page.tsx resolves the theme+template and loads Baloo 2 / DM Sans. Owner picker: additive TenantSettings.orderTrackingTemplate ('A'|'B'|'C', default A) persisted via the builder PATCH/GET, with a themed "Order tracking style" A/B/C card picker in the Mason theme panel. Verified: headless render screenshot-compared 1:1 to the prototype across all 5 statuses × pickup/delivery; 49 tests (model + per-template render matrix + contrast regressions); tsc 0. Adversarially reviewed (4 dimensions × verify) — 8 confirmed findings fixed, incl. WCAG-AA contrast on dark/bright brand presets (readableOn/accentText keep the white-check 1:1 look on warm/red brands but flip to legible glyphs on amber/dark-red), a non-chatty aria-live (announces the status word, not the per-second countdown), and reduced-motion now also freezing the loading skeleton.

    - Fixed (same day): the hero "single vs cluster" decision now keys off total quantity, not line-item count — so a single dish ordered ×2+ (e.g. "2× Marinara") shows the multi-dish cluster (2 small thumbnails + "N items") instead of the single "food photo" hero. The "+N" overflow uses the same unit total so "2 shown + N more = N items" stays consistent. - Real dish photos (same day): the hero cluster thumbnails, the single-item hero, and the itemized-list thumbnails now render each line's real menu photo — joined from the live product by productId in the public order API (items[].imageUrl) — with the striped placeholder kept as a graceful fallback when a product has no image. Direction A only (B/C have no dish thumbnails).

    • Mason builder — container nesting Stage-2 foundation (the 5 layout containers now accept children). The data + adapter + editor-tree already round-trip nested content.children losslessly (unit-tested); this flips acceptsChildren:true for the 5 layout containers (columns / grid / stack / container / section) via an additive def() param + a new isContainerBlock(type) helper, and updates the catalog test. Pure metadata — byte-identical runtime (nothing renders children yet; the gated recursive render + the "Add inside" editing are the next slices). Verified zero existing tenant/template blocks of these types, so the render slice will be purely additive. 319 mason tests; tsc 0; eslint clean.
    • Free Growth (Revenue Share) — the two deferred UI mounts wired in (flag-gated, inert until enabled). The RS-0…RS-8 engine + components were already built; this connects them to their surfaces: RevenueShareBanner now renders atop the tenant Billing page when the tenant is on the free-growth-7pct plan, and PlanComparison (Free Growth vs flat, with the honest break-even note) renders on the register flow when the revenue_share_billing master flag is on — via a new read-only GET /api/public/revenue-share/enabled (fail-safe to off). Both are inert by default, so the billing + register surfaces are byte-identical until the offering is enabled. tsc 0; eslint clean.
    • Phase 37 (Vertical Modules II) — Weigh-and-Pay pricing engine + `Product.weighPricing` (mock-first, parallel-safe). The price-by-weight keystone for self-serve / counter products. New pure lib/weigh/ engine — computeWeighLineItem (gross → tare → net grams × price/kg with market rounding [cents/tenth/whole], every input clamped so a bad scale reading can never charge negative, and an optional min-net floor that applies only to positive sales) + weighCartLine (shaped 1:1 like CateringLineItem, so a weighed item flows straight into the existing order/ledger path). Additive optional Product.weighPricing on the menu model — absent ⇒ ordinary per-unit pricing, zero behaviour change to any existing menu — with a lib/weigh/product.ts adapter (isWeighProduct / weighLineForProduct, returning null so callers fall back to basePrice). Product is imported type-only, keeping the engine DB-free and unit-testable. WS-2 — owner control: a "Sell by weight" section in the product editor (price/kg + tare + rounding, with a live price preview), the field whitelisted through the products create/update API and sanitized server-side (sanitizeWeighPricing clamps + drops junk) + round-tripped via GET, plus a /kg price cue on the menu list. WS-3 — weigh-and-pay checkout: the POS PriceQuote engine is now weigh-aware (a by-weight line is priced server-side from price/kg × scale grams; additive — normal items stay byte-identical), the staff POS route accepts grossGrams, and a new Weigh Station (/dashboard/weigh + sidebar nav) builds a weighed ticket and takes payment through the existing born-paid POS pipeline (unified ledger + loyalty + stock; mock until Phase 40). Real scale hardware deferred (Phase 38). WS-4 — self-serve customer kiosk: a public, read-only weigh kiosk (/kiosk/[tenantId]/weigh) where the customer weighs items, sees live prices, and prints a “pay at the counter” slip — it takes no money and writes no order (the staff POS finalises the sale, safe by construction), backed by a reusable receipt model (lib/weigh/receipt.ts) + a read-only GET /api/public/[tenantId]/weigh/products. WS-5 — hardening: the public kiosk is now opt-in per tenant (settings.weighKioskEnabled, default off — the page + its public endpoint 403 until the owner enables it), the endpoint is rate-limited (60/min/IP), and the staff Weigh Station gets an owner toggle + the kiosk link. WS-6 — scan-to-recall: the kiosk slip now carries a stateless code + QR that encodes the weighed basket itself (no server write, no public endpoint); staff scan or paste it at the Weigh Station to recall the exact lines — no re-weighing (lib/weigh/slip.ts, checksum-guarded; the station re-prices server-side on charge). tsc 0; eslint clean; weigh + price-quote suites green. Commits cce98d3e (WS-0) · 08830bf3 (WS-1) · WS-2 · WS-3 · WS-4 · WS-5 · WS-6 (this entry).
    • FSN Global Kitchen — 700-dish multilingual launch library + Restora 360 redesign (web-first). Net-new curated content + the start of the FSN visual overhaul. tsc 0 errors.

    - Dish library (data): new DishMaster + DishLibraryDB (lib/db/schema.ts) in its own dish-library app-document, with getDishLibraryDB/save/mutate accessors (cached; auto-imports to Postgres via db:import). 700 dishes = 25 countries × 7 breakfast/lunch/dinner/snack, each with name/description/ingredients/steps in en·sv·da·fi·no, authored per-serving nutrition (flagged estimate — NOT a parallel nutrition engine), dietary tags + allergen advisory. Generated via a multi-agent workflow, committed as scripts/data/dish-library.seed.json; seeded by scripts/seed-dish-library.ts (npm run seed:dishes). - Images — correct + unique on all 700. Hybrid pipeline (scripts/dish-library/images.mjs + images-gen.mjs): real stock (Pixabay·Wikimedia·Openverse) keyword-gated + globally de-duplicated + Gemini-vision-verified (482), then Imagen generation for the remainder (218), each vision-checked and downscaled to ~150 KB JPEG in public/global-media/. 0 placeholders, 0 duplicate images. - Surfaces: /fsn/kitchen (discover by country/meal/language + search), /fsn/kitchen/[id] (localized recipe + nutrition + schema.org/Recipe JSON-LD), /fsn/eat-today (AI day-plan: kcal target + macro bar + per-meal picks), public /api/fsn/dishes (+ /[id]) read API (also for the Android app later), and 75 dishes promoted into the FSN feed as recipe posts by 12 curated home cooks (scripts/seed-fsn-kitchen-feed.ts). A standalone HTML review doc is produced by scripts/dish-library/build-html.mjs. - Restora 360 design system (foundation): scoped .fsn-r360 tokens + dark mode (app/fsn/r360-theme.css) ported 1:1 from the design handoff (clay primary, warm-paper Stone, Hanken Grotesk / Newsreader / Geist Mono via next/font); FsnShell chrome + the Global Kitchen / dish / Eat-today screens re-skinned off the tokens. Remaining FSN screens (feed/reels/profile/articles) get the new chrome now and full per-screen re-skins next.

    • Phase 36 — Vertical Modules I (Catering · Bars · Canteens): the deferred full feature scope, completing the three gap-audience modules. Mock-first on the Phase-33 ledger; additive; tsc 0; full suite 3405/3405; next build exit 0. The keystone slice of each module shipped earlier; Phase 36 finishes the rest. Built foundation + money-critical engines by hand, fanned the routes/UI out across 3 parallel module agents, then hardened via a 5-agent adversarial money-model review (6 real fixes applied, below).

    - Catering — package & menu builder, contracts, BEO, final invoice. New pure engine fns (packageLineItem, buildContract/acceptContract [click-sign + a non-PII assent fingerprint], buildBeo/sortedBeoTasks/beoProgress, buildInvoiceRef) + documents.ts buildCateringInvoiceModel (reuses the shared pdf-lib faktura renderer — the deposit already captured is shown as a credit line so "att betala" = the remaining balance, VAT charged on the full event). Routes: packages CRUD + apply-package; contract (issue / accept / void); beo (generate + checkable task list); invoice (issue → CAT-INV-####) + invoice/pdf; balance (take the remaining balance → source:'catering-balance'). Dashboard detail pane gains a Packages manager, a Contract card (generate → terms → click-sign), a BEO run-sheet with a progress bar, and an Invoice card (issue + Download PDF + take balance). An accepted contract freezes the quote (item/delivery edits + apply-package + re-issue are 409'd until voided). - Bar — pre-auth, happy-hour automation, fast-pour grid, rounds, split-by-seat/item, refusals. New engine: happy-hour pricing (activeHappyHour/happyHourPrice/resolveQuickAddPrice — day-of-week + time windows incl. midnight-wrap, percent/fixed/price, first-match), a mock pre-auth hold lifecycle (buildPreAuth/markPreAuthCaptured/markPreAuthReleased — moves NO money; the capture at settle is the only ledger charge), nextRound/groupByRound, seatSubtotal/itemsSubtotal, canSettleTab (age gate), normalizeFastPour. Routes: bar/config (happy-hours + fast-pour + age-gate + default hold), bar/refusals; tabs gains a pre-auth hold on open + set-preauth; add-items stamps a round + seat + the happy-hour-resolved price; settle adds split-by-seat / split-by-item, an age-gate block (402), and pre-auth capture. Dashboard: a fast-pour grid, a happy-hour/fast-pour/age settings panel, an items-by-round view with seat tags + happy-hour badges, a split-mode picker, and a refusal log. - Canteen — employer/school groups, subsidy rules, meal-plan allowances, daypart menus, B2B invoicing. New engine: resolveSubsidy/resolveAllowance (account-override → group → config-default), computeSubsidySplit (percent / fixed-per-meal / fixed-per-day + daily cap; both shares ≥0 and sum to the meal), checkAllowance (day/week/month period caps + meals cap), activeDaypart, billableAccruals/buildB2BInvoice + documents.ts buildCanteenB2BInvoiceModel. The subsidy money model: at the till the diner's wallet covers only the post-subsidy share; the employer/school share accrues as a receivable (canteenSubsidyAccruals, NOT a wallet debit, NOT a ledger charge) — wired into the POS meal seam, backward-compatible (no group → identical to before). The receivable is billed via a B2B invoice (accruals stamped atomically so they can't be double-billed); the group's payment is the one real cash event (source:'canteen-b2b', idempotent, created-gated). Routes: groups CRUD (+ billable preview), config (dayparts + defaults), per-account group/subsidy/allowance overrides, b2b-invoices (issue / pay / pdf). Dashboard gains Groups, Dayparts & Defaults, and Invoices surfaces.

    • Fixed (Phase 36 adversarial review — money-model hardening):

    - Revenue double-count of the canteen subsidy. A canteen-b2b ledger charge is the collection of a receivable for meals already recognised as revenue (via order.total at meal time) — not a new diner-facing sale. It was being counted as gross sales + levied a platform fee + commissioned. Now excluded everywhere canteen-topup is (revenue-share settlementFromLedgerEntry + reporting, summarizeOrderCommissions, the usage meter, and surfaced under summarizeLedger's non-sale breakout). Regression tests added. - Bar settle TOCTOU. Settle re-read the stale pre-lock tab.subtotal for the balance/close decision; a concurrent add-items (a new round) could close a tab with the new drinks unpaid. Now re-reads the fresh tab subtotal inside the lock (mirroring the POS settle). - Catering VAT zeroed on re-quote. The PATCH + apply-package recompute hard-coded taxRatePct: 0, silently dropping VAT for a tenant with cateringConfig.taxRatePct > 0. Both now use the configured rate (matching the create path). - Catering deposit / balance over-collection. Both posted the pre-lock amount with no in-lock re-clamp → a concurrent double-submit could collect past the due. Both now re-clamp against freshly-captured inside the lock; the deposit route also gates its state/timeline leg on the ledger created flag. - Catering contract integrity. An accepted contract is now frozen (re-issue 409'd; quote edits/apply-package blocked) with an explicit void (DELETE) to unlock; the invoice PDF 404s until an invoice is actually issued (no provisional-number drift). - B2B ledger row no longer stamps the invoice id into orderId (per-order rollups can't accidentally match an invoice).

    • Phase 35 — VC-8 (drive-thru lane) + VC-9 (POS/Waiter voice-to-order): the last two surfaces, as thin adapters over the VC-0 engine. A new shared lib/voice-concierge/turn.ts advanceTurn() runs ONE engine turn and, on a confirmed order, creates it through the SAME locked seam the phone uses (born-PENDING, OUR-menu pricing, never settle) + records the session — surface/source injected so the brain is identical across surfaces (one system). VC-8 POST /api/tenant/[tenantId]/voice-concierge/lane (tenant-auth, live-gated, HMAC-signed ConvState, per-car sessionId idempotency, source:'drive-thru') returns the running order as a board payload for a lane confirmation screen. VC-9 POST /api/tenant/[tenantId]/voice-concierge/pos-order (source:'pos'|'waiter', per-ticket idempotency) shows the read-back on screen; the staff member confirms by sending the next turn ("yes") — no auto-submit. Both inherit the engine's allergen pre-router, read-back→confirm, and signed-state guarantees verbatim. 2 tests (58 voice-concierge total); tsc 0; eslint 0. → Phase 35 surfaces VC-0…VC-9 now COMPLETE; only go-live infra remains (real Twilio numbers + STT/TTS, a live narration key, outbound channel send, a per-lane device token for unattended lanes, and flipping voice_concierge_enabled).
    • Phase 35 — Voice & AI Concierge (Universal-Coverage P35): the AI voice agent, customer concierge & staff copilot — buildable scope COMPLETE (VC-0…VC-7), mock-first, behind a fail-safe-OFF master flag. ONE deterministic-first conversation engine drives every surface; the LLM is optional and structurally barred from anything safety- or money-critical. Designed via a 14-agent recon+adversarial-design Workflow (3 blockers + 6 majors baked in pre-code) and hardened by a 33-agent code-review Workflow (5 confirmed fixes applied).

    - VC-0 — the Concierge Engine keystone (`lib/voice-concierge/{engine,types,entity-resolver,messages}.ts`, PURE, surface-agnostic). stepConversation(state, turn, deps) is a serializable state machine that phone / web / WhatsApp / drive-thru / POS all drive — it owns NO I/O (returns a CreateTenantOrderInput; the surface calls createTenantOrder). Reuses the SB-5 TwiML/speech helpers, computePriceQuote (OUR-menu pricing), answerMenuQuestion (deterministic menu Q&A from the feed), and the ITA resolveAllergen/resolveDietary + av.*/dv.* i18n verbatim. Safety invariants, all STRUCTURAL (not convention): (1) an allergen/dietary pre-router (entity-resolver.resolveQuery) runs BEFORE any menu/FAQ retrieval — an allergen-shaped query can never be answered by the ranker or an LLM, only by the deterministic resolver or an escalation; (2) the entity resolver requires a single unambiguous dish (name-token overlap; a tie → clarify/escalate) and maps allergen words via a FIXED lexicon — it NEVER guesses a dish on an allergen question; (3) gluten/dairy/nut/egg/soy/etc. are treated as allergen-class regardless of phrasing ("is it gluten free?" → resolveAllergen('gluten')); (4) low STT confidence on the allergen branch escalates (never resolves a misheard transcript); (5) an order reaches submit only through read-back → confirm → explicit yes (and a "yes" riding alongside a question/allergen word does NOT confirm); (6) once an allergen escalation fires, the order is latched-blocked; (7) the optional narrate LLM is invoked ONLY on no-number "chrome" replies — never an allergen verdict, a price, a quantity, or the read-back. - VC-1 — master kill-switch (`lib/voice-concierge/flag.ts`). voice_concierge_enabled FeatureFlag, fail-safe OFF, mirrors the ITA/Restora-Pay pattern 1:1 (per-key upsert under the MasterDB row lock → no clobber of the shared featureFlags array) + per-tenant TenantDB.voiceConcierge.enabled opt-out. Super-admin master-switch route (audit-logged) + tenant settings GET/PATCH ({masterEnabled, tenantEnabled, live}). - VC-2 — phone voice order surface (/api/public/voice-concierge/[tenantId]/{incoming,respond}). Per-tenant in the PATH (each restaurant points its number's webhook at its own URL — no number→tenant lookup). Twilio signature mandatory (403) and inert until Twilio + ORDER_VOICE_NUMBER are configured AND the tenant is live. Born-PENDING createTenantOrder (source:'voice', escrow:false, idempotent on voice:${CallSid}) — priced ONLY against OUR menu, never opts.settle. The threaded ConvState is HMAC-signed (twiml-order.ts) so a caller can't forge a confirm state or tamper the basket. Zero-LLM on the line by construction. A failed submit speaks an honest hand-off (never a false "confirmed") and queues a callback. - VC-3 — order voice simulator (/api/admin/voice-concierge/order-voice-sim, super-admin, read-only, no DB writes): drives the FULL free-text path so the two "Done when" conditions are CI-provable without a phone — a spoken end-to-end order, and an accurate deterministic allergen answer (plus the negative cases: ambiguous dish, misheard allergen, unmaintained profile). - VC-4 — Customer Virtual Assistant (concierge-responder.ts + the inbox concierge-reply action + a new recordAssistantReply seam in lib/channels/inbox.ts): a deterministic single-turn responder for web/WhatsApp that splits allergen tokens OUT of the P34 menu-question bucket (the verdict comes from resolveAllergen + escalates, never the feed ranker), reusing deriveReply for everything else. - VC-5 — budget-gated narration (narrate.ts): an ASYNC surface helper (resolveAIKey → getBudgetStatus → Haiku rephrase → recordAIUsage('assistant')), fail-safe to the deterministic reply on no key / over budget / any error, and gated by the same shouldNarrate barrier (never an allergen/priced reply). The phone line never imports it. - VC-6 — Staff Copilot / "virtual manager" (copilot.ts + copilot-token.ts + /api/tenant/[tenantId]/voice-concierge/copilot): deterministic NL ops — sales ("revenue vs last week") via computeAnalytics, low-stock/reorder from the threshold list — where every NUMBER is computed, never an LLM. The one mutation, "86 the salmon", is a two-step confirm-before-mutate: an ASK returns a proposal + a short-lived HMAC confirm token bound to the exact (tenant, action, dish, value); EXECUTE verifies it and writes the availability flip + a CopilotAudit row in the SAME mutateTenantDB lock (no audit ⇒ no mutation), naming the exact resolved dish (ambiguous → disambiguate, never a guessed 86). - VC-7 — owner surface: /dashboard/voice-concierge (KPIs from sessions + the staff callback queue + the live/off toggle), a deterministic honest-null insights route, and a "Voice & Concierge" nav entry (lucide Headphones) landed in the SAME commit as the page. - Schema (additive only): VoiceConciergeSession + CopilotAudit + TenantDB.{voiceConcierge, voiceConciergeSessions, copilotAudit}; Order.source widened with 'voice' | 'drive-thru'; and Product.available?: boolean declared (it was a de-facto field already honored by the Food Bazar, channel feeds, SMS/WhatsApp menus, SEO and sitemap — now the copilot's audited 86 sets it). - Money: voice/concierge orders are Restora-collected ⇒ born PENDING, priced from OUR menu, settled later via pay-link/pickup; the cross-truth invariant (computeAnalytics.totalRevenue === summarizeLedger.gross) holds because no opts.settle is ever passed on this path. - Verified: full suite 3369/3369, tsc 0, eslint 0. Remaining = go-live only (not dev): real Twilio number provisioning + STT/TTS providers (webhooks inert without them, like SB-5), a live LLM key for narration (mock/identity dep in dev), live outbound WhatsApp/IG/SMS send, and the two thin remaining surfaces VC-8 drive-thru lane + VC-9 POS voice-to-order (adapters over the existing VC-0 engine). Note: full 86 enforcement in the tenant's own storefront render + public-order rejection is a fast-follow (the flag already hides 86'd dishes across the Bazar / channels / SEO read layer).

    • Phase 23 — RACM AC-2: the AI Executive Council (the last buildable slice of the autonomy capstone — RACM AC-0…AC-7 now complete). The executive-orchestration layer that sits ON TOP of AC-1's approvals queue and the Phase-22 RAOS agent workforce — it builds no second queue, second registry, or second governance engine; it READS the open governed actions + the agent catalog and decides how to rank, where agents disagree, and which coordinated multi-agent play warrants a single governed proposal.

    - `lib/racm/council.ts` (PURE core — no I/O, no clock, and deliberately NO `@/lib/raos` import): convene ranks the open recommendations on a deterministic composite (RACM priorityScore lifted by shared-memory confidence); detectConflicts flags subjects where ≥2 open items pull different ways and escalates them — the council never auto-resolves a disagreement; composeChurnRescue binds the canonical retention play (Growth → Support → Marketing → Founder) to the real registered agents (a planned agent surfaces honestly as available:false); councilToProposal converts the play into one `plan.create` (L3, recommend-only) proposal — so nothing the council produces is ever autonomous. Fully unit-testable in isolation. - *`lib/racm/council-runtime.ts` (the EDGE — the ONLY AC-2 file importing `@/lib/raos/, imported only by the route, exactly mirroring the AC-4 dispatch.ts / AC-5a sync.ts precedent so the policy core never depends on the mechanism and no import cycle forms):** gathers AGENTREGISTRY` + the open queue, annotates each plan step with RAOS `decide()` (shows what governance says **without executing**), routes the play through the existing audited `proposeAction` (deduped by `meta.sourceId`; **INERT until both the RACM and RAOS master switches are on**, like AC-5a), records the convening to RAOS shared memory + the audit log (best-effort, never fatal). **Dispatch is governance-first:** the play reaches the agent bus only after a human has approved its L3 plan (we confirm an approved/executing/executed action with the matching `sourceId`), and each enqueued task is then *also* re-gated by RACM at drain time; the founder agent is subscribed to `agent.task.dead-letter` for failure escalation. - **AC-2c capped LLM "debate" (`lib/racm/council-brief.ts` + reuse of the AC-7 path):** the deterministic council brief is the SHIPPED artifact (every numeral comes from the decision — counts, top priority); the optional Haiku rephrase rides the **existing `narrateGovernanceBriefing`** (the mission-control number-guard reverts any line whose figures drift, RACM's editable daily cap, fails closed to deterministic on AI-off / no-key / over-budget). Zero new AI code, zero edits to the AC-7 files. - **Surface:** new super-admin `GET|POST /api/admin/racm/council` (read-only deliberation always available; `convene` / `dispatch` / `narrate` ops) + an **Executive Council** panel in the `/admin/racm` cockpit (convene button, the deterministic brief + AI rephrase, the coordinated plan with per-step governance + availability, escalations, ranked recommendations, bus health, an approval-gated dispatch). - **21 new tests** (ranking/tie-break, conflict-escalation, churn-rescue composition, the L3/never-autonomous bridge, brief determinism + number-guard order invariant). tsc 0; eslint 0; full suite **3345/3345**. Adversarially reviewed across 5 lenses (import-boundary, governance-correctness, type/API, AI-anti-fabrication, security) → 1 real finding fixed (approval-gated dispatch), the rest confirmed-safe. Buildable RACM scope (AC-0…AC-7) is COMPLETE; what remains is go-live infra only — deploy + wiring real agent executors into the bus's injected-executor seam._

    • Sound & Atmosphere — AP-7 partner invites (recruit → onboard, end-to-end). Closes the loop from the recruit directory (AP-6) to onboarding (AP-2): a super-admin clicks Invite on a prospect → gets a tokenized join link → the partner opens it → a pre-filled public join page → self-registers (→ pending) → admin approves (AP-2/AP-4). New PURE lib/atmosphere/invites.ts (inv_ tokens, SHA-256 hash stored / raw shown once, createInvite/lookupInvite/consumeInvite with 30-day expiry). Additive schema MusicPartnerInvite + MasterDB.musicPartnerInvites?. Routes: POST /api/admin/atmosphere/prospects/invite (super-admin → creates invite + marks prospect 'invited' + returns the link, token once, audit-logged), GET /api/partners/atmosphere/invite?token= (public lookup → pre-fill, 404/410 on invalid/expired), and the AP-2 register route now consumes an inviteToken for attribution. New public page app/partners/atmosphere/join (token-pre-filled partner registration; the partner attests its own licensing) + an "Invite ↗" button on the Recruit screen (copies the link). 3 tests (47 atmosphere total); tsc 0 project-wide.
    • Sound & Atmosphere — AP-6 partner-recruitment directory (researched candidates per market, Scandinavia first). A super-admin page to find real candidate music/atmosphere partners and invite them to build on the platform. Built from live web research into a curated, SOURCED code seed (lib/atmosphere/prospects.ts, PROSPECT_SEED, ~30 real companies — every entry has a website + a source URL; honest "verify before outreach / not affiliated" disclaimer; a market with no local provider is honestly shown as a recruit gap). Markets ordered by owner priority: Scandinavia (SE/NO/DK/FI) → FR → IT → DE → US → CA → GB → AE → PK → IN (ATMOSPHERE_MARKETS). PURE helpers resolveProspects / groupProspectsByMarket (globals bucket + each market surfaces region-specific players and global players HQ'd there — so Sweden shows Soundtrack Your Brand + Epidemic) / setProspectStatus. Additive schema MusicPartnerProspect + MasterDB.musicPartnerProspects? (mutable outreach status only; the catalog is the code seed). New route GET|POST /api/admin/atmosphere/prospects (super-admin, audit-logged) + ProspectsScreen + /admin/atmosphere/prospects page + a "Recruit" nav tab (sa-* design). Outreach status per prospect: prospect → contacted → invited → onboarded / declined. 5 tests (44 atmosphere total); tsc 0 project-wide.
    • Sound & Atmosphere — Partner Provider API: AP-5a play-usage / royalty reporting (mock-first). The partner economics layer: log what plays in each venue and let a partner pull its usage to file royalties with its PRO/label. Additive schema: AtmospherePlayLogEntry + MasterDB.atmospherePlayLog? (append-only, capped). New PURE lib/atmosphere/usage.ts: recordPlays (append + cap to most recent) + summarizePlaysForPartner (filter by providerId + date window → total plays / duration / by-channel / by-tenant). Routes: POST /api/tenant/[tenantId]/atmosphere/plays (the edge player reports tracks; each attributed to the tenant's active provider via resolveProviderForTenant) and GET /api/partners/atmosphere/usage?from=&to= (partner-authed — only the partner's own plays). 3 tests (39 atmosphere total); tsc 0 project-wide. Remaining AP-5 (owner/counsel-gated): a real partner HTTP adapter + flipping the live flag + forwarding usage to the partner's royalty system — none buildable without the partner's API + contract.
    • Sound & Atmosphere — Partner Provider API: AP-3b partner catalog push (a partner brings its own music). Completes the "partners build on it" loop: an approved partner pushes its channel catalog via the API and that catalog drives the venue — no live HTTP adapter to host. Additive schema: MusicPartner.channels? + catalogUpdatedAt?. New lib/atmosphere/catalog.ts (PURE validateChannels — defensive coerce/clamp/cap, never trusts partner input), stored-catalog-provider.ts (storedCatalogProvider(partner) — an AtmosphereProvider built from the partner's stored channels + its licensing attestation), resolve-tenant-provider.ts (resolveProviderForTenant — prefers an APPROVED partner with a pushed catalog for the tenant's providerKey, else registry, else house library; fail-safe). Routes: GET|PUT /api/partners/atmosphere/catalog (partner-authed). The playback route (AP-3) now resolves via resolveProviderForTenant, so an approved partner's catalog plays automatically when the flag is on. 6 tests (36 atmosphere total); tsc 0 project-wide. Next: AP-4 finish (branded owner screens, visual pass) · AP-5 go-live (real adapter + royalty reporting).
    • Phase 22 — Restora Agent OS (RAOS): OS-6 scale & reliability + workflow triggers + traceability — BUILDABLE SCOPE COMPLETE. The final slice; deterministic, mock-first, read-only, additive. New lib/raos/reliability/:

    - `reliability.ts` (PURE) — assessScale (per-store utilization vs cap → posture ok/watch/graduate at ≥80% — signals WHEN to flip each store to Postgres tables / pgvector; the pg path already exists behind the same accessors, so graduation is a go-live flip, not a rewrite); WORKFLOW_TRIGGERS + matchTriggers (the "wire the example workflows" piece — maps high/critical Mission-Control/Growth signal keys → a workflow template, recommend-only: suggests a run, never auto-starts); buildTrace (end-to-end traceability — correlate a run → its steps → their OS-2 bus tasks into one timeline). - A 3rd example template incident-response (probe→open-incident→send-alert[L2]→recommend-scale[L3]) joins churn-rescue + price-review, all using real registry capabilities. (SEO-publish from the roadmap is deferred — it needs an SEO agent capability not in the OS-0 registry; not fabricated.) - Audit-hardening: orchestratorTick now writes raos.workflow.completed/failed to the shared audit log once per run when it becomes terminal. - engine.ts raosReliability() (store posture + trigger wiring) + traceRun(runId). Read-only GET /api/admin/raos/reliability (+ ?run=<id> for a trace) + a Reliability & scale cockpit panel. 4 tests (scale-posture thresholds, trigger matching high/critical+dedup, trace correlation). tsc 0; full suite 2889/2889. → PHASE 22 BUILDABLE SCOPE COMPLETE (OS-0…OS-6). Remaining is go-live infra only: graduate the stores to Postgres/pgvector on volume, and wire real agent executors (RACM AC-3) into the bus's injected-executor seam.

    • Phase 22 — Restora Agent OS (RAOS): OS-5 feedback & learning loop (recommend-only; agents can't silently raise their own autonomy). Observes RAOS's OWN execution outcomes (OS-2 bus tasks + OS-4 workflow runs) and scores each capability — mirroring RACM AC-6's discipline. Deterministic, AI-OFF, read-only (no store of its own — a pure projection over what's already recorded). New lib/raos/learning/:

    - `learn.ts` (PURE) — aggregateCapabilityStats (group bus tasks by agent+capability → done / dead-letter / blocked, success + dead-letter rates, avg attempts, and a signal: healthy/watch/review/insufficient, judged only once ≥5 terminal outcomes), recommendAutonomy (the gated learning), and buildWorkflowStats (per-template completion rate) + buildLearningReport. - The autonomy guard (the whole point): recommendations are RECOMMEND-ONLY and never applied (applied is always false). A healthy L1/L2 reversible capability is flagged only "eligible for a HUMAN-reviewed autonomy check"; a failing one is flagged "tighten-review"; and an L3/L4 capability is NEVER eligible for promotion (stays human-governed) — so an agent can never silently raise its own autonomy. (A dedicated test asserts no eligible-for-autonomy-review record ever targets an L3/L4 capability, and that everything is applied:false.) - `engine.ts` — learningReport() reads the bus + workflow stores + resolves each capability's current risk/reversibility from the OS-0 registry (single source), then builds the report. - Read-only GET /api/admin/raos/learning (super-admin) + a Learning loop cockpit panel (per-capability signals + the recommend-only autonomy recommendations, honest-empty until outcomes accrue). 6 tests (aggregate grouping + healthy/review/insufficient scoring, the L4-never-promoted guard, healthy-but-irreversible holds, workflow completion rate, the no-L3/L4-eligible report-level guard). tsc 0 project-wide; full suite 2879/2879. Next: OS-6 scale & reliability + wire the example multi-agent workflows.

    • Phase 21 — Autonomous Company Mode (RACM): AC-7 optional AI narration (Haiku $5/day) + super-admin model/budget controls. Adds the design's "AI Executive briefing" — but keeps RACM's hard rule that the AI only NARRATES, never decides, and makes the cost knobs one/two-click editable.

    - `lib/racm/briefing.ts` (PURE) — the deterministic executive briefing (the SHIPPED artifact): a worst-state-first headline + status/queue/execution/high-stakes/learning lines, every numeral computed from the governance snapshot + metrics (kill-switch/master state, pending-by-tier, executed/rolled-back, success & autonomy ratios, high-stakes-held, the top review-flagged capability). Works with AI fully off. - `lib/racm/narrate.ts` — optional number-guarded rephrase: asks the configured model to re-word ONLY the prose, then runs the platform's most rigorous number-guard (reused from `mission-control/number-guard`) so a figure can never be invented, restated, rounded, drifted, or relocated — any line that fails reverts to deterministic. Fails to deterministic on EVERY adverse path (AI off / no API key / over budget / no JSON / any error). The Anthropic SDK is lazy-imported in the ledger so it never bloats the non-AI hot path. - `lib/racm/ai-budget.ts` — editable spend guard: model + daily USD cap stored in RACM's own isolated store (not the shared schema), defaulting to Haiku 4.5 / $5/day (owner-chosen). RACM_AI_MODELS offers Haiku ($1/$5) · Sonnet ($3/$15) · Opus ($5/$25). Over the cap (or kill-switched, or a 0 cap) → no call, no cost; usage auto-resets per UTC day. - Super-admin controls in the `/admin/racm` cockpit (the owner's "one or two clicks"): an Executive summary panel with a "✨ AI rephrase" button (guarded; disabled when AI off or over budget; shows an "AI-rephrased" badge), and an AI narration settings panel — a model dropdown (instant), a daily-cap input + Save, and an on/off toggle, with live "spent $X / $Y today". New API ops set-ai + narrate; GET now returns the deterministic briefing + AI status + model options. Every config change is audited. - Reuses estimateCostUsd + the number-guard; no new warehouse, no shared-schema change. 15 new tests (cap/model resolution, kill-switch + 0-cap + day-rollover gating, usage accounting, headline selection, honest number-bearing lines). Total RACM 89 tests; tsc 0 (racm); eslint 0; routes compile clean on live dev (401/307). RACM's deterministic core is unchanged and still runs with AI fully off; this is a decorative, cost-capped narration layer for an internal founder tool.

    • Sound & Atmosphere — Partner Provider API: AP-4 (partial) super-admin Partners UI. Wires the partner-management backend (AP-2) into the admin atmosphere console: new PartnersScreen + /admin/atmosphere/partners page + a "Partners" nav entry in the Content & Licensing group. Lists registered partners with their status + licensing attestation (model / territories / ad-free / royalty-reporting / attested-by) + masked key, and approve / suspend / revoke / reinstate actions against GET|POST /api/admin/atmosphere/partners; KPI row (total / pending / approved) + honest empty state. Matches the admin-atmosphere console design language (sa-* scoped classes, dark operator console). tsc 0 project-wide. Remaining AP-4 (owner-facing branded screens — Library / Now-Playing wired to AP-1/AP-3 + the Activate button): deferred to a visual-pass session (1:1 design fidelity + run :3001) per the design-reference rule.
    • Phase 22 — Restora Agent OS (RAOS): OS-4 orchestration (the slice that composes OS-1/2/3 into real multi-agent runs). A workflow planner decomposes a template into a step DAG, routes each step to a capable agent (OS-0 registry), and dispatches ready steps as OS-2 bus tasks — so every step is RACM-gated for free (a run honestly stops at an L2/L3 step awaiting approval rather than auto-acting). Deterministic, mock-first, additive (its own isolated agent-workflows.json doc store; no shared-schema change). New lib/raos/orchestrator/:

    - `types.ts` + `templates.ts` — WorkflowRun/WorkflowStep (capability, deps, resourceKey, fallback, status, bus taskId) + two example templates (churn-rescue: detect→draft→publish; price-review: analyze→recommend) whose steps name CAPABILITIES the planner routes to agents. - `plan.ts` (PURE) — planRun (route each capability → agent via an injected resolver, preserve the dependency graph) + hasCycle (a malformed/cyclic template is rejected, never a deadlocked run). - `advance.ts` (PURE) — the state machine: readySteps (deps satisfied and conflict-resolution — steps sharing a resourceKey never run concurrently), taskStatusToStepResult (maps the OS-2 bus task status → done/failed/blocked), applyStepResult (retry via a fallback agent once, then fail + cascade-skip everything downstream), rollupRunStatus (completed/failed/running/blocked). - `store.ts` + `orchestrator.ts` — isolated runs store; startWorkflow (plan + persist) + `orchestratorTick` (reconcile dispatched steps from bus outcomes, then dispatch newly-ready steps as bus tasks — so a DAG progresses one layer per cron tick) + routing over the registry + pure statsFrom. - Wired into the existing `GET /api/cron/raos-bus` (orchestrator tick THEN bus drain) — no new cron entry. Read-only GET /api/admin/raos/workflows (templates + run stats) + audited super-admin POST (start a run) + an Orchestration cockpit panel (templates + recent runs with step progress, honest-empty). 22 tests (cycle-detect + routing + unroutable, ready/deps + resource-conflict serialization, fallback-then-skip, rollup, task→step mapping). tsc 0 project-wide; full suite 2858/2858. Next: OS-5 learning loop.

    • Sound & Atmosphere — Partner Provider API: AP-3 playback orchestration (the venue actually plays — through the provider). Resolves what plays per zone by combining the tenant config (zones / ambiance preset / signature genres / safeguards) with the active provider's catalog — so a connected partner's licensed catalog drives the venue, and by default the always-on house library does (mock-first, never silent). New lib/atmosphere/orchestrator.ts: moodForConfig (daypart else ambiance preset), pickChannelForZone (PURE/deterministic — honours the explicit-filter safeguard, prefers mood then signature-genre match), resolveZonePlayback / buildVenuePlayback (now-playing across enabled zones via provider.listChannels/getNowPlaying; each zone's own volume applied; not-activated → nothing plays), applyControl + isControlCommand. New route GET|POST /api/tenant/[tenantId]/atmosphere/playback (requireTenantAccess; GET = per-zone playback snapshot, POST = control command) — resolves the provider via the registry + partner flag. 6 tests (30 atmosphere total); tsc 0 project-wide. Next: AP-4 wire the design-handoff screens (Library/Schedule + Now-Playing) to this + the Activate button.
    • Sound & Atmosphere — Partner Provider API: AP-2 partner-facing API + onboarding (the "partners build on it" surface). External music/atmosphere partners can self-register, authenticate, and submit their licensing attestation. Additive schema: MusicPartner interface + MasterDB.musicPartners? (mirrors ProfitApiKey). New lib/atmosphere/partners.ts (PURE, mirrors the profit-api key engine): rmp_ bearer keys (SHA-256 hash stored, raw shown ONCE), createPartnerRecord/validatePartnerInput/approvePartner/setPartnerStatus/verifyPartnerKey/partnerToPublic (never leaks the hash). partner-auth.ts — authenticateMusicPartner(req) guard (only an APPROVED partner authenticates; 401/403). Routes: POST /api/partners/atmosphere/register (public self-register → pending + key once; partner attests its own licensing → Restora holds no licence), GET /api/partners/atmosphere/me (partner-authed), GET|POST /api/admin/atmosphere/partners (super-admin list + approve/suspend/revoke/reinstate, audit-logged). A registered key is INERT until a super-admin approves (self-register ≠ access). 6 tests (24 atmosphere total); tsc 0 project-wide. Next: AP-3 playback/zone orchestration via the provider · AP-4 wire the design-handoff screens.
    • Phase 22 — Restora Agent OS (RAOS): OS-3 shared goal system (company → department → agent, approved through RACM). Aligns the agent workforce around a goal hierarchy. Deterministic, mock-first, additive (its own isolated agent-goals.json doc store; no shared-schema change). New lib/raos/goals/:

    - `types.ts` — Goal with level (company/department/agent) + parentId (the hierarchy), owner, metric/target/current/unit, status (proposed→active→achieved/abandoned, or rejected), approval tier (from the RACM matrix), priority, source, version history. - `hierarchy.ts` (PURE) — buildTree (company→department→agent forest from a flat list), progressOf + rolled-up tree progress (a parent blends its own metric with its children's average; 4-dp rounded), findMisaligned (a company goal with a parent, or a dept/agent goal whose parent is missing or not strictly higher — flagged, never silently accepted). - `lifecycle.ts` (PURE) — the RACM tie-in: goalRacmLevel (agent→L2, company/department→L3) → proposalApproval via RACM's requiredApproval (single source): agent goals need human approval, company/department goals need executive approval — a goal NEVER auto-activates. canTransitionGoal (legal-only transitions) + suggestGoalsFromSignals (maps ONLY high/critical Mission-Control/Growth signals → proposed goals: risk→a "Mitigate" department goal, opportunity→a "Capture" agent goal; recommend-only, honest-empty otherwise). - `store.ts` + `engine.ts` — isolated agent-goals doc store; PURE cores (applyPropose/applyTransition/updateProgressIn/statsFrom/visibleTree) + async wrappers (proposeGoal/transitionGoal/updateProgress/goalTree/goalStats). - Read-only GET /api/admin/raos/goals (tree + stats) + an audited super-admin POST (propose/approve/reject/achieve/abandon/progress — the propose→approve flow) + a Shared goals cockpit panel (the hierarchy with rolled-up %, awaiting-approval + misaligned badges, honest-empty). 16 tests (tree nesting + orphan-as-root + rollup blend, misalignment detection, approval-tier mapping, transition legality, signal→goal suggestion incl. honest-empty, engine propose/transition/stats/visible-tree). tsc 0 project-wide; full suite 2829/2829. Next: OS-4 orchestration.

    • Sound & Atmosphere — Partner Provider API: AP-1 one-click activation. "One click and music works": new PURE lib/atmosphere/activation.ts — oneClickActivationPatch (enable + sound-on + default to the always-on house library), connectPartnerPatch (switch source, stay on), deactivationPatch (off but keep the source for instant re-activation), and activationStatus(config, {liveEnabled}) → {activated, soundOn, providerKey, provider (resolved description + licensing attestation), live}. New additive route POST|GET /api/tenant/[tenantId]/atmosphere/activate (requireTenantAccess, mirrors the config route; reuses the existing config store + the partner flag): GET returns activation status; POST activates / deactivates / connects-a-partner. Defaults to the house library, so one POST = licence-clean music playing with no partner connected. No new schema; the existing config store validates the provider field. 5 tests (18 atmosphere total); tsc 0 project-wide. Next: AP-2 partner-facing HTTP API + onboarding.
    • Phase 21 — Autonomous Company Mode (RACM): AC-4 governed L2 automations (human-approved → RAOS bus handoff). The L2 tier finally does something real — but only after a human approves, and without RACM ever reaching into a live engine directly. New L2 handoff executors (content.publish, seo.publish, campaign.send, churn.campaign, supplier.recruit) in lib/racm/executors.ts: on (human-approved) execution they produce a handoff artifact, and the execute route emits a GOVERNED event onto the RAOS agent bus (lib/racm/dispatch.ts → publish) so the owning engine/agent performs the actual external write under its OWN RAOS-gated drain. RACM governs + approves + hands off; the engine executes — the clean layering confirmed in the AC-2 coordination (docs/architecture/racm-raos-ac2-integration.md).

    - No import cycle: the RAOS edge (dispatch.ts) is imported ONLY by the API route (a leaf) — RACM's governance core (governance.ts/ledger.ts/executors.ts) has zero RAOS import, so raos → racm stays one-way. buildHandoffEvent(action, mode) is a PURE mapper (topic racm.<type> / .rollback, priority by level L1→4…L4→1, correlationId = action id); dispatchHandoff is best-effort (a bus failure never fails the recorded+audited RACM action). New RacmArtifact kind 'handoff'. - Still fail-safe + human-gated: L2 never auto-executes (the gate routes it to needs-approval); a human approves, then executes; executeAction already gates on isRacmEnabled + the kill-switch. Rollback emits a compensating .rollback event. L4 capabilities remain executor-less (tested — they can never execute or hand off). - Cockpit: the awaiting-execution Execute button now appears for any capability with an executor (L1 internal + L2 handoff), driven by a new executableTypes field on the snapshot; L2 rows note "hands off to its engine". - 7 new tests (handoff executors + HANDOFFTYPES + L4-exclusion; the pure `buildHandoffEvent` topic/priority/payload mapping). Total RACM **74 tests**; tsc 0 (racm); eslint 0; both RACM routes (incl. the one importing the RAOS bus) compile clean on live dev (401/307). Next: AC-2 council (substrate ready — see the coordination note) or the heavier AC-6 infra optimizer / AC-7 LLM briefing._

    • Sound & Atmosphere — Partner Provider API: AP-0 keystone (the contract + registry + always-on house library). The integration layer that lets external, LICENSED music/atmosphere partners plug a catalog into Restora360's one-click experience — so the licensing burden sits with the partner (who already holds the rights), integration is standardized, and the customer still gets one Restora system that works on one click ("100% solution"). Additive, mock-first, fail-safe; zero schema change (reuses the existing AtmosphereConfig.provider seam). New lib/atmosphere/: provider.ts — the AtmosphereProvider partner contract (describe/listChannels/getNowPlaying/control/reportPlays, async, structured AtmosphereResult) + a `LicensingAttestation` model (partners attest their own rights → Restora holds no licence) + capabilities; providers/house-library.ts — a built-in production-library provider that always works out of the box (royalty-free, ad-free, explicit-filterable; attests its own licensing) so a tenant enabling Atmosphere gets licence-clean music with no partner connected; registry.ts — registerAtmosphereProvider/resolveAtmosphereProvider/listAtmosphereProviders, fail-safe (never throws → always falls back to the house library so music never goes silent); a live partner serves only when live mode + the fail-safe-OFF atmosphere_partner_providers flag are on AND it's registered. flag.ts — server gate. Mirrors the payments/domains provider pattern; reuses the existing NowPlaying type. 7 tests; tsc 0 project-wide. Next: AP-1 one-click activation + AP-2 partner-facing HTTP API/onboarding + wiring the design-handoff screens (Schedule/Library/Analytics/Licensing) to the provider.
    • Phase 22 — Restora Agent OS (RAOS): OS-2 agent bus (the piece that lets agents trigger each other — through the governed gate). A durable, prioritized pub/sub + task queue. Deterministic, mock-first, additive (its own isolated agent-bus.json doc store via the row-locked mutateDoc seam — stands in for the production Postgres agent_events/agent_tasks tables; no shared-schema change). New lib/raos/bus/:

    - `types.ts` — AgentEvent (broadcast to topic subscribers, or direct to one agent; priority 1–5; correlationId) + AgentTask (owns agent + the registered capability type, status, attempts/maxAttempts, backoff nextAttemptAt, risk/reversible resolved from the OS-0 registry, escalated) + Subscription. - `core.ts` (PURE) — matchSubscribers (broadcast→topic/* subs minus the source; direct→target), claimNext (highest priority, oldest tie-break, backoff-respecting), `gateTask` (routes every task through OS-0.1 decide → RACM's gate: auto→run, recommend/approval-required→awaiting-approval, blocked→blocked — the bus can never auto-execute what RACM wouldn't allow), and applyOutcome (success→done; failure→retry with BACKOFF_MINUTES [1/5/30], exhausted→dead-letter + escalated). - `store.ts` — the isolated agent-bus doc store (pg row-lock + JSON + mutex; events capped 5k, tasks 10k keeping live ones). - `bus.ts` — async engine: publish (fan an event out to subscriber tasks, capability risk resolved from the registry), subscribe/unsubscribe, enqueue (direct task), `drainOnce` (the cron tick — claim→gate→run auto-approved via an INJECTED executor [mock until real agent execution lands via RACM AC-3]→outcome; execution runs OUTSIDE the store lock so a slow executor never blocks the queue; a dead-letter emits a high-priority agent.task.dead-letter escalation event), and pure statsFrom/busStats. - `GET /api/cron/raos-bus` (CRONSECRET-gated, fail-closed; inert unless `raosenabled) drains a batch of 25. Read-only **GET /api/admin/raos/bus` (super-admin) + an Agent bus cockpit panel (tasks by status, dead-letter/awaiting counts, recent — honest-empty until events publish). The SSE doorbell (the dashboard inbox stream pattern) is the production low-latency nudge layered on top. 11 tests (subscriber fan-out + source-exclusion, priority/backoff claiming, gate routing auto/approval/blocked incl. kill-switch + RAOS-off, retry→dead-letter+escalation, escalation-event shape, stats). tsc 0 project-wide; full suite 2792/2792**. Next: OS-3 shared goal system.

    • Phase 21 — Autonomous Company Mode (RACM): AC-5 (proper) — recommend-only strategic intelligence (the L3/L4 tiers come alive). AC-5a's intake maps Mission Control signals to safe L1 tactical actions; AC-5 proper adds the strategic layer — the same signals, but the high/critical ones escalated into L3/L4 recommend-only proposals (the design's "pricing intelligence change = L4, financial intelligence commit = L4, partnership/expansion proposals"). New lib/racm/intelligence.ts (PURE): strategicProposals(signals) → strategicFromRisk (liquidity→financial.commit L4 "review runway & cost structure"; revenue-concentration→partnership.outreach L3 "diversify"; conversion→pricing.change L4 "review pricing & packaging") + strategicFromOpportunity (expand-adoption/scale-channel high upside→expansion.plan L3). Honest + safe by construction: it escalates ONLY signals the engine already flagged high/critical (no fabricated concern), recommends a review never a specific price/number, and every output is L3 (executive approval) or L4 (never autonomous — a human commits) — tested to confirm none can auto-execute. Distinct meta.sourceId (strat:…) so a single critical signal can yield both a tactical task (AC-5a) and a strategic review without colliding. Wired into syncFromMissionControl (one "Pull recommendations" now surfaces L1 tactical + L3/L4 strategic, deduped). 13 new tests (escalation only on high/critical, per-category type/level mapping, the L4-never-auto + L3-needs-exec gate assertions, distinct source ids). Total RACM 67 tests; tsc 0 (racm); eslint 0; sync route compiles clean on live dev. AC-5 complete. Next: AC-2 council (Phase 22 RAOS) or AC-4 (wire executors into a live engine, L2).
    • Phase 22 — Restora Agent OS (RAOS): OS-1 shared agent memory layer. The substrate that lets the agent workforce share context instead of re-prompting from scratch — one scoped, importance-weighted, versioned memory store every agent reads/writes. Deterministic, mock-first, additive (its own isolated agent-memory.json doc store via the row-locked mutateDoc seam — no shared-schema change), AI-OFF. New lib/raos/memory/:

    - `types.ts` — MemoryRecord with scope (global/company/agent/user/restaurant) + scopeId, kind (fact/preference are keyed & reconciled; note/event are append-only), importance/confidence (0–1), source/provenance, tags, pii, status (active/superseded/retired), version + supersedes chain, and conflictsWith. - `score.ts` (PURE) — deterministic recall ranking: lexical query↔content overlap + the memory's importance + time-decayed recency (30-day half-life), now injected. No embeddings — a pgvector cosine similarity drops in later behind the same `scoreMemory` interface (the same store Support Brain will reuse), exactly as SB-0 deferred embeddings behind BM25. - `conflict.ts` (PURE) — the defining rule: NEVER last-writer-wins for business FACTS. decideWrite → insert / noop / supersede (versioned; preference updates, or an authoritative fact = human source or strictly higher confidence — the old value is kept, never destroyed) / conflict (a lower/equal-confidence agent fact that disagrees is kept ALONGSIDE the existing one, both flagged conflictsWith, for resolution — never silently overwritten). - `store.ts` — the isolated agent-memory doc store (Postgres app_documents row-lock + JSON databases/agent-memory.json + mutex; mirrors the RACM store; capped 10k). - `engine.ts` — PURE cores (applyRemember/recallFrom/retireIn/forgetSubjectIn/statsFrom, testable on an in-memory records[]) + thin async store-backed wrappers (remember/recall/retireMemory/forgetSubject/memoryStats). Recall is scope-filtered (global memories always in scope), excludes superseded, excludes retired unless asked, ranked + limited. GDPR: retireMemory (one) + forgetSubject(scope, scopeId) (full subject erasure) — retired records are kept for audit but excluded from recall. - Read-only GET /api/admin/raos/memory (super-admin) + a Shared memory panel on /admin/raos (counts by scope, conflict count, recent — honest-empty until agents write). 22 tests (tokenize/recency/lexical/score-blend; conflict matrix incl. the never-last-writer-wins fact case + human/confidence supersede + preference latest-wins; engine insert/supersede/conflict/noop, scoped+filtered recall, GDPR retire/forget, stats). tsc 0 project-wide; full suite 2736/2736. Next: OS-2 agent bus (Postgres `agentevents/agenttasks` + cron + SSE doorbell).

    • Phase 21 — Autonomous Company Mode (RACM): AC-5a intelligence intake (the queue fills itself) + true fail-safe execution gating. Turns the platform's own intelligence into governed proposals, completing AC-1's mandate ("every AI suggestion across the platform routes here") — recommend-only, no contended writes.

    - `lib/racm/intake.ts` (PURE, decoupled): proposalsFromIntelligence({opportunities, risks}) maps Mission Control's already-computed opportunities + risk register → RacmProposalInput[]. Opportunities map by category to a safe L1 capability (scale-channel→experiment.schedule, convert-trials→cs.task, expand-adoption→content.draft, unknown→advisory product.proposal); risks → an internal cs.task mitigation with severity-derived impact. Every mapped proposal is blastRadius:'internal' so it classifies L1 and a gate-cleared one auto-executes into a safe internal artifact; each carries meta.sourceId for dedup. Structural input types (not MC imports) keep the mapper unit-testable. - `lib/racm/sync.ts` (the ONLY MC-coupled file): syncFromMissionControl(actor) loads loadMissionContext/buildOpportunities/buildRiskRegister/buildEcosystemValue, maps via intake, dedups against OPEN actions (proposed/approved/executing — so re-running is idempotent; terminal actions don't block a fresh cycle), and routes new ones through the audited proposeAction. Inert until the master switch is on (fail-safe). POST /api/admin/racm/sync (super-admin) + a "Pull recommendations from Mission Control" button on the cockpit (reports created/skipped). - True fail-safe execution gating: proposeAction's auto-execute and executeAction now both check isRacmEnabled() — with the master flag OFF, gate-cleared L1s rest in approved (awaiting execution) and nothing RUNS; flipping the flag on lets them execute. Closes the gap where the flag only gated the API read, not execution. - Reuses Mission Control aggregates (design §1/§5 — "reuses the GI event store / MC aggregates; no new warehouse"); RACM→MC is a read-only dependency (no cycle). 7 new tests (opportunity/risk → proposal mapping, category fallback, severity→impact, L1 auto-execute classification, flatten + stable source ids, empty signals). Total RACM 54 tests; tsc 0 (racm); eslint 0; the full next build passed for AC-0…AC-6/7 and the new sync route compiles clean on live dev (401/307). Next: AC-2 council (Phase 22 RAOS) or AC-4 (wire executors into a live engine, L2).

    • Phase RS — Free Growth (7% revenue share): RS-7 analytics + receivables dashboards (super-admin console complete). Adds the two read/ops dashboards to the Revenue Share admin page. New PURE helper lib/revenue-share/admin-aggregate.ts: revenueShareTenants(master) (tenants on a revenue-share plan, canceled excluded, deduped) + collectOpenFailures (open/retrying receivables, worst-owed first, per-currency totals). Two additive fan-out routes (requireSuperAdmin): GET /api/admin/revenue-share/analytics?period= (fans out over revenue-share tenants only, derives each one's report via RS-4 buildRevenueShareReport, aggregates via aggregatePlatformAnalytics → platform earnings / restaurant payouts / gross + per-tenant table; unreadable tenant skipped, never fails the dashboard) and GET|POST /api/admin/revenue-share/failures (open receivables across tenants; POST retry [mock collection succeeds — recorded-not-charged; real path wraps RS-2 at RS-9] / write-off, audit-logged). The admin page now renders an Analytics section (period toggle lifetime/year/month + KPI tiles + per-tenant breakdown) and a Receivables section (per-currency owed totals + list with Retry / Write-off). Honest empty-states ("no revenue-share restaurants yet"). 3 helper tests (70 revenue-share total); tsc 0 project-wide. RS-7 super-admin console is complete (force-migrate UI is the only optional extra). Remaining Free Growth: RS-9 owner-gated real money; visual verification pending UAT.
    • Phase 22 — Restora Agent OS (RAOS): OS-0.1 — de-dup the governance engine (RAOS now delegates to RACM's gate). Removes the duplicate 4-level decision ladder OS-0 shipped while RACM was still uncommitted. lib/raos/risk.ts decide() now routes the actual gate decision through RACM's pure scoreRisk + evaluateGate (lib/racm/governance) — there is now ONE governance engine (architecture: "RACM defines the policy; RAOS enforces it"). RAOS keeps only what's genuinely its own: the agent model, its 4-mode presentation vocabulary, and its per-layer switch (raos_enabled / agent-paused ⇒ the layer is inert → recommend; L4 ⇒ always blocked). The RACM config is INJECTED (the route reads RACM's live config from getRacmDB()), so RACM's kill-switch / execution-freeze / risk-threshold now govern RAOS autonomy too — engage RACM's kill-switch and the RAOS cockpit shows every capability blocked; freeze execution and L1 auto-execute pauses. No duplicated thresholds (risk.ts imports only RACM's pure governance fns; the autoExecuteMaxRisk lives solely in RACM). RISK_LEVELS keeps RAOS labels but its approval tier + autonomy ceiling now derive from RACM's `requiredApproval` (the legend can't drift from the gate). buildRaosSnapshot(raosEnabled, config) threads the live config and surfaces a governance block; /admin/raos shows the auto-threshold and a kill-switch/freeze banner. Behaviour vs OS-0 is identical except a non-reversible L1 now escalates to approval-required (RACM gate) instead of a silent recommend — more correct. 30 RAOS tests (5 new: delegation, kill-switch halts all autonomy, freeze pauses L1, governance surfaced, RACM-derived legend); tsc 0 project-wide; full suite 2659/2659. Completes the RAOS↔RACM unification; next: OS-1 shared memory (pgvector, shared with Support Brain) · OS-2 agent bus.
    • Phase 21 — Autonomous Company Mode (RACM): AC-6/AC-7 deterministic core — outcome metrics + the learning loop + cockpit Insights. Rounds out the cockpit with the analytics the design's §1/AC-7 calls for, plus the AC-6 learning loop — computed PURELY from RACM's OWN ledger (no external engine, no LLM, no clock; reads nothing it doesn't already own → zero coupling, no RAOS dependency, no contended files). New lib/racm/metrics.ts buildRacmMetrics(actions): execution success rate, rollback rate, autonomy ratio (gate-auto-approved vs human-decided, read from each action's version history), high-stakes-held-for-humans (L3/L4 that never auto-ran — the governance win, stated honestly), throughput by level/status, and per-capability TypeStat with a deterministic learning signal (healthy/watch/review/insufficient). The learning loop only RECOMMENDS (design §5/§6): a clean history surfaces as "eligible for a human-reviewed autonomy check" and a high failure/rollback rate flags "review this capability" — it never auto-raises an action's autonomy ("the system can't silently grant itself more power"). Honest-null throughout (empty ledger → null rates, never fabricated 0%/100%). Wired additively into governanceSnapshot (metrics field) and surfaced as an Insights panel on /admin/racm (four metric cards + the learning-signals list, recommend-only labelled). 8 new tests (null-honesty, autonomy ratio, high-stakes held, success/rollback rates, the three learning signals incl. the human-gated "never auto-raised" guard). Total RACM 47 tests; tsc 0 (racm); eslint 0; cockpit route compile-clean on live dev. This is the deterministic core of AC-6 (learning loop) + AC-7 (dashboard numbers); the heavier remainders — the Guardian-backed infra optimizer (AC-6) and the LLM Executive briefing (AC-7) — stay for later. Next substantive slice: AC-2 council (depends on Phase 22 RAOS) or AC-4 (wire executors into a live engine, L2).
    • Phase RS — Free Growth (7% revenue share): RS-7 super-admin dashboard + nav (now discoverable). The RS-7 control-plane API now has a UI: new admin page app/admin/dashboard/revenue-share/page.tsx ("use client", mirrors the feature-flags page) — enable/disable the package, set the global fee %, and add/remove per-restaurant overrides, all against the existing /api/admin/revenue-share; carries an explicit mock-first ("recorded, not charged") honesty banner. Added a "Revenue Share" entry to the AdminSidebar Billing & Revenue group (/admin/dashboard/revenue-share, Wallet icon — single additive line, verified the diff contained no other session's edits). tsc 0 project-wide. Remaining: analytics + receivables/retry dashboards (need cross-tenant fan-out endpoints); visual verification pending UAT.
    • Phase 21 — Autonomous Company Mode (RACM): AC-3 first safe L1 executors (the MVP completes). With AC-0 (governance) + AC-1 (decision engine + queue) in place, AC-3 gives the approvals queue real executors — but only the safest ones. New lib/racm/executors.ts: the executor framework + six deterministic, INTERNAL, REVERSIBLE executors for the L1 reversible capabilities (cs.task → internal task · churn.tag → at-risk tag · content.draft/seo.draft → unpublished draft skeleton · experiment.schedule → scheduled A/B test · product.proposal → advisory note). Each produces a typed RacmArtifact attached to the action and supplies a rollback note. No LLM, no external-engine write paths touched — they create a draft/task/tag/scheduled record internal to the RACM store, so the slice is trivially reversible and can't collide with other engines (wiring executors into the live Growth/Web Builder/Support engines is the human-approved L2 step, AC-4). applyExecution(action, now) (PURE) advances an approved action approved → executing → executed|failed, attaching the artifact + note (refuses anything not approved, or a type with no executor — so L4 can never execute). Additive RacmArtifact + artifact/executionNote on RacmAction.

    - Ledger wiring: executeAction(id) (kill-switch-guarded, persists the executed/failed record + artifact, audited); rollbackAction now runs the executor's rollback for an honest reversal note; and `proposeAction` auto-EXECUTES a gate-cleared safe L1 end-to-end (proposed → approved → executed in one atomic mutate) — so "AI safely executes only reversible internal ones" is real, while everything else still waits in the queue. - `execute` op added to POST /api/admin/racm/governance; the `/admin/racm` cockpit gains an Execute button on awaiting-execution L1s (non-L1 show an honest "executor lands in AC-4") and an Executed panel showing each artifact (summary + body) with a Roll back control. - This completes the design's MVP: AI proposes structured, risk/ROI-scored actions into one approvals queue, and safely executes only reversible internal ones — L4 never, kill-switch + rollback + audit on everything. 7 new tests (executor coverage = L1-reversible only / never L4, determinism, the approved→executed lifecycle + guards). Total RACM 39 tests; tsc 0 (racm); eslint 0; the three RACM routes compile clean on the live dev server (401 auth / 307 login-gate, no 500s). Next: AC-2 AI Executive Council (event-triggered, budget-capped) — depends on Phase 22 RAOS (concurrent session building `lib/raos/`).

    • Phase RS — Free Growth (7% revenue share): RS-8 customer-facing UI (comparison + banner + flag-gated page). Additive, self-contained UI matching the app's design system (Tailwind, #bf601d accent, rounded-2xl cards) — NO edits to the (cross-session-contended) signup/dashboard pages. New components/revenue-share/PlanComparison.tsx ("Free Forever — pay 7% only when you sell" vs the flat 299 plan, with the honest break-even note: above ~4,271 SEK/mo in fees the flat plan is cheaper), components/revenue-share/RevenueShareBanner.tsx ("You're on Revenue Share Billing" — for the dashboard, plain component so it renders server- or client-side), and a flag-gated public page app/free-growth/page.tsx (server component: isRevenueShareBillingEnabled() gates it — shows the offer + "how it works" when ON, an honest "not available yet" when OFF; respects the fail-safe-OFF master switch end-to-end). tsc 0 project-wide. Deferred (contended): mounting the comparison into the existing register flow + the banner into the dashboard billing page are one-line additive integrations left to avoid clobbering the parallel session editing those files; visual verification (run :3001, screenshot /free-growth) pending UAT.
    • Phase RS — Free Growth (7% revenue share): RS-7 super-admin control-plane (fee rules API). The backend for managing the package — enable/disable the master flag, set the global fee %, and add/remove per-tenant overrides. New PURE helper lib/revenue-share/rules-admin.ts: addGlobalRule / addTenantOverride (APPEND new dated rules — history preserved; resolveFeePercent picks the latest), endTenantOverride (close by effectiveTo, never delete → tenant falls back to global), listActiveTenantOverrides. New additive route app/api/admin/revenue-share/route.ts (requireSuperAdmin, audit-logged, mirrors the feature-flags route): GET package status (flag mode + effective global % + active overrides) and PATCH actions set-enabled (creates/sets the revenue_share_billing flag — persists the RS-0 seed builder), set-global, set-tenant-override, remove-tenant-override. No money moves; the route is a thin wrapper over tested pure helpers. 6 helper tests; tsc 0 project-wide. Remaining RS-7/RS-8 (UI): analytics + failures/retry dashboards, the admin React page + nav, and the signup/dashboard UI — those touch the most cross-session-contended surfaces and benefit from the ui-ux skill + visual verification.
    • Phase 21 — Autonomous Company Mode (RACM): AC-1 Decision Engine + pending-approvals queue + the Autonomous Company Mode cockpit. Builds on the AC-0 governance keystone. Every action already carried the structured §1 decision record (Problem · Opportunity · Evidence · Expected Impact · Confidence · Effort · Risk · Recommended Action · Approval · ROI); AC-1 adds the ranking + the surface a founder actually uses.

    - `lib/racm/decision.ts` (PURE) — priorityScore (deterministic 0–100 from impact + confidence + effort-efficiency + level-stakes; missing fields fall back to a neutral midpoint, monotonic in impact); toDecisionRecord (the §1 record + a completeness share so the UI shows honest "—" gaps, never fake completeness); buildApprovalsQueue (only proposed actions are pending a decision — ranked by priority, ties broken by higher risk then newest — grouped into Executive L3 / Human L2 / Manual-commit L4 tiers; approved actions are surfaced as awaiting-execution, the AC-3 executors' inbox; executed/rejected/rolled-back/blocked drop out). - Additive impact?: 'low'|'medium'|'high' on the proposal/action (structured magnitude feeding priority) — backward-compatible. - `GET /api/admin/racm/queue` (super-admin) — the ranked queue built from the ledger. - `/admin/racm` — the Autonomous Company Mode cockpit (server-gated superadmin, self-contained client; does NOT touch the shared Mission Control client/sidebar to avoid concurrent-session conflicts): the master-switch state (racm_enabled), the kill-switch + L1-freeze controls, the risk ceiling, status counts, the approvals queue (decision cards with level/risk/priority + the §1 narrative + Approve/Reject — "Commit (human)" for L4), the awaiting-execution list with Roll back, the permission registry with per-capability enable/disable, and a propose-through-the-gate form (so the gate is demonstrable before the AC-2 council feeds it). All controls POST to the audited AC-0 ledger. - 8 new tests (priority determinism/monotonicity/fallback, decision-record completeness, queue grouping + ranking + exclusion). Total RACM 32 tests; tsc 0 (racm); eslint 0. Next: AC-2 AI Executive Council (event-triggered, budget-capped) → AC-3 first safe L1 executors.

    • Phase RS — Free Growth (7% revenue share): RS-6 "already-settled" monthly statement. The optional, PAID, watermarked monthly summary the restaurant hands its accountant — bookkeeping only; the fee was already taken per transaction, so NO additional payment is due. Additive schema: MonthlySettledStatement + TenantDB.monthlyStatements?. New lib/revenue-share/statement.ts: buildMonthlySettledStatement(ledger, {periodKey, currency, …}) (PURE — aggregates the month's settlements via RS-1 deriveSettlements: total gross sales / platform fees / VAT-on-fees / transactions; status:'paid', watermark constant SETTLED_WATERMARK), statementToCsv, buildStatementEmail (branded subject/text/html with the "no additional payment required" framing, mirrors the invoice-email pattern), recordMonthlyStatement (upsert by tenant+period — regenerable). The PDF reuses the existing pdf-lib invoice renderer at the route layer (deferred to RS-7/RS-8). Mock-first. 5 tests; tsc 0 project-wide. Next: RS-7 super-admin console + RS-8 signup/dashboard UI (the route/UI wiring for all the pure engines).
    • Phase 22 — Restora Agent OS (RAOS): OS-0 keystone (agent registry + lifecycle + the permission/risk engine + audit). First slice of the shared substrate beneath every AI agent that already runs standalone today. Deterministic, additive, AI-OFF, fail-safe OFF, no schema change, no second warehouse — reuses writeAuditLog + the feature-flag engine. New lib/raos/:

    - `registry.ts` — the modular agent registry (code-defined, read-only): a catalog of the 11 live agents (Growth, Guardian, Compliance, Social Autopilot, Profit, Launchpad, Food Catalog, Experience Studio, WoW Clone, FSN, Mission Control) plus 2 honestly `planned` (Support Brain, Intelligence Engine/RIE — absent on this branch, so listed with no libPath rather than asserting code that isn't here). Each descriptor declares domain, an honest repo-relative libPath (a test asserts every active agent's path exists on disk), trigger, lifecycle, the reused AI-budget source, and capabilities tagged with their inherent risk level. Add an agent = append a descriptor (no other code changes). - `risk.ts` — the permission/risk ENGINE (the mechanism): the 4-level model (RISK_LEVELS L1 reversible/internal → L4 legal/financial/pricing/contracts) + a conservative verb→risk classifier (unknown verb → L3, never auto) + decide(action, governance) — fail-safe by construction: L4 is ALWAYS blocked (never autonomous) regardless of flags; RAOS-off / agent-paused ⇒ nothing autonomous (L1–L3 degrade to recommend); L1 auto-runs only when enabled AND reversible. INTEGRATION NOTE: Phase 21 RACM AC-0 (lib/racm/, built concurrently) owns the canonical governance; per owner decision 2026-06-25 OS-0 ships a self-contained default to avoid coupling to that uncommitted module, with a documented OS-0.1 follow-up to delegate to RACM's gate so there is ONE governance engine (the agent registry/snapshot/UI stay RAOS-owned — RACM has no agent model). - `snapshot.ts` — pure aggregation: agent counts by domain/trigger/lifecycle, capabilities by risk, and the autonomy posture (how active capabilities would be handled right now under the master switch — with RAOS off, zero auto and every L4 stays blocked). `audit.ts` — recordAgentAction records to the shared audit log (PII-free raos.<agent>.<capability> keys); RAOS adds no audit store. `flag.ts` — the raos_enabled master switch (fail-safe OFF, mirrors RIE's rie_enabled) + a pure seed builder. - Super-admin surface: GET /api/admin/raos (requireSuperAdmin, no persist, no AI) + a read-only /admin/raos cockpit (summary tiles, an autonomy-posture bar, the 4-level legend, and the agent registry with per-capability risk badges) + an "Agent OS" sidebar entry. 25 tests (risk fail-safe + L4-never-auto; registry honesty incl. on-disk libPath check; snapshot reconciliation; audit formatting); tsc 0 project-wide; full suite 2594/2594. Next: OS-1 shared memory (pgvector, reused with Support Brain) · OS-2 agent bus (Postgres + cron + SSE doorbell) · OS-0.1 RACM gate delegation.

    • Phase RS — Free Growth (7% revenue share): RS-5 Scenario-B internal withdrawal + retry. The collection mechanic for non-split rails: the full amount lands with the restaurant and the platform fee is withdrawn by a daily sweep; a failed withdrawal becomes a receivable chased on backoff. Additive schema: SettlementFailure + TenantDB.settlementFailures?. New lib/revenue-share/withdrawal.ts: runInternalWithdrawals(db, collect, now) (sweeps pending internal settlements → settled on success), runFailureRetries (re-attempts due receivables), applyWithdrawalOutcome (PURE state machine — success settles + recovers any open failure; failure upserts an open/retrying SettlementFailure with amountOwed = fee + VAT-on-fee, attempts++, backed-off nextRetryAt via RETRY_BACKOFF_HOURS [24/48/96/168h]), dueFailures, writeOffFailure (admin, RS-7), summarizeOpenFailures (super-admin receivables view). The collection call is INJECTED (collect) so success+failure paths test without real money (production wraps the RS-2 provider's transferPlatformFee); mock-first, no live cron wired yet (lands with RS-7/RS-8). 8 tests; my files tsc-clean. Next: RS-6 monthly statement.
    • Phase 21 — Autonomous Company Mode (RACM): AC-0 governance framework (the keystone — governance-first, fail-safe OFF). First slice of the capstone: the governed-execution layer over the engines already built. Builds almost no new business logic — it builds the governance matrix, the decision/approval/rollback machinery, and the audit/kill-switch backbone (design doc: docs/architecture/restora-autonomous-company-mode.md). Nothing executes without passing this layer (design §0). New lib/racm/:

    - `governance.ts` (PURE — no I/O, no clock, no randomness; an audit can replay any decision). The 4-level approval matrix L1/L2/L3/L4 → requiredApproval (none/human/executive/never); a deterministic 0–100 scoreRisk (additive, explainable, monotonic factors: level base + irreversible + monetary + blast-radius + low-confidence) → riskBand; classifyLevel (registry default, an override may only RAISE never lower — pricing.change stays L4 even with an L1 override); the central `evaluateGate` (kill-switch → blocked · disabled type → blocked · L4 → needs-approval, NEVER auto · L2/L3 → needs-approval · L1 only auto-executes when reversible, not frozen, and under the risk threshold); a canTransition/applyTransition state machine (legal-only transitions + appended version history); buildAction (classify → score → assemble → gate). - `registry.ts` — the per-action permission/capability registry (design §3): 19 seed capabilities mapped to the EXISTING engine each governs (SEO/marketing/churn/CS/product = L1 draft; publish/campaign/supplier = L2; partnership/expansion/infra/plan = L3; `pricing.change`/`financial.commit`/`contract.execute`/`compliance.file` = L4, recommend-only, a human commits — mirrors Tax TC-0's "no auto-filing"). An unregistered type can't be proposed at all. mergeRegistry preserves operator enable/level edits while appending new code defaults. - `enabled.ts` — the master kill-switch: the racm_enabled feature flag, fail-safe OFF (absent flag ⇒ disabled), mirroring RIE's rie_enabled; the in-app RacmConfig.killSwitch is the inner emergency stop an operator hits instantly without touching flags. - `store.ts` — an isolated `racm` doc store, self-contained (reuses the generic app_documents row-locked mutateDoc seam + a JSON-mode databases/racm.json atomic write + in-process mutex) so it does NOT edit the shared db.ts (no concurrent-session collisions); normalizes older docs forward; caps the ledger at 5,000. - `ledger.ts` — the audited lifecycle: proposeAction (classify→gate→persist→audit; reflects the gate by auto-approving safe L1 or blocking, else leaves it in the approvals queue), approveAction/rejectAction/rollbackAction/recordExecution (AC-3 hook), and config controls setKillSwitch/setExecutionFrozen/setActionTypeEnabled — every state & config change writes `writeAuditLog` (reused, not duplicated). - `POST/GET /api/admin/racm/governance` (super-admin gated): GET returns {enabled, config, registry, counts, recent} (safe even when RACM is off — reports enabled:false); POST dispatches the audited controls + propose/approve/reject/rollback. - AC-0 records & governs; it executes no business actions (executors are AC-3). Reuses the feature-flag engine, writeAuditLog, the doc-store seam — no new warehouse, no duplication (design §3). 24 pure-core tests (approval matrix, risk bands/determinism/monotonicity, override-raises-not-lowers, the full gate truth table incl. L4-never, the transition state machine, registry integrity asserting the four L4s). tsc 0 (racm files), eslint 0. Next: AC-1 Decision Engine + approvals-queue dashboard (inside Mission Control) → AC-3 first safe L1 executors.

    • Phase RS — Free Growth (7% revenue share): RS-4 reporting rollups (tenant dashboard + platform analytics). Pure aggregation for the fee dashboard and super-admin analytics — source of truth is the existing payment ledger, projected to settlements by RS-1 (no parallel store, no I/O). New lib/revenue-share/reporting.ts: buildRevenueShareReport(ledger, {tenantId, plan, rules, country, currency, now}) → per-period PeriodMetrics for today / this week / this month / this year / lifetime (gross sales, platform fees, sale VAT, VAT-on-fees, net revenue = gross − fees − vat − psp, payment fees, refunds [from ledger refund rows], payouts, tips, order count). Start-of-period boundaries (UTC, week starts Monday via periodBoundaries) so periods nest cleanly; optional currency filter. aggregatePlatformAnalytics(rows) → cross-tenant totals (platform earnings = Σ fees, restaurant payouts, gross) + per-tenant breakdown sorted by fees. Mock-first; no route/UI wiring yet (lands with RS-7/RS-8). 6 tests; tsc 0 project-wide. Next: RS-5 Scenario-B withdrawal.
    • Phase 20 — Mission Control: discoverability (admin nav link). The founder cockpit at /admin/mission-control was fully built but only reachable by typing the URL — no nav entry existed anywhere. Added a "Mission Control" item to the admin sidebar's Dashboard group (components/admin/AdminSidebar.tsx, next to Overview/Guardian, reusing the already-imported Gauge icon — no new import). The page keeps its own super-admin server gate. tsc 0.
    • Phase RS — Free Growth (7% revenue share): RS-3 auto-journal (settlement → balanced double-entry). The headline accounting value — each settlement auto-posts balanced double-entry journal entries so the restaurant's books stay continuously current, feeding the EXISTING bookkeeping / SIE / P&L pipeline (no parallel ledger). Additive schema: JournalLine + JournalEntry interfaces + TenantDB.journalEntries?. New lib/revenue-share/journal.ts: postSettlementJournal(settlement) → a SALE entry (Dr Bank / Cr Sales-by-rate / Cr Output-VAT-by-rate) + a FEE entry (Dr Platform-fee expense / Dr Input-VAT-on-fee / Cr Platform payable), each independently balanced (Σdebit === Σcredit, asserted) with figures taken straight from the RS-1 settlement so they reconcile exactly (e.g. 100 SEK SE: Dr 1930 100 / Cr 3002 89.29 / Cr 2621 10.71; Dr 6570 7 / Dr 2640 1.75 / Cr 2440 8.75). Pluggable RevenueShareAccountMap (SE BAS default, country-swappable); dated VAT-rate → account selection via the existing resolveVat; deterministic idempotent ids (je_<ledgerId>_sale/_fee); recordJournalEntries idempotent persist; refund/reversal settlements post refund/fee-reversal entries that still balance; unsupported VAT jurisdictions post a VAT-free balanced sale. Mock-first (no money moves). 8 tests; tsc 0 project-wide. Next: RS-4 reporting.
    • Phase RS — Free Growth (7% revenue share): RS-2 settlement provider abstraction (mock split + internal). The provider-agnostic seam every settlement flows through (mirrors lib/payments/registry). New lib/revenue-share/settlement-provider.ts: the RevenueShareSettlementProvider interface (processTransaction / splitPayment / transferPlatformFee / refundTransaction / reverseFee, async to match real rails) + two MOCK providers — `mockSplitProvider` (Scenario A: a marketplace rail splits fee from payout AT capture → settled immediately) and `mockInternalProvider` (Scenario B: full amount lands with the restaurant → pending, fee collected later via transferPlatformFee → settled; splitPayment is an honest unsupported failure) — plus resolveSettlementProvider({paymentMethod, scenario, mode}) (rail-aware: SPLIT_CAPABLE_RAILS → split, else internal; mode:'live' throws — owner-gated RS-9). Both providers reuse RS-1 buildSettlement (no money moves; mock:true); refund / fee-reversal return proportional negative mirrors. 10 tests; tsc 0 project-wide. Next: RS-3 auto-journal.
    • Phase RS — Free Growth (7% revenue share): RS-1 settlement model (mock-first, derive-from-ledger). New TransactionSettlement record (additive schema + TenantDB.settlements?) — a permanent per-paid-charge snapshot of the money split (gross / sale-VAT / net / tip / fee% / fee / VAT-on-fee / PSP-fee / payout), with the invariant platformFee + restaurantPayout + paymentProviderFee − tipAmount === grossAmount. New lib/revenue-share/settlement.ts: buildSettlement (PURE money math — fee% from RS-0 resolveFeePercent, sale + fee VAT from the existing dated resolveVat engine, tips pass through 100% and are excluded from the fee base), deriveSettlements / settlementFromLedgerEntry (READ-ONLY projection over the existing append-only paymentLedger — only paid charges, canteen top-ups excluded — the mock-first reporting source, so NO live write path is hooked), recordSettlement (idempotent persist keyed by ledgerEntryId — the entry-point the owner-gated real-money path / a backfill will call), shouldSettle (gate: revenue-share plan + flag on → inert for every existing subscriber). No money movement (mock:true; "recorded, not charged"); reuses the commission % + VAT engines (no parallel engine). 11 tests; tsc 0 project-wide; full suite green. Next: RS-2 provider abstraction · RS-3 auto-journal.
    • Phase RS — Free Growth (7% revenue share): RS-0 keystone (additive plan + fail-safe master switch). First slice of a NEW, additive billing model — free + every feature, monetised by a per-transaction platform fee instead of a subscription (design doc: docs/html_reports/restora360-free-growth-revenue-share-implementation.html). Zero changes to existing plans, prices, permissions, or billing logic. New built-in plan free-growth-7pct (billingModel:'revenue-share', 0/mo, 7% commission, all features) added ALONGSIDE the untouched 8% growth-partner (lib/plans/defaults.ts); it ships isPublic:false so it stays invisible at signup until a super-admin enables it. New lib/revenue-share/: rules.ts resolveFeePercent (PURE fee resolution — per-tenant rule → global rule → plan commission → default, with dated windows + per-country overrides + [0,100] clamp), eligibility.ts getSignupEligiblePlans (revenue-share plans surfaced ONLY when the flag is on; not yet wired into signup — that's RS-8), flag.ts isRevenueShareBillingEnabled (wraps the standard feature-flag engine, fail-safe OFF) + PURE seed builders for the global 7% rule and the revenue_share_billing flag (mode off). Additive schema only: RevenueShareRule interface + MasterDB.revenueShareRules?. Reuses the existing tested computePlatformCommission for the fee MATH (no new money engine); no settlement / money movement in RS-0 (owner-gated, mock-first). Seeding is deferred (the lib has safe code-level defaults, so the package is fully inert until enabled). 13 tests (fee precedence/overrides/date-windows/clamp; plan wiring; signup gating; growth-partner untouched); tsc 0 project-wide; full suite green. Next: RS-1 settlement model · RS-3 auto-journal.
    • Phase 20 — Mission Control: MC-2/MC-3/MC-0.7/MC-5/MC-6 (the heavier slices — buildable scope COMPLETE). Five deterministic, AI-OFF, honest-null slices that finish the founder cockpit, each a surface over engines already computed (committed code-first as 30d7df7c/d8388e06/39dd6194/018e53d3/69375cd2; this is their consolidated doc entry).

    - MC-2 — intervention plans (30d7df7c). interventionFor(reasons, bucket) in lib/mission-control/tenant-health.ts maps each at-risk tenant's worst health reason to a concrete recommended action (canceled→win-back, past-due→recover the failed payment, trial→conversion check-in, inactive→re-engagement…); attached to every row and shown under each name in the At-risk panel, so the founder sees not just who is at risk but what to do. - MC-3 — Business Risk Center + a live Risk pillar (d8388e06). lib/mission-control/risk-register.ts buildRiskRegister(ctx) derives STRUCTURAL/financial risk purely from the loaded Growth metrics — deliberately DISTINCT from the War Room's "what's on fire now": liquidity (signups but zero paying), revenue concentration (≥65% of MRR from one channel), churn banding, weak trial→paid conversion. Each ties to one mitigation; a deterministic posture (100 = low risk) rolls up to a RAG and now feeds the previously-pending Risk pillar (the calibrated score-engine tests are unaffected — they never pass .metrics). No financial data → empty register + honest null posture (never a false all-clear). New Risk Center panel under the War Room. - MC-0.7 — trend log ("are we improving?") (39dd6194). lib/mission-control/trend.ts (PURE) records a tiny daily snapshot of the ecosystem + 10 pillar scores and derives current + day-over-day + week deltas (all null when not computable). Storage is a DEDICATED store — a local MissionTrendDB type + an additive db.ts accessor (getMissionTrendDB/saveMissionTrendDB) to its own mission-control-trend.json doc, NOT the MasterDB schema. Capture is SNAPSHOT-ON-READ, deduped per UTC day, capped to 90, nulls preserved; best-effort (a persistence failure serves the cockpit without the trend, never breaks it). New Sparkline + TrendStrip under the ecosystem card (hidden until ≥2 days of history). A guaranteed-capture cron is a later additive MC-0.7b. - MC-5 — Opportunity Detector (018e53d3). lib/mission-control/opportunities.ts buildOpportunities(ctx, adoption) is the upside mirror of the Risk Center: scale a proven efficient channel (≥3× LTV:CAC, ≥2 customers — needs a KNOWN acquisition cost, so it never fabricates efficiency), convert the open trial backlog, deepen shallow adopters (from the MC-4 distribution). Surfaces ONLY when the data supports it. New Opportunity panel under the Risk Center. - MC-6 — 90-Day Focus Board (69375cd2). lib/mission-control/focus-board.ts buildFocusBoard is a pure SYNTHESIS that introduces no new data — it RANKS the single strongest signal from each engine (top active incident, top structural risk, worst at-risk tenant, top opportunity, lowest concern pillar) into one prioritized, horizoned board (now / 30 days / this quarter), capped at 6, each with a "why" line. Only red/amber pillars are eligible; a healthy row is ignored; owner chose to keep it deterministic (no Haiku narration). New Focus Board panel under the briefing. - 31 new tests across the five (interventions; risk banding + suppression; trend dedup/cap/null-honesty + a verified DB round-trip; opportunity cost-unknown/n=1 suppression; focus-board ranking/horizon/all-clear). tsc 0; full suite 2476/2476. This completes the buildable Phase 20 scope (MC-0…MC-6); remaining is owner/go-live only (a strategic-planning framework if wanted, optional Haiku narration behind a MasterDB AI-cap, the optional MC-0.7b cron).

    • Phase 20 — Mission Control: MC-4 Ecosystem Value Score (adoption depth → stickiness). The genuinely net-new scoring concept. lib/mission-control/ecosystem-value.ts: per-tenant configured adoption depth = plan-tier base (basic/premium/enterprise) + the count of ENABLED add-ons (addonSubscriptions active/trialing, incl. bundled suite features) + non-expired featureGrants, clamped 0–100, rolled up to a platform score + a deep/moderate/shallow distribution + the deepest adopters. HARD BOUNDARY (tested): it reads ONLY configured/enabled state (MasterDB subscriptions + addonSubscriptions + featureGrants) and has NO event/usage input — so it structurally cannot measure OBSERVED usage (the Intelligence Engine P47's domain); Mission Control never imports `lib/intelligence`. MasterDB-only (no per-tenant DB fan-out). Surfaced as an Ecosystem Value cockpit panel beside the at-risk list. 5 tests (depth = base + enabled features, canceled/expired excluded, demo excluded, the no-usage-boundary assertion, null on empty). tsc 0; full suite 2433/2433.
    • Phase 20 — Mission Control: MC-1c AI Copilot (completes the MC-1 trio). lib/mission-control/copilot.ts (answerFounderQuestion): a DETERMINISTIC-first founder copilot that answers the common questions — "what should I focus on", "biggest risks", "how's growth/customers/compliance/infrastructure" — straight from the computed scores + briefing + War Room alerts. No LLM, no fabrication (every numeral comes from buildMissionScores); this rule-based answer is the shipped artifact + the no-key/over-budget fallback, and a later Haiku pass may only rephrase it behind the advisor-narrate number-guard. POST /api/admin/mission-control/copilot (super-admin + rate-limited) + a Copilot chat panel on the cockpit. With MC-1a (briefing) + MC-1b (War Room), the AI Founder Assistant suite is complete: the cockpit now shows Ecosystem → War Room → Founder Assistant → At-risk restaurants → 10 score cards → Copilot. 5 tests; tsc 0; full suite 2428/2428.
    • Phase 20 — Mission Control: MC-1b Executive War Room (live alerts). lib/mission-control/war-room.ts (buildWarRoomAlerts): a PURE surface of signals already detected by the engines — Guardian ACTIVE incidents (resolved excluded; P1/P2→critical, P3→high, P4→medium) + active compliance issues — ranked by severity, with links to the centers. No re-detection, no new alert engine; empty sources render "All clear", never a fabricated incident. Added to the cockpit as a War Room panel above the briefing (and into the GET /api/admin/mission-control payload). 4 tests; tsc 0; full suite 2423/2423.
    • Phase 20 — Mission Control: MC-1a Founder Assistant briefing + MC-0.5 per-tenant health (named at-risk list). Builds on the MC-0 keystone. MC-1a (lib/mission-control/briefing.ts, buildFounderBriefing): a deterministic daily briefing assembled purely from the 11 scores + their tied actions — a headline (the worst-status-escalated Ecosystem line), Today's priorities (red pillars), Needs attention (amber/red, worst-first), Good news (green), and an honest "pending sources" line. NO LLM call: this rule-based briefing IS the shipped artifact (the FBL/RIE no-AI-invented-numbers lesson); a later Haiku pass may only rephrase behind a number-guard. The meta Data-Confidence score is excluded from the business sections (it's a coverage signal, not a priority). MC-0.5 (lib/mission-control/tenant-health.ts, buildTenantHealthList): the one PII-bearing surface, kept separate from the count/rate keystone — per-tenant rows {tenantId, name, score, bucket, reasons[]}, worst-first, founder-only (super-admin) showing tenant BUSINESS identity (no end-customer/diner PII), via a new founder-gated GET /api/admin/mission-control/tenants. Reuses the SAME healthScore() + signal shape loadLiveSnapshot aggregates, so the per-tenant list reconciles exactly with the Company/Customer-Success scores. The cockpit now renders the Founder Assistant briefing + a named "At-risk restaurants" panel (with explainable reasons) above the score grid. NO schema change. 7 new tests (briefing sort/cap/no-fabrication; per-tenant scoring, worst-first, demo-excluded, neutral-unknowns). tsc 0; full suite 2419/2419.
    • Phase 20 — Restora Mission Control: the MC-0 keystone (founder cockpit, surface-not-rebuild). The capstone executive surface — its first slice is a deterministic, AI-OFF score-aggregation engine that READS the engines already built and recomputes nothing they own. New lib/mission-control/: score-engine.ts emits 11 health scores in the Profit-Score shape ({score:0-100|null, rag, confident, subScores, action}), context.ts (loadMissionContext) fans out ONCE to existing accessors — loadLiveSnapshot() (Growth P16), loadGuardianSnapshot() (Guardian P17), buildAnalytics(getComplianceDB()) (Compliance P19) — each try/catch-isolated so a slow/broken engine degrades to a null card. Four scores are real end-to-end (Company + Customer Success off the SAME healthDistribution but with DISTINCT framings; Infrastructure off Guardian health.pct; Compliance off complianceRate), the rest are honest null stubs, and Innovation is a permanent null (Support Brain isn't on this branch — no synthetic vanity proxy). The adversarial design review caught three traps before any code, all baked in: (1) AI is OFF in MC-0 so there's no AIFeature dependency (the union has no mission-control member — using recordAIUsage would be a compile error; platform AI later uses a MasterDB daily-cap, not the per-tenant getBudgetStatus); (2) Compliance null-guards on totalIssues===0, NOT the rate (buildAnalytics returns 100 on empty → a fresh platform must never show a confident green "100% compliant"); (3) the keystone is strictly count/rate aggregates with NO per-tenant identity (the PII-bearing per-tenant list is a separately-reviewed MC-0.5). The Ecosystem master is a local rollupScores() — a tier-weighted average for the number but worst-status escalation for the RAG (one red pillar can't average into green), extracted from the computePlatformHealth pattern (which takes ServiceHealth[], not scores). Reuses ragForScore from lib/profit/rag.ts (one RAG definition). The /admin/mission-control page (server-gated superadmin) renders the Ecosystem headline + 10 pillar cards (null → em-dash + "No data yet"; low-confidence → "seed-backed" badge). NO schema change (computes-and-returns; the trend-log cron is MC-0.7). 8 engine tests (no-fabrication on empty, the totalIssues===0 guard, RAG bands, the Company≠CS invariant, worst-status escalation, Innovation-always-null). tsc 0; full suite 2412/2412. Next slices: MC-0.5 per-tenant health accessor → MC-0.7 trend cron → MC-1 AI Founder Assistant/Copilot/War Room → MC-2 rollups → MC-4 Ecosystem Value Score → MC-3 dependency map/Risk Center → MC-5/6 opportunity + planning. RIE (P47) is NOT imported (Mission Control reads the same upstream Growth/Guardian accessors; MC-4 measures CONFIGURED depth, never RIE's observed usage).
    • Phase 17 — Restora Guardian: CLOSE-OUT to 100% of buildable scope (commit 76ad6f67). A 10-agent adversarial audit found the earlier "buildable scope complete" claim overstated — three genuine buildable gaps (not paid-integration deferrals) were hiding behind it, now all built, wired and tested:

    - GD-6 §5 statistical anomaly detection was DEAD CODE. lib/guardian/anomaly.ts (z-score/EWMA) — the "statistical-first detection" the spec calls the heart of the AI watchdog — was imported by nothing but its own test; no code path could ever open an anomaly-source incident. Now the probe persists a compact ops metric series (new MetricSample / guardianMetrics, capped) and buildMetricSample + detectMetricAnomalies z-score the live latency series each cycle (≥8-sample baseline guard, live-signals-only so seed never fabricates one), opening a P3 anomaly-source incident on a high-latency drift (deduped by metric; the existing budget-gated RCA narrates it). The anomaly baseline excludes the current reading (computed before the sample is appended) — no self-contamination. - GD-6 §13 predictive-failure engine was ABSENT (a named GD-6 deliverable). New lib/guardian/predictive.ts computePredictions runs a least-squares trend over the persisted ops series → time-to-threshold per metric (DB read latency, synthetic latency, synthetic failure rate), surfaced in the AI Insights view with an honest "at the current trend, X in N days" label + a deterministic recommendation. Honest by construction: only metrics genuinely trending toward their ceiling within a 60-day horizon are shown; no fabricated storage/memory figures we can't measure; div-by-zero/NaN/Infinity all guarded. - GD-7 status-page subscribers were collected but NEVER emailed — the subscribe form promised "we'll email you," and nothing did. New lib/guardian/status-notify.ts + the probe now email subscribers on a live, customer-visible (P1/P2) incident open/resolve — sent individually (never a shared To:), dedup-ledgered (each transition once), recency-guarded (no months-old history on first run), cron-only (a manual "Run journeys" never mails or advances the ledger), and the dedup ledger advances only after a successful send (a total send failure — e.g. SMTP unconfigured — retries next cycle instead of silently dropping the promise; this last point fixed from the adversarial review). - Plus 5 polish fixes: synthetic monitoring now honors `expectStatus` (was a dead config field) and flags a slow-but-ok step degraded against its latencyBudgetMs (was inert) with a UI badge; the Incident Center gained assign/note affordances (the API already supported them); analytics gained the spec §14 `1y` window; and the public "Recent incidents" list gained a 90-day cutoff so the label stays truthful. - 110 guardian unit tests (was 87); tsc 0; next build clean. project-overview.html → Phase 17 delivered/100. Architecture doc (docs/architecture/restora-guardian.md) updated. Phase 17 buildable scope is now genuinely 100% — remaining work is paid go-live integrations only (Sentry-class deep error tracking is the spec's one explicit integrate-not-build commodity layer per §0; plus managed uptime, live Slack/Twilio, browser-level Playwright, independent status hosting, managed PITR + APNs/FCM).

    • Phase 34 — Conversational & Social Commerce: BUILDABLE SCOPE COMPLETE (S2 + S4–S9 + dashboard UI). The full channel build-out on top of the keystone (registry) + S1 (createTenantOrder) + S3 (TikTok), all mock-first and reusing one money path. Every settled channel posts ONE ledger charge + ONE order.paid outbox + a paid flip atomically (never the delivery normalizers' paymentStatus:'paid' ledger-bypass); the cross-truth invariant (computeAnalytics.totalRevenue === summarizeLedger.charges.gross) holds for each. Hardened by a 3-lens adversarial review with per-finding verification — which caught a real MAJOR money bug (below) before commit.

    - S2 — deterministic conversation + PII (self-contained, no cross-branch merge). lib/channels/conversation.ts answers menu questions PURELY from the partner menu feed (no LLM, no fabrication — a dietary ask is a hard filter, prices come straight from the feed, an unanswerable question hands off to a human); deriveReply + classifyMessage drive the channel webhooks. lib/channels/pii.ts (stripPii/containsPii) redacts email + phone but keeps prices/quantities (the review's over-redaction of space-separated number runs was fixed — a phone now needs a real cue: a leading + or a 4+ contiguous-digit run). - S4 — WhatsApp Business Cloud inbound (/api/webhooks/whatsapp): GET verify-token handshake + MANDATORY fail-closed X-Hub-Signature-256 verify; a native WhatsApp cart → createTenantOrder(source:'whatsapp') priced against OUR menu (Restora-collected, born pending); a text message → deterministic menu Q&A. Per-request event fan-out capped. - S5 — generic pay-link settlement (lib/channels/settle-link.ts + /api/channels/pay-link): capturePayment OUTSIDE the lock → recordOrderPayment INSIDE one mutateTenantDB → created-gated paid flip + order.paid outbox (the atomic record-pos-sale shape, upgrading the older PayPal route's non-atomic pattern). MAJOR fix from the review: a FAILED capture was recorded under the SAME idempotency key as the eventual success, so a failed-then-paid PSP sequence would leave a paid order stuck pending forever — now the key is scoped to the ORDER and the OUTCOME (${provider}:${orderId}:${linkId}, failed rows under a :failed suffix), proven by a new failed-then-paid test. - S6 — Instagram + Messenger (/api/webhooks/meta): shared Meta verifier; IG-DM/Messenger conversational (Restora-collected) vs IG/FB Shops born-paid (partner-collected, settle provider meta), classified distinctly so a Shop order is never double-charged or stamped-paid-without-ledger. Reuses the existing FSN/Phase-48 Meta connection. The born-paid trust boundary was extracted into a shared, tested buildPartnerOrderBody (adopted by TikTok too) that coerces every partner money field finite-non-negative and rejects a corrupt/empty total. - S7 — Order with Google + SMS (/api/webhooks/{google,sms}): Google ordering is MARKET-GATED (refuses orders where it isn't live) + priced against our menu (Restora-collected); SMS parses "<qty> <dish>" against our menu deterministically + answers menu questions, dedups on the provider message id. - S8 — Unified Conversations inbox (lib/channels/inbox.ts + /api/tenant/[id]/channels/inbox): tenant-scoped aggregation of inbound conversations across channels + staff reply + deterministic→human hand-off, idempotent on the provider message id (no double-count). RAW messages stay tenant-scoped; only scrubbedConversationText (PII-stripped) may ever reach a cross-tenant store. Wired into the WhatsApp/Meta/SMS webhooks. - S9 — Apple/Google Wallet loyalty passes (lib/loyalty/pass.ts + /api/tenant/[id]/loyalty/pass): a READ-ONLY pass from loyalty state — the builder takes no db, so it structurally CANNOT touch the payment ledger — with a signed, offline-verifiable QR token and an honest mock status until wallet certs are configured. - Dashboard UI: a /dashboard/channels connector-management page (consumes the server-gated GET /channels, so market-gated channels are already filtered) with enable/connect/sync + the inbox summary; OrderCard reconciled to show channel-sourced order pills from the single registry. (Nav-catalog entry deferred — that file is mid-edit in a parallel session.) - Tests: ~140 channel/loyalty tests; tsc 0 across all Phase-34 files; full suite 2386/2386. Go-live gates (P40 / per-platform approval): real platform API approvals (Meta WABA + app review, TTS Partner API, Google ordering), real PSP rails + Twilio signature + wallet certs, partner-currency conversion, and per-tenant accountId indexes for high-volume webhooks.

    • Phase 34 — P34-S3: TikTok Shop connector (market-gated, partner-collected, record-only). The first live channel + the first consumer of the born-paid hook. TikTok Shop collects the money itself, so a TTS order is born paid: the inbound webhook (POST /api/webhooks/tiktok) lands it in the existing Orders pipeline via createTenantOrder with a born-paid settle (one ledger charge + one order.paid outbox + a paid flip, atomically) — never the delivery normalizers' paymentStatus:'paid' ledger-bypass. New lib/channels/inbound.ts (findTenantByChannelAccountId resolves the tenant by the non-secret settings.channels['tiktok-shop'].accountId; verifyHmacSignature is FAIL-CLOSED — rejects when the signature is absent OR the secret is unset, the opposite of the delivery webhooks' fail-open) and lib/channels/tiktok.ts (normalizeTikTokShopOrder → a CreateTenantOrderInput + settle; buildTikTokCatalog projects the reused PartnerMenuFeed to a TTS catalog — no reforked menu extraction). The webhook is market-gated: it refuses orders (403) for any tenant where TikTok Shop isn't live (isChannelLive = the channel_tiktok_shop region flag on AND the tenant enabled it), so a non-live region never receives orders — honest, no fabricated availability. Redelivery-safe on two layers: clientRequestId = tiktok:<orderId> makes a redelivery an idempotent replay (no second order), and the ledger idempotencyKey = tiktok:<orderId> is the backstop. escrow:false (Restora never holds TTS money). The adversarial review caught two real MAJOR money-correctness holes at the trust boundary, fixed before commit: the partner total and line-item price/quantity were coerced only against null/undefined, so a corrupt payload (total_amount:-50 or NaN/Infinity, or a non-numeric sale_price) would have written a negative/NaN charge into the ledger and broken the cross-truth invariant — now every money field is coerced to a finite non-negative number, a present-but-corrupt total is rejected (400), and an empty/zero-value order is rejected (no bogus paid order). 15 tests (fail-closed signature accept/reject, normalizer mapping + total/fee/discount consistency, the trust-boundary validation, the born-paid ledger+outbox+flip + cross-truth invariant + refund-linkage, redelivery idempotency, and the catalog projection). Go-live notes (mock until the TTS Partner API is approved per market): a real TTS Shop-eligibility + Partner-API approval gate is a distinct 'needs-platform-approval' state from missing-creds; and the webhook's per-IP rate limit should be re-keyed on the resolved shop/tenant (or given a global ceiling) because TTS sends from a small egress pool. tsc 0 project-wide; full suite 2333/2333. Next: P34-S4 WhatsApp inbound (needs the Support Brain merge, S2) · or S6 IG/Messenger.
    • Phase 34 — P34-S1: `createTenantOrder()` — the single, locked order-creation seam. Extracted the inline order-creation body of the public online-orders route (POST /api/public/[tenantId]/orders) into a reusable helper in lib/orders/create-tenant-order.ts, so every P34 channel (and the web path today) creates an order through ONE path that assigns the orderNumber UNDER the row lock and applies coupon / loyalty / gift-card / escrow / product+ingredient stock identically — never the delivery webhooks' lock-free getTenantDB+push pattern (which races on the number and skips these side effects). Shape mirrors record-pos-sale.ts: a pure `applyOrderCreation(db, draft, opts, now)` (mutates a passed db — unit-testable) + a thin async createTenantOrder wrapper (escrow hold + mutateTenantDB + the post-commit gift-card charge). The public route is rewired to a thin caller with zero behavioral change (verified against git HEAD by a 3-lens adversarial review: byte-identical order object, same idempotent-replay 200, same activity-log + SMS, same 201). Two new seams for channels: an opts.source stamp ('whatsapp' etc. → Order.source) and an optional born-paid `settle` hook — for partner-collected channels (TikTok Shop, IG/FB Shops) whose money the platform already took, it posts ONE paid ledger charge + ONE order.paid outbox event + flips the order to paid, all inside the same lock (the record-pos-sale shape, never the banned delivery paymentStatus:'paid' ledger-bypass). Default (no settle) = born pending with no ledger row (Restora-collected channels capture later via a pay-link). The adversarial review caught a real MAJOR money defect before commit: the settle hook ignored recordOrderPayment's created flag, so a redelivered provider:providerRef deduped the ledger but still re-flipped a second order to paid + emitted a second order.paid — breaking the cross-truth invariant (revenue 500 vs ledger gross 250). Fixed: the paid-flip + outbox are now gated on created, and the currency/amount overrides were dropped so the charge is ALWAYS order.total in the tenant's settings currency (invariant holds by construction). 15 tests (sequential orderNumbers, idempotent replay runs no side effects, coupon/loyalty, product+ingredient stock + backward-compat, born-paid ledger+outbox+flip, the cross-truth invariant computeAnalytics.totalRevenue === summarizeLedger.charges.gross, no-double-loyalty, and the redelivery dedup that now asserts the second order stays pending). tsc 0 project-wide; full suite 2318/2318. Next: P34-S2 merge Support Brain · P34-S3 TikTok Shop (first born-paid consumer).
    • Phase 34 (Universal Coverage P34) — Conversational & Social Commerce: the channel-connector KEYSTONE (mock-first). The start of "sell where customers already are" (WhatsApp / Instagram / Messenger / TikTok Shop / Order-with-Google / SMS). This first slice is the registry seam only — it deliberately mirrors the existing delivery-integrations pattern so every channel behaves exactly like a delivery connector, and it touches no order or payment runtime path. New lib/channels/: types.ts (ChannelMeta with capability + collection:'restora'|'partner' + marketGated + flagKey), registry.ts (a CHANNELS tuple — the channel analog of SYNCABLE_PLATFORMS — + CHANNELS_META, the analog of DELIVERY_PLATFORMS, with a compile-time exhaustiveness guard pinning the tuple to the ChannelKey union so they can't drift), and a pure engine.ts (defaultChannelConfig, resolveChannelConfig, the market/visibility gate isChannelAvailable/getAvailableChannels, the two-key live gate isChannelLive/getLiveChannels = market-flag AND tenant-enabled, hasPendingChannelSync, and runChannelMenuSync — a clone of runDeliveryMenuSync that reuses the same `buildPartnerMenuFeed`/`validatePartnerMenuFeed` and records the same honest prepared/needs-credentials/error status in place). The feature-flag lookup is injected (an isFlagOn callback) so the engine stays I/O-free and the market gate fails safe OFF — a market-gated channel (TikTok Shop, Google ordering) is auto-hidden until its flag is on for the tenant, so a tile can never leak into a region where it isn't live. Schema is additive only: a new open TenantSettings.channels?: Partial<Record<ChannelKey, ChannelConfig>> (an open record, unlike the fixed-key integrations, so new channels need no schema edit), a new ChannelConfig that mirrors IntegrationConfig's field shape but holds no plaintext secrets (live tokens reuse the FSN vault in later slices; only non-secret routing ids live here), and Order.source widened to include the channel keys. Two thin API routes (owner/manager): GET/PATCH /api/tenant/[id]/channels (config + honest status, never a secret; enabling a market-gated channel is rejected server-side unless it's available in the tenant's region) and POST /api/tenant/[id]/channels/sync (mirrors the delivery sync route). Grounded in a recon + adversarial-design workflow whose review banned the delivery normalizers' `paymentStatus:'paid'` shortcut (it writes no ledger row + emits no outbox — a confirmed ledger-bypass) as a channel money model: every settled channel in the coming slices posts one ledger charge + one order.paid outbox + flips the order atomically via the P33 abstraction. 15 channel tests (registry integrity, the two-key gate, fail-safe-off market gate, honest sync status, and the feed-reuse invariant that the sync's itemCount equals buildPartnerMenuFeed's); tsc 0 project-wide; full suite 2303/2303. Next slices (planned, in order): extract a locked `createTenantOrder()` seam · merge Support Brain (resolveDeterministic + PII scrubbers) · TikTok Shop (market-gated, partner-collected, record-only) · WhatsApp inbound + pay-link · Instagram/Messenger · Google/SMS · Unified inbox · Wallet passes. Dashboard UI + nav deferred (the nav catalog is mid-edit in a parallel session).

    Fixed

    • Google "Connect" returned HTTP 500 on a mock-mode production deploy (e.g. restora-server). getStateSecret() threw GOOGLE_OAUTH_STATE_SECRET required in production whenever NODE_ENV=production and the secret was unset — but it ran for mock connects too (signState is called before the mock-mode branch in buildAuthorizeUrl), so clicking Connect on the demo server 500'd. The throw was masked as long as a seed connection already existed; disconnecting it exposed the bug. Fix: the production guard now only applies in live OAuth (!isMockMode()) — in mock mode the state never leaves our own callback, so the deterministic dev fallback secret is safe. Also: the mock authorize URL is now a relative `/api/google/callback?…` path (was the absolute REDIRECT_URI, defaulting to localhost:3001) so the mock loop resolves against the actual host instead of bouncing a demo-server visitor to their own machine. And /api/google/connect now wraps startConnect in try/catch, returning a clean operator-readable error (e.g. a genuine LIVE-mode missing-secret) instead of a bare 500. tsc 0.

    Added

    • Restora Profit (Phase 43) — RP-12d.1: owner per-tenant employer-rate override (the last deferred dev item — Phase 43 is now build-complete). An owner can override the per-country employer labor on-costs (employer-fee % + holiday/vacation %) from the menu Cost & Profit tab, when their real rates differ from the dated country table. New resolveEmployerLoad(country, override?) in employer-rules.ts is the single entry point every loaded-labor calc uses: the owner's figures replace the country default, a fully-specified override clears the "confirm with an accountant" flag (the owner owns the numbers), while a partial or invalid (negative / NaN / cleared-to-null) value falls back to the country table for that half. Wired through all loaded-labor sites — overview-core, cost-summary, the marketing route, the 8 profit routes, and the per-dish `menu/cost-estimate` route (the Cost & Profit tab's own estimate endpoint, which previously hardcoded Sweden — caught by the review, so the per-dish preview can no longer disagree with the profit dashboards). Additive costSettings.employerCost (nullable so a cleared field reverts to the country default); the cost-settings PATCH deep-merges it so saving one field can't wipe the other; two inputs in CostProfitTab + the loaded-rate caption made country/override-aware (was a hardcoded "12% + 31.42%"). No override = byte-for-byte the prior behavior, so existing tenants are unaffected. Hardened via a 3-lens adversarial review with per-finding verification (12 raised, 5 confirmed, 0 blockers, all fixed — the headline being the missed 12th loaded-labor site on the override's own screen). 6 new resolveEmployerLoad tests; tsc 0 project-wide; full suite 2090/2090; next build exit 0.
    • Restora Profit (Phase 43) — RP-12f: open Profit API. A read-only public API so a partner or the owner's accountant can pull this restaurant's profit figures — the same numbers the dashboard shows. New ProfitApiKey on MasterDB (additive) mirrors the existing `CatalogApiKey` engine exactly: a prk_ key, only its SHA-256 hash stored, the raw shown once. profit-api/keys.ts (mint / list / revoke / verify / per-key 120-req/min rate limit) + profit-api/guard.ts authenticateProfitRequest (Bearer prk_… → tenant, 401/429 with Retry-After). Public routes under /api/public/profit/v1/: `overview` (score + cost ratios), `menu-engineering` (per-dish Kasavana–Smith matrix), `costs` (the operating-cost ledger), and a no-auth `openapi.json` so partners self-integrate — all read-only, tenant resolved from the key (no IDOR), each behind an error guard that returns a clean 404 instead of a raw 500 if the tenant is gone. The public surface reuses the SAME engines as the dashboard: overview/menu-engineering go through the shared computeLocationProfit core, and costs through a new shared `cost-summary.ts` `summarizeCostLedger` (manual + auto entries — delivery / commission / loaded labor / marketing — the dashboard /profit/costs route was refactored onto the same helper, so the public total can never undercount). Owner-authed key management at /api/tenant/[tenantId]/profit/api-keys (create returns the raw key once, never the hash; MAX_ACTIVE_KEYS cap; audited) + a /dashboard/profit/api page (mint, copy-once, revoke, curl + OpenAPI docs) + nav. Key possession is the access control; a paid profit-api add-on can gate key creation at go-live. Hardened via a 4-lens adversarial review with per-finding verification; the /costs parity fix (auto-merge) + the missing-tenant error guards + the OpenAPI fidelity pass all came from it. 3 rate-limiter tests + 2 cost-core golden tests; tsc 0 project-wide; full suite 2078/2078.
    • Restora Profit (Phase 43) — RP-12e: multi-location profit roll-up. Chains already have a first-class Organization (tenants linked by organizationId + branchRole); this adds the missing consolidated profit view across every branch. The numeric heart of the per-tenant Overview was extracted into a shared profit/overview-core.ts computeLocationProfit (pure; clock + DB injected) so the per-branch Overview AND the roll-up compute through one path — an org total can never silently diverge from what an owner sees on a single branch (this is the one-system rule, not a parallel engine). profit/rollup.ts rollupLocations consolidates with honesty baked in: money is summed only within one currency (a mixed-currency org can't be summed without an FX rate → totals null + a note, per-branch figures still shown); cost ratios are revenue-weighted (a tiny branch can't swing the org number); the org RAG + score are muted unless ≥60% of org revenue comes from confident branches (mirrors the single-venue coverage gate); the org score is an explicit revenue-weighted average of confident branch scores; idle (no-sales) branches are excluded with a note; and the cross-group worst-dish list tags each dish with its own branch currency and is flagged non-comparable under mixed currencies (never ranks SEK against EUR). GET /api/group/[orgId]/profit (gated by requireGroupAccess + the multi-location add-on; fans out per branch, skipping any unreadable one with a note) + a /dashboard/group/profit page (org cards, consolidated P&L, a worst-prime-cost-first branch comparison, the worst dishes across the group) + nav. Hardened via a 4-lens adversarial review with per-finding verification (the mixed-currency worst-dish currency tagging + a golden test pinning the extracted core came from it). 11 roll-up tests + 2 core golden tests; tsc 0 project-wide; full suite 2078/2078.
    • Restora Profit (Phase 43) — RP-12d: multi-country labor cost. Loaded labor (the biggest non-food cost) was hardwired to Sweden — 31.42% employer fees + 12% vacation — for every tenant regardless of country. New profit/employer-rules.ts (pure) holds a per-country employer-on-cost table mirroring the VAT-module pattern in lib/tax/rules.ts: employerRulesFor(country) → { rules, resolved } with employerTaxPct + vacationPayPct for SE/DK/FI/NO/DE/GB/US/CA (parity with the VAT country set) and a labelled generic fallback for unmodeled countries (Sweden's figures, clearly flagged — never a silent guess). Honesty-by-design, same posture as the VAT modules: only Sweden is needsConfirmation:false (it's already in production use); every other figure is a representative public estimate (needsConfirmation:true) with a source URL and a confirm-with-a-local-accountant note. Field names match staffCost()/loadedHourlyRate() so wiring is a direct pass-through. staffAutoEntry/buildAutoEntries (cost-ledger-auto.ts) now take an optional employer loading — omitted = the confirmed Swedish default, byte-for-byte backward-compatible — and a needsConfirmation loading drops the auto staff-salary cost-ledger line to `low` confidence with a note that flags the on-cost as an estimate to confirm locally. The 9 profit routes (overview, menu-engineering, price-recos + apply, report, forecast, advisor, experiments, combos) + the two buildAutoEntries callers (costs, marketing) now resolve loadedHourlyRate(rawWage, employerRulesFor(country).rules) from db.settings.countryCode (default SE). VAT was already country-aware via resolveVat; this closes the labor half. No new schema (an owner-set per-tenant rate override is the deferred RP-12d.1 follow-up). Hardened via a 3-lens adversarial review with per-finding skeptic verification (4 raised, 3 refuted, 1 confirmed — a nit: the EmployerRules.currency field is inert; resolved with a doc-comment marking it reserved for VAT-module parity + the future country picker, as display currency is sourced independently). 0 blockers. 7 employer-rules tests + 2 country-aware auto-entry tests (incl. a backward-compat guard that the omitted-arg path stays Swedish); 189 profit tests total; tsc 0 project-wide; full suite 2063/2063. Next: RP-12e multi-location roll-ups · RP-12f open Profit API.
    • Restora Profit (Phase 43) — RP-12c: invoice OCR → cost ledger. Snap a supplier/utility invoice and we read it into a proposed cost-ledger entry the owner reviews and edits before saving — an OCR read is an estimate, human-in-the-loop, never auto-committed. profit/invoice-extract.ts (pure): mapInvoiceToCostEntry maps an extracted invoice to a draft source:'ai-estimate' entry — category-guessed (EN+SE keywords, never the auto-only platform-commission, falls back to misc), confidence capped at `medium` (an OCR read is never "high"), and a plain-language warning for every shaky field (no total → amount 0, unreadable/impossible date → today, unknown category, low-confidence read). normalizeInvoice defensively clamps the model output. profit/invoice-vision.ts — reuses the menu-import vision plumbing (filterImageDataUrls/parseDataUrl, `fitImageForVision` downscaling, the bounded 80M/6-image cap) with a forced save_invoice tool; deterministic dev-sample fallback when no AI key. POST /api/tenant/[tenantId]/profit/invoice — extract (budget-gated → 402 before any paid call, metered under 'cost-estimate' only on success, returns the draft, not persisted) + confirm (zod + isSafeLabel + a real-date round-trip → persists as source:'ai-estimate', which is excluded from the accountant P&L export; cost-ledger capped). dashboard/profit/invoice/page.tsx (upload → review/edit → save) + nav. Reuses the existing CostLedgerEntry (no new schema). Hardened via a 3-lens adversarial review with per-finding verification (4 confirmed findings, 0 blockers, all fixed): images are now downscaled before the vision call (was sending raw bytes — cost/memory/>5MB-reject); the date validator now rejects impossible-but-well-formed dates (2026-13-45) so a garbled OCR date can't pass as confident; and a cost-ledger write cap. 7 invoice tests; 186 profit tests total; tsc 0 project-wide; full suite 2054/2054. Next: RP-12d multi-country · RP-12e multi-location · RP-12f open API.
    • Restora Profit (Phase 43) — RP-12b: A/B price testing & elasticity learning. Run one dish at a TEST price for a window, then compare it to an equal baseline window at the CONTROL price. profit/elasticity.ts (pure): arcElasticity (midpoint formula) + concludeExperiment — the winner is the price that earned more contribution per day (revenue when the variable cost is unknown), inconclusive when units are too few or the two prices tie. Honesty call: with only two data points a theoretical "optimal price" is overconfident extrapolation, so the recommendation is the empirically better price, and elasticity is reported only as flagged context ("estimated, not assumed"); a thin-signal win is hedged as an "early signal". PriceExperiment/PriceExperimentResult types + priceExperiments? on TenantDB. GET /profit/experiments (lists tests; running ones carry a live preview, and a test past its `endsAt` auto-concludes and reverts to the original price so a below-cost test can't stay live) + POST (start / conclude / abandon — start applies the test price to the dish's effective field recording appliedField to revert; conclude keeps control / test / the recommended winner). dashboard/profit/experiments/page.tsx + nav. Hardened via a 3-lens adversarial review with per-finding verification (7 confirmed findings, 0 blockers, all fixed): the variant measurement window now spans exactly the same inclusive-date count as its divisor (was a one-directional bias toward the test price); contribution is computed on the net price (VAT-consistent with Track-A, no per-unit VAT double-count); endsAt is now enforced via auto-conclude+revert; plus a fresh below-cost check on conclude, an honest inconclusive button label, and the low-confidence verdict hedge. 10 elasticity tests; 179 profit tests total; tsc 0 project-wide; full suite 2047/2047. Next: RP-12c invoice-OCR → cost-ledger · RP-12d multi-country · RP-12e multi-location · RP-12f open API.
    • Restora Profit (Phase 43) — RP-12a: opt-in competitor lookup (+ the deferred competitor-gap advisor rule). The Phase-3 slice ruleCompetitorGap was waiting on — built mock-first and legally careful. profit/competitor/index.ts getCompetitorAdapter(): mock by default (a deterministic FNV-seeded adapter — zero legal exposure) vs a gated live adapter that fails closed (throws LIVE_NOT_CONFIGURED until COMPETITOR_LOOKUP_MODE=live + a source key, and may only query permissioned/licensed sources — never silent scraping). competitor-budget.ts mirrors the WoW two-tier cap — a global per-UTC-day USD ceiling (503) + a per-tenant sub-cap (429), super-admin-tunable, clamped, day-reset, all fail-closed (with a documented per-instance/go-live caveat). CompetitorPricePoint type + competitorPricePoints? on TenantDB + a competitorLookup toggle on costSettings + CompetitorDailyUsage/CompetitorTenantUsage counters + a competitorLookup config on platformSettings. Routes: POST /profit/competitor/lookup (the full fail-closed gate chain: opt-in 404 → terms 403 → requireRateLimit → global USD 503 → per-tenant USD 429 → AI-budget 402 for live → adapter → record spend only on success), GET/PUT/DELETE /profit/competitor (the owner-entered safe path — always available, no opt-in, no cap), PUT /profit/competitor/settings (enable requires accepting the terms). The deferred `ruleCompetitorGap` is now wired (advisor's 9th rule): fires ONLY when opted in, gross-vs-gross deviation from the dated median, full provenance (median + sample size + as-of), capped + deduped, never fabricated. dashboard/profit/competitor/page.tsx + nav; every point shows its source + observedAt. Hardened via a 3-lens adversarial review with per-finding verification (5 confirmed findings, 0 blockers, all fixed): the headline — mock prices are now excluded from the median so demo data can never drive a competitor-gap insight as if it were a real market signal — plus a real terms-text shown before acceptance, the >>> unsigned-shift distance fix, and the documented USD-cap per-instance/go-live caveat. 13 RP-12a tests (budget caps fail-closed, mock determinism + exclusion, the competitor-gap rule + opt-in gating); 169 profit tests total; tsc 0 project-wide; full suite 2037/2037. Live competitor sources stay gated until counsel ToS sign-off; RP-12b–f (A-B / elasticity / invoice-OCR / multi-country / multi-location / open API) remain.
    • Restora Profit (Phase 43) — RP-11: Forecast + printable Profit Report (PDF). profit/forecast.ts buildForecast (pure): projects revenue and contribution margin forward by applying the venue's learned demand seasonality (learnDemandPatterns/dayDemands from inventory/drivers — the demand-pattern projection loop applied to revenue, not stock) to a baseline daily average. Honest-by-design: never a point claim — every day and every total carries a low/p50/high band whose width grows with the venue's own revenue volatility and shrinks with history depth (and a guard guarantees a real band even for a few-units/day venue where rounding would otherwise collapse it); thin history → enoughData:false ("not enough yet", never a fabricated line); profit is labelled contribution before fixed costs, null when nothing is costable. profit/profit-report-pdf.ts buildProfitReportPdf — a printable report via pdf-lib, replicating the invoice path's WinAnsi sanitizer (so emoji/control chars in a venue name can't make doc.save() throw), streamed bytes — no disk/root write. GET /api/tenant/[tenantId]/profit/forecast (the on-screen forecast) + GET /profit/report?format=pdf (gated by canExportProfit — one-time terms acceptance + per-report review + demo-restaurant watermark wired to TenantRecord.isDemo) + POST /profit/report (accept/revoke terms → acceptedProfitVersion on ProfitProfile). dashboard/profit/forecast/page.tsx (range cards + next-14-days + the acceptance→download flow) + nav entry. Channel-variant prices (lunch/happy-hour/catering/wholesale) were already shipped in RP-4 — verified, nothing to add. Hardened via a 3-lens adversarial review with per-finding verification (7 confirmed findings, 0 blockers, all fixed): the band-never-collapses fix (low-revenue venues), the wizard now carries `acceptedProfitVersion` through its profile rebuild (a step no longer silently re-locks PDF export), the demo watermark wired to the real isDemo flag, a low-confidence day-count caveat (page + PDF), and the documented gross-vs-CM-ratio basis. 12 RP-11 tests (forecast incl. the tiny-baseline band regression + the PDF WinAnsi-survival test + the wizard acceptance-carry regression); 155 profit tests total; tsc 0 project-wide; full suite 2023/2023. Next: RP-12 expansion (competitor lookup / A-B / invoice-OCR / multi-country / multi-location / open API).
    • Restora Profit (Phase 43) — RP-10: Tenant Marketing ROI. A restaurant's marketing economics — built honest and fresh (a deliberate deviation: the platform Growth engine measures Restora's own SaaS acquisition of restaurants from subscriptions/invoices, the wrong domain for a venue marketing to its diners, and the data model has no order→channel link). profit/marketing.ts buildMarketingRoi (pure): a blended efficiency (all marketing spend vs all sales — labelled blended, not channel-attributed), a new/repeat customer cohort + blended CAC + average lifetime value identified by email→phone matching across order history, with the identified-coverage % surfaced and a lowCoverage flag so a partial match never reads as the whole truth, and per-channel CAC/ROAS only from owner-entered attribution (else null → "—", never a fabricated channel number). Spend, revenue, and the cohort all share the actual 90-day window (the merged cost-ledger marketing figure clipped + prorated via ledgerPeriodByCategory, single-sourced — no double-count). MarketingChannelSpend type + marketingChannelSpend? on TenantDB. GET /api/tenant/[tenantId]/profit/marketing + PUT/DELETE for owner channel rows (capped at 200, real-date validation, isSafeLabel on the channel name); customer email/phone are used only to derive an in-memory identifier — never returned or persisted. dashboard/profit/marketing/page.tsx (blended tiles + coverage-gated cohort + an editable channel table) + nav entry. Hardened via a 3-lens adversarial review with per-finding verification (7 confirmed findings, 0 blockers, all fixed): the headline fix scopes avg LTV to the in-window cohort (was averaging in lapsed customers shown beside window-scoped counts), aligns spend to the actual window (no run-rate × months extrapolation), adds the per-tenant channel cap, finite-input guards, real-date validation, and the LTV scope label + disclaimer footer. 10 marketing tests (incl. the cohort-LTV and finite-guard regressions); 143 profit tests total; tsc 0 project-wide; full suite green. Next: RP-11 forecast + PDF report · RP-12 expansion (RP-9 combos shipped in parallel).
    • Restora Profit (Phase 43) — RP-8: the continuous CFO Advisor (Phase-2 begins). Plain-language profit advice from live numbers, deterministic-first. profit/advisor.ts buildAdvisorInsights(ctx) — a pure, LLM-free engine: 8 rules that fire ONLY on live signals (negative contribution margin per dish, prime-cost / labor / food-cost vs the segment benchmark, weak Plow-Horse/Dog margins, an operating-cost MoM spike by category, platform-commission drag, an upcoming dated VAT step) — every number in the prose is a TS expression over ctx (absent data → nothing, never a guess). The 9th rule, competitor-gap, is deliberately deferred to RP-12a (its data + opt-in setting don't exist yet — no dead code). Anti-fatigue rankAndDedupe (built fresh — Guardian has no equivalent): a continuing signal keeps its age, a dismissed insight stays suppressed until its value moves beyond a band measured from the dismissal point, a resolved signal drops. profit/advisor-narrate.ts — optional LLM rewording behind the briefing guardrail plus a numeric post-validation guard: each prose field must carry EXACTLY the same numbers (value, count, order) and dates as its deterministic original, ISO dates are tokenized out (not mis-read as negatives), non-ASCII digits fail closed, and any drift reverts the field to template — so narration can re-word but can never invent, restate, drift, or reassign a figure (the FBL "no AI-narrated numbers" lesson, made mechanical). New AIFeature 'profit-advisor' metering (graceful over-budget fallback — HTTP 200, deterministic text). AdvisorInsight type + advisorInsights? on TenantDB. GET /api/tenant/[tenantId]/profit/advisor (assemble live ctx → build → dedupe + persist the deterministic list inside the row lock → lazily narrate the feed) + POST (dismiss/restore). dashboard/profit/advisor/page.tsx feed + nav entry; per-card "AI" chip only on actually-reworded cards. Hardened via a 4-lens adversarial review with per-finding verification (11 confirmed findings, 0 blockers, all fixed): the headline fix moved the number guard from a per-insight pool to per-field ordered-equality (blocks cross-field number reassignment), pinned the dismissal band so gradual drift resurfaces, closed the dates/non-ASCII/rounding holes, added a unitsSold > 0 live-signal guard, a rule-priority sort (impactScore units aren't cross-rule comparable), an injective insight id, the in-lock dedupe race fix, and the mock-commission "recorded, not yet charged" honesty wording. 27 advisor tests (incl. the per-field-reassignment and multi-cycle-ratchet regressions); 133 profit tests total; tsc 0 project-wide; full suite 1989/1991 (only the 2 pre-existing Mason field-map fails). Next: RP-9 market-basket campaigns (in progress) · RP-10 marketing ROI · RP-11 forecast + PDF · RP-12 expansion.
    • Restora Profit (Phase 43) — RP-7: the Profit Overview dashboard (FINAL MVP slice — RP-0…RP-7 complete). The page the wizard lands on, and the home of the track. profit-score.ts buildProfitScore — a pure, deterministic 0–100 health score = the average of two confidently computable sub-scores: pricing-floor compliance (share of ranked dishes at/above their safe floor) and menu mix (Star + Puzzle share of rankable dishes). Each sub-score is null when its population is empty, and the overall score is the average of whichever are present (or null when neither is) — never a fabricated number. rag.ts ragForScore (≥70 green, ≥45 amber, inclusive). The score + the loaded cost-ratio tiles (food cost / labor / prime cost / net margin) mute (greyed, no benchmark color, target band hidden) whenever cost-ledger + recipe coverage < 60% — a low-confidence figure is never shown as a confident venue-wide number. Deterministic top-3 actions (below-floor dishes → Dogs → thin-margin Plow Horses → prime-cost-over-band → finish-setup → slow Puzzles), urgency-sorted and capped, every count engine-computed. Read-only GET /api/tenant/[tenantId]/profit/overview assembles the score, KPIs, coverage, and a context block (country / allocation method / benchmark source+asOf) over the trailing 90 days. dashboard/nav-catalog.ts profit group (Overview / Menu Engineering / Price Suggestions / Profit Setup); dashboard/profit/page.tsx (score ring + KPI tiles + top-3 + nav cards + the wired-through "How we calculated this" panel + a distinct load-error state). Hardened via a 2-lens adversarial review (0 blockers; 2 majors fixed: the Overview's below-floor count now uses the exact same floor definition as the price ladder — variableCost / (1 − 25%) × 1.05, net-terms but provably equal to the ladder's gross compare — so Overview and Price Suggestions agree on the count; and the cost-ratio KPIs mute on low coverage instead of coloring a small costed slice green; plus the rankedCount rename for an accurate floor denominator, KPI target-band hidden when muted, and apply-failure feedback on the Price Suggestions page). 10 profit-score tests (incl. single-sub-score and boundary cases); 90 profit tests total; tsc 0 project-wide; full profit suite green. Restora Profit MVP (the Operating Cost Ledger + two-track costing + menu engineering + price ladders + the wizard + this overview) is now build-complete; remaining is go-live (counsel-confirmed VAT/benchmark figures, the opt-in competitor lookup) + the post-MVP slices RP-8…RP-12.
    • Restora Profit (Phase 43) — RP-6: the 5-minute Profit Wizard. The first owner-facing page in the track. profit/wizard.ts applyWizardStep — a pure, idempotent, deterministic step engine: each step body is hashed (a retried step is a no-op) and the manual cost entries it writes get a session-independent id (costledger_wizard_<category>), so retrying or resuming the wizard upserts the same line instead of duplicating it (a zeroed cost removes it; an owner-entered cost outside the wizard is never touched). ProfitProfile type + profitProfile? on TenantDB. API GET /profit/wizard (resume the saved profile) + POST /profit/wizard (save a step under the row lock; mirrors the allocation method onto costSettings; audits on completion). dashboard/profit/wizard/ProfitWizard.tsx — a clean 5-step UI (profile → costs → labor → food → sales) mirroring the onboarding wizard, that resumes where you left off (reusing the saved session id), auto-imports where modules are active, lands the owner on the Profit Overview, and carries the "How we calculated this" panel. Hardened via a 2-lens adversarial review (1 major fixed: the cost id was session-keyed → cross-session duplicate rent on resume; plus timestamp-churn, 0-seats, and free-text-cap fixes). 9 tests (incl. the cross-session no-duplicate regression); tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-7 the Profit Overview dashboard (the final MVP slice).
    • Restora Profit (Phase 43) — RP-5: honesty/legal scaffold + "How we calculated this". Makes the whole Profit surface defensible before the dashboards render it. profit/legal.ts extended to REUSE the Tax Assistant's findBannedClaims / isSafeLabel / confidenceFromCompleteness (one canonical implementation, re-exported — no duplication, no circular import) and add a Profit-specific canExportProfit gate (acceptance → explicit review → demo-watermark) keyed to RESTORA_PROFIT_VERSION + the estimate-not-advice disclaimer, decoupled from the Tax Assistant's acceptance state. New components/profit/HowWeCalculated.tsx — a server-safe, no-JS <details> panel that plainly explains every assumption (net-of-VAT pricing, contribution margin vs the allocated fully-loaded net, share-of-revenue allocation, loaded labor at SE 31.42% + 12%, benchmark source/date) and carries the permanent disclaimer; RP-6/7 drop it beside the numbers. Hardened via an adversarial review (verdict: ship; 0 blockers, 1 nit fixed — re-export ExportGateState). 5 tests (banned-claims catch "guaranteed"/"official"/"tax advice"; gate branches; version decoupling); tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-6 the 5-minute Profit Wizard.
    • Restora Profit (Phase 43) — RP-4: price recommendation ladder + one-click apply. The first slice that writes back to live menu prices. profit/price-reco.ts buildPriceLadder: for each dish, a full ladder — minimum-safe (break-even + cushion, grossed through VAT), ideal (hits the segment's target food-cost via suggestPrice), premium, psychological (charm 9-ending), campaign, and per-channel (dine-in / delivery / lunch / happy-hour / catering / wholesale) — with deterministic reasons + a 0–100 confidence score (honestly capped on partial COGS / estimated VAT). Hard rule (enforced + tested): no rung ever falls below the safe floor, and the psychological rung rounds up. PriceRecommendation / PriceChannels / ProfitPositioning types + priceRecommendations? on TenantDB. API GET /profit/price-recos (the reco queue, preserving applied/dismissed status) + POST /profit/price-recos/apply (writes the new price to the effective field — discount if active, else base — recomputes costModel.computed, flips the reco, audited; a below-cost price is allowed but `marginWarning:true`, never silently corrected). Hardened via a 2-lens adversarial review (1 blocker fixed: apply now targets the effective discountPrice ?? basePrice field instead of silently no-op-ing on discounted dishes; plus bounded newPrice to the editor's max, skip dishes with no cost basis, tightened the reco match). 11 tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-5 honesty/legal scaffold + "How we calculated this".
    • Restora Profit (Phase 43) — RP-3: the Menu Engineering 2×2 matrix. profit/menu-engineering.ts buildMenuEngineering: aggregates units sold per dish over a window, computes each dish's Track-A contribution margin (reusing RP-2), and classifies it on the Kasavana–Smith grid — Star / Plow Horse / Puzzle / Dog — popularity = unitsSold ÷ menu-average units (high = ≥ 0.70× average, compared on the raw ratio), profitability = contribution margin ≥ menu-average CM, each with a deterministic recommended action. Honesty gate: a dish that isn't fully costable (no/partial recipe) or whose VAT can't be confidently stripped gets quadrant:null + a "needs costing" note and is excluded from the menu averages — never a fake quadrant. MenuQuadrant/MenuEngineeringItem/MenuEngineeringSnapshot types + menuEngineeringSnapshots? on TenantDB (capped 12-deep history) + quadrant? on Product.costModel.computed. API GET /api/tenant/[tenantId]/profit/menu-engineering (cached latest, or refresh=1 recomputes the default last-90-days window; a custom from/to recomputes its own period and is never cached/persisted). Hardened via a 2-lens adversarial review (0 blockers; fixed a round-before-compare popularity bug, a custom-window cache mismatch, labor-source consistency with the menu Cost tab, and documented the combo-pricing limitation). 8 tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-4 price recommendation ladder + one-click apply.
    • Google + AI integration — IndexNow now fires on every publish surface (not just the site builder). New reusable pingIndexNowForTenant(tenantId, paths) helper (resolves the tenant, skips demo/inactive, builds the URLs, submits — still dormant unless INDEXNOW_KEY + production + a public host). Wired into: the site-builder save (refactored onto the helper), settings save (/ · /menu · /reservations · /contact), menu product create / edit / delete (/menu), and blog publish / edit / delete (/blog + the post URL — only when the post is published, drafts don't ping). So a menu price change or a new blog post now gets fast-indexed too, not just a website-builder publish. tsc 0; 5 indexnow tests green.
    • Google + AI integration — GBP polish + per-domain Search Console + guest waitlist + Reserve-with-Google rail. Six features, all honesty-gated and mock-first, hardened by a 7-agent adversarial review (0 blockers; 2 nits fixed). (1) GBP price level — attributes/price_range ENUM derived from the real menu average (currency-aware via the shared priceLevelFor, so a 100 SEK menu is $ not $$$$), omitted when there are no priced products, with an owner override on /dashboard/settings/google/attributes (Auto by default). (2) GBP EVENT posts — a date-windowed tenant Offer (real start+end date) becomes a richer EVENT localPost; open-ended offers stay OFFER posts; no fabricated schedule. (3) paymentAccepted from real config — removed the hardcoded "Credit Card, Debit Card, Cash" on every storefront; Restaurant JSON-LD + the payments FAQ now list ONLY configured methods (Stripe→cards, PayPal, Swish→GBP pay_other_payment_methods, never NFC, never cash), omitted when none. New shared lib/seo/payment-methods.ts. (4) Per-tenant Search Console — settings.seo.searchConsoleVerification emits a google-site-verification meta ONLY on the tenant's own custom-domain/subdomain pages (gated on x-tenant-base==='root', never the platform path), owner field in SEO → Technical. (5) Guest waitlist — separate WaitlistEntry[] on TenantDB (not overloaded onto reservations): public join page + /api/public/[tenantId]/waitlist (active-tenant + waitlistEnabled gated), staff queue at /dashboard/waitlist with notify-when-ready (mock-first guest SMS on the waiting→notified transition), seat/remove; quoted wait is staff-set, never invented. (6) Reserve with Google — schema.org ReserveAction + GBP DINING_RESERVATION now gated on reservations actually being enabled (fixed a bug that advertised booking for opted-out tenants); plus a contract-gated mock RwG partner rail (rwg-feed.ts/rwg-booking-server.ts + /api/google/rwg/{feed,booking}) reusing the real getAvailableSlots engine, 404s unless RWG_MODE is on, refuses unsigned live traffic. Schema: gbpPriceLevel, seo.searchConsoleVerification, WaitlistEntry/WaitlistStatus + waitlist?/waitlistEnabled?. tsc 0; 41 new/updated tests green (53 across google+seo).
    • Restora Profit (Phase 43) — RP-2.5 + RP-2: market benchmarks + the two-track cost engine. The heart of the menu-engineering math. RP-2.5 profit/benchmarks.ts: SE-seed industry benchmark bands by segment (food-cost 28–35%, prime 55–65%, labor by segment), benchmarkFor(country,segment,overrides) (owner-override → exact → by-segment → generic), and RAG band helpers — all flagged "industry estimate — verify" (a documented go-live gate); MarketBenchmark/ProfitSegment types + marketBenchmarks? on TenantDB. RP-2 profit/{variable-cost,contribution,allocation,loaded-net}.ts: Track A contribution margin = net price (VAT-stripped via resolveVat; an unknown/fell-back jurisdiction keeps gross + flags vatEstimated, never a guessed strip) − VARIABLE cost (ingredient + packaging + variable labor + waste + the per-unit commission fee from the single commission-engine source; no fixed overhead). Track B fully-loaded net = CM − allocated fixed overhead (allocateOverheadPerUnit, default share-of-revenue, exhaustive Σ=pool, fractional-unit-safe), always labelled an estimate. Product.costModel.computed += optional contributionMargin?. Double-count guard: VARIABLE_COST_CATEGORIES + fixedOverheadFromLedger() so the Track-B pool excludes the variable categories already inside CM. Hardened via a 2-lens adversarial review (0 blockers; fixed the VAT-strip gate, the Track-A/B double-count seam, fractional-unit flooring, + the prime-cost comparability note). 29 tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-3 menu-engineering 2×2 matrix.
    • Google + AI integration — Search Console verification meta now actually renders. The Super Admin → SEO field globalSEO.googleSearchConsoleId has long been configured but never output (the admin hint promised it was "injected on the root page"). The platform root page (app/page.tsx) now emits <meta name="google-site-verification"> via generateMetadata. Env-first (GOOGLE_SITE_VERIFICATION, new in .env.example) so the marketing homepage stays statically rendered when an env value is set; falls back to the admin-configured DB value (best-effort, only then dynamic). Verifies the platform property (restora360.com); per-tenant custom-domain verification stays a separate per-tenant field (follow-up). tsc 0.
    • Google + AI integration — Merchant-feed owner card + richer Bazar restaurant JSON-LD. (1) Merchant-feed owner card — the /google-merchant.xml feed was generated automatically but invisible to owners; the Google settings page now shows a "Google Shopping feed" card with the tenant's feed URL (copy button) + the one-time Merchant Center registration steps + an honest note (best for sellable/packaged goods; the dine-in menu is already covered by GBP + structured data). New GET /api/google/shopping-feed returns the per-tenant feed URL (via tenantPublicBaseUrl); co-located client ShoppingFeedCard.tsx, hidden until the URL resolves. (2) Enriched `buildBazarRestaurantLd` — the Food Bazar restaurant JSON-LD now also emits hasMenu (storefront menu), openingHoursSpecification (real weekly hours), and geo (geocoded coords), wired from the Bazar restaurant page. 1 new test; tsc 0.
    • Restora Profit (Phase 43) — RP-1: the P&L now subtracts real operating expenses. Wires the RP-0 cost ledger into the Tax Assistant income statement: bookkeeping/pnl.ts operatingExpensesNet (hardcoded 0 since Phase 19) now equals the owner-entered cost ledger scaled to the statement period, so netResult finally reflects rent / utilities / marketing / wages / etc. Honesty-preserving: only MANUAL owner-entered entries count — estimated/auto figures (modeled commission, shift-derived labor) are excluded from this accountant-facing draft; hasLaborCosts/hasOverheadCosts flip, the coverage note adapts, and per-category opex_* lines join the lite chart of accounts. Period scaling is honest day-proration (monthsCovered): a whole month = 1.0, a full quarter = 3.0, and a boundary-straddling 30-day window ≈ 1.0 (not 2.0) — so the route's default last-30-days view can't ~2× the opex. buildIncomeStatement gains an optional costLedger? input (default empty → every other caller unchanged); the bookkeeping route + the read-only accountant portal both pass db.costLedger. Hardened via a 2-lens adversarial review: each entry is clipped to its active sub-window (a ledgerPeriodTotal/ledgerPeriodByCategory engine pair — a cost that starts/ends mid-quarter isn't charged for the whole period), per-order costs are valued from the period's order volume (no longer silently 0), the honesty flags follow actual non-zero contribution (a one-off / 0% line can't claim a cost class is covered while opex is 0), and only source:'manual' entries feed the draft (a future ai-estimate row can't leak in). 11 new tests; tsc 0; full suite green (only the 2 pre-existing Mason field-map fails). Next: RP-2.5 benchmark seed → RP-2 two-track cost engine.
    • Restora Profit (Phase 43) — RP-0 Operating Cost Ledger (the keystone), build-with-seed. First slice of the new AI menu-engineering / profit-optimization track (plan: docs/products/restora-profit/). Adds the missing operating-cost primitive so the platform can model rent / utilities / wood / marketing / owner-salary / etc. — auto-detected where modules are on, manual otherwise. New pure engine lib/profit/cost-ledger.ts (normalizeMonthly cadence→monthly run-rate, ledgerMonthlyTotal, ledgerByCategory, costCoverage→auto-% + core-bucket completeness, monthPeriodOf; all deterministic — no Date.now()), auto adapters lib/profit/cost-ledger-auto.ts (delivery fees from orders, marketing from cost settings, loaded labor from terminal-shift hours × wage via staffCost(), and platform commission from the commission engine as the SINGLE fee source so auto + manual can never double-count), shared types lib/profit/types.ts (re-exported from schema.ts), and lib/profit/legal.ts (RESTORA_PROFIT_VERSION + the permanent "estimate, not advice" DISCLAIMER_SHORT). Additive schema: TenantDB.costLedger?, costSettings.allocationMethod? (default share-of-revenue). API GET/PUT/DELETE /api/tenant/[tenantId]/profit/costs (auth-gated, zod-validated, audited; GET returns monthly total + operatingExpensesNet + coverage meter + disclaimer). Hardened via a 3-lens adversarial review: a single-calendar-month query guard (a multi-month range can no longer be reported as a "monthly" figure into the P&L), a loaded staff-salary↔manual payroll-tax coupling (the 31.42% employer fee can't double-count), one-off / 0% manual lines no longer shadow a real auto cost, card-fee is a normal manual cost (only platform-commission stays auto-only), and bounded entry ids. 16 tests; tsc 0; full suite green (the only 2 fails are the pre-existing Mason field-map tests). Next: RP-1 wires `operatingExpensesNet` into `bookkeeping/pnl.ts` (today hardcoded 0).
    • Google + AI integration — honest GBP “more hours” (LOW polish). buildMoreHours no longer fabricates a delivery cut-off (regular − 30 min) or a hardcoded Mon–Thu 16:00–18:00 happy-hour the owner never configured. It now emits Takeout during the restaurant’s regular open hours (a sensible default) + Delivery during open hours only when the restaurant actually offers delivery. Consistent with all the other no-fabrication fixes. 1 test; tsc 0.
    • Google + AI integration — Food Bazar discovery boosters (reciprocal `sameAs` + per-dish sitemap). Two refinements so Google can surface the marketplace alongside the restaurant's own result (the "both benefit" goal): (1) Reciprocal `sameAs` cross-link — the Bazar restaurant page now emits sameAs: [storefrontUrl], and the dish Product links its brand/seller back to the restaurant's storefront URL, so Google treats the Bazar listing + the storefront as the same business across two surfaces rather than duplicate pages competing for one slot (each still self-canonicalises, so neither suppresses the other). (2) Per-dish `/item/` URLs in the platform sitemap — every approved restaurant's available, priced, non-bazar-disabled dishes (popular-first, capped 50/restaurant) are now listed, added inside the existing approved-restaurant loop so it costs no extra DB read. 2 new tests; tsc 0.
    • Google + AI integration — Food Bazar dish pages are now server-rendered + structured (per-dish JSON-LD). The /bazar/restaurant/[slug]/item/[productId] page was fully client-rendered with NO metadata and NO structured data, so Google/AI couldn't ingest a dish's price or rating. Split into a thin server page.tsx (the interactive UI moved verbatim to ItemDetailClient) that now emits: `generateMetadata` (title/description/canonical/OG/Twitter from the dish), a per-dish `Product` + `Offer` + `aggregateRating` JSON-LD (buildBazarMenuItemLd — name, image, price / sale price, in/out-of-stock, restaurant brand, and the dish's real star rating from approved item reviews), and a Breadcrumb. New server loader lib/bazar/dish-seo.ts returns null for off-marketplace / unapproved-listing / unavailable / priceless dishes (never emits misleading data). 2 tests. Also fixed a latent excess-property type error in gbp-payload.test.ts. tsc 0. Follow-up: include `/item/` URLs in the platform sitemap (gated to available dishes).
    • Google + AI integration — GBP payload now reads REAL tenant data (no more placeholders). A batch of correctness fixes to the GBP location / media / place-action payload, plus a shared payloadInputFrom helper so the location + resource syncs never drift: (1) `websiteUri` respects the custom domain — was hardcoded {subdomain}.restora360.com (wrong for custom-domain tenants); now resolves via tenantPublicBaseUrl. (2) Real cover + logo photos — buildMedia was pushing FABRICATED placeholder URLs (/banner.jpg, /photos/exterior.jpg, /photos/interior.jpg, /photos/menu-print.jpg) that 404 → Google rejects; it now emits only the real bannerUrl (COVER) + logoUrl (LOGO) + actual product photos, resolving relative URLs to absolute. (3) `FOOD_DELIVERY` place-action + service area only when delivery is offered — adds the delivery CTA and a delivery-area radius from the real deliveryRadiusKm (was a hardcoded 5 km serviceArea on every tenant, including dine-in-only). (4) Special hours from the owner's real special days (was a hardcoded "closed Dec 25"). (5) `openInfo` reflects an active temporary closure (CLOSED_TEMPORARILY; was always OPEN). The two duplicate payload-construction sites collapsed into one helper. 5 new tests; tsc 0.
    • Google + AI integration — GBP service-attributes editor (`settings.gbpAttributes`). Closes the highest-leverage Phase-3 gap: the amenities the honesty rule was omitting because nothing backed them. New GbpServiceAttributes model (dine-in · takeout · curbside · outdoor seating · groups · kids · alcohol · Wi-Fi · restroom · 4× wheelchair-access · dogs-outside) + an owner editor at /dashboard/settings/google/attributes (grouped toggles + save) backed by a dedicated /api/google/attributes-config GET/PATCH (kept off the shared settings route to stay additive). Only toggles turned ON are published (still zero fabrication); buildAttributes emits them via a key→Google-attribute-id map (go-live-verify: ids are category-specific), threaded through serviceSignalsFrom → the attributes sync. Linked from the GBP page. 4 honesty tests; tsc 0.
    • Google + AI integration — GBP Performance / Insights pull. The one materially-unbuilt GBP read surface, now built (mock-first). New fetchPerformanceMetrics client (Business Profile Performance API — the business.manage scope already grants it, no extra approval; real endpoint defensively parsed + flagged go-live-verify) + lib/google/performance.ts (pure shapePerformanceCards + getPerformanceSummary) + /api/google/performance route + an Insights page at /dashboard/settings/google/insights: profile views (Search+Maps impressions), calls, direction requests, website clicks, menu clicks, bookings, food orders, with a 7/30/90-day range and a "sample data" badge in mock mode. Linked from the GBP page. 2 tests; tsc 0.
    • Google + AI integration — Google Merchant Center product feed (the "Shopify model" lever, automatic per tenant). New per-tenant Google Shopping product feed at /google-merchant.xml — served host-aware (subdomain or custom domain), like sitemap.xml/llms.txt — generated automatically from the tenant's real menu: id · title · description · link · image · price · saleprice · availability · brand · producttype · googleproductcategory. lib/seo/merchant-feed.ts (pure buildMerchantItems + renderMerchantFeedXml + xmlEscape). The owner registers the URL once in Merchant Center → the restaurant's sellable items appear as FREE Google product listings (Shopping tab, Search, Images, Lens) at scale, with no per-product verification — the genuine equivalent of what Shopify automates for Google. Honest scope: only published products WITH a price and an image are emitted (Google requires image_link; imageless/zero-price items are skipped rather than submitted to be disapproved), and it's framed as best-fit for sellable / packaged / online-order goods — the dine-in menu itself is already covered by GBP FoodMenus + the Menu JSON-LD. Demo/inactive tenants + the platform host return an empty feed. 4 tests; tsc 0 (my files). Follow-up: an owner-facing "your Google Shopping feed" card (shows the URL + the one-time Merchant Center registration steps); the Content API / multi-client-account PUSH model is the scale layer (needs a Merchant Center account = owner/go-live step).
    • Google + AI integration — Phase 3 (GBP go-live, mock-first): service-attributes sync + an honest payload + legacy reconciliation. Closes the flagged gap where the GBP payload built attributes but nothing ever pushed them. New updateAttributes client method (gbp-api-client.ts — the separate Business-Information v1 /attributes sub-resource with its own attributeMask, mock-mirrors like patchLocation) + syncAttributesToGbp (sync-engine.ts — single-PATCH replace, offline-validated) wired into the sync route (resource: 'attributes'), the umbrella syncAllToGbp, and a new "Service options" row on /dashboard/settings/google. The honesty fix (the important part): buildAttributes previously FABRICATED every value — dine-in, wheelchair access, outdoor seating, pets, "atmosphere", all hardcoded true. It now emits ONLY attributes with a real backing signal — has_delivery (deliveryZones/radius), restaurant_reservations (reservationSettings.enabled), card/NFC payments (paymentSettings), serves_vegan/serves_vegetarian (actual menu dietary labels) — and OMITS everything it can't substantiate (no guessed amenities). 3 honesty tests lock that in. Also fixed a latent Phase-1 bug: acceptsReservations + the reservations auto-FAQ read settings.reservationSettings, but that field lives on TenantDB, not settings — so the cast silently resolved to undefined and always defaulted true; both now read db.reservationSettings. Legacy reconciliation: the orphaned /dashboard/google-business page (which only talked to a mock-only stub) now redirect()s to the real /dashboard/settings/google, and the NEW-badge route mapping was repointed so it clears correctly. tsc 0 (my files); 8 tests green. Mock-first: the real Attributes PATCH only fires with live creds. Deferred — conditional verification banner (needs the live `hasVoiceOfMerchant` signal). External gates (owner/Google action, not code): GBP API-access application, live OAuth creds, the agency organization-account + per-tenant Manager grant, Reserve-with-Google contract. Go-live verify: the exact attributes endpoint/`attributeMask` shape + category-specific attribute-ID allowlist against the live swagger.
    • Google + AI integration — Phase 2: IndexNow fast-indexing (automatic on publish, zero owner action). When an owner saves/publishes their site, the public URLs are now pushed to IndexNow (Bing, Yandex, Seznam, Naver) so those engines re-crawl in minutes instead of waiting for organic discovery. New lib/seo/indexnow.ts (submitToIndexNow + pure tenantPublicBaseUrl / isSubmittableHost / getIndexNowKey) + the /indexnow.txt key-verification route (served for any subdomain/custom-domain host, like llms.txt). Wired fire-and-forget into the builder save (PATCH /api/tenant/[tenantId]/builder) — submits home + menu + reservations + contact + blog + enabled Mason pages (never /account), demo/inactive tenants skipped. Safe-by-default: a complete no-op unless INDEXNOW_KEY is set AND NODE_ENV=production AND the host is a real public domain (local/dev/tailnet/IP hosts are rejected) — so it stays dormant until the owner flips it on for the live domain. Deliberately NOT Google: Google's Indexing API is restricted to JobPosting/BroadcastEvent pages and its sitemap-ping was retired in 2023, so for Google we rely on the Phase 1 sitemap + structured data (using the Indexing API for restaurant pages would violate Google's terms). New INDEXNOW_KEY in .env.example. tsc 0 (my files); 5 indexnow tests green. Other publish surfaces (menu/blog/settings saves) can adopt the same `submitToIndexNow` call as a follow-up.
    • Google + AI integration — Phase 1: own-your-data SEO hardening (automatic, zero owner action, no Google approval). Every tenant storefront now emits complete, correct, honest structured data + crawl directives on publish. (1) Closed the secondary-page metadata leaks — contact / reservations / catering / owner-added Mason pages had only a title+description (no canonical, no robots policy, no OpenGraph), so a "noindex/staging" tenant still exposed them and they self-canonicalised to ?lang= URLs. New shared lib/seo/tenant-metadata.ts (buildTenantSubpageMetadata + robotsFromPolicy) gives them all a custom-domain-aware canonical, the tenant's robots policy, and social cards, derived automatically from the tenant DB. (2) Fixed the blog canonical to resolve via resolveSiteUrl (was the raw request host — could diverge from a custom domain) + added its missing robots policy. (3) Brand-name cleanup — contact / catch-all / menu titles + OG now use displayBrandName (no more &#8211; / scraped-domain leaks). (4) hreflang — home + menu now emit alternates.languages mapping each enabled language to its ?lang= variant (+ x-default), so multilingual storefronts aren't penalised for duplicate content. (5) AI-snippet eligibility — indexable pages now carry max-image-preview:large / max-snippet:-1 / max-video-preview:-1 (via googleBot). (6) JSON-LD honesty — acceptsReservations is derived from reservationSettings.enabled (default on, but a restaurant that turned reservations off no longer over-claims), the "Do you take reservations?" auto-FAQ is gated the same way, and Restaurant.hasMenu now @id-joins the Menu entity (/menu#menu) instead of a bare URL. (7) Fixed the llms.txt custom-domain bug — a custom-domain tenant (luigi.com) was served the platform llms.txt because detection used host.split('.')[0]; now uses the shared resolveHostToTenant (and drops a duplicated PLATFORMHOSTS set). tsc 0; 5 structured-data tests green. Deferred (need app-wide/root-layout changes or the moved-to-viewport Next API): per-tenant <html lang>, Search Console verification meta, theme-color, Speakable; the tenant WebSite SearchAction was skipped (no site-search endpoint to honour). Phase 2 = IndexNow fast-indexing._
    • Google Business Profile: a non-technical, step-by-step Connect + Verify guide beside the button. Owners kept seeing a bare "Connect Google" button with no idea what would happen. The /dashboard/settings/google not-connected state now renders GoogleConnectGuide (new co-located ConnectGuide.tsx): a "takes ~2 minutes, most finish in one click" reassurance, a numbered 5-step walkthrough (tap Connect → sign in to the restaurant's Google account → tap Allow on the permission screen → pick your restaurant → done/syncing) each with a plain-language explanation of exactly what the owner will see, a "what happens after you connect" recap, a collapsible "No Google account yet?" helper (free signup deep-link), and — the half owners ask about most — a collapsible verification walkthrough explaining Google's one-time "is this a real business?" check with all three methods in plain words (postcard code 5–14 days · phone/text code · short on-phone video) and honest expectations. The verification guide is reused (compact) in the location-picker step's empty state, since "no listings found" usually means the restaurant isn't verified yet. A "Verify with Google" action deep-links to business.google.com (verification is completed on Google's side — the API can't do video) with a "see Google's help" secondary link. Copy is accurate to Google's real 2026 flow: ownership stays with the restaurant, we never read Gmail, verify once then everything syncs automatically. Pure-presentational, 0 any, tsc 0. Also repointed the "Google Business" nav item (Marketing section) from the legacy mock-only /dashboard/google-business stub to the real connect+sync page /dashboard/settings/google, so the working flow is easy to find. First owner-facing increment of the Google + AI integration plan; org-account model + Merchant Center feeds + own-your-data SEO hardening are the next steps.
    • Phase 42 — Restora360 OS terminal: light/dark theme + a working Settings screen (the bottom-nav Settings tab goes live). The terminal was dark-only; it now has a light theme + a persisted toggle. New LightTerm palette (same token contract as DarkTerm; a slightly darker mint #00A06E so mint-as-text stays legible on white); TerminalTheme(dark) chooses the palette + flips the status-bar icons + uses lightColorScheme/darkColorScheme. Mode persists per device (TerminalGraph.darkTheme StateFlow + prefs). Per the owner's nav feedback, the toggle lives in a real Settings screen (Appearance: Dark/Light) — reached from the bottom-nav Settings tab, which is now wired (Home active; Payments/Reports still presentational — Reports will become payment history, moving Home's "Recent" there). The Settings screen also shows terminal info (paired-as/section/version) + a "Switch to Normal Phone" exit. The Boot Picker + Secure Boot Splash intentionally stay dark (the "boot" aesthetic) via per-branch theming in TerminalRoot; only the running terminal honors the toggle. ~31 hardcoded dark tints across Home/payment/pairing screens were converted to token-derived values (accent.copy(alpha) over card, textSecondary, accentSub, etc.) so they adapt — faithful to dark mode (the originals were already "accent X% over base") while flowing to light. :terminal:assembleDebug green. Next: light/dark for the `:pos` tablet; Reports/Payments tabs (payment history).
    • Phase 42 — Restora360 OS dual-boot terminal: BYOD "dual-boot" via launcher mode (the real Model-A boot experience). The owner's vision delivered on a personal phone with no hardware ownership / no Device Owner: the user opts in to making Restora the home/launcher, so after unlock the dual-boot picker shows before the normal UI → Normal Phone (hands off to the stock launcher) or Restora OS (the terminal). Fully reversible from system settings. New boot/LauncherRole.kt (isDefaultHome, requestSetDefaultHome → the canonical ACTION_HOME_SETTINGS for reliability across OEMs incl. MIUI, goToNormalPhone → opens the OEM/stock launcher [prefers FLAG_SYSTEM] or leaves the app), a HOME+DEFAULT intent-filter on MainActivity (eligible only — never auto-becomes home), a Boot-Picker "Make Restora your start screen" action (↔ "Restora is your start screen · Change"), and TerminalRoot default-home state refreshed on resume. Honest: nothing runs before unlock (Android security); the user must confirm the system prompt (the same rule that preserves their freedom). Same approach will give the :pos tablet its dual-boot. Boot-Picker footer copy corrected (point at the Normal Phone card, not the inert Restart pill). Adversarially reviewed (3 low fixes: reliable home-settings path, stock-launcher preference, honest copy). :terminal:assembleDebug green. Frames the dual-boot honestly: a real "unlock → picker" on BYOD via launcher (opt-in), a true locked boot on Model-B (Device Owner), and firmware boot as a separate hardware track.
    • Phase 42 — Restora360 OS dual-boot terminal, DB-7: Model-B locked kiosk + go-live gates (optional/mostly documented). The dedicated-terminal path: a company-owned, Device-Owner-enrolled device locks to the terminal and boots into it. New DeviceOwnerKiosk (real lock-task — setLockTaskPackages + startLockTask; Home/Recents off, notification shade suppressed); createKioskController() auto-selects it only when `isDeviceOwnerApp`, else the BYOD SoftKiosk — so a personal phone is never locked (a website-installed APK can't self-grant Device Owner). Added TerminalDeviceAdminReceiver + res/xml/device_admin.xml (the enrollment admin component) and a BootCompletedReceiver that auto-launches into Restora on boot — guarded to Device-Owner devices (no-op on BYOD, so a personal phone is never hijacked on restart). Lock-task is now driven off the boot mode (re-asserted if the OS recreates/restarts a Model-B device into RESTORA, instead of silently un-pinning). Honest framing (per owner discussion): a true power-on boot menu before unlock is impossible for any app (Android security) — the real "boot into the Restora picker, locked" experience is delivered on Model-B dedicated/enrolled devices (auto-launch + lock-task); on BYOD the in-app picker is the honest ceiling (a mode switch); a true-firmware boot menu is a separate hardware/ROM track. Documented gates (P40/P41): enrollment QR/zero-touch, set-as-HOME polish, PCI processor merchant onboarding, fiscalization, offline-auth floor limits — see docs/Android Dual Boot System/DB-7-MODEL-B-AND-GO-LIVE.md. Adversarially reviewed (1 fix: the lock-task re-assert). :terminal:assembleDebug green. DB-0…DB-7 complete — the terminal phase is done; remaining is the go-live gates + the enhancement backlog.
    • Phase 42 — Restora360 OS dual-boot terminal, DB-6: offline store-and-forward. Take payments with no internet — every approved (mock) tap is captured to an encrypted local outbox and auto-synced when a network returns. New pure engine lib/android-terminal/charges.ts was DB-5; this adds the sync target: `POST /api/public/android-terminal/transaction` (device-token authed like /heartbeat; idempotent by `clientTxnId`; per-device + per-IP rate-limited) records the tap into the tenant's Phase-33 payment ledger (recordOrderPayment, cents→major units, tagged mock until P40, source: 'terminal') — so terminal takings show in reconciliation. The cleanup cron is unchanged. Phone (`android/:terminal`): TerminalOutbox (EncryptedSharedPreferences JSON queue, Keystore-backed); capturePayment on every Approved with a stable per-tap `clientTxnId` (rememberSaveable, so a process-death/config-change restore re-captures the SAME id → dedups instead of double-recording); drainOutbox flushes (serialized by a Mutex) on app open, on connectivity-return (observer), and after each capture, with a WorkManager backstop (CONNECTED constraint) for the app-killed case; bindProcessToNetwork pins traffic to validated Wi-Fi over cellular. Failure-class-aware: 401 wipes the token, a permanent 4xx drops the poison entry (never head-of-line-blocks), 5xx/429/IO retries. UI: Approved shows "Saved offline — syncs when you're back online"; Home shows a "N payments saved offline" banner + the seed Today total is now tagged SAMPLE (honesty). New deps: androidx.work:work-runtime-ktx. Adversarially reviewed (7/7 fixes, incl. the critical stable-clientTxnId double-capture fix, the unsynced-drop bound, drain mutual-exclusion, poison-entry handling). Transaction endpoint smoke-tested (recorded + idempotent on retry). tsc 0; 33 android-terminal tests; :terminal green. Deferred: a real store-and-forward processor (P40); the floor-limit gating when the processor lacks offline auth is a P40 concern.
    • Phase 42 — Restora360 OS dual-boot terminal, DB-5 (remainder 2): register → phone "bill push". A POS register sends a bill straight to a paired terminal — no re-keying the amount. Server-mediated (reuses the device-token auth + master DB; no on-phone HTTP server). New pure engine lib/android-terminal/charges.ts (create / find-pending / expire / prune / projections; 10 unit tests). API: owner POST /api/tenant/[id]/android-terminals/[deviceId]/charge (push a charge — supersedes any prior pending one; one bill in flight per device) + GET (latest status for the dashboard); public POST /api/public/android-terminal/pending (device-token authed poll → the live charge) + POST .../charge-result (paid+tip / declined / canceled; idempotent). The cleanup cron now also expires (>10 min) + prunes charges. Phone: polls on Home while idle → an incoming-charge banner (amount + reference) → Take this payment pre-fills the amount and jumps to the tip step → tap → reports paid + tip back; Decline reports declined. Dashboard: each online terminal gets a Send a charge form (amount + ref) with a live status that flips to Paid $X (incl. tip). Additive MasterDB.androidTerminalCharges + AndroidTerminalCharge. Adversarially reviewed (5/5 fixes, incl. a critical: the dashboard called /[deviceId]/charge but the handlers sat at /[deviceId] → the whole flow 404'd; moved them to a /charge route — now smoke-tested end-to-end: push → poll → pay+tip → dashboard shows Paid). Also fixed: a late "paid" past the TTL now still wins (not lost as expired), Decline→"declined" (distinct from the supersede "canceled"), and a per-device throttle on the polled /pending route. MOCK-first (amounts phone/owner-sourced until P40). tsc 0; 33 android-terminal tests; :terminal green. Deferred: the Bluetooth fallback + the `:pos` app calling the same owner route. Polish: the incoming charge now takes over the phone as a dim + blurred full-screen overlay (elevated glowing card, centered, "INCOMING PAYMENT") instead of an inline card that blended into the Today card — so a server can't miss it.
    • Phase 42 — Restora360 OS dual-boot terminal, DB-5: email/SMS receipts. The Approved screen's "Digital invoice" is now live (the print options honestly stay "Soon"): a dialog with an Email / Text toggle → address → Send, with sending/sent/error states that can't double-submit. New pure engine lib/android-terminal/receipt.ts (HTML-escaped email + compact SMS builders, email/phone validators; 8 unit tests). API: POST /api/public/android-terminal/receipt — device-token authed (same constant-time gate as /heartbeat; a 401 makes the phone wipe its token and drop back to Connect), sends via the existing sendMail (email) or the tenant's smsConfig + sendSms (SMS). Honest: in MOCK payment mode the receipt is clearly marked a test receipt — no real charge (real cards = P40). Native side: TerminalApi.sendReceipt + PairingRepository.sendReceipt (reuses the shared markUnpairedFromServer 401 handler) + the ReceiptSendDialog. Adversarially reviewed (4 fixes): per-device + per-tenant rate-limits after auth (an IP-only cap let a paired device blast mail on the tenant's sending reputation / starve other tenants), server-derived total (amount + tip, never trusted from the client), distinct 401→"unpaired" message + state convergence, and clearing the recipient field on channel toggle. tsc 0; 8 tests; :terminal:assembleDebug green.
    • Phase 42 — Restora360 OS dual-boot terminal, DB-5 (remainder): on-site PoS discovery over NSD/mDNS. The "customer-display-easy" way to link a terminal to a POS register without typing an address. The `:pos` register app now advertises itself on the local Wi-Fi (PosRegisterAdvertiser → NSD service _restora-pos._tcp., TXT role/tenant, an ephemeral port held open so it's genuinely reserved; started from PosApp). The terminal discovers registers (PosDiscoveryRepository → NsdManager discover + a serialized resolve queue + a MulticastLock, exposed as StateFlows) on a new "Connect a register" screen (dark/mint, reachable from a Server-Home "Register" row): a live list of nearby registers, one-tap link/unlink, persisted in a plain-prefs PosLinkStore (a LAN name isn't a secret, unlike the device token). Discovery runs only while that screen is open (DisposableEffect start/stop) for battery + mDNS hygiene. Honest scope: this finds + links a register; sending a charge straight from the register to the phone is a later layer (the copy says so). Terminal manifest adds ACCESS_WIFI_STATE + CHANGE_WIFI_MULTICAST_STATE. Also added a scoped `network_security_config` (HTTPS-only by default; cleartext allowed ONLY to loopback/emulator dev hosts) so the terminal can pair against a local next dev server during testing (e.g. via adb reverse) without weakening production. Adversarially reviewed (2 fixes): an epoch guard so a late NSD resolve callback from a stopped session can't inject a stale register into the next scan; the advertiser now holds its socket open so the advertised port is truly reserved. :terminal + :pos assembleDebug green. Deferred: the register→phone amount-push protocol + the Bluetooth fallback.
    • Phase 42 — Restora360 OS dual-boot terminal, DB-5: the Android app's "Connect to your register" pairing (native). The phone side of BYOD pairing, in android/:terminal (reuses :core). First run gates on a new PairingRepository (a StateFlow the UI gates on): Unpaired → "Connect to your register" (dark/mint, designed to fit the terminal system — this screen isn't in the hi-fi mockups), Paired → the payment flow. Two redeem paths, both backed by the DB-4 public endpoints: scan the dashboard QR (CameraX preview + ML Kit barcode; the QR's restora360os://pair?host=…&nonce=…&tenant=… deep-link is self-contained — it carries the server origin) or type the 6-character code against a server address (the always-available fallback; inline camera-permission with a "use the code instead" path so a no-camera phone still pairs). On success the raw device token is stored in EncryptedSharedPreferences (Keystore-backed, corruption-resilient) — never plain prefs, never logged. Heartbeat fires on terminal open + on each completed transaction (reports transport/battery/model/app-version), so the dashboard shows online/last-seen and the 30-day idle clock resets; a 401 wipes the token and drops back to Connect (remote-unpair / 30-day expiry), and a day-25 grace banner warns as the window nears. Server Home now shows the real paired identity (name/lane) instead of the seed avatar. New deps: androidx.security:security-crypto, CameraX, ML Kit barcode; CAMERA permission (optional). Security: a scanned QR is attacker-controllable, so the host is validated to https (http only for localhost/RFC-1918 LAN), closing the QR-phishing + minSdk-26 cleartext gap. Adversarially reviewed (11 agents, 3 lenses × verify): 6 fixes incl. atomic heartbeat state transitions (a concurrent OK could otherwise resurrect a 401-wiped pairing), a cancellation-safe CameraX bind (dispose-before-bind window), and the QR-host allowlist. :terminal:assembleDebug green. (DB-5 PoS discovery now built — see the entry above; real PCI tap-to-pay SDK + merchant onboarding remain P40.)
    • Phase 42 — Restora360 OS dual-boot terminal, DB-4 (remainder): the merchant "Payment Terminals" dashboard page. Completes DB-4 — the owner-facing UI on top of the pairing backend, at /dashboard/terminals (Operations nav, new CreditCard entry beside "TVs & Displays"). Pair a phone: an optional register/section label + Generate code → POST .../android-terminals/pairing-code, which renders the server-built QR (the proxy-aware deep link) alongside the typeable 6-digit code with a live 15-min countdown ("New code" re-mints, single-use), a copy-to-clipboard, and an honest "single-use, expires in 15 minutes" note. Paired phones: a fleet list (GET .../android-terminals, polled every 15 s) with an online dot, name, lane chip, last-seen, and the heartbeat telemetry that DB-5 will report (Wi-Fi / mobile-data / offline transport, battery, model, app version), plus a confirm-gated Unpair (DELETE .../android-terminals/[deviceId] → the phone drops to "Connect to your register" on next contact). A separate Recently unpaired section surfaces the cron's revoked-row audit window, tagged "Unpaired by you" vs "Auto-unpaired (30 days idle)". Footer note states the 30-day auto-unpair + that card processing stays in test mode until go-live (P40). Owner-safe throughout (the route never returns the token hash). tsc 0.
    • Phase 42 — Restora360 OS dual-boot terminal, DB-4: device pairing & lifecycle BACKEND (BYOD). The web side of "turn a server's own phone into a payment terminal" — mirrors the TV customer-display pairing. The owner mints a short-lived credential (a nonce embedded in a QR + a typeable 6-digit code) bound to tenant+lane; the phone redeems it for a long-lived device token (only its SHA-256 hash is stored on MasterDB.androidTerminals; the raw token is returned once); the phone heartbeats (token-authed, constant-time compare) so the dashboard shows online/last-seen; a daily cron auto-unpairs after 30 days idle (+ prunes long-dead revoked rows + expired credentials); the owner can remote-unpair. New pure engine lib/android-terminal/pairing.ts (15 tests). API: owner POST /api/tenant/[id]/android-terminals/pairing-code (mint credential + QR svg via the proxy-aware publicOrigin), GET .../android-terminals (owner-safe list — never the hash), DELETE .../android-terminals/[deviceId] (remote-unpair); public POST /api/public/android-terminal/pair (rate-limited, single-use, 404-not-403) + POST /api/public/android-terminal/heartbeat (401 → the phone wipes its token); cron GET /api/cron/android-terminal-cleanup (CRONSECRET). Additive `MasterDB.androidTerminalPairings` + `androidTerminals`; QR deep-link `restora360os://pair?host=…&nonce=…&tenant=…`. Adversarially reviewed (6 fixes incl. the proxy-aware QR origin). MOCK-first (real card payments are the separate Android slice + P40). tsc 0; 15 engine tests green. (DB-4 completed by the dashboard page above; DB-5 = the Android app's pairing screen is next.)_
    • Phase 36 — Canteen module (prepaid wallets), slice 1: "wallet charged at the till." Built reuse-first off the gift-card engine — designed via a multi-agent workflow whose adversarial review caught a revenue double-count before any code was written. A lib/canteen/ pure engine is a gift-card sibling plus the one op gift cards lack, credit()/top-up (createAccount, code-first generateAccountCode/ensureUniqueCode, previewSpend, applyWalletSpend, credit, applyAdjust, publicProjection; 7 tests). API: accounts GET/POST (list + search / create — code-first, email optional), [id] GET/PATCH (edit / freeze / close / reissue badge), /lookup (scan-a-badge till resolver), [id]/topup (a real charge through the Phase-33 ledger, source:'canteen-topup', + a wallet credit; idempotent — a retried top-up never double-credits), [id]/adjust (manual ±). Till: the POS order route gains optional canteenAccountId/canteenCode — the wallet debit runs inside the existing order write-lock (authoritative balance read-modify-write; insufficient balance → 402) and creates no ledger charge (the meal is the food-revenue event; the krona was already collected at top-up). Accounting honesty: summarizeLedger now breaks out topups, so prepaid wallet float isn't read as a food sale. Dashboard /dashboard/canteen (Operations nav): accounts list + search + new-account, balance + a printable badge code, top-up / adjust / freeze / reissue / close, and signed wallet history. Additive TenantDB.canteenAccounts + Order.canteenAccountId/canteenWalletAmount/subsidyAmount(reserved) + paymentMethod 'wallet'; new UtensilsCrossed nav icon. Deferred (reserved seams): employer/school subsidies (slice 2), groups + monthly B2B invoicing (slice 3), self-service + daypart menus (slice 4+), refund-unspent-balance. 7 engine tests + the ledger-breakout test; tsc 0; full suite green (only the 2 pre-existing mason-field-marker failures remain).
    • Phase 36 — Bar module (tabs). Open a tab, add rounds, verify ID, settle. New lib/bar/ engine (tab subtotals, line totals, lifecycle guards — 3 tests). API: GET/POST /api/tenant/[id]/bar/tabs (list open/recent tabs with paid/outstanding from the ledger + drinks for quick-add; open a tab), GET/PATCH .../[id] (add-items/round, remove-item, rename, verify-age, void), POST .../[id]/settle (settle through the Phase-33 payment abstraction — full or split portion + tip, reusing the split settlement helpers; the tab closes once captured covers the subtotal). New page /dashboard/bar (Operations nav): open-tabs list + a detail panel with quick-add from the drinks menu, free-form items, an ID-verification badge/action, the running subtotal, and a split/tip settle (½/⅓/¼ chips + remaining). Additive TenantDB.barTabs; new Martini nav icon. Reuses the unified ledger, so bar takings show in reconciliation + the per-order view. (Happy-hour is already covered by Offers; hard alcohol-gating per item is a follow-up.) tsc 0.
    • Phase 36 — Catering public enquiry form (completes the catering loop). Website visitors can now submit a catering enquiry themselves: a public POST /api/public/[id]/catering (no auth, lead-friendly — accepts any valid request) creates a source: 'web', status: 'enquiry' record in the same dashboard the owner triages (then prices + takes a deposit). A storefront page /tenant/[id]/catering renders the form in full site chrome (ThemeInjector + SiteHeader + SiteFooter): event date / headcount / fulfilment / dietary / notes, the restaurant's minimums shown as hints, and a confirmation with the CAT-#### reference. The existing Mason CateringSection block's CTA now defaults to /catering (still overridable via buttonLink). tsc 0.
    • Phase 36 — Catering & Events (admin slice). The design-only catering block becomes a real module. A tested pure engine (lib/catering/engine.ts — quote totals incl. delivery/tax/deposit, package pricing, CAT-0001 references, lifecycle transitions, lead-time check) backs a staff workflow: GET/POST /api/tenant/[id]/catering (list + create enquiry), GET/PATCH /api/tenant/[id]/catering/[id] (detail + quote builder + status transitions), and POST .../[id]/deposit (take a deposit through the Phase-33 payment abstraction → unified ledger; auto-confirms a quoted enquiry on first deposit). New page /dashboard/catering (Operations nav): enquiry list + a detail panel with an editable line-item quote builder (live totals + deposit preview), status actions (send quote → confirm → complete / decline / cancel), a deposit collector, and an activity timeline. Additive TenantDB.cateringEnquiries / cateringPackages + TenantSettings.cateringConfig. New PartyPopper nav icon. 5 engine tests; tsc 0. (Public enquiry form + Mason block wiring is the next slice.)
    • Phase 33 (cont.) — refunds (full & partial) from the per-order view. Each paid charge now has a Refund action: POST /api/tenant/[id]/payments/refund refunds through the provider abstraction (mock today; real at Phase 40), records a linked refund row in the unified ledger (refundsLedgerId ties it to its charge), and flips the order to refunded once fully refunded — capped to the charge's remaining refundable amount, re-checked under the write lock so two refunds can't exceed it. The per-order page reworks its summary to Collected / Refunded / Net (collection progress is refund-independent — a refund is money returned, not a re-opened balance to collect) with an inline amount + reason form. New tested refundedForCharge / grossPaidForOrder / refundedForOrder helpers; refunded orders are excluded from the split-bill unpaid list. Owner/manager-gated. 16 unit tests; tsc 0.
    • Phase 33 (cont.) — per-order payment view. A drill-down at /dashboard/payments/order/[orderId] showing every charge/refund for one order (or table bill) — method, amount, tip, status, mock/live tag, provider ref — plus captured / outstanding / tips with a progress bar, and the order's activity timeline. GET /api/tenant/[id]/payments/order/[orderId] resolves the id against orders or table bills (so any ledger row links cleanly) and returns the entries + timeline, with a new tested tipsForOrder helper. Reconciliation transaction rows and the split-bill panel now link into it. Owner/manager-gated. 15 unit tests; tsc 0.
    • Phase 33 (cont.) — global MOCK/LIVE payment banner. A thin, dismissible chrome bar in the dashboard layout (PaymentModeBanner, fed the server-resolved getPaymentMode()) that, in mock mode, reminds staff/owners on every page that no real money is moving — with a link to Payments. In live mode it renders nothing, so production chrome stays clean. Dismissal persists per-browser (localStorage) and is applied after mount, so there's no hydration mismatch. tsc 0.
    • Phase 33 (cont.) — split-the-bill POS flow. The split settlement primitives are now a usable surface. The POS settle route gained additive partial-payment support — an optional amount (a split portion, capped to the balance via chargeableAmount) + a paymentRef (keeps a retried portion idempotent); the order only flips to paid once captured charges cover the total, and the balance is re-derived under the write lock so concurrent portions can't over-collect. The no-amount full-settle path (waiter app, POS pay-later) is unchanged. New GET /api/tenant/[id]/payments/unpaid-orders (orders still owing money, with captured/outstanding from the ledger) and a new page /dashboard/split-bill (Operations nav): pick an unpaid order → even-split quick chips (½/⅓/¼/⅕ via the tested evenSplit) or a custom amount → charge each portion with method + optional tip; a live total / paid / outstanding progress bar; the bill clears when fully paid. New chargeableAmount helper + Split nav icon; "Split a bill" link from the Payments page. 15 unit tests; tsc 0.
    • Phase 33 (cont.) — Cash Drawer page + routes (the Z-report goes live for staff). Wired the cash-drawer primitive into a usable surface: GET / POST /api/tenant/[id]/payments/cash-drawer (open a shift with a float, record pay-in / pay-out, close with a counted total → Z-report; one open shift per tenant, conflicts return 409) and a new page at /dashboard/cash-drawer (Operations nav) showing the live Z-report (opening float + cash sales incl. tips − refunds + pay-ins − pay-outs = expected), the movements log, an add-movement form, a close form with a live variance preview, and a recent-shifts table. Cash sales are derived from the unified ledger (method cash within the shift window) — no settle↔drawer coupling. Owner/manager-gated (requireTenantAccess). New Coins nav icon + a "Cash drawer" link on the Payments page. tsc 0; 14 payments tests green.
    • Phase 33 (cont.) — split-bill, tips & cash-drawer/Z-report primitives. Pure, tested commerce-core building blocks under lib/payments/, reusable on any payment surface. Tips (tips.ts — suggested-tip options, fat-finger-guarding clampTip, withTip) now thread end-to-end through the ledger (new tipAmount, kept apart from sale gross so reconciliation and tip-pooling stay accurate) and are wired into the POS settle route (optional tip) and table-pay (records the bill's gratuity); the reconciliation page gains a Tips KPI. Split-bill (split.ts — evenSplit with exact-cent remainder spread, validateSplitAmounts, and ledger-driven settlement helpers amountCapturedForOrder / outstandingForOrder / isFullySettled, so one order can be paid by several charges). Cash drawer + Z-report (cash-drawer.ts pure computeZReport: opening float + cash sales (incl. cash tips) − refunds + pay-ins − pay-outs = expected, vs counted = variance — plus a cash-drawer-engine.ts open/movement/close persistence engine mirroring the table-payment engine, on an additive optional TenantDB.cashSessions). 14 unit tests; tsc 0. (Split-bill and the cash-drawer engine are ready primitives; their POS UI/routes are the next wiring step.)
    • Phase 33 (cont.) — Payments & Reconciliation dashboard. New owner-portal page at /dashboard/payments (in the Analytics group) reading the unified ledger via GET /api/tenant/[id]/payments/reconciliation: net / gross / refunds / transaction-count KPIs, a by-method breakdown, and a recent-transactions table — every POS, online-checkout and table-pay settlement in one view, each row tagged mock vs live. An honest MOCK MODE banner makes explicit that no real money is moving until go-live (Phase 40 / PAYMENTS_MODE=live). Date-range presets + method filter, backed by a pure, tested filterLedger. Owner/manager-gated (requireTenantAccess); reads the tenant's own ledger only. New Wallet nav icon. 9 unit tests; tsc 0.
    • Phase 33 (cont.) — online-checkout + table-pay wired to the unified ledger. Extended the ledger to every remaining server-side paid transition, additively (no existing payment logic changed): the Stripe webhook (payment_intent.succeeded/failed — idempotent by PaymentIntent id, so a redelivered webhook can't double-post), the PayPal capture route (dev + production, idempotent by PayPal order id), and table-pay via a single record inside markBillPaid (idempotent by bill; a pi_dev_* intent is tagged mock, a real pi_* is tagged live). New flat recordOrderPayment helper reuses the idempotent ledger insert. So every place an order/bill becomes paid now lands one row in TenantDB.paymentLedger, tagged mock vs live — the single source of truth for the reconciliation view. 1 new test (8 total); tsc 0. (Cash-on-pickup orders never flip to paid server-side today, so they're intentionally left un-ledgered — unchanged behavior.)
    • Phase 33 — Commerce Core & Payment Abstraction (mock-first keystone). Introduced a provider-agnostic payment seam under lib/payments/ so every money path codes against ONE interface and the platform runs fully on mock money — real PSP rails (Stripe/Adyen/Swish/tap-to-pay) drop in at Phase 40 without a rewrite. Pieces: types.ts (PaymentProvider interface + PaymentChargeRequest/Result, refunds, PaymentLedgerEntry); mode.ts (getPaymentMode() — defaults to mock, opt into live via PAYMENTS_MODE=live); mock-provider.ts (the dev default — instant paid/refunded, never contacts a PSP, with metadata.__mock test hooks for decline/pending/refund-fail); registry.ts (resolvePaymentProvider — serves the mock in mock mode and fails loud in live mode when no real provider is registered, so real money is never silently mock-settled; registerLiveProvider is the Phase 40 seam); ledger.ts (idempotent-by-key postChargeToLedger/postRefundToLedger + a summarizeLedger reconciliation rollup that tags mock vs live); service.ts (capturePayment/refundPayment, documented to run OUTSIDE the mutateTenantDB lock). Added an additive optional TenantDB.paymentLedger field. Wired the first real path — the POS/waiter in-person settle route (/api/tenant/[id]/pos/orders/[orderId]/payment) — through the abstraction (charge outside the lock → persist order + ledger inside it, idempotent per order), preserving its response shape and already-paid no-op. 7 new unit tests; tsc 0. (Next slices: online-checkout + table-pay paths, the reconciliation dashboard + MOCK/LIVE dev banner, then real rails at Phase 40.)

    Fixed

    • Clone-quality: the account page now wraps in the shared (localized) site chrome instead of feeling like leaving the site. /account rendered its own bare "My Account" bar — a back-arrow to /menu, no navbar / footer / brand / language switcher — and ignored ?lang. It's now a thin server page.tsx that mounts the real SiteHeader (with Mitt konto active + the EN/SV switcher) and SiteFooter around the client account panel (extracted to AccountClient.tsx), resolving the viewing language so the chrome honours ?lang. Verified on ten_kebabtime-se (sv): a localized navbar + footer now frame the sign-in/dashboard. tsc 0. (The account body strings — Sign In / Welcome back / dashboard tabs — are still English; full body-string translation is a larger follow-up.)
    • Clone-quality: a Swedish-default storefront now renders Swedish chrome (was hardcoded English). A sv restaurant opened with an English navbar (Home/Menu/Reservations…), "Book Table" button, and sub-page H1s ("Our Menu", "Reserve Your Spot"…). New lib/i18n/storefront-chrome.ts localizes the storefront's own chrome: default nav labels (SiteHeader + SiteFooter), the "Book Table" button, and the default sub-page headings (defaultPageBlocks + its 4 live-render callers). Only sv flips to Swedish; every other language stays English (the safe universal default — a German tenant doesn't get Swedish chrome). A custom owner-authored navConfig is always rendered as-is. Verified on ten_kebabtime-se: navbar Hem · Meny · Boka bord · Mitt konto · Kontakt, button Boka bord, /menu H1 Vår meny. +3 tests; tsc 0. (Remaining localization: the account page renders its own English header + ignores `?lang`, and cloned menus store `sv === en` copy — both larger follow-ups.)
    • Clone-quality: a kebab shop no longer ships the platform demo menu (sushi/pizza/macarons). The WoW claim back-filled buildDemoMenu() as the LIVE menu whenever a clone's extraction came back empty — so a real restaurant could go live with Sushi Selection, Margherita Pizza, Pad Thai + French Macarons, demo Unsplash photos, and generic Starters/Mains/Desserts/Drinks categories (claim.ts:217). Claim now imports the real extracted menu only; an empty extraction leaves the menu empty — the storefront shows a clean "Our Menu" empty state and the owner imports their menu from the dashboard, rather than fabricating dishes. New isDemoMenuProducts() (derived from buildDemoMenu so it stays in sync) backs an opt-in --clear-demo-menu flag on the normalization script to strip the inherited demo menu from already-affected clones (flagged by default, never auto-cleared). Verified on ten_kebabtime-se: no demo dishes on home or /menu, the empty menu page renders cleanly. +2 tests; tsc 0.
    • Clone-quality (E): scraped-domain names cleaned at the source + a one-time normalization for existing tenants. Closes the baked-in artifacts the render-layer batch couldn't reach. Pipeline guard: the brand name is now cleaned with displayBrandName at the single WoW preview chokepoint (createWowPreview) and at the scrape source (deriveSiteName), so a fresh clone of kebabtime.se stores "KebabTime" — which cascades to the claim-stamped hero headline and the templated seo.metaTitle, so both come out clean for new clones. Data fix: a new idempotent script scripts/normalize-clone-artifacts.ts (dry-run by default, --write to apply) cleans settings.name + the hero headline + seo.metaTitle/metaDescription and drops dev/seed placeholder emails across existing tenant DBs — run it on each deployed server. Verified on ten_kebabtime-se: navbar, hero headline, and <title> all render "KebabTime" with no domain artifact left. +3 tests; tsc 0.
    • Clone-quality batch: scraped-domain names, dev placeholder emails, empty sections + SEO/JSON-LD correctness (from a multi-agent storefront audit). A render-layer batch so existing cloned tenants render polished without a data migration. (A) displayBrandName (lib/text/decode-html-entities.ts) strips a scraped-domain tail + trailing punctuation — "KebabTime.se." → "KebabTime" — and the navbar, footer, homepage <title>/OG and Restaurant/WebSite/Menu JSON-LD all route through it. (B) new lib/contact-clean.ts cleanPublicEmail drops dev/seed placeholder addresses (wow-dev-…@example.test, reserved-TLD) from the contact card, footer, and Restaurant JSON-LD. (C) the contact socials card hides on a clone with no socials (no bare "Stay tuned" box), and the default Blog nav link hides when the tenant has no posts. (D) SEO/JSON-LD correctness — currency-aware `priceRange` (a SEK kebab is $, not $$$$), ISO `addressCountry` + postal-code split out of the locality, real cuisine mapping (kebab/turkish/grill) with no generic-"Restaurant" echo and no duplicate servesCuisine, and empty description/email/cuisine omitted from the schema. 24 unit + integration tests; tsc 0. (Remaining baked-in artifacts — a hero headline + stored `metaTitle` the cloner set to the bare domain, and the footer Blog link — need a one-time data normalization / clone-pipeline fix.)
    • Storefront showed raw HTML entities in the restaurant name + duplicated/unordered opening hours. Two render-layer fixes for cloned/imported tenants — both work without a data migration. (1) The central RestaurantBrand component (navbar/footer/mobile) and the homepage <title>/OpenGraph now decode the stored name, so MENY &#8211; MRS SAIGON renders Meny – Mrs Saigon. (2) OpeningHoursSection now groups weekly hours into one ordered row per weekday via a new pure, tested groupWeeklyHours (lib/builder/opening-hours.ts) — dedup identical ranges, merge split lunch/dinner ranges under one day, sort Mon→Sun — fixing the 14-row duplicated/unordered list (and a duplicate React key). 6 new unit tests; tsc 0. (The stored name still carries the entity, so a few secondary SEO surfaces — sub-page titles, JSON-LD — read it raw until a one-time data normalization; new clones are already clean via the `createWowPreview` decode.)
    • Server-built absolute URLs used `localhost:3001` behind a reverse proxy (37 sites across 18 files). The WoW claim email built its verify URL from req.nextUrl.origin, which behind a tailscale/nginx proxy is the app's internal origin (http://localhost:3001) — so the "Claim my restaurant" link in a real email was unclickable from a phone/another device. An audit found the same class of bug in every server-built external link: the Launchpad claim email, staff-invite "Go to Dashboard", the accountant magic-link, designer-invoice links, QR-code targets (receiving-scanner pairing, location QR), every Stripe redirect (signup, webshop checkout, tenant billing + AI top-up + add-ons, agent/designer/supplier Connect, designer membership), and the Google-OAuth + email-verify browser redirects. New shared helper `publicOrigin(req)` (lib/http/public-origin.ts) resolves the public origin in priority: APP_URL → NEXT_PUBLIC_APP_URL → NEXT_PUBLIC_SITE_URL → x-forwarded-host (+x-forwarded-proto) → host header → req.nextUrl.origin. All 37 sites now route through it (APP_URL is checked first because it is read at runtime, so a deployed box can fix links without a rebuild). Deploy note: set APP_URL on every box (e.g. https://restora-server.tail8deedd.ts.net, prod https://restora360.com) so external links are correct regardless of the proxy.
    • WoW clone restaurant name showed template artifacts (e.g. `" + "Basant`). Some sites' raw <title>/og:site_name contain leaked JS string-concat (" + "); deriveSiteName returned them verbatim, so a cloned site was named " + "Basant. It now sanitises the derived name — strips concat artifacts + stray double-quotes/backticks while preserving apostrophes (Luigi's) and a legitimate mid-name + (Bar + Kitchen). Unit-tested.
    • WoW clone restaurant name showed raw HTML entities (e.g. `Meny &#8211; Mrs Saigon`). deriveSiteName read the page <title>/og:site_name without decoding HTML entities, so a numeric/named entity (&#8211;, &amp;, &eacute;) was stored verbatim — rendered literally on the preview, in the claim-email subject, and as the provisioned tenant name — and it also defeated the generic-word split (Meny &#8211; Mrs Saigon kept the generic Meny). Decoding is now a centralized rule: a shared pure module lib/text/decode-html-entities.ts (decodeHtmlEntities + cleanName) is applied at the single preview-name storage chokepoint (createWowPreview + updateWowPreviewInfo), so every name source (scraped title, AI brand name, caller-supplied) is decoded before it is persisted — not just the title path. deriveSiteName decodes before splitting (so Meny &#8211; Mrs Saigon → Mrs Saigon), and the WoW claim bar decodes defensively for display of legacy previews. So Tom &amp; Jerry → Tom & Jerry, Caf&eacute; → Café. Unit-tested (decode-html-entities + site-health).
    • Completed the public-origin fix for every post-login redirect, FSN OAuth, and env-based email/SEO URL. The WoW/Launchpad claim-VERIFY routes + resumeClaim/provisionFromPreview/launchpad handoff built their post-login /dashboard + /login redirects from req.url → localhost:3001 behind the proxy, so a correct magic link still landed on https://localhost:3001/dashboard; all now use publicOrigin(req). FSN Google/Apple login (authorize + token-exchange redirect_uri) and the social cross-posting connect/callback redirects now use FSN's publicOrigin (given an APP_URL fallback). The cron trial/verification emails, gift-card emails, and the SEO canonical fallback now prefer `APP_URL` over the build-inlined NEXT_PUBLIC_SITE_URL, so a deployed box emits correct links without a rebuild — gift-card emails previously fell back to http://localhost:3000.
    • Restaurant open/closed is evaluated in the restaurant's timezone, not the server's. getOpenStatus compared new Date().getHours() (server UTC) against local opening times, so on a UTC box every tenant read "Opens at 12:00 / closed" during business hours — blocking the storefront banner AND online ordering (cash/card/PayPal share the engine). It now evaluates "now" in the restaurant's IANA timezone (OpenStatusOpts.timeZone) and reads yyyymmdd from local parts. Verified live across 5 zones (Stockholm open · New York/LA/Tokyo closed · Dubai open, for the same 9–5).
    • Super Admin → Tenants: a long tenant name no longer clips the table, and "View site" links to the real storefront. A 60-char unbreakable name stretched the Restaurant column under table-layout:auto, so the card's overflow-hidden cut off Status/Created/Actions for every row (the delete button became unreachable); long name/owner/email values now wrap with a horizontal-scroll fallback. "View site" previously hardcoded http://<subdomain>.localhost:3001 (dead from any deployed box / no wildcard DNS) — it now uses the tenant's customDomain or the host-relative /tenant/<id> route.
    • WoW health-check result page: removed the redundant "Build my better site" CTA band that looped back to /wow when there was no rebuild data (the page already has the inline "See your site rebuilt" button + the claim form).

    Added

    • Webshop Marketplace Admin — full product editing (edit · images · descriptions · categories · bulk tiers). The Products tab could only create + activate/deactivate — no way to edit a price, add an image, write a description, or manage categories. Now every product is fully editable: an Edit button per row opens the form pre-filled; new description field; product images (upload from disk via the new super-admin POST /api/admin/webshop/products/upload → public/uploads/webshop, or paste a URL — with a live thumbnail); a category picker (datalist of existing categories + type-a-new-one); a bulk price-tier editor; plus unit/cost; and a thumbnail column in the table. The model gained imageUrl + description (WebshopProduct), and the product POST/PATCH now accept the full editable field set. The storefront (/shop) renders the product image (replacing the grey SKU placeholder) and the description on each card. Also trimmed the shop's own sidebar to a shop-focused nav — Browse · Your orders · Subscriptions + a single Back to dashboard link (it had been a full copy of the restaurant dashboard nav incl. Menu/Tables/Reviews, which don't belong in a supplies-buying context). tsc 0.
    • WoW claim: resend the magic link + fix a wrong email — don't lose a customer to a junked/undelivered email. The "Check your email" step was a dead end: if the link landed in spam or was never delivered, the prospect was stuck with no way forward. It now offers a Resend email button (40s cooldown with a live countdown), a Use a different email path (correct a typo or try another inbox — the value stays pre-filled), and a check your spam/promotions hint that names the sender. The per-IP claim rate limit was raised 5→8/hour to give genuine retries headroom (each call still sends exactly one email; disposable addresses stay blocked). Verified live (form → sent → resend cooldown).
    • Per-tenant timezone — open/closed + ordering correct anywhere in the world. New TenantSettings.timezone (IANA), set on Business Hours via a full-timezone picker that auto-detects the owner's zone with a live "current local time" readout; the open/closed engine, the online-ordering guard, the storefront banner, and every Food Bazar marketplace listing evaluate in that zone (Europe/Stockholm only as the fallback). Auto-captured at signup — a dashboard component reads the owner's browser timezone on the post-signup "welcome" load and saves it when unset (gated to the welcome param so it can't mis-fire for an owner logging in abroad). WoW set-password + register now hand off to /dashboard?welcome=1.
    • TERRA premium template — pixel-1:1 gap-closure + an opt-in hero press card. The standalone /terra design reference (the source the Mason TERRA template was authored from) is now committed, and the Mason template was brought up to full parity with it using the platform's own editable blocks (no bespoke renderer): a Marquee band (promo-banner / variant:'marquee' — "Seasonal ✦ Wood-Fired ✦ Locally Sourced …"), a Story section (editorial-text / variant:'editorial-split' — eyebrow + headline + body + the founders' pull-quote + image), and a Stats band (stats — 40 local growers · 120 seasonal dishes · 4.9★ guest rating) were added to terra-starter.ts (and the documentary terra.ts tree). To reproduce the hero's floating press-quote, the shared hero gained an opt-in `pressCard` (hero-model.ts + HeroSection.tsx): a glassmorphic, desktop-only testimonial card (★★★★★ + quote + attribution) that renders only when a hero sets `pressCard.quote` — every other template's hero is byte-for-byte unchanged (default-off, theme-token-painted). All additive (editable Mason blocks + real menu/settings stay intact); tsc 0; hero-model + site-template tests green; next build EXIT 0.
    • Phase 17 Guardian — GD-4 deployment watchdog (closes Phase 17's buildable scope). After each recorded deploy, a pure engine (lib/guardian/deploy-watchdog.ts) compares the post-deploy window against a baseline — synthetic-journey failure rate + average latency — and watches for new P1/P2 incidents that opened after the cutover. On regression it opens a deployment-source incident and recommends a rollback, which flows straight into the GD-8 approval-gated remediation (the deploy-rollback play — still recommend-don't-act). Honest by design: a metric with no signal is null (never fabricated), a too-fresh deploy reads watching (not a false "healthy"), and the watchdog only runs on live (recorded) deploys so seed never fabricates a regression. Deploys are recorded via `POST /api/admin/guardian/deploy` — accepting either a super-admin "record deploy" or the cron secret (a platform deploy webhook) — and surfaced in a new Deployments Command-Center view (watchdog verdict + baseline→post deltas + record-deploy form + history). Deployment incidents are internal-only — excluded from the public status page (the customer-facing symptom still surfaces via its own service/journey incident). New optional MasterDB field (guardianDeploys). 8 new unit tests (87 Guardian total); tsc 0; eslint clean; next build EXIT 0. (The other half of GD-4 — Sentry-class deep error tracking — stays a paid go-live integration.)
    • Phase 17 Guardian — GD-8 (approval-gated auto-healing + DR readiness + OTel-ready metrics + mobile). The last Phase-17 slice, right-sized + honest (the spec itself calls OTel tracing and unattended auto-act aspirational). Auto-Heal — a pure engine (lib/guardian/remediation.ts) matches active incidents to a small registry of codified runbook playbooks (Postgres failover, storage/CDN failover, provider circuit-breaker, module cache-flush, app-recycle, deploy-rollback, tighten-rate-limit) and surfaces them as recommendations: every action is approval-gated — recommend, don't act; the recommend→approve→execute lifecycle records an operator-confirmed outcome and Guardian never touches infrastructure unattended (the classic foot-gun stays out). The probe generates + reconciles recommendations from live incidents each cycle (lifecycle preserved, recommendations auto-withdrawn when their incident clears). A new Auto-Heal Command-Center view + POST /api/admin/guardian/remediation (super-admin, audited, GDPR-scrubbed). Resilience & DR — lib/guardian/dr.ts computes a DR-readiness scorecard tracing the reliability/DR strategy (RP-0→RP-7) with honest defaults (audit log, RBAC, expand-contract migrations, Guardian itself = in-place; managed PITR/immutable backups/multi-region = not-configured until go-live — never a fabricated "all green") + the per-data-class RPO/RTO objectives, plus an editable posture (availability tier, last restore game-day, per-control status) via GET/PUT /api/admin/guardian/dr-config; a new Resilience view. Metrics export — lib/guardian/metrics.ts renders Guardian's live signals as Prometheus / OpenMetrics text at GET /api/admin/guardian/metrics (the right-sized take on OpenTelemetry: export for a scraper rather than instrument the monolith; a scrape-token for unattended pulls is the go-live follow-up). Mobile — lib/guardian/mobile.ts builds a push-ready summary digest surfaced in the Resilience view (delivery rides the shipped alerting rails + the native Mobile API Gateway/APNs/FCM at go-live — no fake push sender). New optional MasterDB fields (guardianRemediations, guardianDrConfig). 14 new unit tests (79 Guardian total); tsc 0; eslint clean; next build EXIT 0.
    • Phase 17 Guardian — GD-2 live topology map. A new Topology Command-Center view renders the real dependency graph (clients → app modules → Postgres/storage/CDN + external providers) as a layered SVG with status-coloured nodes and failure propagation — a node is highlighted (dashed red) when something it depends on is critical/offline. Pure engine lib/guardian/topology.ts (graph + propagation) overlays the live service statuses from the snapshot; right-sized (no invented microservice fleet). 4 unit tests; tsc 0; build clean.
    • Phase 17 Guardian — GD-6 (AI watchdog) + GD-7 (reliability analytics + public status page) + MVP polish. GD-6: budget-gated AI root-cause analysis per incident (POST /api/admin/guardian/rca → Problem→Cause→Impact→Fix) + an Ops copilot (POST /api/admin/guardian/agent/chat) — correlation/facts computed PII-free in lib/guardian/rca.ts, the LLM (Haiku) only narrates; deterministic fallback with no key or over a new guardianAiUsage daily cap; a new AI Insights Command-Center view. GD-7: lib/guardian/analytics.ts (MTTR/MTBF/incident-frequency/downtime over 24h/7d/30d/90d) + an Analytics view, and a public, scrubbed status page at `/status` (GET /api/public/status, POST /api/public/status/subscribe) — lib/guardian/public-status.ts strips internal metrics, excludes security incidents, masks PII, and is resilient (never 500). Polish: live revenue-at-risk in snapshot.ts (real average-hour throughput from the order rollup + platform age, honest 0 without data) + editable escalation chain & maintenance windows in the Alerting UI. New optional MasterDB fields (guardianAiUsage, guardianStatusSubscribers). 24 new unit tests (rca/analytics/public-status); tsc 0; next build clean; runtime-smoke verified (public scrubbing, auth gates, subscribe). Reviewed by an adversarial multi-agent pass.
    • Phase 17 Restora Guardian — 24/7 monitoring + incident response (MVP slice: GD-0 + GD-1 + GD-3 + security/impact). A new super-admin Command Center at `/admin/guardian` (own sidebar + scoped .gd-root theme, light/dark, "Run journeys"/CSV), right-sized to the real topology (Next.js monolith + pooled Postgres + providers — no faked microservice/Redis/queue dashboards). Engine in lib/guardian/: tier-weighted Platform Health rollup; synthetic monitoring (HTTP-level owner/customer/supplier/agent journeys against real public routes — auth-only steps skipped honestly, browser-level Playwright deferred); incident engine (P1–P4 auto-detect from journeys/health/security, dedup by fingerprint, ack/resolve/assign/note lifecycle, MTTR); intelligent alerting (severity floor, dedup, maintenance windows, escalation chains → email/SMS rails reused + a webhook seam for Slack/Discord, anti-fatigue by design); statistical anomaly detector (z-score/EWMA, no LLM); security watchdog (assembled from the audit log + a new additive snapshotRateLimitCounters() on the rate-limiter — failed logins/abuse/permission violations, GDPR-masked principals); and customer-impact (real active-tenant blast radius, honest revenue-at-risk). A secret-gated `/api/cron/guardian-probe` (every 5 min) runs the cycle + pages; a manual run never pages. Build-with-seed honesty throughout (live where a source exists, seed tagged "sample"). API: GET /api/admin/guardian (+?format=csv), GET/POST /api/admin/guardian/incidents, GET/PUT /api/admin/guardian/alert-rules, POST /api/admin/guardian/synthetic/run. New optional MasterDB fields (guardianIncidents/guardianSyntheticRuns/guardianAlertConfig/guardianAlerts). 46 new unit tests; tsc 0 errors. Deferred (named follow-ups): paid integrations (Sentry/uptime/live Slack/Twilio number), browser-level synthetic runs, GD-2 topology map, GD-6 AI watchdog/RCA/Ops copilot, GD-7 public status page + MTTR/MTBF analytics, GD-8 auto-healing/mobile/OTel/DR.

    Changed

    • WoW clone — premium "Restora Standard" fallback theme (Phase 4 of the unified flow). When AI theme-matching isn't possible (no key / unreadable design / model failure), the rebuild used a plain Inter + flat-gradient generic theme. The fallback (fallbackDesignDna) is now a curated, premium theme: elegant Playfair headings + Lato body, a refined warm palette, a full-bleed photo hero (never a bare gradient), airy spacing + elevated shadows + transparent-sticky nav + a story section. So a weak match degrades to something beautiful + on-brand, not generic. Verified: builds a complete 10-block site (hero, features, menu, gallery, story, testimonials, CTA, contact, hours, map).
    • WoW funnel — "upload photos" now builds the WHOLE site, not just the menu (Phase 3 of the unified flow). The unified entry's photo path previously ran menu-only extraction. It now sends the uploaded images to the design-DNA route as both the design source and the menu source, so several photos → menu (dishes + prices) AND a matched theme + full builder layout — the same complete site the URL path produces. New handleBuildFromFiles in useWowImport (width-capped upload preserving full menu height); the route's budget raised to 120s for large multi-photo menus. Verified live: one uploaded image → full site with 14 menu items. (The landing hero's separate menu-photo card keeps the menu-only path.)
    • WoW funnel — one result page (Phase 2 of the unified flow). "Build my site" (URL) now lands on a single result that runs everything automatically and shows the full sequence top-to-bottom: health score → before/after → the imported menu (dishes + prices) → email claim. The redesigned health-check page is now that result — it auto-runs from a ?url= param (check, then rebuild, no extra click), from-url returns a slim menu summary so the dishes render inline, and a cream-themed claim form (email → magic link → account + free trial) sits right below the menu. Direct visitors to /wow/health-check keep the original manual check→rebuild behaviour (no auto-run without ?url=). Photos (no existing site/health) still go straight to the /wow/[token] preview.
    • WoW funnel — one front door (Phase 1 of the unified flow). The /wow page previously split into two confusing tools ("Import Your Menu" + "Clone Your Brand") plus a separate health check. It's now a single "Build my whole site" box: paste your website URL → full rebuild (design + menu via from-url), or upload menu photos/PDF → menu — both through the same shipped engine (useWowImport). Demo + "score my current site" (health check) are secondary links. New WowUnifiedImport replaces WowPremiumImport on /wow; hero copy updated to lead with both paths. (Next phases: one result page with health → before/after → menu → email; multi-photo design; premium fallback theme; forced set-password.)

    Added

    • WoW registration — magic link → forced set-password → dashboard + trial (Phase 5, final phase of the unified flow). Claiming a site from a WoW preview was passwordless (magic link → straight to the dashboard). Now the emailed link confirms the email + logs in + starts the trial and lands the owner on a required "Set your password" step (/welcome/set-password) before the dashboard — so the account is fully usable (email + password) in one confirmed pass. New UserRecord.mustSetPassword (set on WoW provision, cleared by the new guarded POST /api/account/set-password); the claim-verify route redirects to the set-password step; the dashboard gate enforces it (a passwordless owner who navigates straight to /dashboard is sent to set a password first). Verified: tsc 0 errors, next build OK, auth gates correct (page 307→/login + API 401 when signed-out). This completes the unified WoW flow (Phases 1–5).
    • WoW free trial is now super-admin managed, site-wide. When an owner claims a site from the WoW funnel, the free trial length was hardcoded (30 days). It's now driven by platform settings: Super Admin → Settings → "WoW Acquisition · Free Trial" exposes a trial on/off toggle (freeTrialEnabled, already platform-wide) and a trial length (days) field (wow.freeTrialDays, default 30, 1–365). provisionFromPreview reads them — trial on → the owner lands in the dashboard on a no-card trial of the configured length; trial off → routed straight to /register/payment (the existing paid-signup gate). The settings PATCH deep-merges the nested wow guards so a partial save can't wipe the cost caps. (The email→magic-link→register flow that starts the trial already existed — this just makes its length/enablement centrally controlled.) Verified: 7 config-resolution checks, tsc 0 errors, next build OK.

    Changed

    • WoW preview "view cloned site" (dev) no longer dead-ends the preview. The dev-only "View your cloned site" shortcut on a URL-clone preview navigated the same tab to the live site, losing the preview with no way back. It now opens in a new tab (and is relabelled "Dev: view the cloned site (new tab, keeps this preview)"), and the underlying dev peek already keeps the preview editable (consume:false). (Production is unaffected — that shortcut isn't rendered; the email "Make it yours" claim is the only CTA there.)
    • AI Menu Intelligence — Slice F (request-level stock-lookup dedup, P9). matchItemImages now coalesces identical stock-photo lookups across a single import: duplicate dish names and shared category fallbacks hit the providers once per unique query instead of once per item (a per-request promise cache), cutting external Pexels/Unsplash/Pixabay calls, speeding up matching, and reducing rate-limit pressure. (The WoW clone enrichment already deduped by name; this closes the gap in the dashboard "match images" flow.) Note on the rest of P9: the original audit's background queue for the "60-second guarantee" was premised on a serverless 60s hard-kill — but production runs self-hosted next start (and the dashboard ai-extract route declares no maxDuration), so route time isn't platform-capped there and a queue is largely unnecessary; content-hash extraction caching is low-ROI since per-menu token cost is image-dominated, not prompt-dominated. tsc 0 errors, next build OK.
    • AI Menu Intelligence — Slice G-semantic (auto-suggested allergens at import, P4). The dashboard menu import now suggests EU-14 allergens per dish and surfaces them on the review screen as removable amber chips ("Allergens (suggested)") — the owner confirms/edits before importing, and the kept ones are written to product.allergens (previously always empty on import). Reuses the exact deterministic detector + catalog matcher from the product editor's detect-ingredients route (detectIngredients + detectAllergens over tenant + canonical catalog, zero AI tokens) via a new menu-semantics.ts; the ai-extract route attaches suggestedAllergens per item (best-effort) and ai-import validates them against the registry before persisting. Safety gate preserved: allergens are SUGGESTIONS the owner reviews — never silently applied — and only on the dashboard flow that has a review step (not the automated WoW claim); the UI is framed "suggested — verify, may be incomplete". Dietary tags are deliberately not inferred (a single non-conforming ingredient would make a dish-level claim false). tsc 0 errors, next build OK.
    • AI Menu Intelligence — Slice G-image (multi-layer image fallback, P5). When the dish-name stock search returns nothing relevant (no token overlap with any result), matchItemImages now falls back to a category-representative query (e.g. an unmatched pizza dish → "pizza italian restaurant") so the item still gets an on-theme photo instead of an unrelated one — the staged degradation the spec's Phase 5 calls for, layered on top of Slice C's English-name search and the existing demo/taxonomy fallback inside searchStockPhotos. The fallback fires only when the primary search is irrelevant (bounded — one extra call), and its results are capped to the "suggest" tier so a category match never auto-assigns or strongly-recommends (honest: it's the right category, not necessarily the exact dish). tsc 0 errors, next build OK.
    • AI Menu Intelligence — Slice D/E (extraction confidence + honest live progress). (D) Per-item extraction confidence (P6) — Stage-1 extraction now also returns a 0-100 confidence per item (how sure the AI is it read the name + price correctly); it's stored on ExtractedItem, used as a tie-breaker in mergeExtractedMenus (the more-confident duplicate wins), and surfaced in the dashboard review: items the AI was &lt;70% sure about get an amber "Double-check this one — the AI was only N% sure" badge, so the owner verifies exactly what's uncertain (the premium "we tell you what we're unsure about" feel). (E) Honest staged progress + adaptive ETA (P7, safe parts of P8) — the import modal's extraction step replaces the fixed 5s ticker with a real elapsed-vs-estimate progress bar + live countdown derived from the actual page/photo count (~9s/page, bounded 12–90s); the checklist advances from real elapsed time, and if it overruns it shows an honest "taking longer than expected — large menu (N pages)" message instead of a silent stall, with a matching "import fewer pages at a time" explanation if a big upload times out. (The hard 60-second guarantee still needs the queue work in Slice F — not promised here.) Verified: 2 new unit tests (confidence normalize + merge tie-break), tsc 0 errors, next build OK.
    • AI Menu Intelligence — Slice A/B/C (cleaner names, any-language photos, resilient extraction). Extends the existing menu-import engine (one system, not a parallel one). (A) Automatic name cleaning — the extractor now strips leading menu numbering / item codes / list markers from dish and category names: 01. Pizza Vesuvio→Pizza Vesuvio, Nr 15 Oxfilé→Oxfilé, A12. Sushi Roll→Sushi Roll, 12) Mix Grill→Mix Grill, • Garlic Bread→Garlic Bread — conservative regex (cleanMenuName in extract-core.ts) that preserves real names (7-Up, 1.5L Cola, 100% Beef) + a matching save_menu prompt rule. Fixes the double-numbering in the WoW preview and the false-duplicate-on-merge bug. (B) Resilient extraction + structured errors — transient AI failures (429/500/502/503/529…) now retry with exponential backoff + jitter (withRetry); MenuExtractionError carries a machine code surfaced as reason on the extract routes so the client can show a precise message. (C) Translate-for-image-search + language detection — Stage-1 extraction also returns a per-item English nameEn and the menu's language (zero extra AI round-trip — it's already reading the menu); image matching (image-match.ts) and the WoW clone enrichment (clone-imagery.ts) now search stock photos with the English name, so Swedish/Turkish/Arabic/Persian/etc. menus get relevant dish photos instead of demo fallbacks. Threaded through the dashboard import (MenuPhotoImportModal → match-images). Verified: 5 new unit tests, tsc 0 errors, and a live kebabtime.se clone (clean names + 7/7 photos via English search). (Remaining roadmap slices D–G — extraction confidence, real staged progress, caching/queue, multi-layer fallback + semantic layer — pending.)

    Changed

    • Free Site Health Check result page redesigned (handoff, high‑fidelity) — same real data. /wow/health-check is rebuilt to the new SaaS‑tool design (docs/Page redesign feedback/design_handoff_site_health_redesign): warm‑cream page with a top radial glow, Schibsted Grotesk + Hanken Grotesk (via next/font), a confident hero + search bar, an animated circular score gauge (SVG donut, animates empty→score, respects prefers‑reduced‑motion), pass/warn/issue stat tiles, an insight callout, a gradient conversion CTA, before→after browser frames, and a scannable two‑column audit grid with per‑check fix lines. All wired to the real site-check report + from-url rebuild (score/grade/pages/checks/summary/before+after are live data, not the prototype's seed) — the gauge colour + grade pill follow the handoff thresholds and counts exclude optional checks. Navbar/footer left as before (the handoff's branded sticky nav was intentionally NOT added); footer stays the snapshot line. Scoped under .r360hc so nothing leaks; collapses to one column ≤760px.

    Fixed

    • WoW URL clone now captures the FULL menu of app-shell / SPA ordering sites (was getting only the first few dishes). Many modern restaurant sites (e.g. grillhousemalmo.se) are single-page apps that scroll an inner panel, not the document, and lazy-render dishes as you scroll — so the old single full-page screenshot only captured the first screen, and the (correct) tile-and-extract step had just 3 of ~100 dishes to read. url-screenshot.ts is rewritten to scroll-and-shoot: it waits for the SPA to render (networkidle), finds the element that actually scrolls (window or the largest inner overflow panel), and steps through it capturing a high-quality viewport frame at each position (15% overlap so no dish is split); those frames become the read tiles. from-url feeds every frame to layout detection + menu transcription. Result on grillhousemalmo.se: 3 → 103 dishes across all 14 categories. Route budget raised to 120s for large menus; capture is bounded (≤14 frames, 55s hard deadline, SSRF hardening preserved). tsc 0 errors, next build OK, verified live.
    • WoW clone: dish photos no longer show as broken images (preview + claimed site). Once the URL clone started importing the real menu, each dish was enriched with a stock photo (Pexels/Unsplash/Pixabay) — but hotlinking those CDNs directly breaks for many visitors (ad-blockers, hotlink/referrer restrictions, regional blocks, signed-URL expiry), so most dishes rendered a broken-image icon. Fixed two ways: (1) a new same-origin caching image proxy GET /api/img?u=… (allowlisted to the stock CDNs only — not an open proxy; image-only, size-capped, rate-limited) that enrichMenuWithPhotos now routes every photo through, so images load from our own domain on the preview and the claimed tenant site (verified: 14/14 dishes load, 0 broken, on both); and (2) the preview menu now hides a failed thumbnail (onError) instead of showing a broken icon. import-to-tenant + the clone hero/feature-card photo picks accept the proxy path.
    • WoW "clone/import from URL" now rebuilds YOUR restaurant — real menu + real name, not a random one. Pasting a site URL ("Import Your Menu", "Clone Your Brand", or the Health‑Check "Before → after") used to screenshot the page for design only: the menu stayed empty (so the "after" preview was a blank accent panel and the claimed tenant got a generic demo menu) and the restaurant name came from the AI, which is deliberately told to invent a fictional name — so you got a different restaurant. POST /api/public/wow/from-url now (1) runs the same menu‑extraction engine the upload flow uses on the captured page tiles, so the visible dishes/prices are transcribed (concurrent with the design‑DNA + layout calls, each best‑effort — a failure just leaves the menu empty as before); and (2) reads the real brand name from the page's own `og:site_name`/`<title>` (deriveSiteName), falling back to the domain (kebabtime.se → "Kebabtime") before ever using the AI's invented name. Result: a populated before/after, a real menu imported on claim, and the correct restaurant name. Both /wow import cards and the Health‑Check rebuild benefit (same endpoint).
    • URL inputs accept a bare domain (`kebabtime.se`), not just `https://www.…`. The server already normalised bare domains, but the Health‑Check field was type="url" (so the browser rejected anything without a scheme) and the /wow import fields likewise. All four inputs (Health‑Check, WowPremiumImport, WowHeroWidget) are now type="text" inputMode="url" with bare‑domain‑friendly placeholders; the server normalises (https:// prefix) + SSRF‑validates as before.

    Added

    • Restora 360 Shop — redesigned storefront (handoff), on the real webshop. The customer storefront at `/shop` is rebuilt from docs/Restora360 webshop design/ with a self‑contained, shop‑scoped light/dark theme (.r360shop tokens in shop-theme.css, data-theme toggle persisted to localStorage, Schibsted Grotesk + JetBrains Mono via next/font) — app shell (sidebar + topbar with language EN/NO/SV, display currency NOK/SEK/EUR/USD, theme toggle, live cart) + storefront (hero, search, category chips with counts, 2‑col product cards with bulk‑tier pricing, sticky order panel with qty steppers + bulk savings). Built on the real catalog (WebshopProduct + resolveUnitPrice/cartSubtotal) and the real checkout/subscribe APIs — not a parallel engine; currency switching is display‑only (charge currency is unchanged at checkout). Added optional WebshopProduct.unit/cost. Phase 1 (theme + shell + storefront); product detail, checkout, orders/subscriptions, and the admin screens follow.
    • Table & Floor designer: rotate any table to any angle. Tables (especially long/banquet tables, which only had one orientation) can now be rotated freely. New optional DesignerTable.rotation (0–359°); the canvas applies transform: rotate() around the table centre (shape + chairs rotate together), and the selected‑table inspector gains a Rotation control — ⟲/⟳ 15° steppers, a free 0–359° slider, and a Reset. Persists through the existing autosave (added to the table-layout PUT validation); round tables are unaffected. (The public /experience/table-designer page is just a static preview — unchanged.)
    • Platform site — Experience/Shop in nav + footer; "Experience" → the Before/After site‑rebuild. Added top‑level Shop (/shop) and Before & After (/wow/health-check — paste your live site → "Before → after" side‑by‑side) links to the landing navbar (desktop + mobile) and footer Product column. (The label first read "Experience" and pointed at the table designer; corrected to the WoW health‑check before/after experience and renamed.)
    • Sound & Atmosphere — owner config now PERSISTS per tenant (backend, provider‑agnostic). First real backend for the music product: an owner config (TenantSettings.atmosphere) carrying the non‑licensing settings — zones (enable/volume/channel), ambiance preset, Auto, safeguards, signature genres. New lib/atmosphere/config.ts (typed AtmosphereConfig + DEFAULT_ATMOSPHERE_CONFIG + resolveAtmosphereConfig/sanitizeAtmospherePatch, fully validated/clamped, 6 unit tests), lib/atmosphere/store.ts (get/save via the tenant DB), and a guarded `GET/PATCH /api/tenant/[tenantId]/atmosphere/config` (requireTenantAccess, returns 401 unauth). Three tabs are wired end‑to‑end (load real config server‑side, save via PATCH, optimistic + reconciled, Saving/Saved indicator, ARIA controls): Zones (enable + volume per zone), Branding (signature genres add/remove + brand‑consistency/safeguard toggles), and the Overview Auto toggle + safeguards. Deliberately carries NO music‑source/licensing data (provider stays none) — that integration stays gated on the licensed‑source term sheet + legal copy (see docs/music). Defaults mirror the seed, so existing tenants get sensible values and the remaining tabs (Schedule, Library, Analytics, Locations) keep rendering seed until wired the same way. tsc clean; tests green.
    • Sound & Atmosphere — owner web dashboard is now responsive (usable in a phone browser). For owners who run their venue from the web with no tablet/phone app: on small screens the /atmosphere sidebar collapses into a sticky, horizontally‑scrollable top nav, the page grids stack to a single column, the daypart timeline scrolls, and the dense Schedule/Locations rows reflow into stacked cards. Branding/Analytics/Billing's fixed grids were moved to responsive grid helpers (.r360-g-*) so they collapse cleanly at ≤900/≤720px. Verified at 390px. Also: Sound & Atmosphere is now linked from both the restaurant‑owner dashboard (Operations) and the Super Admin sidebar (Restaurants), each opening in a new tab (previously URL‑only).
    • Sound & Atmosphere — Super Admin console (web), built 1:1. The vendor operations console at `/admin/atmosphere` (super‑admin gated like the Growth Command Center; its OWN dark sidebar + a separate scoped theme .r360sa so it never clashes with the owner .r360). Recreated pixel‑faithfully from Super Admin Console.dc.html (darker palette + --red alert tokens, dark by default with a light toggle): Fleet Overview (5 KPI tiles, "Venues at a glance" table with Live/Cached/Degraded + OFFLINE/SILENT‑RISK flags, Alert inbox with push‑cache action), Tenants (lifecycle pills + table with status + audited Impersonate + the consent/audit notice), Edge Devices (inventory with firmware‑drift colouring + staged Canary→Early→Fleet rollout), Content & Licensing (clearance KPIs, coverage‑by‑society bars, royalty reports FILED/DRAFT, "no clear license → no play"), and Revenue (dark MRR hero + sparkline, NRR, dunning, plan breakdown). Built on co‑located seed; Schibsted Grotesk + JetBrains Mono via next/font. Source typechecks clean (0 errors); all routes serve (super‑admin gated); Fleet verified 1:1 in dark. This completes every web surface in the handoff (owner dashboard + onboarding + billing + auth/team + Super Admin). Remaining product‑wide: native iOS (reuse the Android domain) + wiring the real backend.
    • Sound & Atmosphere — native Android Manager app (Kotlin/Compose), built 1:1 + APK verified. First native surface of the music product: a new `:atmosphere` module in the existing android/ Gradle workspace (consistent with :app/:pos/:waiter/:bazar, depends on :core; package com.restora360.atmosphere, compileSdk 36 / minSdk 26 / JVM 17). Recreates Manager Mobile.dc.html pixel‑faithfully in Jetpack Compose + Material 3: a custom app bar (venue + screen title + light/dark toggle), a custom bottom tab bar, and four screens — Now (Atmosphere‑AI mood card + Auto toggle, now‑playing with art/tags/progress/transport/volume, 2×2 quick scenes), Zones (per‑zone cards with on/off + volume, muted‑kitchen state), Schedule (live‑now banner + daypart list with the live row highlighted), Alerts (offline‑cache banner + connection/cache status + safeguards). The design tokens (doc 07) are ported to a Compose token system (AtmoColors + AtmosphereTheme, light + dark, defaults to the system setting). It also recreates the FOH Tablet Controller (Tablet Controller.dc.html, landscape, dark by default): top bar (brand + clock + all‑zones‑live/staff‑mode pills + theme toggle), a large Now‑Playing pane (160dp art, 36sp title, transport + volume), a Current‑Mood pane (Auto toggle, occupancy/weather/energy, mood shifts with the active scene), a 2×2 zone grid (tap to select), and a 4‑up scene bar (Lunch/Dinner/Aperitivo/Quiet Close). One app, two idioms — MainActivity branches by form factor (≥840dp width → Tablet Controller, else Manager phone), sharing the same domain. Built on a framework‑free domain + seed (domain/Domain.kt, pure Kotlin with no Android imports so it can back a future native iOS app) — the real Core API/realtime gateway slots in behind it later. Verified: `:atmosphere:assembleDebug` builds clean (0 warnings/errors) into an installable debug APK via the Android Studio JBR. On‑device visual QA is pending (the test tablet is PIN‑locked). Next: the iOS app reusing the domain, and the vendor Super Admin web console.
    • Sound & Atmosphere — owner dashboard (web), Overview screen built 1:1 from the design handoff. First surface of the new in‑venue music product (docs/Restora360 Music Interface/). A self‑contained section at `/atmosphere` with its OWN sidebar + scoped theme (mirrors the Growth Command Center pattern; gated behind a tenant platform_session), recreated pixel‑faithfully from Atmosphere Dashboard.dc.html: warm‑neutral surfaces, single green accent, Schibsted Grotesk + JetBrains Mono (via next/font), light + dark (toggle persisted to localStorage). All seven owner tabs are complete — Atmosphere (now‑playing card with art/track/chips/progress/transport/"Chosen by Atmosphere AI" reasoning, the Atmosphere Engine Auto/Manual + Current Mood + signals, 4‑zone grid, daypart timeline with a live "now" marker, Signature/Safeguards/This‑week trio), Schedule (day pills + LIVE‑NOW timeline + editable daypart rows with genre/energy/volume, live row highlighted), Zones (large per‑zone cards incl. manual/scheduled/disabled states + add‑zone), Music Library (search + filters + licensed channel cards with cover art, PLAYING/+Add), Branding (dark house‑identity banner, signature vs never‑play genres, energy range, brand‑consistency toggles, "fully licensed" badge), Analytics (KPI tiles, atmosphere‑vs‑spend bar chart, listening‑by‑daypart, top channels), and Locations (HQ campaign banner + 6‑venue fleet table with this/offline states). Built on seed data (lib/atmosphere/{types,seed,service} + co‑located per‑screen seed, demo tenant "Bottega Marittima · Stockholm · 1 of 6") behind a single data‑service boundary so the real Core API / Atmosphere Engine / licensed source slots in later with no UI change. Source typechecks clean (0 errors); verified 1:1 vs the prototypes in both themes; all routes serve (auth‑gated). Super Admin console (/admin/atmosphere), onboarding wizard, billing, auth/team and the native phone/tablet apps are the next surfaces.
    • Sound & Atmosphere — Onboarding, Billing, and Auth/Team surfaces (web), built 1:1. The remaining owner‑facing web surfaces of the music product, all recreated pixel‑faithfully from the handoff prototypes on seed data. (1) Onboarding — a full‑screen 6‑step concept‑setup wizard at /atmosphere/setup (Concept → Hours → Signature Sound → Zones → Pair players → Review) with a clickable step rail (done/current/upcoming states), interactive cuisine/vibe/zone selection, progress‑dot footer, and "Go live ✦" finish. (2) Billing at /atmosphere/billing — plan card (Multi‑Location €354/mo, "music licensing included"), this‑cycle usage bars, payment method, next‑invoice breakdown, invoice history. (3) Auth & Team — Team & Roles at /atmosphere/team (member list with Owner/Manager/Staff/Pending badges + role‑permission matrix), full‑screen SSO‑first Sign‑in at /atmosphere/signin, and a full‑screen always‑dark staff‑mode PIN unlock at /atmosphere/unlock. The shell now routes full‑screen surfaces (setup/signin/unlock) chrome‑less and adds an Account nav group (Billing & Plan, Team & Roles) under the dashboard sidebar. Source typechecks clean (0 errors); verified 1:1 vs the prototypes. Every owner/staff‑facing web surface in the handoff is now built — only the vendor Super Admin console and the native phone/tablet apps remain.
    • "Building your site" loading screen redesigned (1:1 to the handoff) + Restora 360 logo on Android app launch. Two brand‑polish items. (1) The /wow/[token] build‑reveal interstitial (BuildReveal) is restyled to the new loading‑screen design — dark warm background, animated hero Restora 360 logo (pulsing halo + rotating arc ring), the top‑left brand lockup, the step list (orange‑gradient check + strikethrough when done, spinning ring when active, hollow ring pending), and a shimmering progress bar with a live Building/Ready label + %. All logic is unchanged (real step progress, skip, once‑per‑token, reduced‑motion); only markup/style changed; the new restora-360-logo.png asset replaces the old ✦ placeholder. (2) The Android :tv display app now opens with a professional ~2‑second branded launch splash: a cold‑start window background (no white flash) → an overlay with the Restora 360 logo + wordmark + a 0→100% progress bar → fades into the display (which loads behind it). The cold‑start window + Android‑12 OS launch frame are a plain brand‑dark screen (no bare logo, no launcher‑icon flash), so the loader is the only branded screen the user sees.
    • TVs & Displays: visual theme picker + sound toggle per screen. The per-device theme dropdown is now a popup of live theme thumbnails (DeviceThemePicker) — Tenant theme / Classic / the 5 premium designs as real mini‑previews — so the owner sees what they're choosing. The per‑device sound control is a simple Sound on/off toggle (default off) instead of a dropdown.
    • Per-device ad sound + display fit fixes (TV/tablet polish). Three fixes from on-device testing: (1) the customer display now letterboxes in black instead of light gray on non-16:9 screens (e.g. a 1280×800 tablet) so it reads as fullscreen, not "web with white margins" (DisplayThemeStage/FitFrame background, black on the live /tv/screen); (2) the Android :tv app uses `sensorLandscape` so it auto-orients the right way up instead of locking to one fixed landscape (fixed "upside-down on launch"); (3) a per-device Sound: default / on / off control in TVs & Displays — a new TvDevice.soundOverride applied in buildTvFeed, so one screen can be muted and another loud regardless of the tenant-wide reel setting.
    • Ad reel: optional video sound (owner toggle). Video ads are muted by default — browsers/TV WebViews only allow a video to autoplay when muted (unmuted would be blocked from starting). A new Sound toggle on the ad reel opts in: on the in-store TV app it plays with audio (the Android WebView permits unmuted autoplay; the Tizen native engine likewise); in a plain browser it starts muted and unmutes on the first interaction (e.g. the Fullscreen button), since browsers block unmuted autoplay outright. New ads.soundEnabled config (default false), threaded through the web <AdSlot> (all 5 themes) and the native ads.js with a muted-fallback so the video always plays regardless.
    • Customer display: true fullscreen "app feel" (browser button + Android immersive). Two fixes so the display reads as a kiosk app, not a web page. (1) Browser — a floating Fullscreen button on /tv/screen (browsers only allow fullscreen from a click) gives a chrome‑free, address‑bar‑free display on any laptop/TV browser; it auto‑fades when idle in fullscreen, reappears on activity, toggles to an exit control, and hides itself inside the native shells (?shell=…, which go fullscreen natively). (2) Android `:tv` — edge‑to‑edge immersive via WindowInsetsController (status + nav bars hidden, swipe‑to‑reveal) plus killing the remaining "web" tells (no scrollbars, no overscroll glow, no pinch‑zoom, no long‑press selection, no haptics). No backend change.
    • Ad reel: YouTube/Vimeo → cached MP4 ingest (the owner's own promo videos, played gap-free). Pasting a YouTube/Vimeo link in the Ad reel now transcodes it once, server-side, into a local MP4 the TV caches and loops — live-embedding can't meet the never-buffer bar and is ToS-bound. New POST /api/tenant/[tenantId]/ads/ingest: the URL is validated + canonicalised (only youtube.com/youtu.be/vimeo.com video URLs ever reach the tool), a pending ad item appears immediately, and yt-dlp (+ ffmpeg) runs off the request path with hard security/abuse limits — argv-only spawn (no shell, URL after --), ≤10 min, ≤250 MB, 3-min timeout, max 2 concurrent imports/tenant, fixed output path. The dashboard shows the pending item with an "Importing…" state and polls until it flips to ready (plays) or failed (remove + retry). Feature-gated: with yt-dlp/ffmpeg absent the endpoint returns a clear "not enabled — upload the MP4 instead" message and nothing else changes (RESTORA_YTDLP_PATH/RESTORA_FFMPEG_PATH overrides; setup + ToS in docs/tv-deployment/VIDEO-INGEST.md). Honest: best for the restaurant's own videos; yt-dlp breaks when YouTube changes, so direct MP4 upload stays the reliable primary path. URL-validation/arg-injection unit tests added; tsc clean. (Transcode path needs on-server validation once yt-dlp/ffmpeg are installed — not present on the dev box.)
    • Cross-brand TV deployment — the customer display now runs on ANY TV, not just Samsung. Researched + adversarially fact-checked (the "type a URL → app icon on any TV" idea is a myth — no consumer TV browser supports it; Roku/Vizio have no web layer at all), then built the realistic coverage. One web app (/tv/screen) stays the single payload; new thin delivery shells point at it: (1) Android `:tv` module (android/tv/) — a lean, framework-free WebView leanback kiosk that covers Android TV / Google TV AND Amazon Fire TV with one APK (keep-awake, immersive fullscreen, autoplay video ads, MENU-key server override, network-recovery + watchdog reload, render-process-death recovery, boot auto-launch, hardened WebView — no file access, MIXED_CONTENT_NEVER_ALLOW); (2) LG webOS `.ipk` (webos/) — a faithful clone of the Tizen .wgt shell (server override, settings overlay, burn-in shift, watchdog, webOS relaunch/back lifecycle) using only standard web APIs; (3) docs (docs/tv-deployment/) — the brand-agnostic "Use Any TV" guide (pre-imaged HDMI Android box + Fully Kiosk → boots the display on Roku/Vizio/any HDMI screen), the commercial-signage URL-launcher guide (Samsung SSSP / LG Play-via-URL), and a verified decision matrix. The recommended non-technical path is the HDMI-box (works on every brand); the store apps add a native icon + auto-launch where wanted. Built source-ready (Android :tv:assembleDebug builds; webOS needs real icon artwork before submission); adversarially reviewed (5 agents) and the confirmed correctness/security/24-7-resilience findings fixed. Native tvOS deferred; Roku/Vizio native + Chromecast intentionally not pursued (dead ends covered by the HDMI-box path).
    • Customer-display AD REEL — rotating image + video ads that never stop, flicker, or buffer (Samsung TV + web). The premium customer-display themes had a single static ad image; they now play a rotating playlist of images and video (MP4/WebM, and best-effort HLS) in the theme's sponsor region. New playlist data model on liveOrdersDisplay.ads (AdItem[] + transition/duration), fully backward-compatible — a legacy adImageUrl is read as a one-image reel (resolveAds, 10 tests). Owners manage the reel from Orders → Customer Display → Ad reel (AdPlaylistManager): upload an image/video (or paste a direct MP4/HLS/image URL), set per-image dwell, reorder, enable/disable, remove — saved instantly via the existing display-config PATCH and picked up on the TV's next poll. A new POST /api/tenant/[tenantId]/ads/upload accepts images (≤10 MB) and video (MP4/WebM, ≤200 MB, streamed to disk under public/uploads/{tenantId}/ads/). The never-buffer guarantee comes from the Tizen .wgt Native engine: a new persistent window.ADS player (tizen/native/ads.js) lives in its own fixed layer — so the board's 4 s feed re-renders never interrupt a playing ad — double-buffers (decodes the next item before cross-fading → no black frame), caches image/MP4 assets to app-private storage (Tizen Download/Filesystem) and plays them from disk so a slow/dropped connection can't stall the reel, skips broken items instantly, and loops forever; each theme's ad region became a transparent slot the player draws over. The hosted web engine gets the same rotating player as a React <AdSlot> (best-effort — no disk cache; that's the Native engine's job). YouTube/Vimeo links are intentionally not live-embedded (they buffer + are ToS-bound) — server-side ingest-to-MP4 is the planned follow-up. Verified: tsc 0 errors, next build green, the ad-config unit tests pass, and the reel rotates/crosses-fades/skips-on-error in the browser; on-panel AVPlay/cache validation pending a TV. (Native caching is fully fallback-guarded — if it's unavailable it plays the remote URL, never blanks.)
    • Samsung Smart-TV display app (Tizen) — pair-by-code-in-admin, fleet management, two display engines. Any TV becomes a Customer / Kitchen / Live-Orders display with no login, no password, no server typing on the screen — the TV shows a 6-char code, the owner opens TVs & Displays → Add a TV in the dashboard (already authenticated), types the code, names the screen and picks its mode, and it goes live instantly. Each screen gets its own revocable per-device token (independent of the shared display token), tracked in a new fleet page with an online dot, model/firmware/resolution, last-seen, inline rename, mode/theme switch, and Refresh / Reboot / Revoke (one-shot remote commands the TV applies on its next poll). Two interchangeable display engines, switchable live on-screen (RED/INFO key → Display engine): Premium (online) loads the hosted /tv/screen web app so the 5 premium customer themes + any new theme work with zero app rebuild; Native (instant) runs a framework-free renderer bundled in the .wgt from local files, fetching only the feed JSON — boots instantly, survives brief server hiccups (keeps the last frame), light on older panels, ideal for 24/7 kitchen screens. It renders the KDS/Live-Orders board and all 5 premium customer themes (Aurora/Hearth/Marquee/Gallery/Pulse, ads on/off), ported pixel-1:1 from the web theme components into a bundled framework-free renderer (tizen/native/themes.js) and fit-scaled from the 1920×1080 canvas. Both pair the same way and speak the same backend contracts. The Tizen shell adds the TV-only concerns: keep-awake, burn-in pixel-shift, watchdog refresh/network-recovery reload, D-pad-navigable settings, and self-host server override. New endpoints: POST /api/public/tv/code/start, GET /api/public/tv/code/poll, POST /api/tenant/[tenantId]/tv-devices/redeem, GET /api/public/tv/device/feed, and GET/PATCH/POST/DELETE /api/tenant/[tenantId]/tv-devices[/id] (the public device endpoints are CORS-enabled — token-in-query, no cookies — so the native engine + web-signage kiosks can call them cross-origin). Additive + tenant-isolated; per-device throttling by token-hash, constant-time token compare, atomic single-use code redemption via the master write-mutex. Verified end-to-end: native engine pairs cross-origin → owner redeems → live Live-Orders board + Customer queue render from real seed data with no console errors. tsc + eslint clean; device-registry unit tests green.
    • Automatic content translation — a standard platform feature (menu · categories · settings), fully owner-customizable. When an owner saves content in their default language, the other enabled languages (driven by settings.languages, so EN/SV for Sweden and the right set for US/UK/CA) fill in automatically in the background, reusing the existing batched-Haiku translator. The engine (lib/i18n/auto-translate.ts, 12 tests) is provenance-safe: each machine value is recorded with the source hash it came from, so re-translation refreshes stale auto values but NEVER overwrites an owner edit (a value that no longer matches its recorded auto value is locked as "manual"); and it's metered under a new `translation` AI feature (shows on the budget pill) + a monthly cap so it can't run away (no key / over-budget / over-cap → it's a clean no-op). Wired on save across all content surfaces: menu items (name/description/ingredients), categories, settings (about + SEO), and the Mason website builder (every authored block text field on home + all pages — a plain-string field is converted in place to an {en,sv} record that the storefront localizes, then the target language is filled). A new Translations manager (/dashboard/translations, under Website & Content) lists every translatable field per language grouped by surface (Menu items / Categories / Restaurant / Website), with a status badge (Not translated / Auto / Edited), inline editing to fix any translation (which locks it), and a re-translate button. Live-verified end-to-end: editing a menu name to Swedish "Färska tomater och basilika" auto-filled English "Fresh tomatoes and basil" in the background; overriding it (menu or a website hero) flips the status to "Edited" and locks it; re-translate restores the machine value; the manager listed 13 website-block fields for the sample tenant. tsc + eslint clean; 36 tests green.

    Changed

    • Multi-currency AI billing + deterministic allergen detection — productized for the SE → US/UK/CA rollout. Two real-world hardening fixes. (1) Multi-currency AI billing. The AI budget (Anthropic, USD) is now charged to each owner in their own currency instead of hardcoded SEK: a USD→local FX table × the 1.5× cut (ownerAmountFromUsd(usd, currency)), reusing the existing currency.ts registry/formatMoney everywhere. The top-up packs are currency-agnostic (allowanceUsd only) and priced + charged (Stripe) in the tenant's currency (SEK 33/83/165 · USD 3/8/15 · GBP 2/6/12 · CAD 4/10/21 · EUR 3/7/14); the budget pill, the menu-import top-up sheet, and the Cost & Profit tab all format in the tenant currency (e.g. SEK now renders "9,56 kr" with the correct Swedish locale). InvoiceRecord gained an optional currency. Internal AI accounting stays in USD; only the owner-facing price/display converts. (2) Allergen suggestions that actually fire. A deterministic EN + Swedish ingredient→EU-14 keyword detector (lib/menu/allergen-keywords.ts, 5 tests) now supplements the human-gated catalog, so "Detect ingredients" suggests allergens for the common cases regardless of how full the canonical catalog is — whole-word matching (so "peanut" never trips "nut", "coconut" never trips tree-nuts) and still owner-confirmed before going live (safety gate preserved). Verified live: "wheat flour, mozzarella, garlic, fresh salmon, sesame seeds" → suggests gluten / milk / fish / sesame (garlic correctly none). luigi set to SEK. tsc + eslint clean; 26 tests green.

    Added

    • AI ingredient detection (SV/EN) + per-dish true-cost model in the menu editor. Two linked menu-editor features, deterministic-first and reuse-built. (1) Detect ingredients from text — a "✨ Detect ingredients" button parses the dish's Ingredients/Description (preferring the Ingredients field, falling back to Description) in Swedish or English, any delimiter (· / comma / no-space / spaces / - ), matching each against the tenant's own inventory and the global IngredientMaster catalog (official + sv/en translations + synonyms). The hard "San Marzano garlic olive oil" (no delimiters) case is solved by greedy catalog-phrase extraction (keeps "San Marzano" as one ingredient). Common lists resolve with zero AI tokens; only prose/unknown terms hit a capped, budget-gated Haiku fallback (metered under a new ingredient-detect feature, visible on the AI pill). A review panel lets the owner accept/reject each line and confirm suggested allergens before they merge (safety gate — allergens stay human-confirmed). New lib/menu/ingredient-detect.ts (8 tests) + POST /menu/detect-ingredients, reusing tokenize/intersectionSize. (2) Cost & Profit tab — a 4th editor tab shows true cost vs price → margin: ingredient COGS (real, from recipeCost + purchase prices) + guided overhead the owner tunes — labor (prep+cook minutes × the loaded staff rate: wage +12% vacation +31.42% employer tax, from staffCost), energy/gas (cook-method presets × the tenant's kWh price), and AI + marketing spread either flat per-dish or by volume (this month's AI spend from the pill + marketing ÷ units sold — owner toggles). Shows profit/dish, margin, the ingredients-only margin for contrast, a "price X to hit Y% margin" tip (reuses pricing), and honest "est." flags on guided lines. New lib/inventory/cost-model.ts (10 tests) + POST /menu/cost-estimate + GET/PATCH /menu/cost-settings; Product.costModel + settings.costSettings (additive, optional) persist via the product routes. All live-verified (detection 5/6 on a real menu; cost math exact: 15 min labor → 55.20, oven 20 min → 2.08, margin 61.2%). tsc + eslint clean.
    • AI-budget popup redesign (1:1 with the design handoff). The top-bar pill's popover is rebuilt to the hi-fi spec: a remaining-first hero (big tabular-mono green number + "of X kr"), a usage progress bar, "used / N AI requests" stats, a "Resets …" chip, then a select-a-pack → confirm top-up flow (radio packs, a "Popular" badge on Medium, the explicit "every 1 kr you add is 1 kr of AI" line, and a CTA that reads "Add N kr to your budget"; toggling a pack deselects). Exact tokens (colours, 20px radius, layered shadow, sizes, spacing, the pop animation) reproduced; keeps the portal/fixed/z-[9999]/focus machinery and the buy logic (Stripe in prod, dev-sim otherwise). Screenshot-verified. Per the handoff's own guidance it inherits the dashboard's brand sans + a system monospace (no extra web-font request).
    • AI budget pill in the top bar — always-visible spend + buy-more, any time (1.5× service-cut pricing). Restaurant owners now see their AI usage on every dashboard page: a compact pill (AiBudgetPill) next to Quick Links shows % of this month's AI budget used with a color-coded bar (green → amber → red, "0 left" when exceeded), so AI can't quietly run away in a single day. Clicking it opens a popover with the owner-facing balance in SEK — "X kr used of Y kr · Z kr left · resets <date>" — and Buy more · any time top-up packs (Small / Medium / Large) that reuse the existing POST …/billing/ai-topup flow (Stripe Checkout in prod, dev-simulated grant when no Stripe key). All owner-facing amounts apply a consistent 1.5× markup over the raw token cost (the platform's service cut), via one rate (AI_MARKUP = 1.5, ownerSekFromUsd): the displayed spend/limit/remaining and the pack prices are all 1.5× the underlying USD, and packs are repriced to exactly 1.5× (33 / 83 / 165 kr) so a pack's price equals the budget it adds in owner terms. GET …/billing/ai-topup now returns an owner-facing display block (spent/limit/remaining SEK + pctUsed). Renders cleanly at the top bar; screenshot-verified; the math checks out (e.g. $0.42 spent of $1 → "7 kr of 17 kr, 42%"). tsc + eslint clean. Follow-up (legibility + z-index): AI (Haiku, capped) is so cheap that one question costs a few öre — which rounded away to a flat "0 kr / 0%" and looked broken even though metering worked. The popover now shows budget figures with 2 decimals (so spent + remaining add up to the limit), a "N AI requests this month" count (concrete proof it's tracking), and "<1%" with a small bar sliver when usage is real-but-tiny (BudgetStatus/display gained requests + pctExact). The pill also refetches on window-focus + every 60 s + on open so it doesn't go stale after AI is used elsewhere (the dashboard layout never unmounts). And the popover now renders via a `document.body` portal (position:fixed, z-[9999]) — like the notifications panel — so it floats above all page content instead of hiding behind stacking contexts (e.g. the menu page's drag-and-drop transforms); closes on outside-click/scroll/resize/Esc. Verified on the menu page: popover fully above the dish cards; tenant with $0.0047 real spend now reads "0.08 kr used of 16.50 kr · 3 AI requests · <1%". A 4-dimension adversarial review (money / CSS / React / UX) hardened two confirmed issues: the SEK figures now reconcile exactly (remaining derived in SEK space + spent clamped to limit, so "used" + "left" always equals "of <limit>" — no ±0.01 kr drift), and the popover honors `role="dialog"` focus (focus moves into the panel on open and returns to the trigger on close, never stealing focus on mount). All checks green.
    • AI Inventory Intelligence — daily email report + capped AI narration + owner-set schedule + "Send now". The daily summary now narrates itself and lands in the owner's inbox. A short (2–3 sentence) AI narration tops the report — the only AI in the whole feature, on the cheapest model, ≤220 output tokens, budget-gated (per-tenant AI budget), with a deterministic rule-based fallback when there's no key / over budget, so it can never fail or surprise the bill. The narration is generated once a day and cached on the tenant, so opening the dashboard never spends tokens. Delivery is owner-scheduled: a new inventory-reports cron runs hourly and emails each tenant when their chosen local hour (sendHour — morning / evening / midnight, in their timezone) matches, at most once a day. A "Send now" button (POST …/inventory/report) generates a fresh report + emails it immediately, any time. The inventory dashboard panel gained the narration box, an "Email me this daily at HH:00" schedule toggle, and the Send-now button; GET/PATCH …/inventory/settings carries the schedule. AI spend is attributed under a new inventory feature in the AI usage analytics. Endpoints verified wired + gated (POST report 401 without a session; cron 401 without the secret). tsc + eslint clean.
    • AI Inventory Intelligence — one-click 3-choice reorder + the dashboard summary panel. The daily summary is now live on the inventory page (Overview tab): a "Today's Inventory Summary" card showing the health score, stock value, food-cost %, waste %, and ranked alerts, followed by the reorder list where each item offers the three picks the owner asked for — Cheapest · Fastest · Same as last time — each showing supplier, line cost, and lead time. One click (with a confirm) places the order via the shared B2B order engine through new `POST …/inventory/reorder`, which recomputes cost server-side, enforces the owner's daily spend limit, and records the supplier as the new "last time". The owner sets/edits the daily reorder spend limit inline (new GET/PATCH …/inventory/settings). The 3-choice ordering is gated on the supplier-recommendations add-on; the base summary shows on inventory-management. Endpoints verified wired + auth-gated. Still deterministic — no AI tokens spent; the capped LLM narration + daily email are the next slice.
    • AI Inventory Intelligence — Phase 1 engine + daily-summary API (deterministic, no AI yet). The "morning report" brain: daily-summary.ts fuses the forecast + cost + health rollups into one snapshot — inventory health score, value, food-cost %, waste %, expiry risk, recipe coverage, and a ranked reorder list (per-ingredient lead/safety, order-up-to-par) with ranked alerts. reorder-choices.ts turns each reorder line into the three supplier picks the owner asked for — Cheapest · Fastest · Same as last time — reusing the existing supplier matching/tiered-pricing/delivery-reputation libs. spend-limit.ts tracks today's restock spend vs the owner's daily limit. Exposed read-only at GET /api/tenant/[tenantId]/inventory/daily-summary (base summary gated on inventory-management; the 3 supplier choices + spend-limit enrich only with the supplier-recommendations add-on). Deterministic + unit-tested (10 new tests); the one-click place-order, dashboard UI, daily email, and capped LLM narration are the next slice.
    • Fixed: the Inventory Copilot ask bar now actually answers inventory questions. It was wired to the general content/marketing assistant, which refused stock/supplier questions ("I'm designed to help with content and marketing"). New inventory-scoped copilot (POST …/inventory/ask) grounded in the real inventory context — stock, the reorder list with cheapest/fastest suppliers, cheaper-supplier finds, waste breakdown (by category + reason), recipe coverage, and the demand-driver forecast. Deterministic-first: the common questions (the hero chips — "Find cheaper suppliers", "Why is my waste so high?", "Forecast next week", "What should I order today?") are answered from data with zero AI tokens; anything else goes to a short, capped, budget-gated Claude call grounded ONLY in the context (rule-based fallback when no key/over budget). It never deflects to marketing. AI spend metered under inventory. 3 tests lock the behavior.
    • Inventory → Overview makeover — "AI Command Center" (Concept A), 1:1 with the handoff. The Overview tab is rebuilt into a warm, owner-first command center that surfaces the whole AI-inventory engine: an Inventory Copilot hero (natural-language daily briefing + a real ask bar wired to the assistant + 4 suggested-prompt chips), 3 AI insight cards (Order now → one-click reorder of the most urgent item; Forecast → next stockout + the demand-driver uplift + a sparkline built from the real upcoming-driver multipliers; Cut waste → top waste category + a map-recipes CTA), a KPI row (health /100 · stock value · waste % · items), the to-reorder board (per item: ★ AI Pick / ⚡ Fastest / Same-as-last supplier tiles + "Order all", spend-limit aware), and the all-ingredients table (days-left bar + Reorder/Watch/Healthy pill + quick +Stock), sorted by urgency. Reproduced pixel-faithfully (exact warm palette, SEK throughout) and screenshot-verified against the prototype. All wired to real data + real handlers — the daily-summary engine gained an all-ingredients list + waste-by-category; one-click order uses the existing reorder endpoint (server-recomputed cost + daily spend limit); +Stock posts a stock movement; the ask bar streams from the existing budget-gated assistant. The Phase 1/2 daily-report, demand-drivers, special-days, and automation controls remain in a collapsed "Daily report & automation" drawer below — nothing lost. Other tabs untouched. tsc + eslint clean; 21 inventory tests green.
    • AI Inventory — weather now works from the restaurant's address (free geocoding). Most venues only set an address, not coordinates. The weather driver now resolves a location from the address via Open-Meteo's free geocoding API (no key) — city-level precision, which is all the ~11 km weather cell needs — and caches the result per restaurant (re-geocoded only if the address changes). Resolution order: explicit coordinates → branch coordinates → cached geocode → geocode-the-address. The cron + report path geocode + cache; the dashboard reads the cache. Verified: "Storgatan 14, 114 51 Stockholm" → Stockholm → 59.33,18.07.
    • AI Inventory Intelligence — Phase 2: demand-driver forecasting (weather + holidays + occasions + own-history), 100% free. The forecast now thinks like an owner: it scales each future day's expected consumption by demand drivers, so an upcoming busy weekend, heatwave, public holiday, religious observance, or local festival pulls reorders earlier and bigger — and the daily summary explains why ("This week: Fri +18% — Friday + Warm 26°C; Sun +35% — Midsummer"). All signals are free, no paid APIs, no extra AI tokens: (1) weather via Open-Meteo (free, no key); (2) holidays + religious observances for ~200 countries via the offline date-holidays library (Ramadan/Eid/Christmas/Easter/Diwali/Midsummer…); (3) day-of-week + seasonality learned from the venue's own order history; (4) an owner "special days" calendar for what AI can't infer. The capped daily narration just mentions the drivers — no extra spend. Centralized weather architecture (per the design): one Open-Meteo call per location cell per day, cached in a shared WeatherCacheDB and reused by every nearby restaurant (a whole city = one call, not one per venue); a daily weather-refresh cron warms the cells; the dashboard reads the cache (never calls out). Super-admin control (/admin/dashboard/settings): weather on/off + an Open-Meteo API key field — free tier for development, drop in a paid key at go-live for the commercial endpoint (key never returned to the browser). Owner UI: a "This week's demand drivers" breakdown + the special-days editor in the inventory panel. Deterministic engine, 5 new driver tests (24→29 inventory tests). All additive + backward-compatible (no drivers → the flat forecast, unchanged). tsc + eslint clean; free Open-Meteo fetch + all endpoints verified.
    • AI Inventory Intelligence — option "inventory effect" editor (set the modifier deltas). The menu editor's Options tab now lets owners attach an inventory effect to any option: per option, add rows like "uses +50 g of Cheese" or "uses −20 g of Onion" (positive = extra, negative = less), picking from the tenant's inventory ingredients with a unit selector. This is the owner-facing half of the modifier-consumption feature — what gets set here flows straight into the live order's net stock deduction + the forecast. The picker only appears when the tenant has inventory ingredients; deltas persist on ProductOption.ingredientDeltas through the existing product save. tsc + eslint clean.
    • AI Inventory Intelligence — modifier/option ingredient consumption on the live order path. Selling an item now deducts net ingredient stock: the base recipe ± the ingredient-deltas of the chosen options ("extra cheese" deducts more, "no onion" deducts less, "add bacon" pulls an ingredient that isn't in the base recipe). This closes the biggest forecasting gap — consumption (and therefore the daily summary + reorder forecast) now reflects real usage in high-customization restaurants, not just the base recipe. The math is a pure, unit-tested helper (netConsumptionPerUnit: base taken as-is for backward-compat, deltas unit-converted to the ingredient's unit, removals clamped so consumption never goes negative; deltas in non-convertible units are skipped, never breaking a sale). Fully backward-compatible: a product with no recipe and options with no deltas deduct exactly as before. ProductOption.ingredientDeltas was added in Phase 0; this wires it into the order's stock-movement write (still inside the atomic per-tenant lock, still idempotent on replay). The owner-facing editor to set deltas per option is the remaining piece.
    • AI Inventory Intelligence — Phase 0 foundation (deterministic engine + data model). Groundwork for the predictive inventory + one-click procurement upgrade, all additive/backward-compatible. Schema: IngredientItem gains persisted reorder settings (reorderPoint/parLevel/maxStock/leadTimeDays/safetyDays/shelfLifeDays) + supplier memory (preferredSupplierId/lastSupplierId, the latter recorded on every goods-receipt → powers "same as last time"); ProductOption gains optional ingredientDeltas (extra/remove consumption); settings.inventoryIntel carries the owner's daily reorder spend limit + report prefs. New deterministic libs (no AI, unit-tested): units.ts (kg↔g / l↔ml conversion), cost.ts (recipe cost, inventory value, food-cost % / COGS — honest about uncostable lines), health.ts (waste %, expiry risk, recipe coverage by product and sales volume). This is the engine the daily summary + 3-choice reorder build on; not yet user-visible.
    • Live Orders Operations board — polished order cards (1:1 with the redesign). The staff kanban cards were rebuilt to the design handoff: a barcode-style number sticker (taps to open the printable receipt), the order total, customer + time-ago, a fulfilment chip + a source-channel pill with a matching colored left edge, clean item lines, and a stage-aware primary action (Accept · Start preparing · Mark ready · Mark collected / Hand to courier) plus a finished "✓ Delivered HH:MM" state. Column headers gained colored status dots. The source channel is derived from real order data only — Online / POS / Waiter (from source/type) — not fabricated delivery platforms. The decline ✕ on incoming orders is now wired (it was a dead button): it confirms, then cancels via the existing restaurant-cancel endpoint (full refund if a payment was held) and drops the order from the board. Drag-to-advance still works; action buttons stop the press so a jitter can't swallow the click.
    • TV Display Redesign — 5 owner-selectable premium Customer-Display themes, each with ads on/off. The Live Orders → Customer Display tab is now a theme picker: a responsive grid of live, scaled 16:9 preview cards for Aurora Split (warm split-screen + sponsor video rail), Hearth (rounded cards over a full-width promo banner), Marquee (dark stage, giant now-serving number), Gallery (editorial Newsreader serif + 4:5 poster ad), and Pulse (glass cards + live progress + ad rail) — plus the original Classic board. Click a card → it saves instantly and the in-store TV picks it up on its next poll; the selected card gets an accent ring + an "On air" badge. An Ads on/off toggle flips every theme's sponsor regions live; an optional uploaded campaign image fills the ad slots (otherwise the designed placeholder). Each theme was reproduced pixel-1:1 from the design handoff (authored at 1920×1080, fit-scaled to any screen via a shared stage) and screenshot-verified ads + no-ads. One source of truth: the same theme components render the owner's preview cards and the public TV — driven by one data shape (mapOrdersToThemeData: preparing oldest-first, ready newest-first so the latest number is the dominant "now serving"; ETAs are honest minutes-in-state, never fabricated). Additive + backward-compatible: the model defaults to theme: 'classic', so existing tenants' TVs render byte-identically until the owner picks a premium theme. The public projection already carries the config, so no API surface changed (the display-config PATCH gained theme / adsEnabled / adImageUrl, validated). The device strip honestly shows the TV as live and the iOS/Android apps as "coming soon" (the native theme port is the agreed fast-follow). Built web-first; tsc/lint/build clean, mapper + resolver unit-tested.
    • Customer TV / pairing screens no longer show the cookie-consent banner. The GDPR consent bar was rendering on the unattended /display/* board and the /tv* pairing screens — kiosk surfaces that set no analytics/acquisition cookies and have no one to dismiss it. The banner now suppresses itself on those device paths (a pre-existing papercut surfaced by the redesign).
    • TV "scan-to-connect" — connect a Customer Display with no typing on the TV at all (YouTube/Netflix-style device pairing). Open /tv on the screen and it shows a big QR + the full address and waits. The owner scans it on a phone that's signed into Restora (/tv/connect/<deviceId>), picks the restaurant, and taps Connect — the TV is polling and connects itself to the live-orders board. No code read off the screen, no URL typed on a remote. Security-by-design: the QR carries only an unguessable 144-bit deviceId; the secret pollToken that unlocks the result is held only by the TV (never in the QR), so a bystander who photographs the QR can't read the display token. Display tokens are resolved live at poll time, never stored in the link. The link is single-use and expires in 10 minutes (the QR auto-refreshes). The 6-char code entry stays as a fallback for smart-TVs that prefer it. New endpoints: POST /api/public/tv/link/start (mint), GET /api/public/tv/link/[deviceId]?poll= (poll, pollToken-gated), POST /api/tenant/[tenantId]/live-orders/tv-link (owner authorize). Verified end-to-end: mint→pending→authorize→linked; wrong/missing/unknown token all return an indistinguishable 404; authorize requires tenant access (401 without a session).
    • TV display pairing: scan-to-pair QR + auto-pair deep link. Pairing a Customer Display screen used to mean typing the long server URL on a TV and then a 6-char code. The dashboard "TV code" modal now shows a QR encoding /tv?code=<code>, and the /tv page reads ?code= and pairs automatically — so a tablet display scans once and lands on the board with zero typing (the code is also still shown for smart-TVs that type it). (For a true keyboard-less TV, shortening the host itself is a Tailscale step — rename the machine / use the MagicDNS short name.)
    • "Coming Soon" pre-launch site gate (super-admin toggle). A new comingSoonMode switch on the super-admin Settings page rewrites the whole public site to the /coming-soon launch page while it's on — keeping `/admin` + `/login` (and operator surfaces) open so you can sign in and turn it back off. The proxy already gated on maintenanceMode; this adds a distinct, correctly-labelled flag (so "maintenance" and "pre-launch" stay separate), surfaced via /api/public/site-status, with a 15s edge cache and fail-open behaviour (a transient read error never locks the site out). Verified: flag on → public routes gated, admin open; flag off → live.
    • 8 premium website templates, each with its OWN design — not the same homepage repainted. Carte, Forno, Maison, Masala, Mercato, Nori, Stack & Volt now ship as code-defined templates (palette + display and body font + demo content), reproduced 1:1 from the source handoff designs and screenshot-verified. The architectural fix behind it: buildStarter now honors a per-template `home` section sequence (each template declares its own ordered sections + variants instead of the hardcoded five), and the storefront injects the template's exact body font (the old fontPair list couldn't render Work Sans / Bricolage / Anton / Shippori Mincho / etc.). Added 7 new shared section variants — Featured popular-list / masonry-utility-grid / signatures-grid / omakase-feature, Contact stats-row (+mono), Editorial editorial-split, PromoBanner marquee — all token-driven, data-bound to the tenant's real menu/hours/address, and fully editable in Mason. Lights stay light, darks stay dark, fonts exact. Everything is additive and runs through the existing template/Mason pipeline (no parallel engine); the 6×10 seed stays untouched (these register as code-templates, like Saffron & Sage / Terra). Terra is registered here too. Also includes: a 2-column + roman-numeral menu-page layout (new MenuLayout.categoryNumbering:'roman' → "I · II · III" category headers; Carte/Mercato), and an accent-background hero "CTA band" (new accentBg hero mode = accent bg + accentInk text + inverted button; Stack/Volt's "Hungry?" closing).

    Fixed

    • Block CTA/button links now point at the tenant's page on path-served sites (not the platform root). On a /tenant/[id]-served site, only hero and split-hero rebased their root-relative links onto the tenant basePath; every other registry block (cta-banner button, delivery-cta, loyalty-cta, contact-info book link, …) sent its CTA to the platform root. BlockRenderer now rebases the whole class of link fields for every registry-rendered block (the boolean showMapLink is excluded; external/anchor links pass through).
    • Food Stories: the media-quota dead end is now recoverable. Hitting the 500-file per-account upload limit used to say "contact support" with no way out. The post-delete + uploads-GC backend already existed (a post DELETE unlinks its own images; a super-admin GC reclaims orphans from edited posts) but wasn't reachable from the UI. Now: the quota error says "delete some older posts to free up space"; the post ⋯ menu offers Delete post on your own posts (removes it for everyone + frees its photos); your profile tiles get a hover delete button; and the super-admin Food Stories page has an Uploads GC card that scans for orphaned files, previews how much it'd reclaim, and deletes on confirm.
    • Master-doc write mutex — concurrent master writes can no longer lost-update (the per-tenant mutex's sibling). mutateMasterDB on the fs path was a bare read-modify-write with no lock (unlike mutateTenantDB), so two concurrent master mutations both read v1 and the later save clobbered the earlier — racing the master-level TOCTOU hotspots (addon activate/cancel, subscription sweeps, cron leases, and the new TV device-link single-use bind). It now serializes every fs-path master mutation through an in-process async mutex (withMasterLock), mirroring withTenantLock (Postgres was already row-locked via mutateDoc). This makes the TV link's pending→linked transition atomically single-use, and closes the same lost-update class for every other master writer. Surfaced by the adversarial security review of the scan-to-connect flow.
    • Per-tenant write mutex — concurrent writes to one tenant can no longer lost-update. The JSON store is whole-file last-writer-wins, so two read-modify-writes to the same tenant landing at once both read the same snapshot and the later save clobbered the earlier. mutateTenantDB now serializes per tenant through an in-process async mutex (withTenantLock) on the fs path (Postgres was already serialized via the mutateDoc row lock). The two consumer order routes that bypassed it — payment-intent (card) and paypal-order — now push under mutateTenantDB, with the order number computed from a fresh read inside the lock so concurrent orders can't collide on it. Verified: a 40-way concurrent unit test lands all 40 writes; 5 concurrent live card orders land with 5 distinct sequential numbers (previously: lost updates + duplicate order numbers). The lock map self-cleans so it can't grow unbounded.
    • Online orders placed while the restaurant is closed now behave consistently — and the live-orders gap is closed. A cash/pay-at-pickup online order placed outside opening hours was rejected (RESTAURANT_CLOSED, 409) and never created — so it never reached the Live Orders board — while card (Stripe) and PayPal orders silently went through and took payment with the kitchen shut. The three public order-creation routes now share one gate (onlineOrderingGuard in lib/bazar/ordering-status.ts): when closed they all reject the same way (no more phantom paid orders while closed), and when open they all accept. Root-caused from a live reproduction (Friday 05:26 vs Friday hours 12:00–22:30). Unit-tested.
    • `db:import` (JSON→Postgres migration) no longer silently drops singletons. The importer used a hardcoded allowlist that omitted five app_documents docs — food-catalog (the 8,187-ingredient catalog), fsn, launchpad, webshop, and visitor-analytics — so a Postgres deploy came up with an empty ingredient/nutrition directory (/ingredient showing "being populated") and empty Food Stories / Launchpad / webshop data. It now *auto-scans `databases/.json** (key = filename stem), so every current and future singleton is carried into Postgres. (scripts/db/import-to-pg.mjs`.)

    Added

    • "Accept orders while closed" toggle (Business Hours). New owner control (default off = today's behaviour) that, when on, lets every online channel — website cash, card, and PayPal — accept orders outside opening hours: pre-orders, and the ability to place test orders out-of-hours and see them in Live Orders. Persists as settings.acceptOrdersWhenClosed and is honoured uniformly by the shared order gate above.
    • Food Bazar honours the pre-order toggle instantly. The marketplace previously gated ordering on opening hours only, so a restaurant with "Accept orders while closed" ON still showed Closed and blocked add-to-cart — contradicting the backend, which would have accepted the order. The bazar API (/api/bazar/restaurant/[slug] + /api/bazar/restaurants) now exposes an acceptingOrders signal (= open by hours OR acceptOrdersWhenClosed) alongside openNow; the marketplace restaurant page gates ordering on it (with a clear "Closed right now — pre-orders open" banner instead of a hard block), and the bazar checkout shows a pre-order notice rather than a "won't be accepted" warning. Reads live tenant settings, so flipping the toggle takes effect on Food Bazar on the next load — no re-deploy of the apps, which read the same APIs.
    • Ingredient & nutrition database link in the main site footer. The public Food & Ingredient Directory (/ingredient — 8,000+ ingredients, searchable, with nutrition/allergen/dietary info) was reachable from the legacy footer but missing from the main landing footer (landing-v2/LandingFooter). Added under Product so it's discoverable from the homepage.
    • Universal hero gains poster-design powers (so it reproduces the real handoff templates 1:1, no per-theme hero). Reproducing the Citrus design from the premium-templates handoff (docs/Restora360 Premium Design Templates/) revealed my generated templates were approximations, not the real designs. Rather than fork a bespoke component, the one universal hero learned what it was missing, so it renders the Citrus poster pixel-1:1 as configuration and stays fully Mason-editable: decorative shapes (soft circles/blobs behind the content — the peach + sage Citrus circles), a display headline scale (poster-size type), an accent-word style toggle (italic Aurora ↔ plain upright Citrus), a status-dot badge (the "● Open every day" pill), and centred buttons on centred heroes. All five are exposed in the hero inspector (Headline size / Highlight word / Status dot / Decorative shapes), so every part stays editable in the same builder — no parallel system. Verified by rendering the Citrus hero under the real Citrus tokens + Fredoka and screenshot-comparing to the handoff. (First slice of reproducing the six handoff templates 1:1 + functional, Citrus first; nav/menu/footer + the live Citrus template wiring follow.)
    • In-builder colour-combination picker — your design's palettes, as two-tone swatches. The builder's "Colour theme" panel showed 12 generic single-colour presets even when a premium template was applied — so you couldn't pick the design's own colour combinations from the builder. Now, for a premium-template tenant, the panel lists that design's colour combinations (its 9-token palettes — e.g. Hanami's Sumi Ink / Sumi & Gold / Washi Light …) as two-tone split-circle swatches (background + accent). Picking one re-skins the whole canvas live and persists just the themeId on Save (no re-install, content untouched). Non-premium tenants get the 12 generic presets, also upgraded to two-tone swatches. The whole panel is warm-styled to match the navbar/footer/block editors, and the theme gallery's per-card colour swatches now use the same two-tone split-circle (shared TwoToneSwatch), so the two colour pickers are visually identical. (Builder GET now returns the template's full themes; PATCH accepts premiumThemeId and validates it against the active template.)
    • Each generated premium template now has a distinct hero (not the same image+stat hero five times). Using the universal hero's full range, each of the five designs gets a cuisine-appropriate hero: Osteria Lume — left-aligned editorial image; Fold — a split hero (photo panel + light text side, sans); Ember & Ash — dramatic full-bleed dark; Hanami — refined left editorial with a lighter overlay + gold serif accent; Citrus — a bright gradient hero (no photo), dark-on-light. Combined with each template's own font + palette, the five now feel genuinely different. Driven by a per-template hero override merged into the starter (split layouts auto-route the photo to the side panel). Verified headlessly across all five.
    • Theme gallery — pick the colour combination (one design, many palettes). Every premium template ships several colour themes (e.g. Hanami's Sumi Ink / Washi Light / Matcha…), but the gallery only ever applied the first one — there was no way to choose. Each gallery card now shows a swatch picker of all the design's colour combinations; selecting one re-skins the live preview thumbnail to that palette and applies it on install (the chosen variantId is sent through the already-variant-aware install pipeline). So a design is "one fully-featured theme with colour combinations to choose from", exactly as intended — the install backend (installThemePackage/resolveVariant) already supported variants; this exposes them in the card DTO (toCatalogCard.variants + defaultVariantId) and the UI.

    Added

    • Every premium template now has a cohesive designed footer (not the generic default). Aurora shipped a bespoke editorial footer, but the five other premium templates (Osteria Lume · Fold · Ember & Ash · Hanami · Citrus) fell back to the plain 3-column default. The editorial footer (eyebrow + wordmark + tagline + Visit/Hours/Reservations columns) is now reusable: a 'editorial' FooterConfig variant (alias of 'aurora', so existing Aurora tenants are unchanged), and the wordmark/tagline typography follows the template's font via FooterConfig.font ('serif' | 'sans'). Each generated template ships its own editorial footer (font from its logoFont, eyebrow + tagline from its identity), so it re-skins by both colour (9 tokens) and font with its themes — verified headlessly: Hanami in Cormorant/Sumi-Ink (dark) and Fold in Poppins/Birch (light) both render correctly. The builder's footer editor opens for 'editorial' too.

    Fixed

    • Switching theme/template no longer keeps the previous design's header & footer. Applying a new theme (e.g. the Kaze Omakase demo pack, or a generated template) changed the page body but left the Aurora navbar + footer in place, because the installer only wrote navbarConfig/footerConfig when the new package happened to ship its own — so a content-only demo pack (or a template with no bespoke footer) inherited whatever was there before. Install now always reconciles the chrome via a single applyChrome step: a package that ships a navbar/footer uses it; one that doesn't resets to the neutral default navbar and clears footerConfig so the token-driven default footer renders in the new palette — never a stale bespoke header/footer. Applies to both the demo-pack and premium-template paths; the theme-only footer-wordmark fallback is preserved. When a content-only pack falls back to the default navbar, its light/dark theme is picked from the just-applied palette's luminance so a dark pack doesn't get a clashing light bar. (Regression-tested: a content-only pack over an Aurora-chromed tenant resets the centred nav + clears the editorial footer; a generated premium template installs its own editorial footer + token navbar.) tsc 0 / 358 theme+mason+builder+nav tests pass.

    Changed

    • Every block inspector now matches the Navbar editor 1:1 (one polished, warm UI/UX everywhere). The Navbar editor's premium look was previously unique to the navbar; the generic block/section inspector (MasonInspector, used by all blocks) now shares the exact same design system — a warm #fffdfa panel, a gradient clay icon badge in the header, vertical icon tab pills (Content / Style / Effects / Motion / Advanced) on a sunken track, Eyebrow section labels, and warm controls throughout (inputs, selects, the MasonSegmented pill control, the MasonToggle switch, ranges, repeater cards). The shared primitives (editor-bits, MasonSegmented, MasonToggle) are reused, not re-implemented, so the two editors can't drift. MasonStyleInspector (the Style/Effects/Motion controls for ordinary blocks) and the `FooterEditor` were warmed to match (the FooterEditor also gains a Serif/Sans control for the new footer font). Universal hero — visual "Pick a style" cards + one home per control. The hero's preset is no longer a dropdown labelled "Style preset" sitting confusingly next to a "Style" tab; it's a visual card picker at the top of the Style tab (a mini-mockup of each look — Image+overlay / Gradient / Slider / Split / Aurora / Editorial / Minimal — grouped "With a background" vs "Type-led", with a selected ring + check), exactly like the navbar's Styles tab. The hero's own controls are routed across the tabs via a new FieldDef.tab (Content = text/buttons/badges/stats/offer; Style = preset cards + background + layout; Effects = shape dividers; Motion = entrance + parallax), and the generic `BlockStyle` Style/Effects/Motion panels are suppressed for the hero — so there is exactly one background control, one divider control, one animation control (no more duplicate "two Styles / two backgrounds / two dividers"). Fully backward-compatible (ordinary blocks keep the generic BlockStyle panels, now warm); verified headlessly across the hero's four tabs + generic blocks; tsc 0 / 318 mason+builder+nav tests pass (stale hero.ctaText field-map entry removed — hero buttons are authored in the Buttons repeater).

    Fixed

    • Home menu teasers (featured / menu-preview / daily-lunch) no longer hide dish photos when the menu page is text-only. A premium template with an elegant text menu (e.g. Aurora's showItemImages: false) globally stripped images everywhere — so the menu-preview block showed plate-icon placeholders even with its own "Show Images" toggle ON, on both the live site and the builder canvas. The global Menu → Appearance text/photo switch now governs the full menu page only; the home teaser blocks keep their OWN "Show Images" toggle (the photo always travels with the item, the block decides display) — in both BlockRenderer (live) and the builder preview-data API (editor). So the block's toggle works as labelled and reflects directly. Additionally, a template's menu layout now seeds only with the demo (theme-and-demo), so applying a template to a real restaurant ("use my content") never forces their menu to text-only / hides their photos.

    Added

    • Universal Hero — P1–P5: the full editor + presets (one hero now expresses every design). Building on P0's layered model, the powerful Hero is now fully authorable in the same Mason inspector and "never breaks the design". Inspector engine (reusable by every block): new field controls — group (a nested-object editor, recursive), repeater (add / reorder / remove array rows), segmented (pill toggle), range (slider) — plus dot-path showWhen conditionals; legacy CTAs / layout / backgroundImage are synthesized into the new shapes so existing heroes show their current content and upgrade on first edit. Backgrounds (Background → Type): gradient (stops + angle), solid, image (+ focal), slider (a CSS-only, JS-free crossfade that really cycles on the live site), video (URL + poster), pattern (dots/grid/noise/topo), none — each hardened so a half-set background can't emit broken CSS. Content: eyebrow · rich headline (an emphasis word → accent + italic) · sub · a buttons repeater (5 styles, new-tab) replacing the two hardcoded CTAs · a badges repeater. Layout: centered/left/right + split (text + media side-panel, 40/50/60) + asymmetric · vertical align · content width · height · shape dividers (wave/tilt/curve/arrow, top + bottom, flip). Motion & extras: entrance animations + stagger · parallax · a live special-offer banner (pill/ribbon/bar, optional coupon). Presets: a "Style preset" selector (Image+overlay, Gradient, Editorial, Aurora, Split, Slider, Minimal) — a preset is just defaults merged under the block's own fields, so picking a look never locks anything and every control stays editable (the old logic that hid image/layout controls on premium/editorial heroes was removed, per "double-check everything is editable"). Aurora absorbed 1:1: the aurora preset reproduces the bespoke aurora-hero essentially pixel-for-pixel (dark token bg, Cormorant serif headline with an italic-accent word, line-bordered outline CTA, full-width bordered stat row) — verified by rendering the bespoke block directly above the preset under Midnight-Gold tokens. Contrast safety: themeMode auto-derives from the background's luminance (a detectably-dark hex / media ⇒ white text; a light or token-driven solid/pattern ⇒ theme-token text) and patterns are no longer auto-darkened — so a light hero stays light instead of washing out to grey, across every background type (stress-tested headless across gradient / solid / pattern / image / slider / split / dividers / offer-banner). 100% additive; normalizeHero covered by 7 tests; tsc 0. (Deleting the now-redundant aurora-hero block + lazy-migrating existing blocks is the next phase, gated on owner verification in the builder.)
    • Universal Hero — P0 foundation (the "one hero for every theme" rebuild). Replacing the hero-per-theme proliferation (hero / split-hero / aurora-hero) with ONE configurable, layered Hero that can express every real-world design, so no future theme needs its own hero block. P0 lands the never-breaks foundation: a pure hero-model.ts (HeroContent input schema + resolved HeroModel + normalizeHero() which maps every legacy flat field — headline / backgroundImage / ctaText / stat1Value… / variant — onto layers), and a rewritten HeroSection that renders purely from the model through composable layers: background (solid/gradient/image/slider/video/pattern) → overlay (color/gradient) → optional split side-panel → content (live offer banner · badges · eyebrow · rich headline · sub · a buttons[] repeater · stats) → shape dividers, with entrance animations + parallax. Every colour resolves through nested token fallbacks (9-token premium var(--accent…) → --color-*) so it re-skins under any theme; isPreview keeps it editor-safe. 100% additive/backward-compatible: existing hero blocks render through the same layers via normalizeHero (covered by hero-model.test.ts); BlockRenderer now passes live offers + rebases every button/badge link. Editor exposure of the new layers (grouped inspector + repeater controls), presets, and absorbing/deleting aurora-hero land in the following phases.
    • Five more premium templates — all functional, themed, and selectable. Osteria Lume (rustic Italian), Fold (bakery & café), Ember & Ash (smokehouse), Hanami (omakase sushi) and Citrus (all-day brunch) now ship as complete, working restaurant sites — not mockups. Each is generated from a small per-template config through a shared builder (premium/generated-starters.ts) on the proven Aurora/Terra blueprint: the platform's real functional blocks (image hero + stat band, a live featured menu preview, an editorial story, the orderable menu-full, the real reservation-form, and contact-info), painted by that template's own 9 tokens (from the seed) + display font (Spectral / Space Grotesk / Anton / Zen Old Mincho / Fredoka, now in TEMPLATE_FONT_LINKS), with a cuisine-appropriate identity + menu + an elegant menu layout. Registering their starters makes all five appear in the theme catalog / owner gallery; applying any yields a fully orderable, bookable site in that template's look, editable in the builder. (Bespoke 1:1 editorial sections like Aurora's hand-built blocks are a per-template polish pass on top of this.) tsc 0 / tests pass; verified Osteria Lume + Ember & Ash render with their distinct palettes/fonts via headless-Chromium.
    • Premium templates: WYSIWYG builder canvas + order-tracking theming + "managed in…" affordances. Three follow-ups so a premium template (Aurora) is consistent everywhere and clearly editable. (1) Editor is now WYSIWYG: the Mason builder canvas injects the active premium template's 9 tokens + display font (scoped to .mason-canvas-theme, mirroring the storefront layout) so the editor shows Aurora's dark/gold/serif exactly like the live site — including the functional blocks (menu/featured) — and re-skins when the owner switches theme. The builder GET route now returns a resolved premiumTheme; premiumActive tracks it. (2) Order tracking matches the design: the order-confirmation success icon re-skins to the theme accent, and the standalone order-tracker page (/track/[tenant]/[order], which lives outside the tenant layout) now injects the same premium tokens + font, so an Aurora order tracker is dark/gold instead of the generic palette. (3) Discoverability: the inspector's note for data-bound blocks (menu / featured / contact / hours / offers / reviews / gallery / blog) now says exactly where the live content is managed (Menu → Products, Settings, Business Hours …) with a deep-link button — so "everything is editable" is obvious. All additive/backward-compatible (non-premium tenants unchanged). tsc 0 / 369 tests pass.
    • Premium template Aurora renders its real 1:1 design AND is fully editable in the Mason builder (not a read-only skin). Applying a "premium" template used to only re-skin the generic block storefront, so the rich reference designs collapsed into a washed-out approximation. Aurora's bespoke sections are now real, first-class Mason blocks — aurora-hero (eyebrow + headline + two CTAs + a three-stat band), aurora-story (the room/menu/cellar columns), aurora-quote (chef's quote), aurora-menu (the eight-course tasting list) and aurora-book (reservations heading + booking card). They render identically on the storefront and the builder canvas, and every field is editable in the same editor everyone uses — inline on the canvas (data-mason-field) and in the inspector (full BLOCK_FIELDS schemas, CTA links via BUTTON_LINK_FIELDS) — no new editor capability was needed; the fixed-slot pattern (à la feature-cards/stats) covers the stat band, the story columns and the eight courses. Every colour is a 9-token theme variable with a Midnight-Gold fallback, so the blocks look like Aurora even on the bare builder canvas and re-skin instantly across all 10 Aurora themes (the tenant layout injects the palette from settings.tenantSite). Applying Aurora seeds these blocks (+ a Stockholm fine-dining identity + menu) via a new Aurora starter, which also makes Aurora appear in the theme catalog / owner gallery (it was previously excluded because it shipped no starter). The earlier read-only tenantSite renderer was removed. Site chrome + pixel polish: the navbar system gained additive, editor-surfaceable options so Aurora's nav is 1:1 and editable — a centered layout (centred serif wordmark + a hairline rule + centred uppercase wide-tracked links + an italic-serif underlined CTA via ctaItalic) and a token theme that paints the bar from the 9 template tokens — plus a new editable FooterConfig (settings.footerConfig, variant aurora) for the editorial footer (eyebrow + serif wordmark + tagline + Visit/Hours/Reservations columns + bottom links), and body refinements (hero pill CTAs, italic-accent story titles). Install no longer "mixes" the design: premium templates write their authored blocks verbatim (a new applyPremiumDesign path) instead of auto-mapping (which previously injected generic menu/contact blocks — the truncated "click for full menu" look); chrome applies in both install modes. All additive (absent config ⇒ existing tenants byte-identical). Verified end-to-end against the reference via headless-Chromium screenshots; tsc 0 / 361 mason + site-template + theme tests pass.

    - Aurora is now a fully FUNCTIONAL restaurant site, not a pretty shell. The menu / reservations pages used decorative bespoke blocks (hardcoded courses, a static booking form) — so the menu ignored Menu → Appearance and never showed the tenant's real menu, and the form didn't book. Aurora now reuses the platform's real functional blocks, themed by the Aurora tokens (the Terra pattern): the menu page is the orderable `menu-full` (live products, add-to-cart, search/allergen/dietary filters, honours Menu → Appearance), reservations is the real multi-step `reservation-form`, and the home gains a live `featured` menu preview funnelling to the orderable menu. Cart / checkout / payment / contact are the platform pages, re-skinned by the 9 tokens. The Aurora demo seed became eight real priced products (the courses), so the demo still reads as the design while being fully orderable; "use my content" keeps the tenant's own menu inside the Aurora skin. The decorative aurora-menu/aurora-book blocks remain registered for optional pure-content use but are no longer the storefront's menu/booking. (Known dark-theme polish: the contact page's brand-colour "Reach Us Directly" card reads heavy under the gold accent.)

    Fixed

    • Mason builder — fixed a React hydration mismatch from dnd-kit's drag contexts. The page bar / canvas / nav-menu DndContexts had no explicit id, so dnd-kit derived each drag handle's aria-describedby from an internal counter that drifts between the server render and the client (React Strict Mode double-renders in dev) — DndDescribedBy-0 vs DndDescribedBy-1 — throwing a hydration warning on /dashboard/site. Each DndContext now passes a stable id (mason-pagebar / mason-canvas / mason-navmenu), making the attribute deterministic. No behaviour change.
    • Mason website editor — three selection/fidelity flaws so the canvas is a true 1:1 mirror of the live page. (1) Section-first selection: a single click on a section now always selects that SECTION (Webflow/Framer-style); clicking a marked field only drills into the field once its section is already selected. Previously the editorial hero is almost entirely editable text, so a click would "steal" the selection into a headline field before the hero was ever selected — making it feel unselectable (the user had to click away and back). Double-click / Enter still edit a field in place. (2) System/chrome sections are now mirrored + selectable: the live home page renders three things that AREN'T website blocks — the auto-promo offer banner (active offer, e.g. "Lunch Express"), the auto-FAQ section, and the site footer — but the editor only showed the auto-promo (and it was a dead, unclickable zone) and omitted the FAQ + footer entirely. All three are now rendered read-only on the canvas in their live order, clearly badged ("Auto · active offer" / "FAQ · automatic" / "Footer · site-wide"), and selectable → a new right-panel explainer (MasonSystemInfo) says what each is and links (new tab) to where it's managed (Offers / SEO / Navigation + Settings). To stay DRY + 1:1 the live FAQ and footer markup were extracted into shared presentational components (StoreFaqSection, SiteFooterView) used by BOTH the storefront and the editor mirror; the builder GET route now also returns faqEntries + footer. The public storefront output is byte-identical (verified by screenshot). tsc 0 / eslint 0 / 276 mason+tenant tests pass.

    Changed

    • RefCapture consent-consistency: the anonymous `?ref=` landing forensic log is now consent-gated. Closes the follow-up the cookie-consent review flagged. The sales-agent referral landing beacon (POST /api/public/referrals/track) used to store an anonymous visitor's IP + User-Agent unconditionally; that identifiable, non-essential storage now only happens with explicit marketing consent and never under DNT/GPC — matching the new consent gate. The functional ref_code cookie is still set regardless (the visitor deliberately followed a partner link, so attributing it is the action they requested, not behavioural tracking), so agent attribution + commissions are unaffected. The signup-time forensic event in /api/auth/register is intentionally left in place + documented: it fires at an actual commission-forming signup, where IP/UA is fraud-prevention under GDPR legitimate interest (recital 47), not anonymous tracking. tsc 0 / eslint 0 (uses the tested consent helpers).

    Added

    • Theme ecosystem redesign (Phase 32) · P2.2 — rendered mini-homepage thumbnails (designs are now distinguishable). Theme cards previously showed a single hero photo (and several packs reused the same stock image), so owners couldn't tell designs apart. Each card now renders a ThemeThumbnail — a scaled mini-homepage mockup (nav + hero + a row of featured dish cards) painted in the theme's own palette, heading font, hero headline, and real demo dishes/prices — so every theme visibly shows its design at a glance, no screenshot pipeline needed. The thumbnail hero uses each pack's own first-dish photo (from the 26-image verified pool) instead of the 3 shared stock heroes, so cards never look identical. toCatalogCard now carries heroHeadline/heroImage/fontHeading/currency/sampleDishes; used by both the owner gallery (ThemeCatalog) and the super-admin Theme Library. +mockup test assertions (62 theme tests); tsc 0; next build exit 0.
    • Theme ecosystem redesign (Phase 32) · P2.1 — retire premium-template mode (truly no legacy). Completes the unification: the old premium-template "mode" (settings.tenantSite + a separate skin engine) is gone, so the builder + storefront run one path. Migration (scripts/db/migrate-premium-themes.mts): every tenant in premium mode is converted to the equivalent standard-mode catalog package (re-skinned "theme-only" — keeps its content, gets the design + palette as customColors), then tenantSite is dropped (the one dev tenant, luigi-pizza/terra, migrated cleanly). Builder: removed the right-sidebar Theme panel's premium picker (MasonThemePanel is now just colour/font/layout settings + "Browse all themes" → the catalog gallery), all premium state/handlers from MasonShell, the canvas premium-CSS overlay, and the premiumTheme↔tenantSite read/write in the builder route. Storefront: the layout's premium-token overlay and the page's premiumizeBlocks call are removed — a theme's palette now lives entirely in colorPresetId + customColors. Deleted the now-unused apply-template route. The site-templates lib stays (the catalog adapts those designs into standard packages). 62 theme tests pass; full repo tsc 0; next build exit 0.
    • Theme ecosystem redesign (Phase 32) · P2 — super-admin-curated unified theme catalog (no legacy dual path). Re-architected ownership per product direction: the super admin controls which themes exist site-wide; restaurant owners only browse + apply. New unified catalog (lib/themes/package/catalog.ts) surfaces ALL installable designs as ThemePackages — the 8 demo packs, the premium templates (Saffron & Sage, Terra) adapted via their starters (editable Mason blocks + menu + the 9-token premium palette mapped to brand colours, so there's no separate template path), and super-admin ZIP uploads (master.themePackages) — gated by themePackageFlags.enabled. Super-admin Theme Library (/admin/dashboard/themes, rewritten): upload/validate a theme ZIP, enable/disable site-wide, export any theme, delete uploads, and capture a test restaurant's site as a theme (/api/admin/themes/{packages,packages/[slug]/export,export-tenant}, all requireSuperAdmin). Import/export removed entirely from restaurant owners (deleted the tenant import/export routes + the owner ImportExport UI). Owner gallery is now a single ThemeCatalog of enabled themes, each shown as a demo-content thumbnail (browse designs without clicking), with one-click "Use with my content" vs "Preview with demo content" (both undoable). Owner install is gated to enabled themes (403 on a disabled slug) and the owner catalog API lists enabled only. Added ThemeVariant.customColors (premium palette → standard brand colours) and a demo thumbnail on every pack (manifest.screenshots[0]). +4 catalog tests (61 theme tests total); full repo tsc 0; next build exit 0. Remaining legacy: the right-sidebar Theme panel's small premium picker still uses the old apply path — flagged as the next follow-up; the full-screen gallery is fully on the new system.
    • Theme ecosystem redesign (Phase 32) · P1e — portable theme ZIP import/export (`.r360theme.zip`). Export a tenant's current site (exportTenantToPackage → manifest + variants + page templates + editable demo content + a sha256 corruption-detection hash) and import a (potentially untrusted, third-party) theme ZIP. The import pipeline (lib/themes/package/{zip,security}.ts, route POST …/themes/import, both requireTenantAccess) parses → runs a security pass → validates the zod manifest + content hash + semver engine gate → sanitizes every imported block → previews → installs (undoable via the rollback snapshot). New ThemeImportExport controls in the gallery header (Import .zip / Export, with a preview→confirm modal). JSON-only packages this version (demo images are URL refs; binary assets via sharp are a follow-up). Hardened by an adversarial security review (17 agents, 7/7 confirmed findings fixed): (1) zip-bomb DoS — entries are now inflated through a bounded stream that aborts past the per-entry cap (was: JSZip fully materialized each entry in memory before the size check, so a ~200 KB entry could expand to ~200 MB and OOM the shared process); (2) CSS-injection / active-scheme URLs — every imported URL-bearing field + style.bgImage is clamped to a safe URL (safeImportUrl), so a crafted backgroundImage/bgImage can't break out of url(...) to inject CSS, and javascript:/data: can't reach an href/src/iframe; (3) untrusted menu/reviews — imported products/categories are element-validated (id/name/price/category) and reviews are coerced (no NaN rating / non-string author crashing storefront SSR); (4) contentHash is documented as corruption-detection, not a signature (no security decision depends on it). +13 ZIP/security tests. Full repo tsc 0; next build exit 0.
    • Theme ecosystem redesign (Phase 32) · P1a/P1c/P1d/P1f — one-click theme install + demo content + rollback. The WordPress/Shopify-style install experience, built additively on the Smart Content Mapping engine (P1b). New unifying ThemePackage model + theme.json manifest (lib/themes/package/types.ts, zod-validated in manifest-schema.ts, semver engine gate in engine-version.ts). An installer (lib/themes/package/installer.ts) with two modes: Install Theme Only (loads the design but keeps the tenant's menu/media/reviews — pages are auto-mapped with the tenant's own data via autoMapBlocks+bakeBlocks) and Install + Demo Content (seeds a full, editable demo restaurant into the normal websiteBlocks/pageBlocks/products/reviews structures). Every install captures a site snapshot first (lib/themes/site-snapshots.ts, mirrors menu/snapshots.ts) so it's undoable; additive TenantDB.siteSnapshots + MasterDB.themePackages/themePackageFlags. 8 seed demo packs (pizza · fine-dining · cafe · bakery · food-truck · sushi · fast-food · multi-location — ~217 dishes, authored via an 8-agent workflow) expand from a small DemoPackSpec via demo-packs/build.ts, with images drawn from a curated, verified food-photo pool. New routes (all requireTenantAccess): POST …/themes/install, POST/GET …/themes/revert, GET …/themes/packages. New UI: a "Demo restaurants" section in the Mason Template Gallery (DemoPackInstaller.tsx) with two install buttons, an inline confirm, progress, and a one-click Undo. Hardened by a 5-dimension adversarial review (23 agents, per-finding verification): registered custom-slug pages in builderPages so the catch-all storefront route serves them; made rollback media-safe (the installer never writes db.media, so snapshots no longer capture/restore it — preventing wipes of photos uploaded after install), symmetric (optional settings/navConfig clear on revert), and drift-proof (capture + restore are driven off one allowlist); aligned the install-bake featured-dish language with the live render (pick(name, lang) instead of hardcoded .en); and seed demo nav even when the tenant had none. +22 install-side tests (both modes, theme-only preserves menu, rollback round-trip + clear/media-safety, custom-page registration, manifest/engine validation, all 8 packs well-formed). Full repo tsc 0 errors. Reuse-first: extends apply-template's write pattern, render-data helpers, and menu/snapshots rather than forking. Remaining in P1: theme adapters for the existing 6 themes/12 palettes/3 premium templates (gallery feed), ZIP import/export (P1e), inspector dynamic-tag picker.
    • Theme ecosystem redesign (Phase 32) · P1b — Smart Content Mapping engine (dynamic tags). First slice of the WordPress/Elementor-grade theme-install experience: an additive, backward-compatible engine that lets theme/demo authors (and the install auto-mapper) drop mustache placeholders — {{restaurant.name}}, {{hours.weekly}}, {{contact.phone | tel}}, {{links.menu}} — into authored block fields that resolve to the tenant's live data. New pure, framework-free lib at lib/themes/content-mapping/: a tag registry (tags.ts — 19 tags across identity/contact/hours/social/links/menu, each with a resolver + sample + format pipes + a bakeAtInstall flag), a resolver (resolver.ts — {{path|format}} grammar; identity-return no-op when a block has no tag, so existing tenants like luigi-pizza render byte-identically), a context builder (context.ts — derives the normalized data bag from TenantSettings, reusing the proven tenantHours/tenantSocials/pick helpers now exported from site-templates/render-data.ts), an install bake step (bake.ts — identity/structure tags become plain editable text; live tags stay tokens so they track Settings), and an auto-mapper (auto-map.ts — Install-Only: maps hero/footer identity from tenant data + guarantees hero/menu/contact sections so nothing renders empty/broken). Live resolution is wired into the single SSR choke point PageBlocks (BlockRenderer.tsx), memoized per render context — so editing Business Hours updates every page automatically with no re-install. +22 tests (resolver/auto-map/bake/registry), tsc 0 for touched files. Backbone for the install pipeline + demo packs + ZIP import/export (next).
    • Mason builder — a full-screen Template Gallery to browse + apply a whole website at once. A new MasonTemplateGallery (opened from a "Templates" button in the editor toolbar and a "Browse all templates" link atop the Theme panel) shows every template as a preview card in two groups: Premium templates (image preview + tagline + "what's inside" tags + a selectable palette picker — premium starters apply the full 1:1 layout, skin-only ones apply just the theme) and Standard layouts (the 6 built-in block layouts, shown as colour-preset previews). Applying any card runs the shared POST /api/tenant/[tenantId]/apply-template, which now also (a) accepts an optional themeId so a premium template applies with the chosen palette, and (b) handles the standard built-in layouts (seeds their home blocks + colour preset, clears any premium skin) — so the gallery is one apply path for both kinds. The editor refetches and the cloned design loads, fully editable. tsc 0 / eslint 0 / 1092 tests pass; gallery verified by screenshot.
    • Premium templates — "Saffron & Sage" cloned 1:1 into the builder as a fully-editable starter template (capability test). Proves the platform can take an external design and turn it into a selectable template that's a true 1:1 clone AND fully editable in Mason. A real source site (a Tailwind farm-to-table restaurant — home + menu) was reproduced as: (a) a premium theme skin — a code-defined SiteTemplate (lib/site-templates/premium/saffron-sage.ts) on the 9-token contract (cream/espresso/saffron/sage palette, 3 theme variants) + Playfair Display/Lato fonts. Registered in the registry (NOT in premium-seed.json, so the "6 templates × 10 themes" seed test is untouched). (b) a 1:1 starter layout (saffron-sage-starter.ts) authored entirely from the platform's OWN editable Mason blocks — hero, featured, delivery-cta, reservation-form, contact-info, google-maps + the menu page's menu-full — plus the navbar/brand, contact/hours/socials, and the full 26-dish, 7-category menu seeded as real editable products. Applying it (new POST /api/tenant/[tenantId]/apply-template, surfaced as a "Use this template" button on each starter-bearing premium theme in the Mason Theme panel) seeds all of that into the tenant and reloads the editor, so every section is a normal editable block. The one genuine gap was filled additively: the hero block now supports an eyebrow, a secondary CTA, and an optional quick-facts stats row in its image variant (new inspector fields + eyebrow is now inline-editable on canvas; the editorial variant hides the image-only controls). Verified by seeding a throwaway demo tenant and screenshotting the live home + menu pages against the source — faithful match (transparent hero navbar, eyebrow + italic accent headline + dual CTAs + stats, themed sections, real menu with add-to-cart). tsc 0 / eslint 0 / 1092 tests pass.
    • Phase 18 — AI Support Brain + Voice (hybrid, cheap): buildable slice shipped. A self-learning, cost-optimized support layer where the LLM is the LAST resort. A new framework-free engine src/lib/support/ answers questions through cheap deterministic layers first — L1 FAQ → L2 BM25 lexical search over the existing Docs Center → L3 guided troubleshooting → L4 human-approved learned answers — all with zero LLM cost; only when none is confident does it reach L5 (a budget-capped Claude Haiku call grounded strictly on retrieved Help Center excerpts, honesty-ruled, metered under a new support AIFeature) and finally L6 (human hand-off → a platform support ticket). The learning flywheel: every L5 answer becomes a PII-stripped pending learned answer that a super-admin must approve before it's ever reused (the "never learns a wrong answer" gate); a thumbs-down rejects it. Owner UI: an assistant on `/dashboard/support` (above the ticket center) with chat, on-device voice (Web Speech STT + TTS — no cost), layer/provenance badges, source citations to the Help Center, feedback, and one-tap escalate (which opens a real ticket and refreshes the list). Super-admin UI: a Support Brain console (/admin/dashboard/support-brain, in the sidebar) showing the L1–L6 resolution distribution, the cost metric ("% answered without AI" / LLM share), a feedback tally, and the approval-gated learning queue. Routes (all auth-guarded): POST /api/dashboard/support/{ask,escalate,feedback} (tenant) + GET/POST /api/admin/support-brain (super-admin). GDPR: PII is scrubbed before anything is stored; the approval gate is the second line of defense. 17 new engine tests (541 total pass); tsc 0 / eslint 0. Deferred: pgvector embeddings (lexical search substitutes behind the same rankKnowledge interface), all-portal/footer surfaces + Twilio telephony, the AI knowledge-curator / auto-docs.
    • Phase 18 — SB-0: semantic search delivered as BM25 + synonym query-expansion (no vector store reinvented) → Phase 18 is 100% complete. Closes the last SB task without building a redundant vector store. There is no embeddings provider or pgvector store anywhere in the repo (the DB is a JSON-document store), and a real vector store is explicitly designed to be ONE shared platform store (the Catalog/GFI + Agent-OS/RAOS depend on the same one) — so a Phase-18-only copy would be the wheel-reinvention to avoid. Instead, SB-0's function (semantic-grade retrieval) is delivered cheaply: a curated, high-precision synonym query-expansion layer (lib/support/synonyms.ts) feeds the BM25 ranker so the deterministic layers now match paraphrases the raw tokens miss — "can't log in" finds a "sign in" article, "picture" finds a "photo" doc, "remove" finds a "delete" step — with zero embeddings, infra, or cost. Integration is careful: each query term expands to a group (term + synonyms); a synonym hit lifts the BM25 score (at 0.75× an exact match) and counts the term as matched once, so recall improves without inflating the overlap/confidence denominator (no false-confident answers). This raises the share of questions the cheap layers resolve — directly serving the "LLM is the last resort" flag. A real shared pgvector store remains an OPTIONAL future platform-scale optimization that drops in behind the same rankKnowledge interface. +4 engine tests (589 total pass); tsc 0 / eslint 0. With SB-0 delivered, Phase 18 (SB-0…SB-6) is complete — the only open items are go-live/scale config (a live Twilio number; pgvector if/when measured scale demands it), neither of which is Phase-18 dev work.
    • Phase 18 — SB-5: voice agent (telephony) built with a mock provider — configure Twilio at go-live. The Support Brain now answers phone calls, riding the SAME deterministic brain. New lib/support/voice.ts (pure, tested): TwiML builders, sanitizeForSpeech (strips links/markdown, caps length for TTS), interpretConsent/wantsAgent (DTMF + speech), voiceTurn (L1–L4 → spoken answer, else hand-off), voiceConfig (env), and twilioSignature/validateTwilioSignature (constant-time HMAC-SHA1 request verification). The call flow: a GDPR consent gate ("press 1 / say yes; press 0 for a person") → caller speech → the brain answers (Twilio TTS), offering follow-up turns → when unsure or asked, a human hand-off opens a platform support ticket with the (consented) callback number. Two Twilio Voice webhooks — POST /api/public/support/voice/{incoming,respond} — that are inert and safe until telephony is configured (no DB writes, no auth surface) and Twilio-signature-verified once live (+ rate-limited). Because there's no phone in dev, a super-admin call simulator (POST /api/admin/support-brain/voice-sim + a "Voice agent" panel in the console) exercises the exact brain turn and shows the TwiML the caller would hear — so the functionality is provable now. Call metrics (calls / AI-resolved / escalated) surface in the console. Go-live = config only: set TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / SUPPORT_VOICE_NUMBER and point the number's Voice webhook at /incoming. On-device Web Speech voice already ships in the chat widget. A focused adversarial review (3 lenses + verifiers) caught a blocker — a silent/unintelligible caller could loop forever and never reach the human hand-off — plus consent-flow + GDPR-accuracy gaps; all fixed: the retry counter is now threaded through every turn (gather action and the no-input <Redirect>) so silence always escalates after one retry, the call-entry no-input redirect hands off to the consent state machine instead of looping back to /incoming (which also double-counted the call metric), the consent re-prompt opens a ticket on silence, and the hand-off ticket never asserts "caller consented" on a path where consent wasn't given. +10 engine tests (588 total pass); tsc 0 / eslint 0. With SB-5 built, the only remaining Phase 18 item is SB-0 (the shared platform pgvector store — BM25 substitutes behind the same rankKnowledge interface).
    • Phase 18 — SB-3 + SB-4 + SB-6: "Ask AI everywhere", the knowledge curator, and per-tenant/CSAT analytics (Support Brain to its full on-seed scope). Builds out the Support Brain past the initial slice. SB-3 — Ask AI everywhere: one reusable floating widget (components/support/AskAiWidget.tsx — chat + on-device voice (Web Speech STT/TTS) + layer/provenance badges + Help-Center citations) now appears across the whole product: a path-aware PublicAskAi mounted once in the root layout puts it on every public surface (marketing, Help Center, Docs, Learn, Food Bazar, FSN) and self-hides on owned/authenticated routes; the owner dashboard (DashboardAskAi) gets the full L1–L6 brain (feedback + one-tap escalate) scoped to the active tenant; the supplier/designer/agent portals get it too. A new public, abuse-safe endpoint POST /api/public/support/ask serves anonymous visitors the deterministic L1–L4 layers (FAQ → KB → guided → approved-learned) with no LLM, no DB writes, and per-IP + global rate limits, falling back to an honest "browse the Help Center / contact us" hand-off. Multilingual (lib/support/locale.ts): an optional lang makes the L5 answer return in the visitor's language (a one-line prompt directive — a few tokens, no quality loss); deterministic answers stay source-language so the cheap path stays free. SB-4 — knowledge curator + ticket-prevention + auto-docs (lib/support/curator.ts): deterministic token-overlap clustering of the learned-answer log surfaces repeated questions (documentation gaps that keep hitting the paid LLM); a super-admin drafts a durable Help-Center answer from a cluster (budget-bounded Haiku grounded strictly on the existing docs, with a deterministic stub fallback when no key is configured — build-with-seed), reviews it, and approves → it becomes an approved LearnedAnswer the brain serves at L4, so the next identical question is answered for free (ticket prevented at the source). The same "never publish an unreviewed answer" gate applies: a PII tripwire blocks approval, and clusters already covered by an approved entry are filtered out. SB-6 tail (lib/support/stats.ts): the console now shows CSAT (overall + a 14-day trend from daily feedback buckets) and a per-tenant breakdown (resolutions, reached-AI %, escalations, CSAT) — every /ask, escalate, and feedback now also records per-tenant + daily counters. Console (/admin/dashboard/support-brain) gains the curator (clusters → Draft → Approve/Dismiss) + the satisfaction/per-tenant panels. A 5-lens / 2-verifier adversarial review (22 agents) confirmed 7 findings (10 dismissed as intended/fail-closed) — all fixed: the curator clustering now uses single linkage (it compared only against the cluster seed, fragmenting repeated-question groups), cluster ids are derived from a member-set hash (no top-keyword collisions), the public widget's hide-list now covers every customer-facing tenant surface (/pay /track /display /tv /scan /gift-cards /shop /invoice + FSN — it was leaking onto diners' checkout/track/display screens), approve-proposal validates + mints inside the row lock (a double-submit could mint a duplicate) and scrubs other restaurants' names from the curated entry, and the widget stops its mic/TTS on unmount. +23 engine tests (curator/locale/stats/learning; 575 total pass), tsc 0 / eslint 0. Phase 18 is now built to its full buildable scope — the only remaining items are true infra/external gates: SB-0 the shared pgvector store (platform-wide "one vector store"; lexical BM25 substitutes behind the same rankKnowledge interface) and SB-5 Twilio telephony (on-device voice already ships; only the paid PSTN bridge is gated).
    • Phase 18 — adversarial review pass: cross-tenant safety + correctness hardening. A 5-lens / 2-verifier multi-agent review of the slice surfaced 14 confirmed findings, all fixed. Security (the headline): the learning store is global (an approved answer is reused for every tenant), so the PII scrub was broadened to also strip structured ids (SO-2026-00001, RB-2041) and 4+ digit runs, plus redact the originating tenant's own name; a containsPii post-scrub tripwire now quarantines (never stores) any draft where an identifier survives; each pending draft records its originTenantId (shown in the queue), a thumbs-down can only reject its own tenant's draft, and the super-admin queue carries an explicit "approving publishes to every tenant — redact tenant-specific detail" warning. Correctness: escalations are counted on a separate escalations field (no longer double-counted as an layerStats.human resolution, which skewed the LLM-share metric); getBudgetStatus is now .catch(null) so a super-admin probing a non-existent tenant fails safe to the human hand-off instead of a 500; the L3 guided sign-in flow dropped the generic access/sign/log triggers that hijacked unrelated questions. Dedup/perf: brain-opened and tenant-portal tickets now share one buildSupportTicket builder (identical id scheme/owner resolution/defaults); the static knowledge index is memoized; the learned list is capped at 500 without ever evicting unreviewed pending drafts; the admin console gained r.ok/res.ok guards + an error state. +13 engine tests (30 support / 552 total pass); tsc 0 / eslint 0.
    • Phase 30 — Restora Experience Studio: P7b — the public Experience API + asset-library scale-out (Phase 30 100% on seed). Closes Phase 30. A new POST /api/public/experience-studio/v1/generate lets a tenant generate experiences from their own stack / CI / headless site builder: key-auth via a new lib/experience-studio/api-guard.ts authenticateExperienceRequest that reuses the existing `rck_…` Restora API keys (one key per tenant works across the Catalog + Experience APIs — each endpoint gates on its own add-on), confirms the tenant still holds experience-studio, and applies the shared per-key rate limit (120/min). Crucially, both the public API and the in-dashboard one-click flow now run a single shared engine — a new lib/experience-studio/generate-core.ts runTenantExperience extracted from the tenant route (which was refactored onto it) — so quota (atomic reserve + compensating refund), the Food-DNA AI-budget lane, brand-kit resolution, compose, and WOW score are defined once, not duplicated. Scale-out (read): a new pure assets.ts filterAndPageComponents (filter by cuisine/category/status + free-text, stable id sort, NaN-safe clamped limit/offset, pre-page total) backs an optional ?cuisine=&category=&status=&q=&limit=&offset= on the admin library GET — the read stays bounded as the catalogue grows past the seed, while the full normalized-store/CDN offload stays volume-gated (same posture as the Catalog's P8–P10). 61 experience-studio tests (+2), tsc 0 / eslint 0 on all P7b files. Phase 30 is now 100% built & delivered on seed (P0 → P7b) — the remaining items are go-live/expansion gates (real curated art + measured 3D weights, the paying-pilot cuisine expansion, the live SEO scorer, the real Stripe overage charge, the volume-gated CDN offload), not dev work.
    • Phase 26 — Food Stories Network · long-form articles (E3.4): a Medium-style `article` post type. FSN authors can now publish long-form reads alongside stories + recipes. A new 'article' post type carries an Article (lib/fsn/article.ts — title + Markdown body with newlines preserved + optional subtitle/cover + tags; read-time is DERIVED server-side from word count, never trusted from input; the normalizer only bounds/trims, it deliberately does NOT strip Markdown). Create flow: the composer gains an Article mode (a 4-step Media → Details → Write → Review wizard with a live MiniMarkdown preview + word/read-time counter). Read: a new SEO'd reader /fsn/article/[id] (schema.org BlogPosting JSON-LD with an absolute canonical URL) renders the body via MiniMarkdown; a new browse hub /fsn/articles (+ FsnArticles, ?type=article feed) with featured hero + tag filters; nav + footer links; article cards in the feed/discover-rail, profile (a dedicated Articles tab), tag pages and explore — all reuse the existing PostCard. Structural safety was the design constraint: MiniMarkdown renders all user text as auto-escaped React children (never dangerouslySetInnerHTML), its link branch now enforces a scheme allowlist (internal / — excluding protocol-relative //host — plus http(s)/mailto; everything else, incl. javascript:/data:, drops the href), and the single JSON-LD articleBody sink is <→<-escaped centrally in JsonLd. Author deletion scrubs the article field in the cascade. Delivered understand → implement → adversarial review (a 5-dimension / 17-agent workflow with per-finding refutation). The review caught a critical visibility IDOR — the new article reader (AND, pre-existing, the recipe reader) rendered the post body + JSON-LD without a `canViewPost` gate, so a leaked id could read a private / followers-only post; both pages (body + generateMetadata) now 404 via the FSN-10 anti-oracle gate, with title/deck no longer leaking in the <head>. Also fixed from the review: relative→absolute BlogPosting URL, gallery alt text (article + recipe), the article-filter featured hero no longer showing an off-filter article, and the Review-step preview now renders Markdown. +8 article tests (58 FSN tests pass), tsc 0 / eslint 0.
    • Phase 26 — Food Stories Network · E7 commerce bridge (the moat): dish → orderable storefront + auto-built restaurant brand profiles. An FSN food story or recipe can now feature a REAL dish from a Restora restaurant's live menu — "dish-tap → real order," the uncopyable differentiator. A post carries an optional dish snapshot (lib/fsn/bridge.ts — denormalised so it survives later menu edits/deletes); the create flow gains a menu-item picker (GET /api/fsn/dish-options) shown ONLY to authors affiliated with a restaurant — operators (account.linkedUserId → TenantRecord.ownerId) or owner-VERIFIED staff (lib/fsn/affiliation.ts) — which is the abuse gate (you can't deep-link an arbitrary restaurant's menu). The shared post card + recipe detail page render an "Order this dish →" card; a new auto-built brand profile /fsn/restaurant/[tenantId] (SEO-metadata'd, GET /api/fsn/restaurant/[tenantId]) shows the restaurant's live orderable menu (order CTA → the storefront) + the FSN stories featuring its dishes. Reuses the live TenantRecord + products (no FSN-side duplication); the snapshot keeps the feed fast and resilient. +5 bridge tests (39 FSN tests pass), tsc 0 / eslint 0. Closes the highest-value remaining E7 gap; the buildable-web tail left is pre-launch hardening (post-delete / uploads GC / request-cache), Verified-Pro + creator tips (Stripe-gated), long-form/video, and FSN-UI i18n.
    • Phase 26 — Food Stories Network · pre-launch hardening: post deletion + uploads GC + getFsnDB cache. Closes the TODO blockers before opening public UGC. (1) `DELETE /api/fsn/posts/[id]` — author-only soft-delete (status:'deleted', never the admin 'removed' takedown) that scrubs the post, hard-drops its edges (likes/saves/comments/notifications), auto-resolves open reports on the post AND its now-deleted comments, and unlinks the post's media so the per-account upload quota is recovered; pure cascadePostDelete + a path-traversal-safe URL→disk resolver, with an author "Delete post" menu on the feed card. (2) `POST /api/admin/fsn/uploads-gc` — super-admin orphaned-file garbage collector (deletes files referenced by NO post image [every status] or avatar and older than a grace window; dry-run by default, audited; pure collectReferencedUrls/findOrphanUploads). (3) A short-TTL (5s) read cache for `getFsnDB` so the 2–3 doc parses per authed request collapse to one: the read path is cached while mutateFsnDB reads a FRESH object (never corrupting the shared cached read object) and saveFsnDB refresh-hots it, so a write is visible immediately on the instance — TTL kept short because suspension/revocation (FSN-10) live in this doc. Delivered understand → implement → adversarial review (two orchestrated workflows); the review caught + this commit fixes a cross-account media-deletion IDOR (post image URLs are now owner-scoped both at create time AND at unlink time), a 403/404 existence oracle (now 404 for both, matching the FSN-10 anti-oracle pattern), orphaned comment-reports on cascade, and a GC TOCTOU (re-validates the live reference set against a fresh read immediately before deleting). +13 tests (45 FSN tests pass), tsc 0 / eslint 0.
    • Phase 19 — Tax & Compliance Assistant · Slice G: country-module framework (TC-7) — Phase 19 complete. The VAT engine's registry is now seeded with 8 jurisdictions — Sweden, Denmark, Finland, Norway, Germany, United Kingdom, United States, Canada — so adding a country is a data edit, not core code. Each module declares its taxSystem (vat / gst / sales-tax), whether rates are automatable, and needsConfirmation (every non-SE module ships unconfirmed, publicly-sourced defaults to be confirmed by a local tax professional — build-with-seed). Dated + sourced highlights: DK 25% flat (no food reduction); FI 25.5% std / 14% food; NO 25% std / 15% groceries / 25% dine-in; DE 19% std / 7% food with restaurant dine-in 19% → 7% from 1 Jan 2026 (a real dated change, like Sweden's); GB 20% std with groceries/cold food zero-rated and catering 20%. US (sales tax) and Canada (GST/PST/HST) vary sub-nationally → modeled as NOT auto-computed: resolveVat returns supported:false with guidance to use a tax engine (Avalara/TaxJar) or accountant rather than fabricating a rate. The income statement's per-rate recognition now works for every VAT country; a Germany-2026 restaurant-VAT notice was added to regulation-watch; the Bookkeeping page shows a jurisdiction banner (unconfirmed-rates / not-auto-computed / VAT-return-is-SE-only); new GET /api/tax/countries lists the modeled jurisdictions; a consistency test pins every overlapping standard rate to the live webshop table. VAT-return prep + deadline reminders remain Sweden-only by design (other countries' return forms/deadlines are the progressive next step). Direct e-filing stays explicitly out of scope (TC-0). +5 tests (76 tax/bookkeeping tests pass), tsc 0 / eslint 0. Phase 19 (TC-0 → TC-7) is now build-complete on seed — remaining work is go-live gates (counsel-confirmed rates per country, a live reminder cron, additional country return-forms) and progressive country expansion, not core dev.
    • Phase 19 — Tax & Compliance Assistant · Slice F2: financial-report approval workflow — TC-6 complete. A per-period report now has a lifecycle — draft → ready-for-review → reviewed → approved → exported — that ties sign-off to the right human. Pure lib/tax/approval.ts is the state machine (allowed transitions + which actor may perform each: owner vs accountant) plus summarizeForApproval, which freezes the period's headline figures as a versioned snapshot. A new FinancialApproval on TenantDB stores status, version, the frozen snapshot, who/when at each stage, the accountant's review note, and a full history. The owner route …/tax/approvals prepares a report (snapshots the live books) and advances it (PATCH; "re-prepare" refreshes the snapshot + bumps the version); the accountant — via their scoped link — can perform exactly ONE bounded write, …/accountant/[id]/[token]/review (mark reviewed + note), validated by the same token. Owner UI: a "Report approvals" panel on the Bookkeeping page; the accountant page gains a review form on ready reports. Every transition is audit-logged (visible in the TC-6 audit center). TC-0 held — "approved" means the user signed off on a DRAFT; Restora never files. +6 tests (71 tax/bookkeeping tests pass), tsc 0 / eslint 0. TC-6 (accountant collaboration + audit center) is now complete; only TC-7 (country-module framework) remains.
    • Phase 19 — Tax & Compliance Assistant · Slice F1: accountant scoped access + audit center (TC-6, part 1). External accountants can now view a tenant's financials read-only without a Restora account, via a scoped magic-link — modeled on the existing token pattern, so no new auth role. A new AccountantAccessToken on TenantDB + pure lib/tax/accountant-access.ts (expiry math, revocation/expiry validation, token masking) drive it. The owner-gated …/tax/accountant route invites (emails the link), lists (with live status), and revokes; the public, token-authenticated …/accountant/[tenantId]/[token] route validates the unguessable, revocable, TTL token on every request (the token IS the authorization), records the view, and returns a read-only snapshot (summary, P&L, VAT return, data-quality, deadlines, regulation alerts) rendered on a standalone /accountant/[tenantId]/[token] page. An audit center (…/tax/audit) surfaces who invited / viewed / exported by reading the immutable audit log filtered to a finance prefix; CSV/SIE exports now write that trail too. Owner UI: an "Accountant access" + "Audit trail" panel on the Bookkeeping page. TC-0 held — read-only drafts, every grant/view/export audit-logged, links revocable + expiring (default 90 days). +4 tests (65 tax/bookkeeping tests pass), tsc 0 / eslint 0. Next: Slice F2 (TC-6 part 2 — the prepared→reviewed→approved approval workflow).
    • Phase 19 — Tax & Compliance Assistant · Slice E: VAT/PAYE deadline monitor + regulation watch (TC-5). Two pure, deterministic modules (no LLM). lib/tax/deadlines.ts (upcomingTaxDeadlines) computes Swedish skattekonto deadlines — VAT momsdeklaration (monthly/quarterly) due the 12th of the second month after the period; employer declaration (AGI) due the 12th of the month after — with the 17th bump for January/August, validated against real Skatteverket dates (Q2-2026 VAT → 2026-08-17, Q3 → 2026-11-12, Q4 → 2027-02-12). Each deadline carries days-until + urgency (overdue/due-soon/upcoming); yearly VAT is deliberately not computed (the date varies → surfaced as a note, never a guessed date). lib/tax/regulation-watch.ts (getRegulationNotices) is a curated, dated, sourced registry of regulation changes (seeded with the 2026–2027 food-VAT cut) shown when active/upcoming/recently-ended — every notice carries a source URL and "based on public info, not advice" (TC-0). New GET /api/tenant/[tenantId]/tax/calendar serves both; the Bookkeeping page renders an "Upcoming tax deadlines" + "Regulation alerts" panel. +10 tests (61 tax/bookkeeping tests pass), tsc 0 / eslint 0. Go-live trigger (not dev work): wire the deadline list into the notification/cron system for push reminders, and optionally a budget-capped AI agent that drafts new regulation notices from public sources for human review. Next: Slice F (TC-6 accountant collaboration + audit center).
    • Phase 19 — Tax & Compliance Assistant · Slice D: missing-data detection + risk & completeness scoring (TC-4). New pure lib/bookkeeping/data-quality.ts (assessDataQuality) scans the books + tenant settings for gaps that would make a report/VAT-return wrong and warns before the user generates or exports — it never blocks (the user stays responsible, TC-0). Findings: inconsistent VAT (some paid sales without VAT while others have it → critical) vs no-VAT-on-any-sales (warning — could be legitimately exempt), sales at an unrecognised rate, missing organisationsnummer / VAT number, unpaid orders excluded from the cash-basis books, sales-but-no-expenses, multi-month period gaps, and empty periods. Each finding carries a severity; the weighted total → a completeness % → the TC-0 confidence band (reuses confidenceFromCompleteness) → a risk level (low/moderate/high/critical). Wired additively into GET /api/tenant/[tenantId]/bookkeeping (dataQuality in JSON) and rendered as a risk/completeness banner above the bookkeeping summary, so warnings appear before the Export CSV / SIE buttons. +9 tests (51 tax/bookkeeping tests pass), tsc 0 / eslint 0. Next: Slice E (TC-5 VAT/PAYE deadline monitor + regulation-watch).
    • Phase 19 — Tax & Compliance Assistant · Slice C: VAT-return (momsdeklaration) prep + SIE export — MVP TC-0…TC-3 complete. Two new pure modules close the liability-safe MVP. lib/bookkeeping/vat-return.ts (buildVatReturn) maps the books to Sweden's momsdeklaration boxes — 05 taxable sales net, 10/11/12 output VAT at 25/12/6%, 48 input VAT, 49 = (10+11+12) − 48 — rounded to whole SEK, with payable/refund and reviewFlags for everything we deliberately DON'T derive (reverse charge, EU/imports, self-supply, exempt sales, any unrecognised rate). lib/bookkeeping/sie.ts (buildSie4) emits a valid SIE type-4 file (Sweden's universal accounting-interchange format) — header (#FLAGGA/#PROGRAM/#GEN/#SIETYP 4/#ORGNR/#FNAMN/#RAR/#KONTO) + one balanced double-entry verification per paid row (debit bank / credit sales + output VAT for sales; mirror for purchases), using an overridable BAS-aligned default account map. Both wired into GET /api/tenant/[tenantId]/bookkeeping (additive vatReturn in JSON; gated ?format=sie download) and the Bookkeeping page (a momsdeklaration box table + review flags, and an SIE download button beside Export CSV — both behind the TC-0 acceptance + review gate). TC-0 honesty held: the VAT return is a labeled DRAFT (Restora never files), the SIE account map must be confirmed, and SIE (no comment syntax) carries the disclaimer on a response header + the UI. +17 tests (42 tax/bookkeeping tests pass), tsc 0 / eslint 0. Phase 19 MVP (TC-0 legal spine → TC-1 books → TC-2 VAT engine → TC-3 P&L + VAT-return + SIE) is build-complete on seed; remaining TC-4…TC-7 (missing-data/risk, deadline monitor + regulation-watch, accountant collaboration, country expansion) are the next slices.
    • Phase 19 — Tax & Compliance Assistant · Slice B: income statement (P&L) + chart-of-accounts-lite + sales-by-VAT-rate (TC-1/TC-3). New pure lib/bookkeeping/pnl.ts (buildIncomeStatement) reuses the bookkeeping aggregation and the Slice-A VAT engine to: split paid sales into VAT-rate buckets read from the VAT actually charged (not assumed); flag any bucket whose rate the jurisdiction's module doesn't list for the period (a review/risk signal feeding TC-4); compute a lite P&L (revenue net → COGS proxy → gross profit → net result); and emit a neutral chart-of-accounts-lite. Carries an honest coverage block stating wages/rent/overhead are NOT included, so it's never mistaken for a complete income statement (TC-0). Wired into GET /api/tenant/[tenantId]/bookkeeping (additive incomeStatement, country from TenantSettings.countryCode, default SE) and rendered on the Bookkeeping page as an Income-statement panel + a Sales-by-VAT-rate table (with a "review" badge on unrecognised rates). +8 tests (31 tax/bookkeeping tests pass), tsc 0 / eslint 0. Next: Slice C (VAT-return prep + SIE export).
    • Phase 19 — Tax & Compliance Assistant · Slice A: deterministic SE VAT rules engine (TC-2 core). New lib/tax/rules.ts — a per-country, category-aware, date-effective, sourced VAT engine (resolveVat({ country, category, date })) for tax PREPARATION, replacing flat rate constants. It models Sweden's reduced-rate structure including the temporary food/takeaway cut from 12% → 6% (1 Apr 2026 – 31 Dec 2027, reverting to 12%), while restaurant/dine-in stays 12% and alcohol/tap-water stay 25%. Rules are data-driven + versioned + dated + sourced, so a rate change is a data edit (never core code), and a country registry (VAT_MODULES) makes adding a jurisdiction additive (TC-7 framework). Unknown country / invalid date → supported:false (never a guessed rate); a known country with no dated rule for a category falls back to standard, flagged for review (feeds the TC-0 confidence model). Used by bookkeeping / VAT-return prep + reports only — live POS/checkout pricing is unchanged (webshop/tax.ts), and a consistency-guard test ties the engine's SE standard rate to that live table so the two can't silently drift. All figures stay accountant-ready DRAFTS under the TC-0 legal gate; rates require tax-professional confirmation before filing. +10 tests (22 tax/bookkeeping tests pass), tsc 0. Next: Slice B (P&L + chart-of-accounts-lite), Slice C (VAT-return prep + SIE export).
    • Phase 16 — Growth Command Center: COMPLETE on seed (GI-0 → GI-8). The super-admin "virtual COO/CMO" is now fully built on the build-with-seed posture. Every Growth Command Center module that has a real platform source is live: Growth KPIs + channel drill + Direct Joins + funnel + real MRR forecast (GI-7) + AI advisor (AI-light, budget-gated), plus AI Budget, Suppliers, Food Social, WoW & Health, PoS Revenue, Food Bazar GMV, Feedback, and Hiring. Acquisition is captured forward at signup (GI-0) and frozen per tenant; analytics/attribution are consent-gated (banner + DNT/GPC); the heavy per-tenant order/review scan runs off-request in the order-rollup cron; the dashboard exports to CSV (GI-8). The honesty rule held throughout — the notional 1% PoS take is shown as a labeled rate (never revenue), NPS shows "—" (no survey source), and the only seed-tagged module is Waste & Recycling (no platform data source exists) alongside the representative leaderboards/briefing sections. Remaining items are go-live/scale TRIGGERS, not dev work: schedule the order-rollup cron in prod (run-once in dev); actual fee settlement for PoS/Bazar/supplier commissions (Stripe Connect, Phase 39+); a real NPS/CSAT survey source for live NPS; a waste-collection integration for the Waste module; deepened live leaderboards (GI-5); and a Postgres events table — explicitly volume-gated (no warehouse early). Cumulative this session: PRs #4–#14. 84 growth/consent tests, tsc 0 / eslint 0.
    • Phase 16 — GI-8: Growth Command Center CSV export. GET /api/admin/growth?format=csv (super-admin gated, same data the dashboard renders) returns a multi-section CSV — Growth KPIs, channel economics (MRR/tenants/ROI/CAC/LTV, with unknowns as "—"), and every module's KPIs — via a pure, RFC-4180-escaped buildGrowthCsv (lib/growth/csv.ts, +2 tests). An Export CSV button sits in the Command Center top bar. tsc 0 / eslint 0.
    • Phase 16 — live Hiring module (from the Food Stories Network). The Hiring module is now real, built from getFsnDB() hiring data: open roles (FsnJob status open), applicants (FsnJobApplication), placements (HireEvent), active employers (distinct hiring tenants), and open-to-work talent (via computeWedgeMetrics). panelA is the real ATS pipeline (applied → screening → interview → offer → hired → rejected); panelB the application-outcome split; the list ranks top hiring employers (names resolved from the tenant registry). Pure buildHiringModule in lib/growth/modules-live.ts (+1 test). With this, the only seed-tagged Growth modules left are Waste & Recycling (honestly — no platform data source exists; the inventory wasteReason is internal spoilage, not external collection) and the representative leaderboards/briefing/advisor sections. tsc 0 / eslint 0.
    • Phase 16 — live PoS Revenue, Food Bazar GMV & Feedback via a pre-aggregated order rollup (closes the per-tenant-scan gap). A new /api/cron/order-rollup scans every non-demo tenant's orders + reviews ONCE (off the dashboard request path, CRON_SECRET-gated, per-tenant try/catch) and stores a fixed-size aggregate on MasterDB.platformOrderMetrics; the Growth Command Center reads it to live-wire three previously-seed modules — PoS Revenue (real transaction volume / count / avg ticket / by-order-type / top restaurants), Food Bazar GMV (real marketplace GMV/orders/avg + configured take rate, identified by Order.bazarUserId), and Feedback (real review responses / avg rating / sentiment + rating distribution). Pure aggregator + module builders in lib/growth/rollup.ts. Honesty held: the platform's "1% PoS take" is notional (never billed) → PoS shows it as a labeled rate ("1% · modeled · not billed"), never a fabricated kr revenue; NPS has no survey source → shows "—" (real avg-rating/sentiment from tenant reviews instead). An adversarial review caught + fixed two real defects: currency scoping was a dead no-op (orders carry no currency → now scoped at the tenant level via TenantSettings.currency), and hidden/unapproved reviews were counted in the platform rating (now excluded, matching the moderation schema); refunded orders are excluded too. Modules fall back to seed when the rollup hasn't run yet. +8 rollup tests, tsc 0 / eslint 0.
    • Phase 16 — GDPR erasure wired into tenant deletion. The deleteEventsFor helper (lib/growth/events.ts) is now actually invoked when a data subject is deleted: the super-admin tenant delete (DELETE /api/admin/tenants/[tenantId]) and the expired-demo cleanup cron both drop the deleted tenant's growthEvents from MasterDB in the same mutation that removes the tenant. Closes the Phase 16 follow-up where the erasure helper existed but was unwired. tsc 0 / eslint 0 (helper already covered by 6 events tests).
    • Phase 16 — GI-7: real MRR forecast history (drops the forecast "sample" tag). The Growth Command Center's MRR-forecast panel previously drew a fabricated history (8 seed points + 1 live anchor, sample-tagged). It now plots a real point-in-time monthly MRR series — a new pure buildMrrHistory in lib/growth/metrics.ts walks each of the last 9 months and sums monthlyValue for subscriptions that had started by that month and weren't yet canceled (display-currency only, trialing/incomplete excluded), plus the current add-on MRR as a flat overlay so the latest point reconciles with the live MRR KPI. snapshot.ts exposes it (mrrHistory), the route serves it as forecastHist with forecastLive:true, and the panel is sample-tagged ONLY if the snapshot fails (seed fallback). The future projection remains a clearly-labeled estimate; the SVG already guards a flat/zero series (no NaN on low-data dev). +3 buildMrrHistory tests (78 growth/consent tests pass), tsc 0 / eslint 0.
    • Cookie-consent banner + consent-gated analytics (GDPR/ePrivacy). Delivers the consent primitive the Cookie Policy promised. A global ConsentBanner (components/consent/ConsentBanner.tsx, mounted in layout.tsx) lets visitors Accept all / Essential only; the choice is stored in a first-party, non-httpOnly cookie_consent cookie readable both server- and client-side (lib/consent/consent.ts, pure + tested). All NON-essential marketing/attribution storage is now gated on explicit consent: the GI-0 acq acquisition cookie only fires with cookie_consent=all — enforced on the server (/api/public/track-acquisition is the source of truth) AND the client beacon, and the capture re-runs via a CONSENT_EVENT if consent is granted after landing (same-page first-touch preserved). Honors both Do-Not-Track and Global Privacy Control (GPC) — the banner stays hidden and nothing is captured under either signal (the legally-recognized opt-out successors). The banner is hydration-safe (renders null until mounted), keyboard-accessible (role="region", real <button>s), and links to /legal/cookies. A focused adversarial review confirmed no consent-bypass for acq (forged cookie values fail the 'all' allowlist) and drove the GPC support + a guard-on-success retry. +4 consent tests (75 growth/consent tests pass), tsc 0 / eslint 0. Follow-up: the older sales-agent `RefCapture` still writes a forensic ReferralEvent (IP/UA) unconditionally for fraud detection — review whether to gate/de-PII it for consistency (separate, referral-system scope).
    • Phase 16 — Growth Command Center: forward first-touch acquisition capture (GI-0 plumbing). Turns the channel attribution from derived into real captured data for new signups. A client beacon components/growth/AcquisitionCapture.tsx (mounted globally in layout.tsx beside RefCapture) reads the UTM params + an EXTERNAL document.referrer on first landing and POSTs them to a new public, rate-limited POST /api/public/track-acquisition, which sets a first-touch httpOnly `acq` cookie — PII-minimized (lib/growth/acquisition.ts: referrer reduced to a HOST only, every field length-bounded, nothing stored server-side). At signup, /api/auth/register classifies the signals (classifyAcquisition) and freezes the channel onto a new TenantRecord.acquisition (TenantAcquisition — channel + utm + referrer host + firstTouch, best-effort so it never blocks registration); lib/growth/snapshot.ts now prefers the frozen channel, falling back to the ledger derivation for tenants that predate capture (backward compatible). A 3-lens adversarial review (security/GDPR · correctness · integration, 14 agents) confirmed 2 findings, both fixed: (1) the marketing acq cookie set without honoring Do-Not-Track (inconsistent with the sibling /api/track) → now respects DNT in both the route and the client, with a TODO to route both this and RefCapture through the consent banner the Cookie Policy promises (a platform-wide gap, not yet built); (2) a blocked self-referral leaked the agent's own code into acquisition.refCode and misclassified as referral → the register block no longer feeds the sales-agent REF_COOKIE as a generic partner code (agent attribution already sets salesAgentId → sales-agent), so a blocked self-referral correctly falls to direct/UTM/referrer. +5 acquisition tests (67 growth tests pass), tsc 0 / eslint 0. Go-live: gate on explicit marketing consent once the consent primitive exists.
    • Landing page — locked Hero A (Editorial) as the homepage hero. After comparing the two directions, removed the A/B compare switcher (a design-preview affordance), deleted the Hero B (command-center) variant + its dashboard mock, and locked in Hero A (food-forward editorial). Drops the fixed bottom-center toggle, its footer clearance, and the now-unused Star import / hero state — Hero A stays a client component for the interactive order demo. tsc/eslint 0.
    • Landing page — mobile polish (hamburger menu + lighter images). Adds the mobile menu the makeover was missing: below 900px the section links + "Log in" collapse into a hamburger toggle (LandingNav is now a client component with aria-expanded/aria-label; menu closes on selection), while the "Start free trial" CTA stays visible for conversion. Also halved the landing food art (~2.9 MB → ~1.5 MB) by resizing the source PNGs to 900px max (next/image still serves optimized webp at runtime). tsc/eslint 0. Closes the two follow-ups flagged with the landing makeover.
    • Public landing page makeover — the new Restora 360 homepage (ported 1:1 from the design prototype). Replaces the homepage (/) with the warm Scandinavian-editorial "website makeover" design (docs/Restora 360 official website makeover/Restora 360 Landing.dc.html), built as React/Next components under components/landing-v2/ and composed in app/page.tsx (SEO JsonLd preserved). Scoped _ds design tokens (clay/stone/pine/honey/denim, warm shadows, Hanken/Newsreader/Geist-Mono) live in a landing-theme.css bound to .r360-landing so nothing leaks into the rest of the app. Sections (prototype order): sticky nav · A/B hero switcher (Editorial vs Command-center, kept as an in-page toggle per the owner) with an interactive 4-step "push an order through" demo · value reframe · 6-feature grid · owner-dashboard mock · how-it-works · 7-app ecosystem grid · Food Bazar band · for-agencies · 199 kr / 7-day-trial pricing · FAQ (native <details>) · final CTA · footer. CTAs wired to real routes (/register, /login, /wow, /designers/register) via next/link; lucide-react icons; next/image for food art (SVG via unoptimized). Fully responsive (desktop grids collapse at 980/900/760/640/560/460px). Built foundation + hero by hand, then fanned the 11 static sections out to a parallel agent workflow; a 3-lens / verifier adversarial review (17 agents) confirmed 8 findings, 6 fixed (hero-switcher footer-overlap clearance, ProductDashboard inner-mock mobile collapse, mobile side-gutter relief, Hero-B outline stars, WCAG-AA contrast for muted/subtle text, demo aria-live status) — the 2 "remove the switcher" findings were reconciled by keeping the switcher (owner's choice) and fixing its overlap instead. tsc 0 / eslint 0. The previous landing components (components/landing/*) stay — still used by /contact, /help, /ingredient, etc. Follow-ups: a mobile hamburger for the in-page section anchors (hidden < 900px today); compress the food PNGs (~3 MB source, runtime-optimized by next/image).
    • Phase 16 — Growth Command Center: live-wire 5 more modules from their real stores (follow-up). Replaces the representative seed with LIVE data in the modules where a cheap, real source already exists — the build-with-seed → live graduation. A new pure builder layer lib/growth/modules-live.ts (lean inputs, decoupled from the DB schema, unit-tested) turns real platform data into the dashboard's module shape, wired into GET /api/admin/growth as independent per-module try/catch overlays (one failing source can never blank the others; a module with no real data keeps its build-with-seed tag). Now live: AI Budget (← buildAIUsageAnalytics() — real USD spend MTD, active tenants, over-budget count, tokens, spend-by-feature, top-consumer scorecard; honestly in USD, the cost-law currency, not kr), Suppliers (← getSuppliersDB() — GMV + 1% revenue, active suppliers, fulfilment rate, category mix, per-supplier scorecard), Food Social (← getFsnDB() — accounts, posts, engagement mix, most-followed profiles), WoW & Health (← getMasterDB().wowPreviews + the live rules-first health distribution — preview→live claim funnel + health donut + recent migrations), and Food Bazar (← getFoodBazarDB() — listings, ad campaigns/spend, take rate). PoS Revenue, Waste, Feedback, Hiring stay seed-tagged on purpose — PoS needs a per-tenant order scan with no global index (kept honest rather than faked), and the others have no cheap source yet. A 3-lens / verifier adversarial review (19 agents) confirmed 9 findings, all fixed + re-verified, every one a data-honesty defect on this "virtual COO" surface: the Suppliers "On-time rate" was fabricated (it measured the mere presence of a deliveredAt stamp — structurally ~100%, no promised-date baseline exists) → replaced with a genuinely-derivable "Fulfilled" rate (delivered ÷ completed, the codebase's reputation convention); the supplier volume map keyed by company name (so two same-named suppliers merged GMV) → re-keyed by id, names resolved only at render; "Active suppliers" silently fell back to the full listed count when there were zero orders (a directory count shown as activity) → now the true transacting count (honest 0); GMV summed mixed-currency order totals under a hard-coded "kr" → scoped to the platform currency with a currency-aware formatter; GMV/active counted cancelled/refunded orders → excluded (revenue-bearing only); the MRR-forecast panel drew 8 fabricated historical points as a live trend with no marker → now sample-tagged ("history sample · now live" — only the now-anchor is live); a posts-only FsnDB could throw and silently downgrade the live module to seed → null-safe; and a redundant getAISettings() round-trip was removed (the analytics already returns the budget). +10 module-builder tests (each finding regression-locked; 63 growth tests pass), tsc 0 / eslint 0. Remaining Phase 16 follow-ups unchanged: forward UTM capture at signup, wire the GDPR erasure helper into the platform data-subject delete, GI-7 forecast depth (a real MRR history series), GI-8 export/scale; a Postgres events table only on volume.
    • Phase 16 — Growth Command Center: the full design-faithful build (11-module super-admin app, matching the prototype 1:1). Builds on the GI engine (below) into the complete "virtual COO/CMO" the prompt + Growth Command Center.dc.html prototype specify. A full-screen super-admin experience at /admin/growth (its own dark espresso sidebar + 11-module switcher, sticky serif top bar, warm light/dark theme toggle) gated on the admin session — deliberately outside the admin dashboard chrome. The `_ds` design tokens (clay/stone/pine/honey/denim, warm shadows, Hanken/Newsreader/Geist-Mono) are ported into a scoped growth-theme.css (.gc-root, with the prototype's warm dark map) so it can't leak into the rest of the app. Growth Command module (1:1): an AI executive-briefing banner, 5 KPI tiles, an interactive revenue drill-down (Channel/Country/Industry → click a segment → its children + a 6-metric panel (MRR/Tenants/ROI/CAC/LTV/Churn) + breadcrumb/Back), an MRR forecast SVG with a 7/30/90 horizon toggle, an acquisition funnel, the AI Growth advisor (insight cards + chat + suggestion chips), and sales/web-designer leaderboards. The 10 other modules (PoS Revenue · AI Budget · Direct Joins · Food Bazar · Food Social · WoW & Health · Suppliers · Waste & Recycling · Feedback · Hiring) share one template (4 KPIs + a bar panel + a donut/bars panel + a list with status badges). Live where a source exists, build-with-seed elsewhere (the owner's call): the API (GET /api/admin/growth) deep-clones the representative dataset (lib/growth/seed.ts, ported from the prototype) and overlays real numbers from the GI engine + a shared lib/growth/snapshot.ts — Growth KPIs, the channel drill, Direct Joins, and WoW tenant-health (from a new rules-first lib/growth/health.ts); modules without a source yet render the seed, flagged live:false with an honest "representative data" note. Net-new spine: POST /api/track — a public, consent-gated + DNT-respecting + PII-minimized + capped growth-event ingestion route (lib/growth/events.ts; no raw IP, coarse country only, sanitized scalar props) writing MasterDB.growthEvents; and POST /api/admin/growth/agent/chat — the budget-gated, AI-light advisor (every number computed in TS; the LLM only narrates the supplied aggregates; a deterministic templated answer when no key — never invents figures). A 4-lens / 2-verifier adversarial review (21 agents) confirmed 10 findings, all fixed — the headline being data honesty on a "virtual COO" surface: the channel drill is now rebuilt 1:1 from real per-channel economics (was double-mapping sales-agent into two segments and dropping the referral channel) with unknown ROI/CAC/LTV shown as "—" not fabricated; the funnel is a single coherent live lifecycle (was mixing 2 live stages into a seed funnel with stale %); every genuinely-representative section (country/industry drill, sub-segment breakdown, briefing, advisor cards, leaderboards) now carries an honest "sample" tag (the global note was suppressed once any live data existed); the forecast "now" point is anchored to live MRR (was diverging from the KPI); the churn KPI is relabeled "Cancel rate (all-time)"; Direct-Joins % uses a non-demo denominator; and growthEvents gains age-based retention + a subject-erasure helper (GDPR). The whole module is now responsive (collapsing grids + sidebar at ≤980/560px). +22 engine tests (health/events + the GI suite; 577 total pass), eslint 0, source typechecks. Follow-ups: live-wire the remaining modules (AI Budget from the usage ledger, Food Bazar/PoS/Suppliers/Social from their stores) + forward UTM capture at signup + wire the erasure helper into the platform data-subject delete; a Postgres events table only on volume.
    • Phase 16 — Growth Command Center: the MVP (GI-0 channels + GI-1 attribution + GI-2 executive dashboard), build-with-seed. The super-admin "virtual COO/CMO" — where customers come from, the CEO numbers, and channel ROI in one place. Net-new spine, framework-free + tested, with everything else reused, nothing duplicated. GI-0 (lib/growth/channels.ts): a canonical 13-channel taxonomy + a deterministic classifyAcquisition that DERIVES each customer's channel from signals the platform already stores (sales-agent code → agents, web-designer link → designers, demo flag, and UTM/referrer via classifyUtm/classifyReferrer when present) — so the dashboards populate from existing data today, with the classifier already wired to consume real UTM the moment forward capture is added. GI-1 (lib/growth/attribution.ts): a real attribution engine — first / last / linear / time-decay / position multi-touch credit over each customer's journey, aggregated per channel (revenue + customers) and explicitly labeled a modeled estimate; deterministic (time-decay references the journey's own last touch, no clock). GI-2 (lib/growth/metrics.ts + GET /api/admin/growth + /admin/dashboard/growth): MRR/ARR (base plans ÷12 + add-ons), paid/active/trial/demo customers, trial→paid conversion, monthly churn, signups + month-over-month growth, a 12-month signups/revenue trend, and per-channel economics — CAC / LTV / LTV:CAC / ROI with the real agent CAC computed from the commission ledger and an honest "—" wherever spend isn't tracked yet (never a fabricated number). The dashboard has a live attribution-model selector and reuses the existing cookieless visitor analytics for a top-of-funnel traffic panel. GDPR-first + cheap by construction: super-admin-gated, aggregates only (no raw PII), deterministic math (no AI spend in this MVP), and no new tracking added — it's a synthesis layer over existing revenue/commission/visitor data, distinct from /admin/dashboard/analytics. A 4-lens / 2-verifier adversarial review (24 agents) confirmed 11 findings, all fixed: channel attribution now derives agent/designer customers from the referral/commission ledger + designer links (the canonical FKs are unset on seed, so the table was collapsing to one "Direct" row and stranding the real agent CAC); money is summed only within the display currency (no silent USD+SEK mixing); month bucketing is UTC (matches stored timestamps); classifyUtm anchors AI tokens (so "email"/"dubai" no longer route to AI-search); the touch sort is transitive with undated touches; agent CAC nets clawbacks; and the labels are honest ("Signup → paid" not "Trial → paid", "Churn (all-time)" with the monthly count beside it, MRR scoped "base + add-ons"); a model-switch refetch failure no longer wipes the dashboard. 41 engine tests (channels/attribution/metrics), tsc 0 / eslint 0. Follow-ups: forward UTM capture at signup (GI-0 plumbing), funnels + health/churn + AI Growth Agent + forecasting + export (GI-3…GI-8); a dedicated event store / warehouse stays scale-gated (Postgres-first, no warehouse early).
    • Phase 28 — Food Business Launchpad: the post-graduation founder bridge (closing the journey loop). Turns the funnel's bare "claimed" confirmation into a real "you're live" hub — the journey's final "…→ customers → growth" handoff. After a founder claims their storefront, the O3 result now shows, alongside "Log in to my dashboard", three next-step cards that deep-link to surfaces the platform already ships: their growth plan (/launchpad/grow), the Learning Center (/launchpad/learn), and List on Food Bazar (/dashboard/food-bazar), plus the honest milestone framing (launched → first customers → growing — the G4→G6 CFJ stages). It's a thin bridge, not new surface — every link points at a route that already exists (all four verified live: no dead links) and the copy carries no legal/tax fact, no advice, no guarantee (trust seam intact). The funnel's static internal /launchpad/* navigation also moved onto next/link (resolving the no-html-link-for-pages lint the prior LC link tripped). tsc/eslint 0, launchpad suite 156, build EXIT 0. With this, Phase 28's buildable-on-seed scope is complete; the remaining items (counsel sign-off, conversion/retention measurement, +country/+type breadth, canonical RAOS) are go-live/trigger gates, not dev work.
    • Phase 28 — Food Business Launchpad: Learning Center expansion (foundational tracks). Grows the Learning Center beyond the node-bound wedge how-tos into the doc-10 track structure. A new FBL_TRACKS + FBL_FOUNDATIONAL_LESSONS in lib/launchpad/learning.ts add general beginner lessons across Money & pricing · Getting & keeping customers · Running the day-to-day · Safe food basics — concept lessons that teach a skill, deep-link to the live tool that does it (business plan, growth plan, offers, reviews, social, bookkeeping), and — for anything regulated — point the founder to THEIR roadmap for the actual rules (seeYourRules). The trust seam is preserved: foundational lessons carry no CKG node and state NO jurisdiction-specific legal/tax fact (no rates, thresholds, deadlines — a test enforces it), are advice-clean (findAdviceLanguage), and can never enter the SEO sitemap/llms.txt (those still surface only verified, node-bound lessons). New /launchpad/learn index lists every track + lesson; the lesson page now renders foundational (node-less) lessons (tool link + roadmap pointer) alongside the existing live-cited node-bound ones; reachable via a "Learning Center →" link from the funnel. 2-lens adversarial review caught a real major — a foundational pricing lesson asserted a tax conclusion ("a sale price usually includes VAT") — fixed to defer entirely to the roadmap, plus: repointed two lessons off a then-unbuilt route, made the route-validity test read the real /launchpad + /dashboard dirs (no hardcoded list → no dead bridges), softened an imperative, and added a seeYourRules invariant test. 11 learning tests, full launchpad suite 150, tsc/eslint 0, build EXIT 0.
    • Phase 28 — Food Business Launchpad: the post-launch GROWTH playbook (the "…→ customers → growth" stage). Completes the founder journey's final stage on seed. A new lib/launchpad/grow.ts buildGrowthPlan produces a deterministic, beginner-friendly growth path in 4 ordered stages (Launch → get first customers · Turn first orders into regulars · Get found locally · Scale up), each step deep-linking into the live Restora tool that does it (menu, builder, offers, QR, reviews, loyalty, email, CRM, Google Business, social, SEO, Food Bazar, analytics, delivery, hiring, + tables/reservations). It's a thin bridge, not a second growth engine — every step points at a tool the platform already ships (no duplication of Growth Intelligence). Lightly tailored per business type (restaurant/café → table ordering + reservations; food-truck → "tell people where you'll be"; home-bakery → online orders first). New GET /api/launchpad/grow?token= (deterministic, rate-limited, not TC-gated — it serves growth guidance, never a legal/tax fact) + a /launchpad/grow page (GrowthPlanClient) with per-step done-ticks (localStorage), reached from the funnel's "Your growth plan →" link. Honest by construction: practical suggestions, never guarantees — a test rejects any guarantee/fabricated-stat language. 2-lens adversarial review (honesty/anti-duplication + correctness) found only minors, all applied: hedged an unsourced "most local discovery…" stat, gave each step a stable id (progress keys on it, not array index — matching the funnel's identity scheme), and the route-validity test now reads the real dashboard route directory (so a deleted/renamed tool fails the test instead of shipping a dead bridge). 6 grow tests + launchpad suite 151, tsc/eslint 0, build EXIT 0.
    • Phase 28 — Food Business Launchpad: the reviewer / counsel VERIFICATION CONSOLE (retire the launch blocker). Makes the human-verification + counsel sign-off — FBL's one real go-live gate — fast and safe. (1) Bulk verify / reject a cell as a set: POST /api/admin/launchpad/nodes {action:'bulk-verify'|'bulk-reject', nodeIds[]} applies the EXACT same per-node gate as single-verify (now a shared ckg.ts verifyBlockReason helper — cited + advice-clean + not-already-served, so the rule can't drift between paths), under one row lock, MON-logging each change and returning a per-node result; bulk-reject can never delete a verified/served node. (2) A per-node researcher briefing (verification.ts nodeBriefing): the exact official pages to confirm each node against — its own citations plus same-authority cell pages (matched by normalised authority head, so a tax node never surfaces a labour page). (3) A downloadable, hand-to-counsel verification packet (counselPacketMarkdown): the cell, the machine pre-checks, and per node the claim + verify-against list + confirm checklist + a sign-off line — markdown-escaped so AI-drafted node text can't forge a section, and it never asserts a legal fact or a clearance. The admin Launchpad page gains a "Verify all cited & clean" button + a "Download counsel packet (.md)". 2-lens adversarial review confirmed the trust gate is sound (no gate-equivalence break, no verified-node deletion, super-admin gated); its findings were applied — the shared verifyBlockReason (DRY + tested), discoverSources de-duped by URL, the briefing match tightened to authority-head equality, the packet markdown-escaped, and the bulk audit log now records per-node verified/rejected ids. 21 verification/registry/ckg tests + launchpad suite 129, tsc/eslint 0, build EXIT 0. The verification + counsel sign-off itself remains the human go-live gate; this only makes it efficient.
    • Phase 28 — Food Business Launchpad: the FULL business plan + investor pitch deck (doc 08 BP-5/BP-6). Upgrades the plan-lite draft into a complete, audience-aware BusinessPlan (lib/launchpad/plan.ts): typed sections (exec summary · company · market · SWOT · operations · compliance · startup costs · budget · revenue forecast · funding · milestones · risk · appendix), re-ordered + re-emphasised per AUDIENCE (self / bank / investor) and specialised per business type (home-bakery / café / food-truck / restaurant), plus a deterministic pitch-deck projection (buildPitchDeck — 10–12 slides that cannot disagree with the plan, with a mandatory non-removable TC-0 disclaimer slide). Exports to Word (.doc) and Markdown; the deck prints to PDF. GET /api/launchpad/plan now takes ?audience=self|bank|investor; the /launchpad/plan page gains an audience selector, a Plan⇄Pitch-deck toggle, and Word/.md/Print buttons. Deterministic by law, never fabricated (doc 08 §1/§7/§8.1): every number is a founder input or a pure calculator result carrying a basis; market size/competitors are an explicit founder-research blank (never generated); compliance lists only VERIFIED roadmap steps; gaps render "Verification required"; the TC-0 disclaimer + the §9.2 export furniture ("projections are estimates, not guarantees" · "verification required must be confirmed") are on every export. 2-lens adversarial review caught a real major — legal-readiness "X/Y done" counted optional steps in the numerator but not the denominator, so a ticked optional step could read as "fully compliant" (or >100%) on a bank/investor document — fixed (required-only on both sides) + regression-tested; plus: revenue forecast now shows low–high bands not false-precision points, the registration fee shows "Verify with the authority" instead of a fabricated 0–1000 band, the §9.2 export furniture block was added, and planToMarkdown now escapes founder input. The OOXML §16 open-decision is resolved dependency-light (Word-openable HTML .doc + print-HTML deck — no heavy OOXML lib). 16 plan tests, full launchpad suite 138 tests, tsc/eslint 0, build EXIT 0. Deferred (go-live/expansion): the fact-fenced AI narration layer (BP-3), native .docx/.pptx, the landlord/grant audiences, and living-plan regeneration.
    • Phase 30 — Restora Experience Studio: P7b — the public Experience API + asset-library scale-out (Phase 30 100% on seed). Closes Phase 30. A new POST /api/public/experience-studio/v1/generate lets a tenant generate experiences from their own stack / CI / headless site builder: key-auth via a new lib/experience-studio/api-guard.ts authenticateExperienceRequest that reuses the existing `rck_…` Restora API keys (one key per tenant works across the Catalog + Experience APIs — each endpoint gates on its own add-on), confirms the tenant still holds experience-studio, and applies the per-key rate limit (a dedicated exp: 120/min window, throttled before the feature-gate DB read so an unentitled key can't spam free 403s). Crucially, both the public API and the in-dashboard one-click flow now run a single shared engine — a new lib/experience-studio/generate-core.ts runTenantExperience extracted from the tenant route (which was refactored onto it) — so quota (atomic reserve + compensating refund), the Food-DNA AI-budget lane, brand-kit resolution, compose, and WOW score are defined once, not duplicated. Scale-out (read): a new pure assets.ts filterAndPageComponents (filter by cuisine/category/status + free-text, codepoint-deterministic id sort, NaN-safe clamped limit/offset, pre-page total) backs an optional ?cuisine=&category=&status=&q=&limit=&offset= on the admin library GET — value-based param detection (an empty ?cuisine=/?limit= is treated as unset, never a silent truncation), full library when no params; the full normalized-store/CDN offload stays volume-gated (same posture as the Catalog's P8–P10). 2-lens adversarial review confirmed the refactor preserves every guarantee (quota/refund/published-only/IP gate/status codes) and the tenant binding is sound (tenant comes from the stored key record, never request input); the DoS-amplification + empty-param + sort-determinism hardenings above were applied from it. 61 experience-studio tests (+2), tsc 0 / eslint 0, build EXIT 0. Phase 30 is now 100% built & delivered on seed (P0 → P7b) — the remaining items are go-live/expansion gates (real curated art + measured 3D weights, the paying-pilot cuisine expansion, the live SEO scorer, the real Stripe overage charge, the volume-gated CDN offload), not dev work.
    • Phase 30 — Restora Experience Studio: P7a — brand kits + franchise consistency + white-label. The enterprise layer. A new lib/experience-studio/brand-kit.ts resolveExperienceSettings (pure) merges REQUEST → a location's own kit → the org/franchise kit → defaults — so a franchise sets one org brand kit and every branch inherits a consistent look at generate time, with no propagation/multi-write (a location can still override). GET/PUT /api/tenant/[tenantId]/experience-studio/brand-kit: scope:'tenant' saves the location kit (owner); scope:'org' saves the franchise kit and is additionally gated by requireGroupAccess (group owner/manager only) and the HQ holding Product-B — so one branch can never overwrite another org's look. White-label (a kit flag) drops the "Powered by Restora 360" mark in the renderer. The Studio page gains Save-as-brand-kit / Save-for-all-locations + a white-label toggle, and prefills from the saved kit. 2-lens adversarial review confirmed the franchise authorization is sound; it caught a real major — resolveExperienceSettings returns full copy objects with undefined for unset fields, and the compositor's {...defaultCopy, ...opts.copy} spread let those undefineds overwrite the template defaults (empty headline in the common case) — fixed by a definedOnly filter in the compositor (drops undefined keys before the merge) + a regression test, plus the HQ Product-B gate and a richer org audit entry. 59 experience-studio tests, tsc/eslint 0, build EXIT 0. The public Experience API + asset-library scale-out are P7b.
    • Phase 30 — Restora Experience Studio: P6 — expand (a 2nd cuisine + the 3D mode, proven purely additive). Demonstrates the architecture is additive by design: a Pizza Journey experience (one EXPERIENCE_PACKS row) + PIZZA_SEED_COMPONENTS (crust→basil) makes a whole new cuisine work with zero engine changes — the compositor, WOW scorer, Food-DNA matcher, Experience-DNA selector, and renderer are all registry/library-driven (no hardcoded cuisine). The 3D mode lands the same way: the pizza components carry a 3d glb artifact, so a 3d request now resolves exact for pizza while burger still gracefully falls back (the same unchanged artifactForMode/MODE_FALLBACK). Emergent correctness: the per-experience performance budget naturally fences heavy 3d off mobile (5 layers × ~800 KB > the 1.5 MB mobile budget → over-budget) — exactly the MVP-fence "3d on a performance trigger". Adversarial verification verdict clean (no cuisine cross-contamination, consistent seed data, the perf gate is intended). 54 experience-studio tests (6 new), tsc/eslint 0, build EXIT 0. Sushi / drinks / desserts + more packs (Sushi Assembly, Coffee Brewing, …) follow the identical data-only recipe on a paying-pilot trigger; real curated cuisine art + measured 3D weights at go-live.
    • Phase 30 — Restora Experience Studio: P5c — the tenant Studio page + the visual renderer + pay-to-continue overage. The Product-B experience is now self-serve. A new `/dashboard/experience-studio` (owner portal) — feature-gated (a clean upsell when not entitled) — lets a restaurant set its look + paste a dish photo → Generate → see the rendered experience + its WOW score + the monthly quota. A new components/experience-studio/ExperienceRenderer.tsx draws the composed pack: the scene layers stacked bottom→top, each animating in per the pack's motion timeline, under the brand colours + display font with the copy/CTA overlay (build-with-seed: placeholder category-coloured slabs; a real curated image renders as <img> at go-live). A new POST /api/tenant/[tenantId]/experience-studio/overage (pay-to-continue, doc 04 §2b) — gated, units 1–100, dev-simulates the charge and grants overageUnits under the row lock; the quota bar surfaces "Buy N (+SEK)" when exhausted. 2-lens adversarial review caught a real major — the prod overage path opened a Stripe checkout the central webhook doesn't handle (kind:'experience-overage'), so a paying tenant would get nothing — fixed by returning an honest 503 `OVERAGE_UNAVAILABLE` in prod until the real Stripe+webhook is wired at go-live (never charge for nothing), plus 3 UI fixes (a feature-denial 402 now flips to the upsell, friendly error copy, the Buy-10 price shown). tsc/eslint 0, build EXIT 0. P5 exit gate met on seed — a Product-B tenant self-serves end-to-end. Deferred: the live SEO scorer (needs the experience published as a page) + the real prod overage charge (a go-live billing task, alongside ai-topup's same webhook gap).
    • Phase 30 — Restora Experience Studio: P5b — the tenant one-click Generate-Experience flow + per-tenant quota. The engine is now tenant-facing. A new POST /api/tenant/[tenantId]/experience-studio/generate — gated by requireTenantAccess and requireFeature('experience-studio') (the Product-B add-on, 402 if not entitled) — optionally detects the dish from a photo (Food DNA, on the platform AI budget lane, best-effort: a missing key / over-budget / failed detection never blocks), then Experience-DNA auto-selects → composes from the shared published library (no draft leak) → WOW-scores (the IP/human-rating gate intact), returning {pack, score, detected?, quota}. A new lib/experience-studio/quota.ts (pure) — the doc-04 §2b per-tenant monthly quota (20 included + purchased overage units); the route atomically reserves a slot under the tenant row lock (no concurrent overshoot) and pauses with 402 `QUOTA_EXHAUSTED` when used up. 2-lens adversarial review confirmed the auth + feature-gate + tenant-isolation + published-only composition + honest scoring all hold; it caught a real major — a reserved quota slot was billed even when the generation threw afterward (no refund path) — fixed: the post-reservation work is wrapped in try/catch with a compensating refund (a failed generation is never billed), plus a clean 503 when the tenant doc is unavailable. 511 repo tests, tsc/eslint 0, build EXIT 0. The tenant UI + pay-to-continue PURCHASE + transparency panel + live SEO scorer are P5c.
    • Phase 30 — Restora Experience Studio: P5a — Product-B tier + plan-gating + the AI budget lane. The productization begins. A FEATURE_REGISTRY `experience-studio` add-on (the Product-B tier, 4,500 SEK/mo · 45,000 SEK/yr, requireFeature-ready for the tenant flow). A new lib/experience-studio/ai-budget.ts — the Product-B AI margin guard (doc 04 §2a): a per-UTC-day USD spend lane with a super-admin daily-cap override + kill-switch that gracefully disables all Experience Studio AI when hit; mirrors the proven FBL launchpad lane. Wired into the Food DNA route — it now returns 429 when the daily budget is exhausted and records spend after a successful call. Super-admin budget status (today's spend vs cap) + cap/kill-switch controls on the studio page (GET returns it; a set-ai-budget action sets it, zod-bounded 0–1000, row-locked, audit-logged). 2-lens adversarial review caught a real major — a 0 cap (the intended off-switch) leaked the first call of a fresh UTC day because the no-usage short-circuit ran before the cap check — fixed (cap ≤ 0 evaluated first) + regression-tested; the read-then-record soft cap (super-admin-only, ≤1-call overshoot under concurrency, same shape as the FBL reference) is documented in the route. 510 repo tests, tsc/eslint 0, build EXIT 0. Per-tenant quota / pay-to-continue overage + the transparency panel are P5b/5c (doc 04 §2b).
    • Phase 30 — Restora Experience Studio: P2 — the AI Food DNA engine. Closes the engine loop (photo → Food DNA → matched library slots → compose → WOW score). lib/experience-studio/food-dna.ts (pure): buildFoodDnaMessages (an image-only, never-invent vision prompt — the FBL deep-research trust posture), parseFoodDna (balanced-brace JSON extraction that resumes past prose braces), vetFoodDna (clamp confidences 0–1, dedupe, cap, drop punctuation-only names; null on no-dish), and matchDnaToComponents — maps the detected ingredients onto the published library by ingredient NAME (a same-category placeholder is never counted as covered), each component claimed at most once (no inflated coverage), with dish-alias resolution (cheeseburger→burger) and the ≥0.9 confidence gate. food-dna-runner.ts does the Haiku-first vision call (cost-bounded; the Product-B budget lane is P5). POST /api/admin/experience-studio/food-dna (super-admin, key-gated → 503 in dev) returns {dna, match} (matched/unmatched components + coverage %); a "Detect from photo" panel on the studio page. 2-lens adversarial review caught three real majors — the parser bailed when prose before the JSON contained braces; the category fallback over-matched (3 veggies → all "lettuce", inflated coverage); exact foodType→cuisine equality gave 0% for "cheeseburger" — all fixed + regression-tested. SSRF confirmed absent (Anthropic fetches the image URL, not our server). 43 tests, tsc/eslint 0, build EXIT 0.
    • Phase 30 — Restora Experience Studio: P4 — Experience DNA + the WOW Score scorer. The executable form of the P0.1 rubric. lib/experience-studio/wow-score.ts scoreExperience(pack, inputs) → 8 weighted sub-scores (sum 100) → composite + the ship gate, matching the rubric doc exactly (hard floors Performance≥75/SEO≥70/Visual≥80/Brand≥75, disqualifiers, ≥90 ship / ≥85 P4-interim targets). The six net-new dimensions are structural heuristics, each source-tagged (heuristic/override/external) so a number on placeholder art is never mistaken for verified — a human/model supplies the truth via overrides; performance reads the compositor's real perf verdict; SEO takes the live scorer's seoScore when present. lib/experience-studio/experience-dna.ts selectExperience(signals) auto-picks pack/mode/perf-target/brand/copy (cuisine→pack, price→fidelity, audience→budget; honest default + rationale). The compose route gains an optional signals (Experience-DNA) input and now returns {pack, score, selection?}; a WOW-Score panel on the studio preview shows the composite, band, gate verdicts, sub-scores, and blockers. 2-lens adversarial review (cross-checked against the rubric doc) caught a real major — the IP/rights disqualifier failed open (omitted flag / empty [].every() let un-cleared placeholder art score shippable) — fixed: IP now fails closed (anything but an explicit clearance blocks), an empty-scene disqualifier was added, the route guards the empty-set case, and — strengthening the rubric's honesty intent — `shippable` (go-live) now also requires the subjective dims to be human/model-rated, not heuristic (heuristics satisfy only the P4-interim dev gate). So the seed's branded Burger Explosion honestly reports composite 91, P4-interim ✓, but NOT shippable until a human/model rates it + rights are cleared. 36 tests, tsc/eslint 0, build EXIT 0.
    • Phase 30 — Restora Experience Studio: P3 — the reconstruction + experience-generation compositor. The engine behind the first experience. A new lib/experience-studio/compositor.ts — composeExperience(components, packId, opts) turns the published library into an experience pack: assembled scene layers (bottom→top by zIndex via assembleStack), a staggered motion timeline, branding + editable copy slots, and a per-target performance verdict (evaluatePerf: mobile 16 layers/1.5 MB · desktop 24/4 MB, byte-estimated by format). An EXPERIENCE_PACKS registry ships Burger Explosion first (pizza/sushi/etc. are added here additively in P6 — never a compositor rewrite). Mode-agnostic end-to-end: a 3d compose request transparently resolves to the anim/2d artifacts today and the verdict surfaces fallbackLayers so a degraded scene is never mistaken for true 3D. Super-admin read-only POST /api/admin/experience-studio/compose + a "Compose preview" panel on the studio page (ordered layers · motion · perf · copy). P3 exit gate met: a full Burger Explosion pack composes end-to-end from the seed library (proven by tests + the preview). 2-lens adversarial review: compositor math (no off-by-one/NaN/wrong-total) and route security (super-admin, read-only, zod-bounded, published-only — no draft/curated/tenant data can reach a pack, no XSS) both confirmed clean; 2 items applied (fallbackLayers clarity; brand colour/font format validation at the compose boundary so the deferred P5 renderer can't receive a CSS-injection payload). 24 tests, tsc/eslint 0, build EXIT 0. The polished visual renderer + the tenant one-click flow are P5; with real curated art it becomes the jaw-dropping experience — no compositor changes needed.
    • Phase 30 — Restora Experience Studio: P1.3/P1.4 — asset generation pipeline + super-admin curation queue. Builds on the P1 foundation. A new lib/experience-studio/generation.ts: buildGenerationSpec + generationPrompt (deterministic, so a generated set composites together — the "consistent components" requirement), placeholderArtifacts (dev has no image-gen provider — build-with-seed; go-live wires a cost-capped image model), draftFromGeneration (always status `draft` — generation never publishes), and the resolveCuration state machine (draft→curated→published; reject deletes but never a live component). The read route gains POST /api/admin/experience-studio/assets: generate (creates a draft; never overwrites a curated/published component → 409) and curate/publish/unpublish/reject/set-ip-cleared — all row-locked (mutateExperienceStudioDB), super-admin-gated, audit-logged. A super-admin curation queue UI at /admin/dashboard/experience-studio (+ an AdminSidebar entry): generate → curate → publish, with a per-component rights toggle and a library-health snapshot. The publish gate is now real: canPublishComponent requires the 2d floor and a human rights sign-off (ipCleared) — AI-generated art is never auto-cleared, and libraryHealth.ipUncleared surfaces the go-live IP gate (the seed's 6 placeholders are honestly flagged uncleared). 2-lens adversarial review confirmed the curation gate is non-bypassable (publish runs inside the row lock against the fresh component; generation can't yield a non-draft; a published component can't be deleted/silently mutated); the one real finding — a structurally-dead licence check — was fixed by replacing it with the ipCleared gate (+ preserve createdAt on regenerate). 17 tests, tsc/eslint 0, build EXIT 0; both routes + the page registered.
    • Phase 30 — Restora Experience Studio: P1 Food Asset Library foundation (building on seed). First code for Product B. A new mode-agnostic asset model (lib/experience-studio/assets.ts): FoodAssetComponent (cuisine · category · modes{2d,anim,3d,mobile,dark,theme-adaptive} · catalog ingredientId · theme tokens · license · curation status · version · zIndex), with artifactForMode — a deterministic fallback chain that proves the locked additive-3D constraint (a 3d request resolves to anim/2d today, so dropping in real 3D artifacts later touches no consumer code), plus assembleStack and libraryHealth (status counts, mode coverage, catalog-link/licence gaps, and a missingFloor2d guard for the "2d is the universal floor" invariant). A platform-level shared store (get/save/mutateExperienceStudioDB — the launchpad doc pattern: PG row-lock + JSON fallback, deep-cloned seed, lastUpdated bumped inside the PG lock) + a super-admin read API GET /api/admin/experience-studio/assets (library + health). Dev seed: 6 burger components (bun→patty→cheese→lettuce→tomato→bun, 2d+anim each, catalog-linked, licence-tagged) — build-with-seed: real curated assets + the paying pilot + the 90+ proof are go-live/expansion gates, not dev blockers. 2-lens adversarial review: 0 majors; 3 hardening items applied (deterministic last-resort, the missingFloor2d floor signal, PG-path lastUpdated). 10 tests, tsc/eslint 0, build EXIT 0; route registered + super-admin-gated.
    • Phase 30 — Restora Experience Studio (Product B): task breakdown + P0 kickoff (docs-only, no app code). The premium experience generator (~4,500–5,000 SEK/mo) is broken into small tasks and P0 (validate-first, near-zero code) is started. New docs under docs/products/restora-experience-studio/: `01-tasks.md` (the full P0→P7 breakdown, with every "reuse" rewritten as "update/extend [system] with [new capability]" per the owner's framing — a real upgrade to Product A's spine, not passive reuse); `02-wow-score-rubric.md` — the P0.1 quality standard: the 8 sub-scores (Visual·Motion·Conversion·SEO·Performance·Brand·Uniqueness·Storytelling), weights summing to 100, 0–100 bands, the ≥90 ship gate + hard floors + disqualifiers, and which dimensions extend existing systems (the shipped SEO/AI-SEO scorer, performance metrics) vs are net-new rubrics — the P4 automated scorer must match it; `03-p0-validation-playbook.md` — operationalizes the non-code P0 tasks (concierge proof, pre-sell/LOI with a template, AI-asset spike acceptance protocol, legal/IP review checklist) with explicit acceptance criteria. The P0 exit gate (≥1 paying pilot and a hand-proven ≥90 experience and a working AI-asset spike) gates the P1 library build; nothing beyond P0 is built until it's met. Plus `04-ai-cost-and-billing-model.md` — an owner decision (2026-06-13) locking a transparent, cost-plus, never-lose-money AI billing model: the tier includes a generation quota, a super-admin daily USD cap disables the feature gracefully when hit, and per tenant the quota either pauses (default) or allows pay-to-continue overage priced cost-plus with the actual AI spend shown in SEK so it's a fair deal — reusing the existing AI cost-law rail, implemented in P5. TODO.md gains a Phase 30 section.
    • Delivery menu/price sync — a scheduled cron + shared logic (completes the buildable scope). The per-tenant sync logic is now a shared lib/delivery/sync.ts (runDeliveryMenuSync + a cheap hasPendingDeliverySync pre-check) reused by both POST /delivery/sync and a new `GET /api/cron/delivery-menu-sync` that walks every tenant (scan-capped at 250, re-sync-capped at 50/run, with honest hitScanCap/hitPerRunCap flags — no silent truncation), rebuilds + re-validates each partner feed, and refreshes the honest per-platform status so a dashboard never shows a stale "prepared" after the menu changed. CRON_SECRET-gated, fail-closed in production. 3 new tests; tsc 0, eslint 0. Live-verified against the running platform: 401 without the secret, and one run scanned 14 tenants → synced the one with menu-sync on → wrote foodora: prepared · 5 items (creds present) and wolt: needs-credentials · 5 items (no creds) — exactly the honest split.
    • Delivery menu/price sync — the stub now builds + validates a real partner feed, honestly. "Sync Menu Now" used to log a line and return a fabricated {n} items synced without building or recording anything. Now POST /api/tenant/[id]/delivery/sync builds a real partner menu/price feed from the canonical menu (buildPartnerMenuFeed — honours each platform's pricing toggle: with it off, items carry no price; drops unavailable/unnamed/zero-priced items), validates it, and records an honest per-platform status on the integration (lastSyncStatus = prepared when credentials are present and the feed is valid / needs-credentials when the API key + restaurant ID are missing / error with the reason; plus lastSyncedAt + lastSyncItemCount). It never claims a push to a partner we can't reach — the feed is exposed at the new GET /api/tenant/[id]/delivery/feed?platform=foodora for a partner to pull (or a future live merchant-API push to upload), and the dashboard shows the real status ("Menu feed prepared — N items … the live push to foodora runs once their merchant API is connected") instead of a fake "Synced!". syncPricing is now actually read. New lastSync* fields on IntegrationConfig; both routes requireTenantAccess-gated; API keys never exposed in the feed. tsc 0, eslint 0 (3 pre-existing set-state-in-effect lints on the page's data-loading effects left untouched), 3 new feed tests; routes live-verified (401-gated). Documented in docs/api/endpoints.md.
    • *Food Bazar app — location fallback now shows the nearest other cities, distance-badged. When the chosen city has no restaurants, the app already fell back to other cities — but they looked local. Now the fallback feed is ordered nearest-first (great-circle distance over a city-coordinate table) and every out-of-town card carries a clear "{city} · ~{N} km" / "{region}" badge (e.g. "Stockholm · ~615 km / Sweden", "Lund · ~16 km / Skåne"), under a banner reading "No restaurants in {city} yet — showing the nearest cities, each marked with its distance". So the app is never empty and* the user is never misled into thinking a far place is nearby. :bazar builds green.
    • Phase 28 — Food Business Launchpad: automated extraction-to-graph (DR-2/6/7) + an FBL-local agent layer. The Phase-4 breadth accelerator, trust-law-safe by construction. A new daily cron GET /api/cron/launchpad-research (the Automated-Research agent) walks the curated source registry across all 12 cells, picks uncovered official pages (planAutoResearch — deduped + capped, ?cap= 1–12, default 4), and runs the same guarded fetch+extract as the manual super-admin tool (extracted into a shared research-runner.ts: SSRF + allow-list re-validated on the URL and every redirect hop) to draft `needs-review` candidates — CRON_SECRET fail-closed in production, budget-capped (stops the moment the daily AI budget is spent), and a no-op without an AI key. A new research-agent.ts is the FBL-local agent layer (doc 07 roster — explicitly not the canonical platform RAOS substrate, which stays a separate deferred build): a typed FBL_AGENTS roster (each agent's role + the one trust rule it can't break + its real implementation), planAutoResearch, and reconcileProposed (only ever ADDS to the human queue; never mutates a verified, served fact — a draft colliding with one is a likely regulation change, surfaced to the operator MON feed, never applied). The human verification gate is untouched: nothing here ever serves or verifies. Two-phase adversarial review (4 lenses → 16 findings) caught three real MAJORs, all fixed + re-reviewed (verdict: _fixes-confirmed_): (1) the cron's non-atomic whole-document write could clobber a human verification committed mid-run → it now plans on a read-only snapshot, the long fetch/LLM loop touches no shared state, and a single row-locked `mutateLaunchpadDB` folds drafts against the fresh graph (the freshness cron + the admin freshness route were hardened the same way); (2) the coverage check missed path-redirects (verksamt.se/ → /start), re-drafting the same page every run and burning budget → coverage now also matches proposedFromUrl (the originating registry URL; the citation still pins the fetched URL); (3) a verified-source conflict was lost on a zero-draft run → conflicts are now recorded to the MON changeLog (conflict-detected) and always audit-logged. 457 tests (7 new), tsc 0, eslint 0, build EXIT=0.

    Fixed

    • Food Bazar search actually finds dishes now — three relevance bugs fixed. (1) No diacritic folding: q=tiramisu returned 0 against "Tiramisù della Casa" — fatal for an Italian/Swedish marketplace. fold() (NFD + combining-mark strip) now normalizes both query and haystack in the scorer AND the did-you-mean suggester, so tiramisu⇄Tiramisù and rakor⇄Räkor match both ways. (2) Synonym over-recall: the synonym map expanded bidirectionally, so a SPECIFIC query recalled its whole category — q=margherita returned 62 results (3 real + 59 junk pizzas). Expansion is now asymmetric: a GENERIC key ("pizza", "coffee") still widens recall to its dishes, a specific term only boosts already-matched results. q=margherita → 5, all real. (3) Swedish names were unsearchable: the route only fed the English name into the scorer; every locale now rides along as altNames (same match ladder, slightly lower weights) — q=husets vin finds "Vino della Casa / Husets vin (glas)". tsc 0, eslint 0, 346 tests (4 new), live-verified on the running bazar (accented + Swedish + generic + garbage queries).
    • Maintenance / coming-soon gate restored — it was silently lost in the `ui/` repo restructure. Commit 0b114ef made the Super-Admin maintenance toggle actually gate the public site, but the restructure moved proxy.ts without the gate hunks and dropped GET /api/public/site-status entirely (live 404) — platformSettings.maintenanceMode was write-only again. Re-applied the exact original implementation: the proxy rewrites gated platform paths to /coming-soon when the flag is on (15s module cache; /admin, /login, /register, /dashboard, role portals, /api stay live; tenant storefronts unaffected) and the boolean-only status route is back. Found by a full-platform health audit (3-agent scan of logins, 88 routes/links, and DB/kill-switch integrity) after a dev-DB reset; the same audit found exactly ONE broken link platform-wide — the Luigi homepage hero CTA pointing at the non-existent /order (now /menu, in the tenant data). tsc 0, eslint 0; /api/public/site-status verified live (200, {maintenanceMode:false}).
    • WoW clone / new-tenant provisioning broken — the `template.json` artifact was missing after the `ui/` restructure. Visiting a WoW preview and clicking "View your cloned site" (and the real magic-link email claim, and ordinary /register signups) bounced to /login?wow=error. Root cause: createTenantDatabase reads databases/tenants/template.json as the seed for every new tenant and throws if it's absent — and that file lives under the git-ignored databases/ tree, so it was never carried into the new ui/ app root during the src/→ui/ move. Every tenant-creation path (not just WoW) was failing locally. Restored a clean, PII-stripped template derived from the sample tenant (identity strings blanked; all data arrays — orders, customers, staff, reviews, media, etc. — emptied; socials/coordinates/Swish number cleared; Google Business disconnected; AI usage reset) and force-added it to git (git add -f, past the databases/ ignore) so it's a proper tracked repo artifact present in dev and prod builds. Verified: claim/dev?token=… now 307 → /tenant/[id] (the live cloned storefront, HTTP 200, no sign-in / no email required) instead of /login. (So: "View your cloned site" now shows the site directly, matching the intended behaviour.)
    • Catalog scale fix — process cache + prebuilt indexes + bounded directory (the N3 unblocker). The ~16 MB catalog doc (8k+ ingredients) was re-read, re-parsed, and re-indexed on every request. Now: a process-level doc cache in getFoodCatalogDB (parse once, 60 s TTL, invalidated on every write via invalidateFoodCatalogCache; save/mutate keep it consistent), prebuilt indexes (getCatalogIndex: slug→record map for O(1) findPublishedBySlug, the sorted published list, and the nutrition match candidates — built once per cache generation, keyed to a cache token), and the /ingredient directory caps its render to 300 rows (search narrows the 8k+). Result: /ingredient 1.6–2.2 s → 0.27–0.62 s, /ingredient/avocado warm 0.086 s; the nutrition calculator reuses the cached candidates. Cold loads still pay one 16 MB parse — the eventual horizontal-scale step remains the normalized-tables migration (catalog P9), but this removes the per-request cost and unblocks N3 (FoodBazar can read the catalog cheaply). tsc 0, eslint 0, 303 tests, build EXIT=0.
    • Phase 29 / Phase A — USDA importer hardening (adversarial review). The panel web-verified all 14 USDA nutrient-number→key mappings as correct (no silent wrong nutrition); the real defects were units + attribution: (1) sodium & cholesterol were stored as raw mg under keys the page renders as grams (a latent 1000× trap) — now normalized to grams at import in both usda-fdc.ts and livsmedelsverket.ts (precision-preserving), so the catalog is single-unit; (2) the public Catalog API + ingredient page stripped licence info — toPublicApiIngredient now carries a sources attribution block and the "Data & sources" panel renders each source's licence + link (required for CC-BY/ODbL), via new catalogAttributions + licence/url on publicSources; (3) the USDA import route now has a rate limit; (4) importUsdaFdc calls `assertAllowedSource` (enforces the no-scraping allow-list at ingest), USDA category is set to other (off-vocab FDC groups don't reach the controlled field), and the dev populate asserts no duplicate ids. The cross-source canonical-merge + slug-uniqueness are confirmed by-design deferrals (records quarantined in needs-review) — the next Phase A slice. 295 tests, tsc 0, eslint 0, full build EXIT=0.
    • Phase 28 — Food Business Launchpad: P1 legal-posture hardening (adversarial review). The trust gate passed the review (no un-verified fact can reach a founder); the legal-posture concerns are fixed: (1) an FBL-specific advice/guarantee detector (findAdviceLanguage) now catches the postures a tax keyword-filter missed — guarantees, completeness claims, "no licence required", "we recommend", threats — while allowing cited process language and neutral nouns ("authority"); (2) it runs at both verify-time and serve-time (folded into canPublishNode, so advice prose is dropped on egress even if a future authoring path slips it through) — replacing the over-blocking TC-0 export linter here; (3) the disclaimer is now always shown (client state defaults to the constant, not gated on fetch success). Also: the SE VAT node no longer asserts the rate (attributed to Skatteverket); cellCoverage is effective-date-aware (agrees with the served roadmap); pricing() returns null on non-finite margin (no silent 0%) and echoes the computed margin; the seed is deep-cloned per request; and the SEO/H1 copy is scoped to "every step we show is verified" (not claiming verification before anything is verified). 284 tests, tsc 0, eslint 0, build ✓.
    • Phase 26 — Food Stories Network: `next build` regression from the fail-closed secret. The P1-hardening SESSION_SECRET fail-closed check threw at module-evaluation time, which next build (production mode, no runtime env) hit while collecting page data — breaking the build for every route importing fsn/session.ts. Now the secret is resolved lazily per token operation, so it still fails closed at request time in production (the real security condition) without breaking the build.
    • Phase 26 — Food Stories Network: P1 security/GDPR hardening (adversarial review FAIL → resolved). A 3-agent panel (auth/session · GDPR/PII · moat integrity) found real defects; all blockers + majors fixed: (1) account takeover — auto-claim linked a verified profile by unverified email match (anyone registering with a worker's email seized their owner-verified ✔). Replaced with an owner-issued, single-use claim code (proof of employment, rate-limited); registration no longer auto-claims, and claimEmail was removed entirely (data-minimisation). (2) PII leak — searchTalent returned raw TalentProfiles (work email, operator ids, internal ids); added a publicTalentProfile projector and route it through search + /me + profile responses. (3) broken role gate — EMPLOYER_ROLES used non-existent role strings (real owners locked out, wrong operators saw recruiters_only); fixed to tenant-owner/superadmin and search is now employer-only (403 otherwise). (4) consent — owner-verified-but-unclaimed profiles now default to private (no public discovery until the person claims + opts in). Plus: rate limits on register/login/claim, fail-closed `SESSION_SECRET` in production + timing-safe token compare, erasure now scrubs the profile name/skills/headline, and Open-to-Work re-stamps freshness only on a real false→true transition. 4 tests updated (274 total). tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: hardening from the WS-2..WS-7 adversarial review (FAIL → resolved). A 3-agent panel (correctness · security · legal) found real defects; all blockers + majors are fixed: (1) concurrency — every webshop mutation (checkout, cron, fulfillment, subscriptions, admin coupons/products) now runs through a new row-locked mutateWebshopDB/mutateSalesAgentsDB (mutateDoc SELECT … FOR UPDATE), replacing last-writer-wins reads+writes that could drop orders, miscount coupon redemptions, or double-record commissions. (2) commission return-window was dead code — webshop commissions are now recorded `withheld` on ship (the payout engine only pays pending), released to pending only once isCommissionReleasable passes (return window elapsed) via the daily cron, reversed on return, and computed on the net (post-discount) amount. (3) overselling — own-stock is decremented at order creation and restored on return. (4) checkout qty is bounded; the replenishment cron fails closed without a configured CRON_SECRET in production. (5) analytics now allocates discounts pro-rata so the seller/product breakdowns reconcile with net revenue. Plus: dueReorders staleness floor, an order.taxSupported marker, and an honest VAT-table relabel (domestic-only; cross-border/GB/NO not modeled). 256 tests, tsc 0, eslint 0, build ✓.

    Changed

    • Domain sweep — the platform now lives at `restora360.com` (LR-1). All ~100 hardcoded restora.io / restora.app references across 64 source files (env-fallback site URLs, canonical/OG metadata, sitemap + robots + llms.txt, OpenAPI/Postman docs, bazar SEO pages, docs-center article prose, subdomain examples, support emails, UI mockups, tests) now point at restora360.com; NEXT_PUBLIC_SITE_URL remains the runtime override everywhere it already applied. The two PLATFORM_HOSTS sets (host-resolver + llms.txt) gained restora360.com/www.restora360.com as the live domain while keeping `restora.io`/`restora.app` as legacy aliases, so old links keep resolving during the transition; tenant-subdomain resolution (<sub>.restora360.com) works unchanged (anything non-platform resolves by first label). Verified live by Host header: restora360.com → platform llms.txt/robots with correct absolute URLs; restora.io still aliases. tsc 0, eslint 0, 340 tests, build EXIT=0.

    Fixed

    • Android POS — uniform menu cards + owner-configurable receipt VAT. Menu-grid cards are now a uniform height regardless of name length (the product name reserves two lines and the image band is always present, so a long-named item no longer makes a taller card than its neighbours). The receipt VAT/moms rate is now owner-editable in the web dashboard at Settings → Payments → "Receipt VAT (Moms)" (0–100 %; stored on paymentSettings.vatRatePct, merged by the existing settings save — no allow-list/zod change needed) — the POS /pos/menu endpoint reads it (default 12 % SE food) and the kvitto's Momsspecifikation uses it. Backend tsc 0; all three apps build green and reinstalled.
    • Android POS/Waiter — six polish enhancements (kvitto, centered dialogs, cart images, order types, tablet insets), adversarially reviewed. (1) Swedish receipt (kvitto): a Kvitto.kt component renders the standard kassakvitto content — säljare + org.nr + adress, datum/tid, kvittonr, line items, moms-specifikation (12% backed out of VAT-inclusive prices; the rate is now a configurable TenantSettings.vatRatePct), Att betala, Betalsätt — rendered as a single 42-column monospace block (uppercase säljare + ===/--- rules + a Momsspecifikation Sats/Netto/Moms block + Swedish labels Äta här/Avhämtning/Leverans + sv-SE money like 249,00 kr) on cream #fffdf6 paper with a custom zigzag GenericShape torn edge, forced light theme so it's readable in either app mode; layout matched to a reference POS the owner shared. The same text drives the share/print action. It is honestly labelled an orderkvitto, not a control-unit-signed kassaregister receipt (a registered kontrollenhet is the venue's legal duty); shown after a POS sale and after a Waiter table-settle. (2/4) Dialogs centered + sized (matched to the reference POS): OptionsSheet/PaymentSheet/Kvitto are centered cards with 24dp rounded corners and a fixed header/footer + scrollable middle (Add/Confirm never scroll off-screen or hide behind the keyboard). The options dialog now shows the dish photo on top and options as selectable pills (orange when selected), like the reference. A real bug was fixed in the width capping: fillMaxWidth() pins min=max=screen, so a following widthIn(max) was ignored and the dialogs spanned a full 1280dp tablet; the order is now widthIn(max).fillMaxWidth(), giving tidy ~36% centered cards (verified on the owner's tablet via screencap). (3) cart lines show product thumbnails. (5) POS order-type selector — Dine-in / Pickup / Delivery (dine-in shows a table field, others an optional name); backend pos/orders accepts the new types + optional customerName. (6) statusBarsPadding() on the staff top bars so the theme toggle isn't under the system clock on edge-to-edge (targetSdk 36) tablets. Hardened by a 5-lens adversarial review (kvitto correctness · dialogs · order-type flow · state/lifecycle · insets): 28 raised, 18 confirmed, 9 fixed (PaymentSheet fixed-footer, configurable VAT, dine-in empty-table validation, transient showReceipt so a killed app can't get stuck on a null receipt, cart-clear on auth-expiry, receipt long-name/imePadding/share-text) and the rest rejected with reason (global order-numbering is pre-existing platform-wide behaviour; English payment buttons stay consistent with the English app chrome; counter type is a legitimate backend value kept as a fallback). Backend tsc 0; all three apps build green and reinstalled on the owner's phone.
    • Android — POS "couldn't load menu" was a `NetworkOnMainThreadException` (response body read off-thread). ApiClient ran the HTTP execute() on Dispatchers.IO but read the response body (requireSuccessText() / the customer-display reader) back on the caller's coroutine — which for the data fetches is the Main dispatcher. Small responses (orders, KDS) were already fully buffered by execute(), so reading them never touched the socket and silently worked; the large, chunked POS menu (50 products + option groups) wasn't buffered, so body.string() did a real socket read on the main thread and StrictMode killed it right after the 200 — the load coroutine died before decoding, the screen showed "couldn't load the menu", and every Retry repeated it. Fix: read the body inside withContext(Dispatchers.IO) (made requireSuccessText a suspend fun; wrapped the customer-display reader). All authed calls in all three apps benefit; the latent bug just hadn't surfaced until a payload was big enough not to pre-buffer. Diagnosed on-device via adb logcat. (Also along the way: the POS app had a typo'd server address saved from an earlier sign-in — `https://198.168.0.96` instead of `192.168.0.96` — hidden behind the password-only "Welcome back" screen; the remembered screen now **shows the server address** so a wrong/moved server is visible and fixable via "Change server" instead of being a trap.)

    Added

    • Food Bazar app — third-party availability ("Also order on foodora / Wolt / Uber Eats"), config + deep-link, super-admin gated. The consumer app's promo field accepts real restaurant/partner coupon codes (validated live via the existing POST /api/public/{tenantId}/validate-coupon), and restaurants can now surface the partner apps they're also listed on — no fabricated competitor prices, just an honest "open in the partner app/website" deep-link. Two public bazar routes (GET /api/bazar/restaurants, GET /api/bazar/restaurant/[slug]) now return an availableOn: [{ platform, orderUrl }] array, double-gated: a platform shows only when the restaurant enabled it with an order URL (settings.integrations[p].{enabled, orderUrl}) AND the super-admin has that platform switched on site-wide (providerSettings.delivery[p].enabled, defaulting to the big-three on). So the super-admin Providers page is a real site-wide kill-switch over every restaurant's partner links. The Android app maps availableOn → the "Also on" pills on home/search cards and a dedicated "Also order on" strip on the restaurant page (each pill carries an open-in-new affordance and opens the partner URL via ACTION_VIEW); the illustrative cross-app price comparison is kept seed-demo-only since live partner prices aren't ours to assert, and unknown platform keys are filtered so a future provider can't crash the pill row. Backend tsc 0, eslint 0; live-verified end-to-end on the running marketplace — per-restaurant gate (a restaurant with no integration returns []), the super-admin kill-switch (a restaurant-configured Wolt link drops out when Wolt is disabled site-wide, leaving foodora only), and defaults; the :bazar module builds green.
    • Phase 28 FBL — the last two functionality pillars: MON (regulation-change monitoring) + F8 (business-plan export). MON (doc 14): every change to the SERVED graph — a fact verified, unverified, edited, discarded, or auto-demoted by the DR-5 circuit-breaker — is now logged as a NodeChangeEvent (cap 200, newest-first) in LaunchpadDB.changeLog, and the super-admin console gets a "Regulation changes" feed showing each change with its founder impact (cellImpact = active founders — claimed or within-TTL — in that cell; a broad node counts the whole country). Pure monitoring.ts (recordChange/cellImpact/changeFeed); recorded at the right moment (reject captures the node before splice; every error branch returns before any log write, so a failed action can never masquerade as a change); the feed exposes node titles + counts, never founder PII. The founder-facing alert stays the RP-5 in-app "roadmap updated" banner — this is the operator/audit view. F8 (doc 08 BP-5/6): business-plan-lite is now currency-aware (SEK/NOK/DKK by country, with a per-country editable food-VAT default — SE 12 / NO 15 / DK 25 — clearly marked "verify with your tax authority", never asserted as fact) so plans are correct across all 12 cells, not just SE; and a new `planToMarkdown` export + a "Download (.md)" button on /launchpad/plan give a portable, self-contained document (carrying the assumptions register + disclaimer) to keep or paste into Word/Docs — beyond the existing print→PDF. A 2-lens adversarial review (MON · F8) caught a real major — the on-screen pricing label hardcoded "12% VAT" while the value used the country's actual rate — now driven from forecasts.vatPct so the label can't mis-state the rate; the minor sv-SE date hardcode was switched to ISO too. 450 tests (6 new), tsc 0, eslint 0, build EXIT=0; live: the MON feed is super-admin-gated, the plan page renders the export.
    • Phase 28 FBL — the full 3×4 grid: 5 grid-completing cells (NO food-truck/restaurant, DK café/food-truck/restaurant). The launchpad now serves every (country × type) combination across SE/NO/DK × home-bakery/café/food-truck/restaurant — 12 verified cells — with the honest global floor for everything else (Finland, etc.). Each cell is authored to the established trust posture (seed needs-review; hedged; no fabricated specifics; allow-listed official citations) and adapts the proven type templates to each country's authorities — NO via Brønnøysund/Skatteetaten/Mattilsynet (+ Oslo kommune for skjenkebevilling/public space), DK via Erhvervsstyrelsen-Virk/Fødevarestyrelsen/Skattestyrelsen (+ Københavns Kommune for alkoholbevilling/public space). kk.dk + helsedirektoratet.no added to the research allow-list; DISCOVER worklists for all 5; the guide cross-product + sitemap now cover all 12 cells. A parallel 5-cell adversarial review returned 0 blocking/major findings (all "ship"/"ship-with-fixes"); the one substantive minor was applied — the NO alcohol nodes (café + restaurant) now cite Helsedirektoratet's national skjenkebevilling guidance (with Oslo kept as an example), so the citation is authoritative for any Norwegian founder, not just Oslo. 444 tests (15 new), tsc 0, eslint 0, build EXIT=0; live (dev-verified): all 12 cells servable, 12 guides in the sitemap, unseeded cells fall back to the honest floor. (Seeds dev-verified in the gitignored DB only — full QA + reset before go-live.)
    • Phase 28 FBL — breadth ×3: SE food-truck, SE restaurant, and DK home-bakery (3rd country). Three more seeded cells take the launchpad to 7 verified cells across 3 countries (SE/NO/DK) and 4 business types (home-bakery, café, food-truck, restaurant). SE food-truck (8 nodes) is structurally distinct — a MOBILE food facility (mobil livsmedelsanläggning) plus an optional permit to trade on public space (cited to the Police under the Public Order Act). SE restaurant (9 nodes) is the flagship — premises + food-safety + tax + optional staff/alcohol. DK home-bakery (8 nodes) pushes the country axis to Denmark with the right authorities — business/CVR via Erhvervsstyrelsen (virk.dk), food via Fødevarestyrelsen, tax/moms via Skattestyrelsen (skat.dk). All authored to the same trust posture (seed needs-review; hedged; no fabricated thresholds/fees; allow-listed official citations) — polisen.se + 5 Danish hosts added to the research allow-list; DISCOVER worklists for each; the guide cross-product + sitemap now cover all 7 cells. A parallel 3-cell adversarial review returned "ship", 0 findings on every cell (no wrong-authority citations, no fabricated specifics, sound DAGs, advice-clean). 429 tests (9 new), tsc 0, eslint 0, build EXIT=0; live (dev-verified): all 7 cells servable (7/9/8/9/8/10/8 steps), 3 new guides 200, 7 guides in the sitemap, unseeded cells (e.g. DK×restaurant) correctly fall back to the honest floor. (Seeds dev-verified in the gitignored DB only — full QA + reset before go-live.)
    • Phase 28 FBL — completing the matrix: NO × café (the 4th cell). A 2nd type in the 2nd country proves the engine handles any (country × type) combination. seed-no-cafe.ts (10 cited nodes — Brønnøysund + Skatteetaten + Mattilsynet authorities, business registration via Altinn, food-business registration, MVA, internkontroll, allergen info, plus the optional employer registration and alcohol-serving licence/skjenkebevilling) reuses the café shape under Norwegian authorities, same trust posture (seeds needs-review; hedged; no fabricated specifics; allow-listed official citations — skjenkebevilling cited to the kommune, employer to Altinn). Wired into defaultLaunchpadDB + the DISCOVER worklist (NO_CAFE_SOURCES); the guide cross-product already covers it (/start/en/cafe/in/norway); the sitemap now lists all four guides. The launchpad now serves a complete SE/NO × home-bakery/café grid, with the honest global floor for everything else. 420 tests (3 new), tsc 0, eslint 0, build EXIT=0; live (dev-verified): all 4 cells servable (7/9/8/10 steps), NO café guide 200, sitemap lists 4 guides, optional steps excluded from the readiness denominator (3 required legal). (Seed dev-verified in the gitignored DB only — full QA + reset before go-live.)
    • Phase 28 FBL — the country axis: a 2nd country cell (NO × home-bakery). The engine now generalizes across countries, not just business types. seed-no-home-bakery.ts (8 cited nodes — Brønnøysundregistrene + Skatteetaten + Mattilsynet authorities, business registration via Altinn, food-business registration, MVA-when-over-threshold, internkontroll, labelling) authored to the same trust posture as the SE wedge (seeds needs-review; hedged summaries; no fabricated NOK/%/thresholds — those stay in the cited source; real official Norwegian citations). Six NO authority hosts added to the research allow-list (brreg.no, altinn.no, skatteetaten.no, mattilsynet.no, lovdata.no, oslo.kommune.no); a NO DISCOVER worklist (NO_HOME_BAKERY_SOURCES); the guide gains norway (/start/en/home-bakery/in/norway); the sitemap now enumerates all publishable cells (SE home-bakery, SE café, NO home-bakery). Country isolation holds — publishableNodes filters by country, so NO nodes never leak into an SE roadmap (test-asserted). A focused adversarial review PASSED with no rewordings required (correct-authority citations, no fabricated specifics, sound DAG, advice-clean, needs-review, all 6 hosts genuinely official). 417 tests (3 new), tsc 0, eslint 0, build EXIT=0; live (NO dev-verified for the demo): NO roadmap servable (8 dependency-ordered steps), guide 200, an unseeded country (DK) correctly gets the honest global floor, SE cells unregressed. (Seed dev-verified in the gitignored DB only — full QA + reset before go-live.)
    • Phase 28 FBL — breadth: a 2nd seeded cell (SE × café) + optional/conditional roadmap steps. The multi-cell engine is now exercised end-to-end beyond the home-bakery wedge. (1) SE × café seed (seed-se-cafe.ts, 9 cited nodes — kommun + Skatteverket authorities, food-premises registration, F-skatt, moms, egenkontroll, allergen information, employer registration, alcohol-serving permit) authored to the same trust posture as the wedge (seeds needs-review; accurate-shaped, hedged summaries; no fabricated fees/rates/deadlines; real official citations — Livsmedelsverket, Skatteverket, verksamt, Folkhälsomyndigheten, the kommun). Wired into defaultLaunchpadDB, the programmatic guide (/start/en/cafe/in/sweden), and the DISCOVER worklist (SE_CAFE_SOURCES); folkhalsomyndigheten.se added to the research allow-list (the serveringstillstånd authority). (2) Optional/conditional steps — a real engine capability surfaced by review: ComplianceNode.optional (e.g. "only if you serve alcohol", "only if you hire") flows through RoadmapStep, renders an "If applicable" badge on /launchpad + the guide instead of a deadline, and is excluded from the readiness denominators (checklistScore + legalStepCount) so a solo, alcohol-free café founder isn't stuck below 100%. A focused adversarial review of the seed PASSED (no factual over-claim, citations on the correct authority, sound DAG, advice-clean, needs-review) — its two findings (a citation-attribution nit and the conditional-step modeling) were both applied. 414 tests (8 new — café well-formed/cited/advice-clean/no-specifics/DAG + optional-denominator handling), tsc 0, eslint 0, build EXIT=0; live (café dev-verified for the demo): café roadmap servable with 9 dependency-ordered steps, 2 optional, legal denominator = 3 required; guide 200; home-bakery unregressed (7 steps). (Seed dev-verified in the gitignored launchpad.json for the demo — full QA + reset before go-live per the build-with-seed mode.)
    • Phase 28 FBL — RP-5 the living roadmap: regeneration diff (founders see what changed, progress preserved). The DR-5 → re-verification loop now closes on the founder's side. Each roadmap step carries a content stepSignature (a pure, browser-safe FNV-1a hash over its nodeId/title/summary/citations/deadline — control-char delimited so fields can't run together and mask a change); the roadmap + profile APIs emit it on every step. diffRoadmapSteps (pure) compares the founder's last-seen signatures (kept per-cell in localStorage) against this visit's and reports added / updated / removed steps — with a first-visit guard so a brand-new visitor never sees a phantom "updated" banner. The /launchpad client persists the signature map each load, shows an "Your roadmap was updated since your last visit" banner when something changed, and preserves checklist progress automatically (it's keyed by nodeId — a surviving step keeps its tick, a removed one simply drops; the diff never blocks the render). An independent review PASSED (diff/persistence correct, first-visit guard real, progress genuinely preserved, additive APIs) — its one finding (an ambiguous field separator) was a display artifact, and the separator is now an explicit delimiter. 408 tests (2 new: signature stability/sensitivity, diff added/removed/changed + first-visit), tsc 0, eslint 0, build EXIT=0; live: all 7 steps carry signatures.
    • Phase 28 FBL — the Global Contract floor: every cell returns an honest, useful answer (doc 04 CE-2 / doc 20 K5). A founder who picks a country/type Restora 360 hasn't verified no longer hits a blank "in preparation" — they get an honest signpost. src/lib/launchpad/global-floor.ts (pure) returns, for any non-servable cell, the categories of authority a food business generally deals with (business registration · tax/VAT · food safety · premises/municipality · employment) with soft, hedged "what it usually means" + "how to find yours" guidance pointing at the kind of authority — and asserts no specific legal requirement, names no specific authority, states no fee/rate/threshold/deadline (those are facts, and facts only ever come from the verified CKG). It is advice-clean by construction and verified:false always. The roadmap API returns it only when the cell is not servable (it can never borrow another cell's verified facts), and the /launchpad client renders it in place of the bare "in preparation" box, under the assistant-not-advisor disclaimer. Reflected query-param labels are charset-bounded and lint-guarded — a crafted businessType (e.g. you-dont-need-a-permit) collapses to "a food business", so advice posture can never be injected into served prose. A dedicated adversarial legal-posture review scrutinized every string and PASSED (no requirement asserted, no harmful universal, no trust-leak, disclaimer always shown); its two softening nits were applied. 406 tests (5 new: advice-clean across cells, no fabricated specifics, never-verified, injection-guard, category coverage), tsc 0, eslint 0, build EXIT=0; live: NO/home-bakery → 5-domain floor, SE wedge still serves its 7 steps (no floor), injection probe neutralized.
    • Phase 28 FBL — counsel-ready verification dossier (print → PDF). The Phase-1 GATE worksheet is now a document you can hand a lawyer. A new print-friendly /launchpad/verification-dossier (noindex; the repo's established print→PDF pattern) renders the verification report as a per-node dossier — each node's claim, kind, authority, citations (with the exact passage the source should say, from the verified excerpt), the "what to confirm" checklist, and a Reviewer / Date / Verified-&-complete sign-off line — under a prominent "this is a worksheet, not a clearance" banner restating that a named reviewer + SE counsel are the true gate. The data stays super-admin-gated (the page just formats the existing /api/admin/launchpad/verification-report; a non-admin sees "sign in as a super-admin", never the content), so no new legal surface. Linked from the admin "Phase-1 GATE readiness" panel ("Open counsel dossier →"). Pure presentation over already-reviewed logic. 401 tests, tsc 0, eslint 0, build EXIT=0; live: page renders, data API-gated.
    • Phase 28 FBL — verification-readiness report: the reviewer / counsel worksheet for the Phase-1 GATE (doc 20 §9). The capstone that operationalizes the true blocker. src/lib/launchpad/verification.ts (pure) assembles, per node, the claim + citations/evidence + freshness + a plain "what to confirm" checklist (open the source, confirm the authority, confirm nothing's missing; AI drafts get an extra "read the evidence quote against the source" note), and a cell-level GATE readiness summary: served vs verified counts, advice-language leaks and uncited-verified anomalies scanned over the human-verified population (not over publishableNodes, which pre-filters them — so it actually catches a node a human mis-marked), DISCOVER coverage, and freshness health. New super-admin GET /api/admin/launchpad/verification-report + a "Phase-1 GATE readiness" panel on the admin console. It asserts no legal fact and verifies nothing — readyForCounsel means the machine pre-checks pass (never legal clearance), and the report ALWAYS surfaces that a named reviewer + SE counsel signing off on completeness is the true, non-machine-verifiable gate. An independent review confirmed the cardinal risk (implying legal clearance) is absent and the anomaly checks run over the right population — PASS, no defects. 401 tests (5 new), tsc 0, eslint 0, build EXIT=0; live: route 401-gated.
    • Phase 28 FBL — R2/DR-1: the legal source registry + DISCOVER worklist (feeds the research tool). The deep-research pipeline gains its first stage (doc 05 §5[1] / §6.3): a curated registry of official, legally-readable pages to research for a cell — the reviewer's worklist of where to look (NOT legal facts; those stay in the CKG behind the human gate). src/lib/launchpad/source-registry.ts defines ResearchSource (authority · url · domain · kind · scope · status) and the SE × home-bakery worklist (8 official pages across tax/food-safety/licensing/startup — Skatteverket, Verksamt, Livsmedelsverket, Bolagsverket, the Stockholm kommun). discoverSources(country, businessType) returns the cell's active sources, scope-matched and re-filtered through the research allow-list at runtime (a mis-entered URL is excluded, never surfaced). New super-admin GET /api/admin/launchpad/sources returns the worklist with a cited flag (which pages a node already references — a conservative "effort visible" hint, exact-URL match), and the admin console's research panel now shows the DISCOVER worklist with one-tap "Use ↑" to load a source into the drafting input — closing DISCOVER → EXTRACT → STRUCTURE → human gate. An independent review PASSED it (no scope leak, no non-official URL, route read-only + super-admin gated). 396 tests (4 new: every URL allow-listed, scope-match/no-leak, domain coverage), tsc 0, eslint 0, build EXIT=0; live: route 401-gated.
    • Phase 28 FBL — F3: four founder financial-planning calculators (runway · startup-capital · loan · cash-flow forecast). The Quick-numbers suite on /launchpad grows from 6 to 10 tools, all pure/deterministic/ex-VAT (planning numbers, never financial advice — every input the founder's own and editable). `runway` — months of survival at the current burn (revenue ≥ costs → honestly "sustainable", no fake infinite number). `fundingNeeds` — how much capital before you open = one-time setup + ramp burn (clamped ≥0 — you can't "need" negative capital) + a full-fixed-cost survival buffer (the conservative cushion, never the optimistic one). `loanPayment` — standard annuity → monthly payment + total interest (0% → straight-line) for equipment/premises finance. `cashFlowForecast` — a 12-month projection with optional revenue growth → ending balance, lowest point, and the first month cash would run out. An independent review re-derived every load-bearing number and PASSED all four with no correctness or honesty defects (praising the burn-clamp + full-cost buffer choices). 392 tests (4 new, each hand-computed), tsc 0, eslint 0, build EXIT=0; live: /launchpad renders the 10 cards.
    • Phase 28 FBL — TS-6: served-fact liability dashboard + the auto-demote circuit-breaker (the last P0 trust-spine item). The trust spine now has its assurance layer (doc 13 TS-6 / doc 17 §9.10). (1) Circuit-breaker: a verified node's cited authority page that goes unreachable for ≥3 consecutive freshness runs is treated as gone — its served facts are auto-demoted to needs-review so we stop serving a fact whose source has vanished. This is strictly fail-safe: the automation can only ever stop serving, never auto-verify or auto-publish, and it's deliberately distinct from a content change (which stays a human-gated DR-5 flag — a redesign must not unpublish a still-correct fact). runFreshnessCheck now tracks consecutiveUnreachable per source (incremented while down, reset when reachable again); the breaker runs in both the admin check-now and the daily cron, audit-logged, and demoted facts return to the human queue. (2) Liability dashboard: servedFactHealth (pure) powers a super-admin panel — served-fact count, % within the re-review window, stale/undated counts, flagged + unreachable source counts, and the at-risk-node list the breaker would demote next. Both are pure, deterministic functions. A 2-lens adversarial review (fail-safe direction · integration) confirmed 0 confirmed findings — no path can auto-verify, and the breaker never trips on a mere content change. 388 tests (3 new: breaker threshold/served-only, consecutive-unreachable accounting, health metrics), tsc 0, eslint 0, build EXIT=0; live: gate 401, cron exercised end-to-end (7 checked, the dead Skatteverket F-skatt source now at unreachable=1 → correctly not demoted until 3 runs), roadmap still serves all 7 verified nodes.
    • Phase 28 FBL — completing the human-verify gate (doc 05 §5[5] · §9.2): edit-then-approve, reject, and source evidence. The deep-research drafting tool now has a full review loop. The human gate (/api/admin/launchpad/nodes) gains edit-then-approve (fix a draft's wording — re-linted with findAdviceLanguage so advice posture can't slip through edit→verify) and reject/discard (remove a bad draft), each hard-guarded to non-verified nodes only — a served (verified) node can never be edited or deleted here; you must unverify it first, so live legal content is never silently changed. And the reviewer no longer approves blind: the research model now returns a verbatim sourceQuote per draft, and verifiedExcerpt keeps it only if it provably appears in the fetched source (normalized substring match — a fabricated quote is dropped), stored on the citation (SourceCitation.excerpt, additive) and shown as a quoted blockquote beside each draft in the console (doc 05 §9.2 "never approve blind"). A 3-lens adversarial review (gate guards · evidence integrity · UI) confirmed 0 blockers — the verified-node guards hold and fabricated evidence can't reach the reviewer — with 2 minors fixed (raw-enum error codes → readable copy; edit-form reseed on reopen). 385 tests (2 new — verifiedExcerpt + excerpt-attach), tsc 0, eslint 0, build EXIT=0; live: the gate stays super-admin-gated (401) and the public roadmap is unaffected.
    • Phase 28 FBL — deep-research engine: EXTRACT+STRUCTURE stages (doc 05), AI source-drafting behind the human gate. The breadth accelerator — and trust-law-safe by construction. A new super-admin endpoint POST /api/admin/launchpad/research runs the designed pipeline's middle stages: [2] FETCH an allow-listed official source (skatteverket.se, verksamt.se, livsmedelsverket.se, a kommun…), [3] EXTRACT + [4] STRUCTURE via a single budget-capped model call that is instructed to pull candidate requirements from that page's text ONLY (R1 sourced-or-it-doesn't-exist — never from the model's own weights), then saves them as `needs-review` nodes (proposedVia: 'ai-research') for the existing human verification console. It is incapable of serving, publishing, or verifying anything (R2 the human gate) and never overwrites an existing node — an AI draft can only add candidates to the reviewer's queue. The pure core (src/lib/launchpad/research.ts): isAllowedSource (official-host allow-list; look-alikes like skatteverket.se.evil.com rejected), buildResearchMessages (extract-only / no-invention / no-advice prompt), parseResearchDrafts (balanced-bracket, string-literal-aware), and `vetProposedNodes` — the trust vetter that forces `verification:'needs-review'` regardless of what the model returns, pins every citation to the URL actually fetched (a model-supplied URL is ignored — no fabrication), and drops advice/guarantee posture (findAdviceLanguage). The admin console gains a "Research a source" panel + an "AI draft — verify vs source" badge on proposed rows. A 3-lens adversarial review (no-auto-publish · legal-only-sourcing/no-fabrication · integration) caught a real blocker and I fixed it: the initial fetch used redirect:'follow', so an allow-listed host that 302-redirects to a private/internal or non-official host would be fetched anyway (SSRF + a citation falsely attributed to the official source). It now follows redirects with redirect:'manual' and re-runs both the allow-list and the SSRF guard on every hop (mirroring fetchHtmlGuarded), citing only the URL whose bytes were actually read; re-verified by an independent agent (both blockers closed, no new defect). 383 tests (10 new — the trust invariants are the tests: never-verified, citation-is-the-fetched-URL, advice-drop, allow-list, redirect-safety), tsc 0, eslint 0, build EXIT=0; live: the route is super-admin-gated (401), and the public roadmap serves zero AI drafts. (Residual, pre-existing platform-wide: `validateExternalUrl`'s DNS-rebinding TOCTOU window — mitigated here by the static official-host allow-list, which rebinding can't forge.)
    • Android apps — login/launch UX pass across all three apps (keyboard, remembered user, splash, show-password), adversarially reviewed. Four requested enhancements, all in the shared :core so every app gets them: (1) the sign-in fields no longer hide behind the keyboard — the form is a vertically-centered scroll column with imePadding() applied outside the scroll (keeps the focused field above the IME regardless of adjustResize). (2) a "Welcome back" remembered-user mode — once a device has signed in, the screen pre-fills the email and asks only for the password (email + server are remembered; the password is only stored if you opt into "stay signed in" — now default OFF so a normal sign-in never persists a password); "Change server" edits the address without losing the identity, "Different account" clears it. (3) a brand splash (logo + wordmark + progress bar) on cold start via a shared BootGate — while logged in it holds until the first data sync resolves (ApiClient flips the signal on success or failure in a finally, so a dead server reveals the app's own error instead of hanging; capped at 3s). (4) a show-password eye toggle. Hardened by a 4-lens adversarial review (Compose correctness · boot/lifecycle races · credential handling · cross-app/theming): 15 findings raised, 10 confirmed after per-finding verification, 6 fixed (2FA-back now clears the typed password, imePadding/scroll order, sync-on-failure splash reveal, stayLoggedIn default→off, server-editable in remembered mode, AppRoot theming made consistent with Pos/Waiter) and 2 rejected with reason (resetting the splash on logout would flash it on every sign-out; masking the email / FLAG_SECURE defeats the feature and breaks the shared-Activity customer display). All three apps rebuilt green and reinstalled on the owner's phone.
    • Phase 28 FBL — GEO/SEO surfacing of the Launchpad (doc 12) + the delivery-platform VAT toggle (doc 09 §4.3). Two buildable-now, zero-legal-risk additions that close deferred items and serve the acquisition priority. (1) The Launchpad is now AI-citable + crawlable, behind the same publish gate as the guide. New publishableLessons(nodes, country, businessType, now) returns only the LC lessons whose bound CKG node currently passes publishableNodes (verified + cited + effective). The platform /llms.txt gains a "Start a food business" section (the personalized roadmap, the verified guide, and each published lesson) — carrying an explicit assistant-not-advisor preamble so an LLM that grounds in these pages inherits the posture and cites the named authority — and the sitemap lists each published lesson page. Both follow the guide's discipline exactly: an unverified node's lesson is never advertised (it shows a "being re-verified" notice on its own page and silently leaves the sitemap/llms.txt until the human gate re-verifies); the llms.txt section is platform-host-only (tenant subdomains keep their merchant file) and degrades to nothing if no content is published. (2) `deliveryMargin` gains a VAT mode. Delivery apps display the gross (VAT-inclusive) price the customer pays and commission the operator on that gross — but the VAT is remitted to the state, so what you actually keep must be measured on the net (ex-VAT) revenue (doc 09 §4.3: entering app prices as if ex-VAT overstates the keep). With the new optional vatRatePct + priceIncludesVat, the calculator backs the VAT out before "what you keep" (commission stays on the gross), exposing netOrderValue/vatPortion; a one-tap "includes 12% VAT" toggle on the card (new SE_FOOD_VAT_DEFAULT_PCT) shows the honest, lower kept amount. Fully back-compatible — with no VAT fields the output is byte-identical to before (existing test values preserved). A 3-lens adversarial review (VAT economics · trust-law publish-gate · integration) raised 9 findings, 0 confirmed after independent verification — the math, the gate, and the wiring are sound. 375 tests (2 new), tsc 0, eslint 0, build EXIT=0; live-verified: all 7 lessons + the guide surface in llms.txt (with the preamble) and the sitemap, the tenant-subdomain file excludes them.
    • Two new native Android apps — Restora 360 POS (register) + Restora 360 Waiter — on a shared `:core` module. The android/ project is now a Gradle workspace: :core (shared data layer, API client, auth/login, theme, brand mark, and a reusable sale toolkit — cart model, photo menu grid, options sheet, one-screen payment sheet) feeds three installable apps — :app (Live Orders/KDS/Customer Display, unchanged), `:pos`, and `:waiter`. Two staff-only API routes added (no public exposure, requireTenantAccess-gated): GET /api/tenant/[tenantId]/pos/menu (localized menu + option groups by id + tables + currency/Swish settings) and POST /api/tenant/[tenantId]/pos/orders (server-recomputes every price from the live menu — options referenced by group/option id, not name-matching; status starts accepted → straight to KDS; cash/card/swish with optional already-paid), plus POST .../pos/orders/[orderId]/payment to settle an in-person payment (idempotent). Additive schema (exact Phase-27 design-doc field names so later phases reuse them): Order.type gains 'counter'|'dine-in', new tableId/tableNumber/source, and paymentMethod gains 'card'; the KDS feed's broken table lookup (it matched table ids against order ids) is fixed to use tableNumber/tableId. POS app: photo menu grid + search + category chips → live cart with qty steppers → one-screen tender with cash (change calc), card (external terminal), Swish (number read-off), "today" order history with payment state, phone/tablet responsive (side-by-side menu+cart ≥840dp), light/dark. Waiter app: floor of tables (occupancy + running total + "ready to serve"), tap a table → its open orders with kitchen status + Served action → Add order (same menu/cart grammar, sends an unpaid dine-in order to the kitchen) → Take payment settles the whole table's bill. All three apps build green (debug + R8 release); POS + Waiter installed + verified on the owner's phone. tsc 0 across the new routes + the Order union ripple (bazar history projection widened to match).
    • Phase 28 FBL — LC Learning Center MVP (doc 10, LC-0→3): "Learn how" on every legal step, with no second CMS. Lessons live in code (src/lib/launchpad/learning.ts — 7 beginner-grade lessons covering the full SE × home-bakery wedge: Skatteverket contact, F-skatt, moms, kommun registration + contact, egenkontroll, labelling), each bound to its CKG node via the `lessonRef` contract: the lesson carries only practical prose (how to fill the form, what to bring, what happens next) while THE LEGAL FACT is rendered live on the lesson page from publishableNodes — cited, verified, freshness-monitored, never duplicated (an unverified node → an honest "being re-verified" notice, not a stale copy). New /launchpad/learn/[lessonId] page (ISR 1h, static params from the lesson registry, unknown ids 404); /api/launchpad/roadmap + /api/launchpad/profile roadmap steps now carry lessonId; "Learn how →" links on the funnel O3 result cards and the roadmap checklist rows. The trust law is test-enforced: every lesson is advice-clean (findAdviceLanguage over titles/intros/steps/tips), binds to a real seed node with full wedge coverage (a new node without a lesson fails the suite), and never restates rates/fees/amounts (%/kr/SEK regex gate — numbers live only in the node, where DR-5 watches them). 373 tests (5 new), tsc 0, eslint 0, build EXIT=0; live e2e: lesson page renders the live verified fact + citation, all 7 roadmap steps enriched, bogus lesson ids 404.
    • Android app v1.2 — the flapping "Connection problem" banner actually fixed (root cause) + brand polish. Root cause: Node HTTP servers drop idle keep-alive sockets at ~5s — the app's exact poll cadence — so OkHttp kept reusing a connection the server was closing at that instant; every race surfaced as a phantom error despite perfect Wi-Fi. Three-layer fix: (1) OkHttp ConnectionPool keep-alive cut to 3s (sockets evicted before Node kills them), (2) every poll transparently retries once (500ms) on transport-level IOException, (3) banner requires 3+ consecutive failures AND >15s without fresh data, restyled from red warning to a calm amber "Reconnecting…" strip. Verified the proxy fast-paths raw-IP hosts (no per-request tenant resolution involved). Brand/UX: RestoraMark/RestoraWordmark (gradient clay R-tile + "Restora 360", matching the web) in header + login, header hairline, status-colored accent edge on order cards, lanes on soft panels. versionCode 3 / v1.2.0, installed + verified on the owner's phone.
    • Android app v1.1.x polish — uniform display tiles, scheme-free server input, dark/light modes. Customer Display: ONE order number per row in every column with identical tile height, text size and margins (Ready keeps its pulsing border but the same geometry) + an independent light/dark toggle on the display itself (persisted; dark = near-black with status-tinted lanes for dim dining rooms). Staff side gains an app-wide light/espresso-dark toggle (header sun/moon; themes login + Live Orders chrome — KDS stays always-dark, toggle hidden there). Login: the Server field accepts a bare address (192.168.0.96:3001, your-domain.com) — IP-style hosts probe http→https, hostnames https→http, full URLs still respected. All iterations installed + verified on the owner's phone over adb.
    • Android app v1.1 — Customer Display view + per-device navigation + phantom-network-error fix. Third view in the switcher: Customer Display — the customer-facing queue (Accepted / Preparing / Ready-for-Collection with pulsing ready tiles, big distance-readable numbers, restaurant name + welcome message + clock, owner-configured per-column page rotation and ready-chime). It reuses the platform's display-token + public PII-stripped endpoint (GET/POST .../live-orders/display-token then GET /api/public/[tenantId]/live-orders?token=... — the exact same source as the paired web TV display; token reused, only minted if absent so an already-paired TV never breaks), and renders chrome-free immersive (admin bars + system bars hidden; discreet focusable exit + Back). Navigation per device class: phones now get a bottom NavigationBar (Orders · Kitchen · Display — thumb-friendly) with a slim header; pads/TVs get the segmented switcher centered in the top bar. Bug fix: the recurring-but-harmless "network error" banner was (a) in-flight requests cancelled by view switches/screen-off being caught as failures — CancellationException is now rethrown, and (b) single Wi-Fi blips surfacing instantly — the banner now requires 2+ consecutive failed polls and reads "Connection problem — retrying…" while stale data stays on screen. versionCode 2 / v1.1.0; debug + release build green.
    • Native Android app (`android/`) — Live Orders + Kitchen Display for phones, tablets and Android TV. New Kotlin/Jetpack Compose app (com.restora360.orders, repo-root android/ — zero changes to ui/) with a segmented view switcher between the two owner surfaces: Live Orders (Incoming → Accepted → Preparing → Ready → Delivered-today lanes, accept/reject with confirm, one-tap advance, optimistic updates with revert-on-failure) and Kitchen Display (always-dark, NEW/ACCEPTED/PREPARING/READY lanes, per-second age timers with the web KDS's 3-min amber / 8-min red escalation, item options + removed ingredients + notes, Bump). Talks to the existing dashboard APIs (POST /api/auth/login + verify-2fa for the platform_session cookie — native HTTP clients aren't bound by cookie flags/CORS; GET .../orders, GET .../kds, PATCH .../orders/[orderId]) on a 5s poll that pauses when backgrounded — no new server code, tenant isolation unchanged. Form factors: phones get tabbed lanes, ≥600dp pads get side-by-side boards, TVs get LEANBACK_LAUNCHER + banner, D-pad focus highlights (scale+border), overscan padding, keep-screen-on; touchscreen required=false. Unattended devices: opt-in "stay signed in" silently re-logs-in when the 24h session lapses (app-private storage; 2FA accounts fall back to the sign-in screen). Synthesized chime/beep/bell (no assets) with a persisted mute. Brand-matched (clay #BF601D / warm paper / espresso). Debug + debug-signed release APKs build green (AGP 8.13, Kotlin 2.2, compose BOM 2025.09, minSdk 26 / target 36).
    • Phase 28 FBL P3 — DR-5 source freshness / change-detection (the deterministic diff pipeline) + funnel-observer fixes. Every VERIFIED node's cited source URL is now monitored: the page is fetched (SSRF-guarded via the shared validateExternalUrl; https-only, private hosts blocked, 12s timeout), its visible text normalized (scripts/styles/tags/entities/whitespace stripped, lowercased — so nonces and asset churn never false-positive) and sha256-hashed; a changed hash FLAGS the citing nodes for human re-verification in the admin console — nothing is auto-unverified (a cosmetic site redesign must not silently unpublish verified facts; the human gate decides). Verifying a node clears its sources' flags (baseline hash preserved); flags persist across runs until a human acts. Surfaces: GET/POST /api/admin/launchpad/freshness (super-admin; impact map of affected nodes), a "Source freshness" panel on the admin Launchpad console (check-now button + per-node changed/unreachable warnings), and GET /api/cron/launchpad-freshness (CRON_SECRET, fails closed in production without it). Also: the CFJ funnel observer no longer freezes old launches out of G5/G6 observation (filters un-observed tenants BEFORE capping), and profile pruning runs on wizard traffic too (not create-only). 368 tests (6 new), tsc 0, eslint 0, build EXIT=0. Live e2e against the real authorities: 7 sources checked, second run stable, cron gated — and the first run caught a genuinely dead citation (the seeded Skatteverket F-skatt URL now 404s; flagged for the reviewer — exactly the designed workflow).
    • Phase 28 FBL P3 (no-legal-risk slice) — the wedge calculator suite + the first coverage-gated programmatic guide. (Calculators, doc 09): four new pure functions — `bakeryBatch` (#7, THE wedge tool: batch cost ÷ usable yield after waste + packaging + optional labor; no waste clamp — ≥100% waste honestly returns null instead of fabricating optimistic sellable items, a review catch), `profitEstimate` (#4 P&L-lite, pre-tax/ex-VAT), `deliveryMargin` (#8: platform-commission bite + the own-site comparison, labeled "before card fees & delivery"), `staffCost` (#9: wage + semesterlön 12% (semesterlagen) + arbetsgivaravgift 31.42% (skatteverket.se, 2026) — both editable inputs, compounding verified algebraically correct) — all money inputs explicitly ex-VAT in the UI (doc 09 §4.3's incl-VAT optimistic-margin trap). Six calculator cards now on /launchpad; 362 tests (every expectation independently recomputed by the review panel). (The guide, doc 11): /start/en/home-bakery/in/sweden — the Type×Country programmatic page behind a real publish registry: isGuidePublishable = verified-servable AND substantial (≥5 steps, ≥3 legal — servable alone would permit exactly the thin page doc 11 forbids; review catch), enforced at render (dynamicParams=true so a clean CI build can't silently 404 the route — review catch) and mirrored by the conditional sitemap entry; Article+HowTo+FAQPage+Breadcrumb JSON-LD (HowTo = actionable steps only; FAQ numbers derived from the live roadmap); de-orphaned via /launchpad + the platform footer Resources column. tsc 0, eslint 0, 362 tests, build EXIT=0; live: guide 200 w/ valid JSON-LD ×4, bogus cells 404, sitemap gated, all six calculators render.
    • Phase 28 FBL P2 remainder — business-plan-lite + the admin funnel card + acceptance hardening (P2 buildable scope complete). (BP-lite, doc 08 BP-0→2 + BP-4): buildPlanLite is deterministic by law — every number is the founder's own input, a pure-calculator result, or a clearly-marked editable default, and ALL of them are listed in an assumptions register with sources; the legal section mirrors only the founder's VERIFIED roadmap (the publish gate upstream); the TC-0 disclaimer block is part of the document. Served by TC-gated GET /api/launchpad/plan (computed on demand — nothing stored, GDPR-light) and exported via the print-friendly `/launchpad/plan` page (token read from localStorage, never the URL; the review gate = the founder sees the full document including the assumptions before printing; browser print→save-as-PDF — the repo's established export pattern). Linked from the O3 roadmap. (Admin): the CFJ funnel card on /admin/dashboard/launchpad — 6 gate counts with self-reported G2/G3 visibly marked. (Acceptance hardening, from the review's minors): tcAcceptedSha — the sha256 of the exact acceptance text (provable wording, doc 02 §12); the go-live acknowledgment ("checklist done ≠ legally cleared — I verify & file") is now a REQUIRED un-pre-checked checkbox at the claim moment, enforced server-side (400 without) and timestamped (goLiveAckAt); the provisioning handoff writes an audit-log entry (tenant, TC version, acceptance + ack timestamps) per doc 16 §5.2 "audited". tsc 0, eslint 0, 357 tests (4 new), build EXIT=0; live e2e: plan 400-gated pre-TC → 200 post (7 steps, 7 registered assumptions, break-even computed); claim without the ack → 400, with → 200 + stamped.
    • Phase 28 FBL P2 — the pre-auth founder funnel + the graduation handoff + the 6-gate CFJ funnel (the "prove the economics" phase). The Launchpad stops being read-only: (O1) /launchpad/start — 3 quick tiles (country·type·premises, wedge-locked SE×home-bakery) → an ephemeral, GDPR-minimal profile (256-bit token, NO PII — the email only ever travels inside the signed claim token) + a deterministic teaser (the first 3 verified+cited tasks full, the rest locked titles — everything flows the existing publish gate, nothing un-verified can leak). (O2) the 8-input wizard (autosaved per step) + the versioned TC-0 acceptance (un-pre-checked; a text bump forces re-acceptance; the full roadmap is gated on a CURRENT-version acceptance). (O3) the personalized roadmap checklist with per-step Trust badges, citations and the mandatory assistant-not-advisor disclaimer rendered inline. (Handoff, doc 16 §5.2) "Go live with Restora 360" → the WoW magic-link handshake verbatim (15-min HMAC token, disposable-email gate, idempotent scanner-pre-click resume) → provisionFromLaunchpad mirrors WoW provisioning (atomic profile claim → tenant from template → user + tenant + 30-day trialing subscription → session → /dashboard?welcome=launchpad), with hardened failure windows from the adversarial review: rollback is guarded on the claiming tenantId (a loser can't un-claim a winner), a pre-master-push failure deletes the orphan tenant doc (retries no longer die on "already exists"), a post-push failure keeps the claim and recovers with a session (no founder walled off behind email_exists), Swedish names NFD-transliterate for clean subdomains (Kanelbullar på Söder → kanelbullar-pa-soder), and no fabricated contact details (the WoW placeholder phone is not written). (CFJ funnel) 6 gates: G1 validated (wizard+TC complete, system) · G2 registered / G3 licensed (founder-claimed checklist milestones, flagged `selfReported` so no dashboard can present them as system facts) · G4 launched (provisioning, system) · G5 first-customers / G6 growing (≥5 orders in 30d) system-observed from real tenant orders by GET /api/admin/launchpad/funnel. All five public routes rate-limited (incl. a 300/h global ceiling on O1); progress ids validated against the founder's own roadmap. tsc 0, eslint 0, 353 tests (8 new), build EXIT=0; live e2e: O1→O2→O3→claim→provisioned tenant (+1 exactly, triple-click safe), G1–G4 recorded, zero PII in profiles/events (panel-verified across 286 probe profiles).
    • Restora 360 rebrand + complete platform-site navigation + the "R" brand mark. (1) Rebrand: every word-boundary "Restora" → "Restora 360" across src (1,776 strings in 204 files — UI copy, SEO titles/metadata, JSON-LD, llms.txt, OpenAPI/Postman titles, docs-center + citation-hub content, emails, all 18 locales) AND the deployable static landing (index/help/llms/robots/og-image/favicon — the live-served public/static/ mirror swept too, and restora360-coming-soon.zip rebuilt from the swept sources). Identifiers/keys/slugs/domains untouched (adversarially verified: no key/enum/storage-key/slug changed; 346 tests green). (2) Brand mark: new RestoraMark (gradient "R" tile + "Restora 360" wordmark) used by the landing header/footer, /login, /register, /register/payment, /admin/login and /wow (wine-glass icon retired); new src/app/icon.svg favicon + regenerated favicon.ico/apple-touch-icon/og-image.png via sharp; the old "Restora·" dot-lockup pattern converted to the proper wordmark on 12 more surfaces (coming-soon, learn, site-check, /p/[slug], designer/supplier/agent navs + registers, verify-account). (3) Navigation: the platform header gains Website Builder (/wow) · Food Bazar · Learn; the footer services strip + link columns now cover every public product (/wow, /fsn, /shop, /ingredient, /site-check, /learn + the partner/help set — 16 tiles, clean 8×2). Review fixes: two would-be 404 links (/gift-cards, /chains have no index routes) removed; header switched to the lg breakpoint with nowrap labels (it overflowed 768–1230px); homepage now emits Organization + WebSite JSON-LD (previously none). tsc 0, eslint 0, 346 tests, build EXIT=0; live-verified (header/footer links, /static mirror title, R-tile on /wow, JSON-LD present).
    • Menu images on/off for owners — one switch, honored on every tenant-site surface (+ menu-settings consolidation). The existing Item photos control (Menu → Appearance & Layout → menuLayout.showItemImages: photo cards vs a clean text menu) is now respected everywhere a dish renders on the owner's site, not just the /menu list: the item popup (ProductCustomizeModal gains hideImage; cart lines drop the thumb too) and the home-page menu blocks (featured + daily-lunch + menu-preview items via toPreviewItem; menu-full now falls back to the owner's saved layout). BlockRenderer derives the gate from `settings.menuLayout` (run through `normalizeMenuLayout`) rather than ctx.menuLayout, so it reaches the home page without activating the dormant menu-preview layout override there (which would have discarded owner-authored builder block settings — caught + avoided in adversarial review). FeaturedSection now collapses the image area to a clean text card when an item has no photo (previously a gray placeholder box). All default themes handle both states automatically. Write-path default fixed: DEFAULT_MENU_LAYOUT.showItemImages + normalizeMenuLayout(absent) are now photos-ON, matching the live default for tenants with no saved layout — so opening the Appearance tab (or a theme/builder save spreading the default) can no longer silently flip a photo site to text-only; a saved layout without the key still means a text menu. IA consolidation: the duplicated Account → Settings → "Menu Display" tab is gone — descriptions + ingredients toggles moved into Menu → Appearance & Layout next to Item photos (same menuDisplay setting, one save; orphaned MenuDisplayTab/MenuSettingsTab deleted); the menu page's tab bar is now horizontally scrollable at 320px (it's the only home of these controls now). Deliberately out of scope: the Food Bazar keeps its own marketplace presentation, and gallery/hero blocks (site décor) still may use dish photos. tsc 0, eslint 0 on new code, 342 tests, build EXIT=0; live e2e on luigi-pizza: photos OFF → 0 rendered dish images and 0 placeholder boxes on /menu + home; ON → 50 on /menu, 7 in the home featured block.
    • Webshop card checkout (Stripe) — the last Phase 13 gate moves from integration-gated to env-gated. /shop checkout now offers Pay by invoice (net-30, default — the B2B loop) or Pay by card: the checkout API accepts payment: 'invoice'|'card'; with a real Stripe key the card path creates the order (pending) + a Stripe Checkout session (ONE consolidated server-priced line = net + VAT; metadata-bound to the order) and returns checkoutUrl; the return leg hits the new `POST /api/webshop/checkout/confirm`, which verifies the session is paid, bound to this order, and that the charged amount + currency match the server-computed total before marking the order paid (idempotent per session; a different session can never claim a paid order; cancel returns honestly to a pending invoice order). Dev-sim (no key): card orders settle immediately (stripeSessionId: 'dev_sim') so the flow is e2e-testable — and production without Stripe rejects BEFORE any DB write (503, no phantom orders). WebshopOrder gains paymentMethod/stripeSessionId/paidAt. Mirrors the proven billing add-on Checkout pattern (same STRIPE_LIVE gate + confirm-on-return). tsc 0, eslint 0, 342 tests, build EXIT=0; dev e2e: card → 201 status:'paid' + devMode, invoice unchanged (pending), confirm 503 without Stripe, junk payment value 400.
    • Phase 13 — Restora Webshop CLOSED 100% (buildable scope): the final surface pass. All WS-0…WS-7 cores were already shipped + adversarially hardened; recon showed the WS-6 admin UI (Analytics/Products/Coupons/Fulfillment tabs + barcode pick) and the WS-4 customer UX (Subscribe & Save + /shop/subscriptions) were also already live (stale TODO notes corrected). This pass ships the four genuinely missing surfaces: (1) /shop instant search — a token search box over name/category/SKU with the exact searchCatalog all-tokens-match semantics; (2) frequently-bought-together — the recommendations API gains ?fbt=<productId> (anonymous co-occurrence counts over real orders — no customer data, so no auth; active products only) and the cart sidebar suggests up to 3 companions with one-tap add; (3) the agent webshop-earnings card — the dashboard API + KPI grid now split out the 2% product-sales stream (earned + the amount still inside the return window); (4) the agent leaderboard — /agent/leaderboard (+ sidebar entry) over a new pure buildAgentLeaderboard (rank by EARNED commission — paid/pending/batched; reversed + withheld excluded; suspended agents dropped; stable name tiebreak) with the subscription / add-on / webshop split per agent and the caller's own rank row when outside the top 10. Integration-gated remainder (deliberately not code): Stripe card checkout (B2B pays by invoice), carrier labels/webhooks, multi-warehouse ops, non-EU tax engines, third-party-seller KYC, multi-currency rails, optional capped LLM NL-search. tsc 0, eslint 0, 342 tests (2 new), build EXIT=0; live e2e: FBT returns the real co-occurring product (count 2) from seeded guest orders, due-reorders stays 401 unauthenticated, leaderboard 401-gated, /shop renders the search box + catalog (visually verified).
    • WoW clone fidelity CF-2 — the style-fidelity pack (shape dividers · glassmorphism · scroll-parallax). The last planned fidelity tier from the cloning-engine doc (§Phase 4): three new BlockStyle fields — `divider` ('wave' / 'tilt' / 'curve': an SVG shape cut along the block's bottom edge, painted in the page background colour so sections flow into each other instead of meeting in straight lines), `glass` (frosted glassmorphism: translucent surface + backdrop blur; an explicit bgColor/gradient wins, the blur stays), and `parallax` (a subtle scroll drift in pure CSS — animation-timeline: view() behind @supports + prefers-reduced-motion, so it's zero-JS and a graceful no-op on older browsers). All three render in BlockRenderer's style wrapper, are owner-editable in the builder's Style panel (incl. reset), and are clone-mapped: the DNA vision call now detects `sectionDividers` (how the source's sections MEET — wavy / diagonal / arc / straight), the mapper puts the detected divider + a wow-mode parallax on the hero (the classic "wave under the hero"), and dark wow-mode designs get glass benefit cards (scheme-gated — frost is invisible on white). BuilderState's mirrored BlockStyle kept in sync. tsc 0, eslint 0, 340 tests (2 new: divider/parallax mapping, scheme-gated glass), build EXIT=0; visually verified live (wave divider cutting the hero image).
    • FSN P3 (M1+M2) — the monetization wedge: the `fsn-recruiter` subscription + ATS-lite hiring pipeline. The roadmap's "cash engine" test (doc 16 §Phase 3) is live. M1 — recruiter subscription: a new `fsn-recruiter` add-on (199 SEK/mo / 1 990 SEK/yr) in the feature registry — sold through the existing billing add-on marketplace (trial/Stripe checkout included). Outbound talent search is the paid surface: GET /api/fsn/talent/search now returns 402 + the registry's live feature metadata for un-entitled tenants (posting jobs + viewing your own applicants stays free — the deliberate freemium line); the hiring dashboard's Find-talent tab renders the standard UpgradePrompt from the 402 body (no hardcoded-price drift). The paid search also now honors profile privacy opt-outs (isPrivate/allowDiscovery — members who opted out of discovery are not sold to recruiters) and accepts both superadmin cookies. M2 — ATS-lite: applications carry a pipeline stage (Applied → Screening → Interview → Offer → Hired / Not selected; additive — old rows read as Applied) via PATCH .../fsn/jobs/[jobId]/applications/[appId] (operator-auth, web-designers 403, rate-limited, no-op-safe, suspended applicants 409). Employers get per-applicant stage controls + an interview scheduler (datetime + BYO https video link, normalized + validated, reschedulable, errors surfaced — never silently swallowed); leaving interview/offer clears the interview details (a rejected applicant never keeps seeing a join link). Applicants get a new `/fsn/applications` page (stage, interview time, join link; linked from the jobs board) and a new `application` notification type whose actor is the restaurant (denormalised name; the unread row per job is replaced, not appended — stage churn can't flood the inbox or bloat the FsnDB doc). `Hired` closes the loop on the moat: it records the HireEvent wedge metric (idempotent per profile+restaurant, audit-logged, cross-path consistent — the Find-talent button no longer falls back to a phantom 'platform' employer), and job-board hires are never undercounted: applicants without a talent profile get a minimal self-owned one created at hire time. Hardened via a 3-lens adversarial review (16 findings fixed). tsc 0, eslint 0, 338 tests (4 new), build EXIT=0; live e2e: 402 → add-on purchase (201, dev billing) → 200 · post → apply → screening → interview → hired (hireRecorded: true) · no-op guard, notification replacement (1 unread row after 3 moves), interview cleared on rejection, bad stage/http link → 400.
    • FSN-10 — the launch-safety pack (Phase 26): moderation, blocking, hardened media, tracked sessions. The design corpus (doc 14/R3) makes moderation a hard precondition for opening UGC publicly — this closes it. (A) Report + block: POST /api/fsn/report (post/comment/account, 6 reasons, rate-limited per-account + a global hourly ceiling against flag-bombing, idempotent per reporter+target) and POST/DELETE /api/fsn/block/[accountId] with mutual invisibility — posts/comments/notifications vanish both ways, follow edges severed, and blocked users can't follow/comment/like/save across the block (silent 404s, never revealing it). ⋯ menus on post cards + profiles; a Blocked-accounts list (Settings → Privacy). (B) Super-admin moderation queue (/admin/dashboard/fsn/moderation, new sidebar entry): open reports with content preview + author/reporter context; actions Remove content (status 'removed' — reinstatable, distinct from user deletes; comment takedowns adjust commentCount), Reinstate, Suspend (kills every live session permanently — devices do NOT resurrect on unsuspend), Unsuspend, Dismiss — all audit-logged. (C) Media hardening: uploads are decoded + re-encoded through sharp (stored bytes are our pixels — kills polyglot/spoofed-MIME payloads; EXIF/GPS stripped; transparency flattened to white), 50 MP pixel-bomb guard, 64px floor / 2400px ceiling, 500-file per-account quota. (D) Tracked sessions: every login/register/bridge/OAuth writes a device record (sid tokens); getFsnSession validates centrally against the store, so device revocation, "Sign out everywhere else" (legacy-cookie callers are upgraded in place so it keeps its promise), suspension and GDPR deletion bite immediately; device list in Settings → Security. Hardening from a 4-lens adversarial review (16 findings fixed): a critical pre-existing pre-auth DoS — the handle-collision suffix loop never terminated on a 20-char handle (2 requests froze the entire Node process; now a bounded uniqueHandle helper, regression-tested live); suspension evasion via OAuth/bridge (status-filtered lookups minted a fresh active account on the same email — now refused with an honest message); suspension now revokes sid records so unsuspend can't resurrect a compromised device; invisibility extended beyond feeds to search (posts/tags/people), the public recipe page, talent profiles, employer talent search, saved/collections, notifications, and meal pages; comment threads + like/comment/save gated on post visibility (leaked ids can't touch private/followers-only posts); 2FA backup codes consumed atomically (replay race fixed); honest suspended-login message (403 after credential proof, not "wrong password"); GDPR erase now wipes sessions (IP/UA) + block edges + the uploads folder, and the export gains sessions/blocks/reports/likes while stripping the admin-only removedReason; token validator rejects missing-exp payloads; expired devices hidden from the list. tsc 0, eslint 0, 334 tests (11 new), build EXIT=0; live e2e green (register→post→report→block→mutual invisibility→comment-cut→logout server-side revocation→stolen-cookie 401→media re-encode/reject→DoS regression in 26ms). (Admin queue actions not e2e'd locally — no superadmin in the dev DB; gate verified 401 + primitives unit-tested.)
    • WoW clone fidelity CF-1 — tone-matched clone copy (+ P-a hero CTA intent). Every clone used to ship the IDENTICAL generic copy ("Why dine with us / Fresh, made daily / Crafted with care") — the loudest template-generic tell against the owner's "extremely similar" bar. New `src/lib/wow/clone-copy.ts`: ONE bounded text call (the same cheap AI_THEME_MODEL, max_tokens 1000, 10s timeout / no retries — fail-fast because the deterministic defaults ARE the fallback, metered via recordWowUsage toward the global daily WoW ceiling, skipped on the fallback DNA so failed extractions never pay to harmonise with the cliché) generates a validated CloneCopyPack (per-field caps, surrogate-safe truncation, benefits all-or-nothing) from abstract DNA fields only (personality/cuisine/invented brandName/tagline/ctaIntent — never source content, so the copy is original by construction); applyCopyPack swaps the mapper's hardcoded strings in place (links/images/icons/layout untouched), wired into both clone routes after the imagery touch-ups, kicked off early so the call overlaps the menu/photo work. Legal hardening from a 4-lens adversarial review: the DNA tool's inherited tagline field is now explicitly INVENT-only (it could previously transcribe the source's slogan — the one field that broke "never copy text"); the copy prompt bans verifiable-shaped claims (founding years, heritage, awards, "#1"/"best", certified-sounding sourcing claims) with a validateCopyPack pattern backstop that drops any field slipping through; the interpolated DNA strings are framed as untrusted data (screenshot prompt-injection guard). P-a: DesignDnaSpec.ctaIntent ('order'|'reserve', vision-detected, optional) — ordering-led sites get an "Order Online" → `/menu` hero CTA (the review caught the original /order target: that route doesn't exist — it would have 404'd every order-led clone), unknown keeps the reservation default. Also fixed from the review: split-hero.ctaLink is now basePath-rebased in BlockRenderer (it previously pointed path-served sites at the platform root); the hero-guarantee + claim-time name stamp no longer mistake the story split-hero for the page hero (the name was clobbering the story headline); the dead reservation-cta.ctaText phantom key is gone and ctaTitle is prompt-anchored to booking voice; the story split-hero subheadline is re-capped at 160 chars; the dev mock (WOW_COPY_MOCK=1, never production) is claim-free by policy. tsc 0, eslint 0, 323 tests (14 new), build EXIT=0; keyless e2e verified (extract → claim → live site renders deterministic defaults unchanged); mock e2e verified (pack generated → applied → intent honoured). (Real-model copy quality not verifiable in this dev env — no resolvable AI key; the call mirrors the proven `design-dna.ts` pattern.)
    • `static/help.html` + `static/restora360-coming-soon.zip` — self-serve deployment kit for the pre-launch page. A branded, step-by-step HTML guide (upload → DNS/SSL → the no-database email-capture model → verify → Search Console/Bing indexing → at-launch cleanup; noindex, explicitly marked "don't upload this file") plus a ready-to-extract zip containing exactly the 8 deployable files — so the restora360.com upload needs no repo knowledge.
    • Standalone pre-launch landing page (`static/` at the repo root) + CORS on the newsletter endpoint. A dependency-free HTML version of /coming-soon that deploys ALONE (no app/build) so the brand is indexable + AI-citable while the platform is under development, with a working launch-list signup. `static/index.html` mirrors the real page (same English copy — h1, 5 pillars, dark showcase, the 7-question launch FAQ — same #bf601d brand, Geist + system fallback, inline SVG icons, responsive 320→1280px, native <details> FAQ). SEO: canonical, trimmed meta description, full OG (1200×630 PNG, rendered from an SVG source via headless Chrome) + Twitter cards, apple-touch-icon.png, honest hreflang. AI SEO (GEO): one JSON-LD @graph (Organization + WebSite + SoftwareApplication with AggregateOffer low-price 199 SEK + FAQPage that exactly mirrors the visible answers), plus static `llms.txt` (llmstxt.org manifest with answer-first facts), `robots.txt` with the platform's AI-crawler allowlist (GPTBot/Google-Extended/Perplexity/Claude/CCBot/Applebot-Extended…), and `sitemap.xml`. Email → admin: the form POSTs {email, source:'coming-soon', locale} to the real /api/public/newsletter/subscribe, so signups land in Super-Admin → Marketing & Growth → Newsletter; the endpoint now has an OPTIONS handler + CORS headers so a static page on a different origin gets readable success/validation responses (set window.RESTORA_API_BASE); offline/unreachable signups queue in localStorage and auto-flush on the next visit (409-safe: idempotent server). A 5-lens adversarial review (SEO · GEO · a11y · capture · fidelity) drove the hardening: WCAG-AA contrast fixes (accent text #a3521a, grays ≥4.5:1, input border), heading-order fix, <noscript> notice, success focus management, honest 429 ("try again" instead of fake success), JSON-LD↔visible FAQ exact match, the site-check CTA is probe-gated (never a 404 on a static deploy), and a view beacon feeds Visitors analytics. Verified: JSON-LD parses, PNG dims exact, live round-trip → subscriber stored, CORS preflight/POST/400 all readable, real 375px + 1280px renders clean.
    • WoW clone fidelity — two new page-builder blocks (`logo-strip` + `editorial-text`). Closing the next tier of the AI-website-cloning roadmap (split-hero, feature-cards and the masonry/bento gallery were already shipped — the roadmap doc was stale). `logo-strip` — a row of partner / "as featured in" logos (greyscale → colour on hover); a real builder + template block. The clone mapper still drops the logos section rather than fabricate empty placeholders (we never copy a site's logos), so cloned previews stay clean while owners/templates can use the block with their own press logos. `editorial-text` — a magazine-style story: eyebrow + headline over a 1–3 column body with an optional drop cap; dnaToBlocks now routes text-heavy `story` sections to it (visual stories stay on split-hero). Both wired through every builder integration point — WebsiteBlockType, the section registry (palette + defaults), the editor field schema, and the live-site BlockRenderer — so they're addable, editable, render on tenant sites, and emitted by the cloner. tsc 0, eslint 0, 309 tests, build EXIT=0; mapper verified (editorial story → editorial-text, visual story → split-hero, logos dropped). (Built on a new `feat/wow-clone-blocks` branch off the parked FSN branch.)
    • FSN-9b — Google & Apple social sign-in (env-driven). Real OAuth login for Food Stories that activates the moment provider credentials are present. `/api/fsn/oauth/[provider]/start` → provider consent → `/callback` (GET for Google, POST/formpost for Apple) verifies a signed, stateless `state` (HMAC + PKCE for Google; ES256 client-secret JWT for Apple via node `crypto`), reads the verified email/name, **find-or-creates** the `fsnaccount (by owned email; records oauthProvider), and issues the fsnsession`. The join screen's Google/Apple buttons light up when configured (via **`/api/fsn/oauth/config`**) and otherwise stay "Soon"; until then `start` redirects to `/fsn?error=oauthunavailable (no half-working flow). A **dev-only mock** (FSNOAUTHMOCK=1, never in production) makes the whole flow e2e-testable. Security: CSRF-safe signed state (tamper-rejected), verified-email only, secrets stripped by publicAccount. **To activate:** Google → GOOGLEOAUTHCLIENTID`/`GOOGLEOAUTHCLIENTSECRET (register {origin}/api/fsn/oauth/google/callback); Apple → APPLEOAUTHCLIENTID`/`APPLEOAUTHTEAMID/APPLEOAUTHKEYID`/`APPLEOAUTHPRIVATEKEY; shared GOOGLEOAUTHSTATESECRET` (≥32 chars in production). tsc 0, eslint 0, build EXIT=0; verified — live not-configured gate + OAuth core unit tests 10/10 (mock, authorize URL, signed-state + tamper, Google/Apple identity); provisioning is bridge-identical. (The live Google/Apple console flow isn't run here — needs real credentials; Apple especially is unverified against Apple's servers.)_
    • FSN-9a — two-factor authentication (TOTP) for Food Stories accounts. Settings → Security's "2FA: Coming soon" is now real: enable via an authenticator app (POST /api/fsn/me/2fa setup → QR + secret → verify a 6-digit code → enable), one-time backup codes, and disable (TOTP or backup code). Reuses the platform totp.ts (base32 / HMAC-SHA1). The /fsn login now issues a challenge when 2FA is on — {twoFactorRequired:true} drives a code step in the sign-in card; a TOTP or a backup code (consumed once) completes it. New FsnAccount.twoFactorEnabled/Secret/BackupCodes, and `publicAccount` strips the secret + backup-code hashes (verified they never appear in /me). Also fixed a verifyTOTP RangeError (timing-safe compare on mismatched lengths) by guarding it to 6-digit codes before calling it. tsc 0, eslint 0, build EXIT=0; e2e-verified (enroll → TOTP login, wrong-code → 401, backup one-time + consumed, disable, normal login after). Deliberately still "coming soon" (need real credentials / session store): Google/Apple OAuth login and session/device management.
    • FSN-8 — Operator → Food Stories bridge. A signed-in operator can now get a Food Stories presence without a second sign-up: a "Join Food Stories / Open Food Stories" card on `/dashboard/hiring` (operator-branded) calls `/api/fsn/bridge`, which provisions (or reuses) a Food Stories account for the logged-in UserRecord and issues the fsn_session. Per ADR-FSN-identity this is not a merge — it's a separate fsn_account (own namespace + cookie) with an optional `linkedUserId` back to the platform user; an account already registered at /fsn with the same (owned) email is adopted/linked rather than duplicated. GET reports whether one exists (card label), POST provisions-or-logs-in. Auth-gated by getSession() (platform session). tsc 0, eslint 0, build EXIT=0; auth gate (401 unauth) verified + the provision/cookie path mirrors the smoke-tested register flow. (Full operator-login→bridge e2e not run locally — no operator seeded in this dev DB.)
    • FSN-7 — real media (profile photos + multi-image posts). Two photo-rich gaps closed. (1) Avatars: TalentProfile.avatarUrl + a shared `FsnAvatar` (uploaded photo, else initial-on-gradient) now render everywhere — feed post cards, the mobile stories rail, search "Top Chefs", the profile hero (rounded-square), and the nav account button. Hydration (hydratePost/hydrateComment/hydrateNotification) + the people-search projection carry avatarUrl. Upload lives in Settings → Profile ("Change photo" → /api/fsn/media → PATCH /me/profile). (2) Multi-image posts: FsnPost.imageUrls[] (cover = first; imageUrl kept as cover for back-compat). The create flow now uploads every selected photo (not just the cover); POST /api/fsn/posts validates + stores the gallery; the feed PostCard renders a gallery (1 = hero, 2 = split, 3+ = bento with “+N”), and the recipe page shows a thumbnail strip. GDPR erase scrubs imageUrls too. Additive + back-compat (single-image posts and initial avatars unchanged). tsc 0, eslint 0, 309 tests, build EXIT=0. (Built on the new `ui/` app root after the repo restructure; verified via build + tests — no live dev server was running for e2e.)
    • FSN — account menu + sign-out in the nav. The top-bar avatar is now a dropdown (Your profile · Settings · Saved · Sign out), so logging out is reachable from every FSN page including the profile (previously sign-out only lived on the /fsn account page). Sign-out clears the fsn_session cookie and returns to /fsn.
    • FSN makeover — fidelity rebuild of 7 screens to match the mockups exactly. The owner flagged that the create-post flow, search and profile diverged from the stitch_restora screenshots. Rebuilt faithfully: (1) Create-post is now a full-page flow (FsnCreate at /fsn/create, replacing the cramped modal) — Media (drag-drop card + preview grid) → Details ("Step 2: Details & Story", 2-column with a large media preview + underline-input form + tag chips) → Recipe Info ("Refine Your Masterpiece", spacious Ingredients name/quantity rows + numbered Instructions, "Back to Media" / "Continue to Summary") → Review & Post (2-column: Post Settings — visibility, Allow-Comments, Show-in-Global-Feed, tags, Final Checklist — beside a live feed-card preview) → Published (a full celebratory "Masterpiece Shared!" canvas with View Post / Return to Feed + "What's next?"). (2) A visible search bar in the top nav (input, not just an icon) → (3) `/fsn/search` results page (FsnSearchResults): "Results for …", All/Recipes/Chefs/Tags filter tabs, a Featured-Recipes grid, Top-Chefs cards with Follow, and a Trending-tags / Quick-tags sidebar. (4) Profile refined to the mockup — a large rounded-square avatar, an Edit Profile (self) / Follow (others) action in the hero (FsnProfileActions), and Posts / Recipes / Saved tabs with icons (Saved self-only) + series-style category labels on the photo-tile grid. The feed CTA + mobile FAB now route to /fsn/create; the old modal wizard was removed. tsc 0, eslint 0, 309 tests, build EXIT=0; all four routes verified live.
    • FSN makeover P6 — mobile pass (the makeover is now complete). Drove a 9-agent parallel audit of every FSN screen at 320–768px against the *_mobile mockups (50 gaps: 6 high / 21 med / 23 low), then implemented the fixes. The signature mobile pattern: a new `FsnStories` curators rail — a horizontal-scroll of chef avatars with gradient rings at the top of the feed (mobile-only; desktop keeps the Discover sidebar). Epilogue headlines on mobile loaded via next/font (a new src/app/fsn/layout.tsx exposing --font-epilogue; the shell applies it to h1–h3 under 768px). iOS safe-area handled — the bottom-nav gets pb-[env(safe-area-inset-bottom)] + a fixed h-16 + elevation shadow + an active-tab pill, and <main> clears it via pb-[calc(5rem+env(safe-area-inset-bottom))]. A pervasive 44px touch-target sweep across the shell (header icons, bottom-nav), feed (tabs, comment send), profile (follow, tabs, touch-reveal tiles), recipes (chips), wizard (close, photo-remove always-visible on touch, responsive recipe grid), settings, contact (full-width CTA), saved and explore. Recipes hero goes mobile-tall (aspect-[4/3]→16/9→21/9); filter chips and recipe categories scroll horizontally; the help bento is 2-up on phones. tsc 0, eslint 0 (the prior font-<link> warning resolved by next/font), 309 tests, build EXIT=0; mobile chrome verified live. All 7 makeover phases (P0–P6) shipped.
    • FSN makeover P5 — support center + contact + join OAuth (mockup-faithful). A `/fsn/help` center matching the mockup — "How can we help you today?" search, a 4-topic bento (Getting Started · Account & Security · Creating & Sharing · Recipes & Collections), featured articles, and a "Still need help?" row (Email Support → contact; Live Chat / Community Forum honestly "Coming soon"). 24 help articles were generated then adversarially verified against the actual FSN code by a multi-agent workflow — the verifier caught and fixed real inaccuracies (e.g. the verified badge needs an employer claim code, not work-email auto-claim; only the cover photo is published; collections have no public/private toggle), so nothing describes a non-existent feature. Articles render at `/fsn/help/[slug]` (statically generated, FAQPage JSON-LD for SEO/GEO) via a dependency-free MiniMarkdown. A `/fsn/contact` form (category · subject · description → POST /api/fsn/support, a new FsnSupportTicket store; works signed-in or anonymous-with-email; rate-limited) with an inline success screen. The join screen gains "Continue with Google/Apple" buttons marked "Soon" (honest — email+password is the real path). Help + Contact wired into the shell footer. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (ticket 201/400, pages render, FAQ schema present). (Remaining: P6 mobile pass.)
    • FSN makeover P4 — the settings suite (mockup-faithful) + real backend. A new `/fsn/settings` with the mockup's sidebar (Profile · Notifications · Privacy · Security), reachable from a gear in the shell. Profile: display name, username, bio, personal website → PATCH /me/profile. Notifications: Activity + Updates toggles (likes/comments/follows/mentions/newsletter/announcements/recommendations) + a push radio (all/personalised/off) — persisted (delivery turns on when those channels ship). Privacy: private profile, show-online-status, search-indexing, discoverable, tagging policy — persisted, with two wired for real (search-indexing → profile noindex; discoverable + private → excluded from people search) — plus Download my data (GET /api/fsn/me/export, full JSON) and Delete account (typed-DELETE confirm → the existing GDPR erase). Security: password change (POST /api/fsn/me/password, verifies current — real); 2FA, sessions and connected apps honestly shown as "Coming soon". New TalentProfile settings fields; /me now returns the owner's private settings. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (settings persist, password 200/400, export JSON). (Remaining: P5 support + OAuth, P6 mobile pass.)
    • FSN makeover P3 — the create-post wizard (mockup-faithful). A new `FsnCreatePostWizard` modal replacing the inline composer: a stepped flow matching the design — Media (drag-drop / pick, multi-select preview grid with remove + cover marker) → Details (Story/Recipe toggle; title or caption, description, cooking time, servings, difficulty, tag chips) → Recipe Info (recipe only: ingredient name+quantity rows + numbered instruction steps, add/remove) → Review (Visibility Public/Private, an Allow-Comments toggle, a readiness checklist) → a "Published!" success screen with View-post. Opens from a "Share a food story or recipe…" CTA in the feed. Allow-Comments is now real backend (allowComments on the post; the comments route returns 403 when off). Honest scope: the first photo is persisted as the cover (multi-image gallery is a later backend extra) and difficulty rides along as a tag. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (wizard recipe publish + comments-off→403 / on→201). (Remaining: P4 settings suite + backend, P5 support, P6 mobile pass.)
    • FSN makeover P2 — recipes-browse + explore hero + demo seed data (image-rich). A new `/fsn/recipes` browse page (added to the shell nav): a featured hero (21:9 photo + title + meta + CTA), filter chips (All + popular #tags), and a "Trending now" grid of recipe cards (GET /api/fsn/posts?type=recipe). The explore page gains a Forest-Green hero banner above trending. To make every screen look alive (the mockup is full of food photography), a dev-only seed (POST /api/fsn/dev-seed, blocked in production, idempotent) populates 6 chef profiles + 16 image-rich posts/recipes + comments + a follow graph, using the repo's /global-media food photos — so the feed (ranked), recipes, profiles and explore now render full and visual. tsc 0, eslint 0, 309 tests, build EXIT=0; seed + screens verified live. (Remaining: P3 create-post wizard, P4 settings suite + backend, P5 support, P6 mobile pass.)
    • FSN makeover P0 — the Food Stories sub-brand + feed/profile restyle (from the stitch_restora mockup). After a 7-agent analysis of the 38-folder mockup (docs/products/food-stories-network/18-mockup-makeover-plan.md), FSN adopts its own Forest-Green/Terracotta identity (src/lib/fsn/theme.ts) scoped to /fsn (the operator dashboard + marketing keep burnt-orange). New shared `FsnShell` chrome — glassy sticky top nav (logo · Feed/Explore/Jobs · search/bell/saved/avatar) + a mobile bottom-nav + FAB + footer — replaces the marketing chrome on FSN pages. The feed is rebuilt to the mockup: an 8+4 grid (photo-hero post cards + a "Discover Trending" rail), Forest-Green interaction bar. The profile gains a hero avatar, Posts/Followers/Following stats, Posts/Recipes tabs, and a photo-tile grid (hover engagement overlay). Composer + SaveButton swept to the green palette. tsc 0, eslint 0, 309 tests, build EXIT=0. P1 now done — every remaining FSN screen (explore, notifications, saved, collections, tag, jobs + job detail, recipe detail, the account/join page, profile) is on FsnShell + the Forest-Green palette (the marketing Header/Footer removed from all /fsn routes; only the pre-auth /recipe-card marketing widget keeps the main brand). (Remaining: P2 new screens, P3 create-post wizard, P4 settings suite + backend, P5 support, P6 mobile pass.)
    • Phase 26 — FSN: the public job board (the verified-hiring moat, consumer side). Restaurants post openings from `/dashboard/hiring` → Jobs (operator-auth: POST/GET /api/tenant/[tenantId]/fsn/jobs, GET applicants + close per [jobId]); the public `/fsn/jobs` board (search + cards) and `/fsn/jobs/[id]` detail (with schema.org `JobPosting` JSON-LD for Google Jobs / GEO) let FSN members apply with their profile (POST /api/fsn/jobs/[id]/apply, idempotent + optional note) — and the employer sees applicants (with verified-employment badge) right in the dashboard. New src/lib/fsn/jobs.ts (pure: normalizeJobInput, publicJob, jobJsonLd). Reuses the roster's operator-auth pattern + the FSN session; GDPR erasure drops applications. Linked from the feed nav. This connects the content network to the hiring moat — jobs ↔ verified talent. tsc 0, eslint 0, 309 tests, build EXIT=0; verified (public board + both auth gates 401 + search).
    • Phase 26 — FSN: Collections (named Pinterest-style boards). Save now supports boards — the bookmark opens a picker (SaveButton) to save to "All saved", an existing collection, or a new one created inline. GET/POST /api/fsn/collections (list with item counts / create) + GET /api/fsn/collections/[id] (a board + its posts, public-viewable / owner-only for private); /api/fsn/posts/[id]/save takes an optional collectionId (one save per post — saving again just moves it to the chosen board). The `/fsn/saved` hub now shows your boards as cards + a create form + the all-saved list, and `/fsn/collections/[id]` is a board page. GDPR erasure drops collections too. tsc 0, eslint 0, 309 tests, build EXIT=0; e2e-verified (create board → save into it → itemCount 1 → board lists the post). The full social loop is now like · comment · follow · save-to-collections, with a ranked feed, notifications and discovery.
    • Phase 26 — FSN: ranked Discover feed + notifications (activity inbox). Ranked feed — Discover is now the "Recommended" feed, ordered by a deterministic hot score (hotScore: recency-decayed engagement, comments/saves weighted above likes) instead of pure reverse-chronological; Following stays chronological (the trust feed). Pure + unit-tested (309 total). Notifications — like/comment/follow now generate an activity notification to the target (no self-notify; de-duped per unread kind). GET /api/fsn/notifications (list + unread count) + POST (mark all read); a `/fsn/notifications` inbox (actor avatar + verb + post snippet, unread highlight, auto-marks read on view) and a bell with an unread dot in the feed header. GDPR erasure scrubs notifications too. tsc 0, eslint 0, build EXIT=0; e2e-verified (3 interactions → 3 unread → mark-read → 0; self-like suppressed).
    • Phase 26 — FSN: Save/bookmark (the 4th social verb) + the Saved page. Every post/recipe card gains a bookmark that saves it to your account (POST/DELETE /api/fsn/posts/[id]/save, idempotent + denormalised saveCount), and `/fsn/saved` lists your bookmarks newest-first (GET /api/fsn/saved). Reachable from a bookmark icon in the feed. Completes the like · comment · follow · save loop. PostCard owns save state locally; GDPR erasure now also drops the subject's saves. tsc 0, eslint 0, 308 tests, build EXIT=0; e2e-verified (save→1, saved list, unsave→0). (Named Pinterest-style collections remain a later slice.)
    • Phase 26 — FSN next slices: structured recipes (AI auto-fill) + discovery (explore · tags · search). Recipes are now first-class — a post with type:'recipe' carrying a structured RecipeCard (title, ingredients, steps, prep/cook/servings). The feed composer gains a Recipe mode with AI auto-fill (paste any recipe → the existing /api/fsn/recipe-card Sonnet call structures it into the fields), recipes render as rich recipe cards in the feed, and /fsn/recipe/[id] is a full recipe page with schema.org Recipe JSON-LD (SEO) + an estimated nutrition panel computed by reusing the deterministic nutrition engine on the ingredient lines. Discovery: /fsn/explore (search people · #tags · stories/recipes via GET /api/fsn/search) + clickable `/fsn/tag/[tag]` pages (GET /api/fsn/posts?tag=), reachable from a search icon in the feed and #tag links on every card. New reusable FsnComposer, FsnPostList, FsnExplore. tsc 0, eslint 0, 308 tests, build EXIT=0; e2e-verified (recipe post → feed card → tag page → search → recipe detail with schema.org + nutrition). Still deferred: collections/save, ranked feed, notifications, job board.
    • Phase 26 — Food Stories Network: the social platform (P2 "Content Core" MVP). /fsn/feed is now a real professional-social feed for the food world, grounded in the FSN design corpus. Members post food stories (text + #hashtags + an optional photo via POST /api/fsn/media), browse a Discover or Following feed (GET /api/fsn/posts, cursor-paginated, visibility-respecting), and like · comment · follow (/api/fsn/posts/[id]/like, /comments, /api/fsn/follow/[accountId] — all idempotent, denormalised counts). Profiles became social profiles (/fsn/talent/[id] resolves by account or profile id) with an avatar, verified badge, open-to-work + LinkedIn, a Follow button and the person's post timeline (?author=). New src/lib/fsn/social.ts (pure, testable: normalizePostInput, extractTags, selectFeed with cursor, hydratePost) + 5 tests (308 total); reuses FSN auth (getFsnSession), mutateFsnDB, rate limits, the #bf601d brand + the real landing chrome. GDPR erasure now soft-deletes the subject's posts/comments + drops their follow/like edges. Stored pragmatically in the FsnDB doc (normalised Postgres tables are the documented scale step). tsc 0, eslint 0, build EXIT=0; e2e-verified (two accounts: post → discover → follow → following feed → like → comment). Deferred per the roadmap: structured recipes/AI auto-fill, collections, ranked discovery, notifications, the public job board.
    • Food Stories Network — friction-free join + self-serve profiles + LinkedIn (was employer-verified-first). Joining is now just email + password (the handle is auto-derived from the email, collisions auto-suffixed; name optional) — no more upfront handle/display-name form. Critically, a member can now create their own profile without an employer code: PATCH /api/fsn/me/profile creates a self-owned TalentProfile (flagged selfCreated) on first save, so the /fsn page leads with an "I'm looking for work" toggle + name, headline/bio, LinkedIn link (strict *.linkedin.com validation; free Restora branding) and skills. Employer *verification is now a bonus badge*** ("Get the verified badge" via the claim code), not a prerequisite. tsc 0, eslint 0, 303 tests, build EXIT=0.
    • Nutrition surfaces — swapped to the real product chrome. The directory, detail, calculator and /fsn pages now use the canonical landing Header + Footer (logo, nav, "Get Started Free" CTA, the full footer) instead of the bespoke NutriMetric chrome, for true ecosystem consistency. Removed the now-dead NutriChrome/RestoraLogo shims; page content keeps the NutriMetric layout + the #bf601d brand colours.
    • Nutrition surfaces — brand polish to match the Restora ecosystem. The redesigned nutrition pages now wear the real product brand instead of the NutriMetric mock's black/white: the canonical Restora logo (extracted to a shared RestoraLogo component — the burnt-orange gradient badge + the "Restora." wordmark, verbatim from the landing header) in the header + footer, and the product's burnt-orange `#bf601d` (hover #a3521a) on every interactive element across the directory, detail, calculator, My-Meals and the public meal page — buttons, the search submit, active filter chips, sliders, macro/protein bars, focus rings, card/bento hover borders, links, and the category badge. Footer aligned to the product's grey link palette. Off-brand #e67e22 usages unified to #bf601d. tsc 0, eslint 0, build EXIT=0.
    • Nutrition surfaces — full visual redesign to the NutriMetric reference (directory · detail · calculator). All three nutrition pages rebuilt to match the supplied designs exactly, on a shared design system (NutriChrome header/footer, canvas #fdf8f8, Inter, flat tonal cards, functional colour-coding). Directory (/ingredient): filter sidebar (calorie/protein/fat sliders + category chips + Reset), search + result count + Sort By, a 3-col ingredient card grid (icon in the image slot, name + 100 g + 2×2 macro grid with a protein bar + category badge), client pagination. Detail (/ingredient/[slug]): hero (large icon + category badge + title + quick calories/protein/fat box + description), a 4-card bento (Macros · Fats · Minerals · Vitamins) with per-row % Daily Value (new NUTRIENT_DV/percentDV), a detailed composition breakdown, allergens/dietary, and unique source attributions. Calculator (/nutrition): the "My Mix" builder — a public ingredient typeahead (/api/nutrition/ingredient-search) → add items with editable gram amounts → a live right-panel (estimated calories, macro-distribution bars, micronutrients, confidence) that recomputes via the engine; the N7 Photo path now populates the mix, and Save & Share posts to the N6 meals API. tsc 0, eslint 0, 303 tests, build EXIT=0.
    • Nutrition Engine — N7: estimate nutrition from a food PHOTO. POST /api/nutrition/from-image — a budget-capped Claude vision call (reusing the menu-import parseDataUrl/fitImageForVision + the forced extract_ingredients tool) identifies visible foods + estimates portions; the nutrition stays deterministic (catalog lookup) and the result is tagged parse-method `image` (the engine's lowest confidence prior). Tighter rate limit + the shared nutrition daily cap; gram-from-pixels is clearly an ESTIMATE. The /nutrition calculator gains a "📷 Photo" upload.
    • Nutrition Engine — N6: save & share meals (social). Saved meals on FSN accounts: SavedMeal on the FSN store, GET/POST /api/fsn/meals (session-gated; POST computes + snapshots the estimate) + DELETE /api/fsn/meals/[id], a "My meals" section on the /fsn account page (save · list · share · delete), and a public `/meal/[id]` page (the colour-grouped nutrition card + ingredients + Recipe JSON-LD with NutritionInformation + disclaimer) for shareable links. Reuses FSN auth/session + the shared <NutritionFactsCard> + the deterministic engine. The Nutrition Intelligence Engine is now complete end-to-end (N0–N7). 303 tests, tsc 0, eslint 0, full build EXIT=0.
    • Nutrition Engine — N3: estimated nutrition on FoodBazar menu items (the scale fix's payoff). Each FoodBazar item now shows an expandable "Nutrition (estimated)" panel (calories + macro split in the summary, the full colour-grouped <NutritionFactsCard> on expand) — rolled up from the product's recipe → linked canonical ingredients via a new shared summarizeRecipeNutrition (reuses aggregateNutrition + rollUpAllergens + macro-%). Computed server-side in the public bazar feed using the cached catalog `byId` index (cheap now that catalog reads are cached — exactly what the scale fix unblocked); only products with a linked recipe get values, and everything is labelled an ESTIMATE ("from the recipe — not exact or medical"). tsc 0, build EXIT=0, 303 tests; no new eslint debt (the item page's pre-existing setState-in-effect warnings are untouched). The Nutrition Intelligence Engine's buildable phases N0–N5 are now complete — N6 (social meals) / N7 (image) remain deferred per the plan.
    • Nutrition Intelligence Engine — N1/N2/N4/N5 (deterministic): the public nutrition calculator. src/lib/nutrition/ — a deterministic-first estimation pipeline + a free public calculator. N1 engine.ts toGrams (mass exact · volume w/ density · count/household units like egg/slice/clove/cup/tbsp with documented defaults, flagged "assumed"). Matching matchIngredient (token overlap + precision/recall weighting so "olive oil" → "Oil, olive" not "Mayonnaise…with olive oil") against the 8,187 published catalog records. N2 composeEstimate → a NutritionEstimate (totals, macro-%, calories-from-P/C/F, a kcal cross-check, a fused 0–100 confidence from coverage × match-quality × assumptions × parse-method × consistency, and a per-line explain trace). N4 cooking.ts — a deterministic cooking-method factor table (raw/fried/grilled/boiled… → concentration + added oil), each a confidence-lowering assumption. N5 (deterministic) parse.ts (typed text → meal lines: "200g chicken breast", "2 eggs", "1 tbsp olive oil", "180g cooked rice") + POST /api/nutrition/calculate (rate-limited) + the public `/nutrition` calculator (type ingredients → grouped nutrition card + confidence badge + macro split + explain panel + disclaimer) + NUTRITION_DISCLAIMER/vetNutritionOutput ("estimate, not medical"). Reusable `<NutritionFactsCard>` (the colour-grouped card) now shared by /ingredient + /nutrition. In the sitemap. Everything labelled an ESTIMATE. 7 tests (303 total). tsc 0, eslint 0, full build EXIT=0.
    • Nutrition Engine — N5 AI fallback (natural language). A natural:true mode on /api/nutrition/calculate: a budget-capped Sonnet call (forced-tool extract_ingredients) turns free text ("I made pasta with cream, mushrooms, chicken and parmesan") into ingredient + estimated-quantity lines — but the nutrition itself stays deterministic (catalog lookup), tagged ai-parsed (a lower confidence-prior). Dedicated daily USD cap (src/lib/nutrition/ai-budget.ts + master.nutritionAiUsage), rate-limited; AI only extracts, never invents nutrition or health claims. The calculator gains a "Describe in words (AI)" toggle. tsc 0, build EXIT=0. (Remaining: N3 menu/FoodBazar wiring; N6/N7 deferred.)
    • Ingredient nutrition card — colour-grouped layout (matches the owner's reference design). The nutrition card now groups nutrients by colour: macros (blue #f0f7ff), fats incl. cholesterol (yellow #fffdf0), minerals (green #f2faf3), vitamins (pink #fff5f8), water (grey #f8f8f8), in a 3-col grid (2 on mobile), each cell showing value + unit + label. Driven by a new NUTRIENT_GROUP map + a clustered NUTRIENT_ORDER in the registry, so every surface that renders nutrition inherits the grouping. Build ✓.
    • Nutrition Engine images — P1–P4: specific icons across the whole catalog + rollout. Built a curated 665-entry food keyword/phrase → game-icons map (via a workflow over the verified palette, ingredient-icon-keywords.generated.ts, 88 icons self-hosted) and a conservative word-order-aware resolver: exact curated name → multi-word phrase match (longest first, so "black pepper" → spices beats single "pepper" → bell-pepper) → first matching food word (USDA names lead with the main food — "Spices, pepper, black" → spices) → category fallback (never blank). This lights up specific icons for the 8,187-ingredient catalog at render time, no per-record storage. Genuine universal gaps (vinegar/mayonnaise/onion) use a close substitute — game-icons covered the rest (shiny-apple, milk-carton, raw-egg, bell-pepper…), so no AI-generation needed. P4 rollout: <IngredientIcon> wired into the `/ingredient` directory (replacing the identical folder-emoji on all 8,187), the admin `IngredientDrawer`, plus the /ingredient header + recipe-builder picker from P0. Also: the directory name text is smaller + truncates with a full-name tooltip (fits one line). tsc 0, eslint 0, 296 tests, build EXIT=0.
    • Nutrition Engine images — P0: real food icons (game-icons.net) replace generic emoji. Per the deep icon research (emoji can't fix it — Unicode has no oil/vinegar/sauce codepoint), introduced game-icons.net (CC BY 3.0) as the ingredient icon source, self-hosted (no runtime CDN): 43 SVG bodies fetched into src/lib/food-catalog/icon-bodies.generated.ts. A shared `<IngredientIcon>` component renders the inline SVG (tinted via currentColor) with a deterministic fallback chain iconRef → curated name/category map → emoji → • (ingredient-icon-map.ts). The oils-sauces that looked identical are now distinct — Olive oil → olive, Vegetable oil → oil-can, Tomato sauce → tomato, Ketchup → ketchup, Vinegar → wine-bottle. Wired into the recipe-builder picker (ProductIngredientsEditor) + the `/ingredient` page header, with the CC BY 3.0 credit line. Additive optional iconRef on IngredientMaster (for storing resolved icons in P2). Self-hosted, GDPR-clean, no third-party calls. tsc 0, eslint 0, build ✓. (P1 curate-defaults / P2 seed-tail / P3 AI-gap-fill / P4 full-rollout to follow.)
    • Catalog now serves the complete USDA reference, with vitamins + single-source attribution. Per owner direction: the catalog is rebuilt as the complete USDA Foundation + SR Legacy reference (8,187 foods), PUBLISHED (pure USDA nutrition, no safety fields → publishable; importUsdaFdc gained a status option, used by the bulk script + cron) — so /ingredient shows the full list, not 87. 47 common concepts are renamed to clean names (Avocados, raw, California → Avocado) so /ingredient/avocado etc. resolve. The ingredient page's nutrition card now renders the full ~27-nutrient set including all vitamins (driven by the NUTRIENT_ORDER/NUTRIENT_LABELS/NUTRIENT_UNITS registry, each value with its unit). The "Data & sources" panel shows unique source names only (via catalogAttributions, not per-field rows) — for the rebuilt catalog that's a single USDA FoodData Central chip. Build ✓, /ingredient + /ingredient/avocado 200.
    • Nutrition Engine N0 + full USDA bulk import — complete raw-food reference, all nutrients, auto-refreshed. The shared `NUTRIENT_UNITS` registry (src/lib/food-catalog/nutrients.ts: each key → kcal/g/mg/µg, with labels + display order) so micronutrients store + render in natural units (iron in mg, vitamin A in µg) instead of rounding to zero. The USDA + Livsmedelsverket importers now map the full ~27-nutrient set — added the 16 micronutrients (potassium, calcium, iron, magnesium, zinc + vitamins A/C/D/E/K + B-complex), all numbers + units verified against the live FDC API (vitamin A as RAE #320, vitamin D as µg #328); sodium/cholesterol revert to mg (natural units, no forced grams). A one-time bulk import (scripts/import-usda-bulk.ts) pulled the complete Foundation + SR Legacy reference (~8,187 foods) into the local catalog with full nutrients (lean provenance, idempotent cing_fdc_<id>, needs-review); the catalog now holds 8,274 records. A weekly resumable refresh cron `/api/cron/catalog-usda-import` (CRON_SECRET-gated, cursor-walked) keeps it current. importUsdaFdc/mapFdcToIngredient gained a lean option (one nutrition provenance row vs per-nutrient — avoids 160k rows at scale). Scale note: the catalog JSON doc is now ~16 MB — fine for dev (page loads ~0.5s), but the prod path is the catalog's normalized-tables migration (P9) or a separate lookup index. 6 tests (296 total). tsc 0, eslint 0, full build EXIT=0.
    • Phase 29 / Phase A — USDA FoodData Central: the PRIMARY global raw-ingredient nutrition base. Per the licence-verified source research, USDA FDC is CC0 / public domain (US Government work) — the cleanest possible licence for a sellable catalog (no attribution, no share-alike, no resale limit). New src/lib/food-catalog/usda-fdc.ts — pure mapFdcNutrients (by USDA nutrient number → canonical keys; energy prefers measured #208, falls back to Atwater #957/#958 so Foundation Foods aren't left energy-less; tolerates search + detail + bulk shapes), mapFdcToIngredient (English names.official, per-field open/conf-75 public-domain provenance, a usda-fdc code), and idempotent importUsdaFdc (id cing_fdc_<fdcId>, lands `needs-review`, never writes allergens — the safety gate). POST /api/admin/food-catalog/import/usda (super-admin, searches Foundation + SR Legacy). API key read from env (USDA_FDC_API_KEY, never hardcoded). 5 tests (295 total). tsc 0, eslint 0, full build EXIT=0. names.official stays English/ASCII (slugs are accent-free); Swedish lives in translations.sv.
    • Phase 29 — Restora Catalog: dev populate script for the public ingredient pages. scripts/seed-catalog-nutrition.ts (run npx tsx) now builds a published catalog from the English ingredient defaults with USDA FDC nutrition as primary (English→English matched — far cleaner than the Swedish fuzzy match) + Swedish names from Livsmedelsverket, so /ingredient/<slug> pages (e.g. /ingredient/avocado) render with nutrition + source attribution. Strict first-segment matcher (correct-or-skip: rejects salt→"Butter, salted", brands, dried/powder forms) + a small verified curated-id override for staples where search picks the wrong variant (whole egg vs yolk, whole vs dried milk). No allergens written (safety gate). Writes the gitignored databases/food-catalog.json (local dev; prod populates via the route/cron). 72 of 87 with USDA nutrition, 47 with Swedish names.
    • Phase 29 — Restora Catalog: Livsmedelsverket (Swedish Food Agency) nutrition import. A new official-tier source (registered in LEGAL_SOURCES, CC BY 4.0 with attribution) that imports the Swedish food-composition database (~2,575 foods, per-100 g nutrients) into the canonical catalog. src/lib/food-catalog/livsmedelsverket.ts — pure mapNutrients (by language-independent euroFIR codes → canonical keys; kcal energy kept as calories), mapLivsmedelToIngredient (per-field official/conf-98 provenance, Swedish name + scientific name + a livsmedelsverket code), and idempotent importLivsmedelsverket (deterministic id cing_lv_<nummer>; new records land `needs-review` for a human to categorise + publish; re-import refreshes nutrition). Nutrition only — allergens are never imported (composition data ≠ allergen declarations; allergens stay behind the two-key safety gate). POST /api/admin/food-catalog/import/livsmedelsverket (super-admin, chunked + nextOffset) + a resumable CRON_SECRET-gated cron /api/cron/catalog-livsmedelsverket-import (walks a cursor, ~120/run, wraps for refresh). Deterministic, no AI. 3 tests (290 total). tsc 0, eslint 0, full build EXIT=0.
    • Phase 28 — Food Business Launchpad: the AI Startup Advisor (trust-grounded). POST /api/launchpad/advisor — a single budget-capped Sonnet call that explains the founder's roadmap, grounded ONLY in this cell's verified+published steps (it never invents legal/tax facts). It declines honestly (ADVISOR_IN_PREPARATION) when nothing is verified — so it can't even invoke the model until counsel verifies, keeping the AI risk behind the same Phase-0 legal gate. The model output is re-linted with the FBL advice/guarantee detector (findAdviceLanguage → a safe fallback if it trips) and the disclaimer is always attached; rate-limited per IP + a dedicated daily USD cap (src/lib/launchpad/ai-budget.ts). Pure buildAdvisorMessages/vetAdvisorOutput (3 tests). UI: an "Ask the startup advisor" box on /launchpad. 3 tests (287 total). tsc 0, eslint 0, build ✓.
    • Phase 28 — Food Business Launchpad: Phase-1 net-new core (trust-first; wedge = SE × home-bakery). Inherits TC-0 verbatim (assistant not advisor; nothing legal is served un-cited or un-verified). New src/lib/launchpad/ckg.ts — the Compliance Knowledge Graph (ComplianceNode: business-type/license/authority/form/deadline/task + source citations + a verification state + effective-dating) with the publish gate canPublishNode (cited and human-verified) + cellCoverage (the honesty state). roadmap.ts — deterministic, dependency-ordered assembly from published nodes only + a 0–100 checklist with a legalReadiness sub-score. calculators.ts — startup-cost / break-even / pricing (VAT-aware). The SE × home-bakery seed is 7 cited nodes, deliberately needs-review — a code author isn't counsel, so the gate holds them until a human verifies (Phase-0 legal gate). Row-locked app_documents 'launchpad' store. GET /api/launchpad/roadmap (public, verified-only) + the human gate GET/POST /api/admin/launchpad/nodes (super-admin verify/unverify, reusing the TC-0 forbidden-language linter). UI: a public `/launchpad` (roadmap or an honest "in preparation" + checklist + calculators + disclaimer, in the sitemap) + a super-admin `/admin/dashboard/launchpad` verification queue. Deterministic, no AI. 7 tests (283 total). tsc 0, eslint 0, build ✓.
    • Phase 26 — Food Stories Network: P1 wedge instrumentation (the gate measurement, NOT the feed). Makes the P1→P2 gate evaluable. New src/lib/fsn/metrics.ts — pure computeWedgeMetrics (verified · claimed · open-to-work · fresh-open · active accounts · hired-via-FSN · hiring employers); 2 tests. A HireEvent (WCFA) record on the FSN store + POST /api/fsn/talent/[profileId]/hire (employer-only, idempotent per profile×restaurant) wired into the "Mark hired" action in the /dashboard/hiring search. A super-admin gate dashboard at /admin/dashboard/fsn (GET /api/admin/fsn/metrics, in the admin sidebar) showing the metrics + the explicit P1→P2 gate note. This builds the measurement the gate depends on — the consumer feed (P2) stays gated until the wedge is proven. 2 tests (276 total). tsc 0, eslint 0, build ✓.
    • Phase 26 — Food Stories Network: P1 consumer/professional half (FSN accounts + self-claim + Open-to-Work + erasure). A separate FSN auth namespace per ADR-FSN-identity — src/lib/fsn/session.ts (an fsn_session cookie + fsnAccountId, reusing the HMAC-JWT pattern; not UserRecord). POST /api/fsn/register (+ auto-claim: registering with your work email instantly claims your owner-verified profile via findClaimableProfile against the roster claimEmail), /api/fsn/login, /api/fsn/logout, GET /api/fsn/me, POST /api/fsn/me/claim, PATCH /api/fsn/me/profile (the worker self-manages Open-to-Work — toggling on re-stamps `openSince` so recruiters see freshness — plus visibility + skills), and `DELETE /api/fsn/me` (GDPR right-to-erasure: scrubs PII + de-lists the profile). A public `/fsn/talent/[id]` profile (public-visibility only) with Person JSON-LD (SEO P1) and a `/fsn` account page (register / login / claim / manage). Pure helpers (validateRegistration, normalizeHandle, findClaimableProfile, personJsonLd, publicAccount) tested. 7 tests (273 total). tsc 0, eslint 0, build ✓. P1 now covers both halves (employer moat + worker accounts); remaining is dense-metro seeding (ops/data).
    • Phase 26 — Food Stories Network: P1 hiring-wedge core (the verified-employment moat + search-first hiring). With P0 GO given, built the P1 MVP core. src/lib/fsn/talent.ts — TalentProfile + verified employment + Open-to-Work as a state with provenance + freshness decay + bias-safe search/ranking (role/skill/open-freshness/verification/completeness; name/photo/age/gender/ethnicity/school excluded per the doc-09 §6 policy); 4 tests. Persisted on a row-locked app_documents 'fsn' store (getFsnDB/saveFsnDB/mutateFsnDB). The moat reuses existing operator auth: GET/POST /api/tenant/[tenantId]/fsn/roster — the owner one-taps to confirm a StaffMember's employment against the live roster (requireTenantAccess, audit-logged) → an uncopyable ✔ no standalone competitor can replicate. Search-first hiring: GET /api/fsn/talent/search (owner/manager/super-admin = verified employers, who alone see recruiters_only candidates). UI: a `/dashboard/hiring` page — "Your team" (verify) + "Find talent" (ranked search with the ✔ badge + open-freshness chip). 4 tests (270 total). tsc 0, eslint 0, build ✓. P1 remaining: consumer fsn_accounts registration/sessions, staff self-claim + Open-to-Work management, Person/JobPosting JSON-LD, dense-metro seeding.
    • Phase 26 — Food Stories Network: P0 validation started (ADR + recipe-card widget). Following the red-teamed validate-first roadmap, started P0 (the near-zero-build demand gate). Froze the identity model in docs/decisions/ADR-FSN-identity.md — fsn_accounts as the system-of-record for consumers/creators with an optional, verification-gated link to operator UserRecord (the moat is the verified-employment edge against the live POS, not a merge); scope tier = vertical product. Built the pre-auth recipe-card widget (P0 scope item 2 — tests "does structure delight?"): a public `/recipe-card` page where pasting any messy recipe returns a clean, structured, shareable card via ONE budget-gated Sonnet call. Pure src/lib/fsn/recipe-card.ts (normalizeRecipeCard — robust, bounded, never-throws + schema.org Recipe JSON-LD; 3 tests), src/lib/fsn/ai-budget.ts (daily USD cap), POST /api/fsn/recipe-card (rate-limited per IP + budget-capped, no data stored), in the sitemap. The rest of P0 is owner/research (seed count, hiring-WTP interviews, fake-door) → GO/NO-GO before P1 (the hiring wedge) builds. 3 tests (266 total). tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: WS-2 warehouse barcode-picker UI. A super-admin pick-and-pack flow at `/admin/dashboard/webshop/pick/[orderId]` (in the dark admin shell) reusing the existing BarcodeScanner (live camera) + an autofocused field for USB keyboard-wedge scanners. New pure src/lib/webshop/picking.ts — recordScan (match a code to a pick line by SKU/product id, increment up to qty, reject over-pick) + isPickComplete; 4 tests. Per-line progress turns green as items are scanned; once every line is picked, "Complete pick → Packed" advances fulfillment (super-admin, transition-validated). A Pick link appears on pickable orders in the admin Fulfillment tab. 4 tests (263 total). tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: WS-1 finished — B2B invoice + customer order portal + one-click reorder. New src/lib/webshop/invoice.ts — buildWebshopInvoice produces a Swedish faktura (net-30, OCR, due date, VAT lines from the order's resolved rate) reusing the bookkeeping InvoiceModel; 3 tests. For B2B, pay-by-invoice is the primary path, so this completes the commerce loop without a card processor: browse → order → net-30 invoice → bank transfer → admin fulfils. A customer `/shop/orders` portal (GET /api/webshop/orders) lists orders with status, a printable `/shop/orders/[id]/invoice` faktura page (owner/super-admin only, window.print() → PDF), and one-click reorder (re-checkout a past order's lines). Order + subscription links added to the shop header. 3 tests (259 total). tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: the UI layer (admin + customer). A super-admin Marketplace Admin at /admin/dashboard/webshop (in the admin sidebar, admin_session-gated) with Analytics / Products / Coupons / Fulfillment tabs over the WS-2/WS-6 APIs (+ new GET /api/admin/webshop/orders). Customer-facing: a `/shop/subscriptions` page (manage auto-replenish — pause/resume/cancel — + predicted "time to reorder" from WS-5), a "Subscribe & Save" action on every shop product card, and a subscriptions link in the shop header. Makes WS-2/WS-4/WS-5/WS-6 operable end-to-end. tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: WS-7 global & scale (deterministic VAT + regional catalog). src/lib/webshop/tax.ts — an EU VAT-rate table + resolveVatRate/isSupportedTaxCountry/calculateTax (adds VAT to a net B2B amount; unsupported country → supported:false, no tax) and src/lib/webshop/regional.ts — filterCatalogByRegion (a product with no regionCodes sells everywhere); 3 tests. WebshopProduct.regionCodes added; WebshopOrder carries buyerCountry/taxRate/taxAmount. POST /api/webshop/tax/calculate (VAT preview), GET /api/webshop/catalog?country= (region-filtered), and checkout now computes VAT on the post-discount net for the buyer's country. Honestly integration-gated (not built): real carrier APIs (labels/tracking), multi-warehouse supply-chain ops (capital), non-EU tax engines (Avalara/TaxJar), third-party seller onboarding + payout KYC, and multi-currency payment rails — EU VAT is deterministic, the rest needs contracts/capital. Rates must be tax-pro-confirmed per jurisdiction before launch. Deterministic, no AI. 3 tests (256 total). tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: WS-6 marketplace admin (coupons + analytics + product CRUD). src/lib/webshop/discount.ts (applyCoupon — percentage/fixed, clamped to subtotal, validity/min-order/max-uses/currency guards, every rejection reason typed) + src/lib/webshop/analytics.ts (aggregateSales — revenue net of discount, top products, seller split, date-filtered, excludes cancelled); 3 tests. Checkout now applies a coupon server-side (revalidated, never trusts a client discount; bumps redemption) and returns the net total. Super-admin APIs: /api/admin/webshop/coupons (+/[id]) CRUD, /api/admin/webshop/products (+/[id]) CRUD with SKU uniqueness + soft-delete, and /api/admin/webshop/analytics. WebshopDB.coupons added. Deterministic, no AI. 3 tests (253 total). tsc 0, eslint 0, build ✓. Next: the admin dashboard UI (product/coupon managers + analytics charts).
    • Phase 13 — Restora Webshop: WS-5 cheap AI commerce (statistical core). src/lib/webshop/recommend.ts — deterministic, no-AI: predictReorder (next-reorder interval from a customer's order spacing + confidence), frequentlyBoughtTogether (co-occurrence), dueReorders (predicted due within a window), and searchCatalog (keyword, all-tokens-match); 4 tests. GET /api/webshop/recommendations (signed-in customer's due-for-reorder products) + GET /api/webshop/search?q= (free keyword search). The LLM natural-language search/copy layer is an optional budget-capped enhancement, deferred so search stays free (rules-first). 4 tests (250 total). tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: WS-4 auto-replenish subscriptions. src/lib/webshop/replenish.ts — pure nextRun (now + interval, min 1 day), dueForReplenishment (active + due), buildReplenishmentOrder (re-prices via buildWebshopOrder), planReplenishment; 4 tests. WebshopDB.subscriptions (+ WebshopSubscription) added. POST/GET /api/webshop/subscriptions (create / list own, signed-in) + PATCH /[id] (pause/resume/cancel, owner only) + a CRON_SECRET-gated daily cron /api/cron/webshop-replenishment (creates a pending order per due subscription, advances the schedule). Deterministic, no AI. 4 tests (246 total). tsc 0, eslint 0, build ✓. Next: "Subscribe & Save" PDP toggle + customer subscriptions page.
    • Phase 13 — Restora Webshop: WS-3 agent 2% commission. src/lib/webshop/commission.ts — computeWebshopCommission (2%, cents-rounded, input-guarded), resolveReturnWindowDays (most-conservative seller wins: Restora 14d / dropship 21d / supplier+third-party 30d), and isCommissionReleasable (fulfilled + return window elapsed + not returned/cancelled); 3 tests. Orders are attributed to a referring sales agent at checkout (agentId, validated against active agents only). When an order ships, a pending commission entry is recorded into the existing sales-agent ledger (SalesAgentCommissionEntry.sourceType extended to 'webshop') — idempotent, feeding the existing payout pipeline (no parallel engine). Deterministic, no AI. 3 tests (242 total). tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: WS-2 warehouse fulfillment core. src/lib/webshop/fulfillment.ts — a deterministic fulfillment state machine (Unfulfilled → Allocated → Picking → Packed → Shipped → Delivered, + Returned from shipped/delivered), canAdvanceFulfillment (forward-only; returned-is-terminal), buildPickList, and statusForStage (maps to the customer-facing order status); 4 tests. WebshopOrder gained fulfillmentStage + shipment (carrier/tracking/timestamps). GET/PATCH /api/webshop/orders/[orderId]/fulfillment (super-admin / Restora ops — pick list + transition-validated stage advance + carrier/tracking, audit-logged). Carrier label printing + tracking webhooks are integration-gated (real carrier accounts) and deferred. Deterministic, no AI. tsc 0, eslint 0, build ✓.
    • Phase 13 — Restora Webshop: WS-1 storefront MVP (dropship-first). A persisted webshop store (app_documents 'webshop' via getWebshopDB/saveWebshopDB, seeded with 6 restaurant-tech/supply products + an orders list) + a pure buildWebshopOrder (src/lib/webshop/order.ts) that re-prices and re-validates availability server-side (never trusts client prices), enforces a single currency, and totals; 3 tests. GET /api/webshop/catalog (active products) + POST /api/webshop/checkout (builds + persists a pending order, guest or signed-in). A public `/shop` storefront (ShopClient) — catalog with bulk-tier pricing, a client cart, and checkout — added to the sitemap. Deterministic, no AI. 3 tests (235 total). tsc 0, eslint 0, build ✓. Next: WS-1 finish (Stripe checkout card + invoice + customer portal/reorder), WS-2 warehouse.

    Changed

    • WoW funnel `/wow` page — premium redesign (functionality-preserving). Rebuilt the public /wow entry to match the design reference: sticky nav, hero, the two functional import cards (Import Your Menu + Clone Your Brand — real upload buttons + URL fields), WOW-mode toggle, demo + free-health-check link cards, a 4-box bento showcase, final CTA, and footer — styled with Playfair Display + Plus Jakarta Sans, primary #e67e22, and Material Symbols icons (upload_file, screenshot, restaurant_menu, palette, qr_code_2, …). The proven import logic was extracted into a shared useWowImport hook so the landing-hero WowHeroWidget stays byte-identical (unaffected) while the new WowPremiumImport powers /wow; all flows preserved (menu extract · screenshot/URL clone · demo · health-check). No fabricated stats; self-contained visuals (no hot-linked images). tsc 0, eslint 0, build ✓.

    Added

    • Phase 19 — Tax & Compliance Assistant: TC-0 legal-responsibility framework (built first AND enforced). New src/lib/tax/legal.ts — versioned acceptance (needsAcceptance), the mandatory export gate canExport (acceptance then explicit per-report review; demo data force-watermarked "SAMPLE DATA — NOT FOR FILING"; no bypass path; disclaimer always attached), a word-boundary banned-claims guard (findBannedClaims/isSafeLabel — rejects official / guaranteed / filed / approved / compliant / accurate / authority / tax-return …, hyphen-aware, no substring false-positives), disclaimerLines, and confidence labelling. A 3-agent adversarial review returned FAIL (2 blockers) — both fixed before commit: the framework was dead code while a live /api/tenant/[tenantId]/bookkeeping?format=csv VAT export shipped unguarded. Now the boundary is enforced: that export is gated (acceptance + reviewed=1, with the disclaimer + any watermark prepended to the file), acceptance persists on TenantSettings.taxAssistant via a new audit-logged POST/GET /api/tenant/[tenantId]/tax/accept, the bookkeeping dashboard gained an "I've reviewed & verified" checkbox + accept-on-409 retry, and the designer/agent earnings-summary CSVs now carry the disclaimer. Deterministic, no AI. 7 tests (232 total). tsc 0, eslint 0, build ✓. Next: TC-1 books view, TC-2 deterministic VAT engine.
    • Phase 13 — Restora Webshop: WS-0 catalog + B2B tiered-pricing core. New src/lib/webshop/catalog.ts — WebshopProduct (physical goods + bulk priceTiers + sellerType: restora / supplier / dropship / third-party) with pure helpers: resolveUnitPrice (the highest-minQty bulk tier the order qualifies for, else base), lineTotal, cartSubtotal (rejects a mixed-currency cart), and isAvailable (dropship/made-to-order always available; own-stock respected). Reuses the SupplierProduct tier concept. Deterministic, no AI. 4 tests (231 total). tsc 0, eslint 0, build ✓. Next: WS-1 storefront MVP (dropship/supplier-fulfilled first), WS-2 warehouse/WMS.
    • Phase 11 — Growth Opportunities Hub CLOSED at 100% (buildable scope). The full legal AI lead-discovery engine ships (GO-0…GO-5): a legal-gated opportunity model (no scraping), a deterministic 0–100 scorer, AI lead intelligence + multi-channel outreach (GDPR/PECR-gated, budget-capped), a persisted pipeline + designer workspace, territory + dedup + heat-map, and GO-5 — a CRON_SECRET-gated weekly re-scan cron (/api/cron/growth-rescan, Mon 04:00, ≤100/run) that re-scores active stored opportunities (deterministic, no AI) + enriches missing coordinates via capped geocoding (≤15/run); pure dueForRescan selector (active-only, stalest-first, capped; tested). Legal throughout — the re-scan only refreshes opportunities that already passed the GO-0 gate. 222 tests, tsc 0, build ✓. The only remainder is integration-gated: new-lead discovery needs live official Places/GBP API access; the SEO-scorer auto-audit + partner gamification hooks graduate alongside. Hardened across this phase by a 3-agent adversarial legal/GDPR/correctness review.
    • Phase 11 — Growth Opportunities Hub: GO-4 territory + dedup + heat-map. New src/lib/growth/territory.ts — matchesTerritory (precise radius via the existing bazar haversine when coordinates exist, else city/region/country text match), dedupKey/isDuplicate, and heatmap (aggregate opportunities by location → count + avg score, busiest first); 4 tests. The create route now dedups atomically inside the row lock (409 on a same-restaurant duplicate in the owner's pipeline), and the /growth/opportunities workspace shows an opportunity heat-map (clickable location chips that filter the pipeline). GrowthOpportunity gained an optional coordinates. Deterministic, no AI. 4 tests (221 total). tsc 0, eslint 0 (new code), build ✓. Next: GO-5 compliant background re-scan.
    • Phase 11 — Growth Opportunities Hub: GO-3 pipeline + persistence + designer workspace. Opportunities now persist (MasterDB.growthOpportunities) and move through a 10-stage pipeline (New → AI-Qualified → Contacted → Meeting → Demo → Proposal → Negotiation → Won → Onboarded, plus Lost). Pure src/lib/growth/pipeline.ts — PIPELINE_STAGES, a forward-only `canTransition` (→ Lost from any active stage; terminals final), and needsFromSignals (the "needs-X" tags); 3 tests. POST/GET /api/growth/opportunities (legal-gated create via createOpportunity; owner-scoped list; score + needs computed server-side) + PATCH /[id] (transition-validated, owner/super-admin only). A role-gated workspace at `/growth/opportunities` (GrowthWorkspace): add from a legal source, filter by need, and advance the stage. Deterministic, no AI. 3 tests (217 total). tsc 0, eslint 0 (new code), build ✓. Next: GO-4 territory + opportunity heat-map (geo), GO-5 compliant background re-scan.
    • Phase 11 — Growth Opportunities Hub: GO-2 AI lead intelligence + multi-channel outreach. POST /api/growth/outreach (sales-agent / web-designer / super-admin only) generates either lead intelligence (overview · qualitative likelihood · best approach · talking points · demo features) or a channel-specific outreach draft (email · SMS · WhatsApp · LinkedIn · phone). Pure prompt-builders + parsers in src/lib/growth/outreach.ts (honesty rules — only the provided facts, no fabricated stats; PECR opt-out baked into email/SMS; 5 tests). The route gates in order: role → legal provenance (createOpportunity) → GDPR/PECR `canContact` (refuses to draft outreach we couldn't lawfully send — opt-out, suppression, lawful basis, legal source) → a daily growth-AI USD cap (src/lib/growth/ai-budget.ts); the opportunity score is recomputed server-side (client values never trusted). 5 tests (214 total). tsc 0, eslint 0, build ✓. Next: GO-3 pipeline + designer "needs-X" workspace + persistence.
    • Phase 11 — Growth Opportunities Hub: GO-0 model + GO-1 deterministic scorer (legal-gated foundation). New src/lib/growth/opportunity.ts — a pure, AI-free core for finding restaurants worth onboarding. GO-0: provenance is a required, validated property — createOpportunity is the only gate and rejects any source off the legal allow-list (Places/GBP APIs · licensed/opt-in · owner-permissioned · CSV · manual — never scraping). GO-1: scoreOpportunity returns a true 0–100 opportunity score (no website / no ordering / weak SEO / not mobile / unclaimed / multi-location) → reasons + recommended products, plus a clearly-labelled value estimate. GDPR/PECR: canContact requires a positive lawful basis + legal source + PECR subscriber-type (individual/sole-trader → consent; corporate → legitimate-interest) + opt-out + case-insensitive suppression (O(1) batch overload). Hardened against a 3-agent adversarial review (legal/GDPR · correctness · architecture) — closed 1 blocker + 3 majors before commit. Deterministic, no AI. 10 tests (209 total). tsc 0, eslint 0, build ✓. Next: AI lead intelligence + multi-channel outreach (GO-2), pipeline + designer "needs-X" workspace + persistence (GO-3), territory/heat-map (GO-4), compliant background re-scan (GO-5).
    • Phase 15 — Demo Platform: super-admin Demo Funnel dashboard. A read UI for the DA-7 PLG funnel at /admin/dashboard/marketing/demo-funnel — demos created, kept (demo → paid), live now, expired, and claim / retain rates, plus community counts — computed server-side from demoStats + the registry. Cross-linked from Visitor analytics. So the funnel numbers are one click away the moment the WoW funnel goes live. Super-admin gated. 199 tests. tsc 0, eslint 0, build ✓.
    • Phase 15 — Demo Platform + Academy + Certification CLOSED at 100% (buildable scope). The full PLG ecosystem ships end-to-end: anonymous demo creation → guided first-run → "keep this site" conversion → funnel analytics → AI-search discovery → Academy (25 lessons) → certifications (client + server directory) → community (showcases + forums). Deterministic-first, budget-capped AI, honesty rules throughout; 199 tests, tsc 0, build ✓. The only remainder is non-code or integration-gated: quick-start videos + an external benchmark-report PR (content/marketing), the anonymous in-dashboard demo session (needs a per-demo-owner auth design — the claim→real-dashboard path already grants dashboard access), and partner-type certs / lead-distribution / revenue-share (needs the partner programs).
    • Phase 15 — Demo Platform: Academy depth (Course schema · customer-success journey · in-sandbox CTA). The Academy page now emits schema.org `Course` JSON-LD (DA-6 — AI-search/Google citability), a customer-success journey strip (DA-7 — first 5 minutes → month 1, each linked to a docs guide), and a "practice in a free demo" CTA (DA-3). Pure src/lib/academy/journey.ts + academyCourseLd (2 tests). 199 tests. tsc 0, eslint 0, build ✓.
    • Phase 15 — Demo Platform: DA-4 server-saved certifications + public directory. Signed-in learners can save their Academy progress to their profile and opt into a public certified directory at `/learn/academy/directory`. New POST /api/learn/academy/progress validates lesson ids and persists to MasterDB.academyMembers; the directory derives each member's certifications server-side and shows name + tier badges — opt-in only, no email. The Academy shows a "Save my progress / list me in the directory" control when signed in. Deterministic, no AI. 197 tests. tsc 0, eslint 0 (new code), build ✓. Next (partner-system-gated): Partner/Agency/Consultant/Supplier certs + lead-distribution + revenue-share.
    • Phase 15 — Demo Platform: DA-5 community forums. A discussion surface at `/community` — 5 groups (General · Owners & Operators · Marketing & Growth · Partners · Feature Requests) with threads + replies. Pure model src/lib/community/forum.ts (groups, input validation/sanitization, visibility + pinned-first sort; 5 tests) persisted on MasterDB.communityThreads. Posting is sign-in-gated + rate-limited; super-admins moderate (pin / lock / hide / delete). SSR list + thread pages with auth-aware client forms (CommunityForms). Added to the sitemap + llms.txt. Deterministic, no AI. 197 tests. tsc 0, eslint 0, build ✓.
    • Phase 15 — Demo Platform: DA-5 community showcases (UGC → SEO/AI flywheel). A public "Built on Restora" gallery at `/showcases` — sourced only from APPROVED Food Bazar listings (the existing owner-opted-in public marketplace) joined with the tenant registry, so it can never surface a restaurant that isn't already publicly listed. Pure src/lib/community/showcases.ts (selectShowcases — approved/active/non-demo, featured-first, capped; 3 tests) + an SSR, crawlable page that links each restaurant to its existing /bazar/restaurant/[slug] profile (added to the sitemap + llms.txt → more indexed pages feeding the AI-search flywheel). Deterministic, no AI. 192 tests. tsc 0, eslint 0, build ✓. Next (auth/persistence/moderation-gated): the forums / groups / expert discussion surface.
    • Phase 15 — Demo Platform: DA-4 Academy certifications. At `/learn/academy/certifications` — tiered certifications earned by completing a track's levels in order: 2 levels → Associate, 3 → Professional, 4 → Expert, all 5 → Master. Pure model src/lib/academy/certification.ts (contiguousLevelsComplete / tierForTrack / earnedCertifications; 4 tests — non-contiguous progress earns nothing). AcademyCertifications (client) derives earned tier badges + per-track progress-to-next from the same localStorage progress, and is printable (Print / save as PDF). Linked from the Academy + added to the sitemap. Deterministic, no AI. 189 tests. tsc 0, eslint 0, build ✓. Next (persistence-gated): a public certified directory + Partner/Agency/Consultant/Supplier certs + lead-distribution + revenue-share.
    • Phase 15 — Demo Platform: DA-3 Restora Academy. A learn-by-doing path at `/learn/academy` over the Docs Center: 5 role tracks (Owner Essentials · Operations & Service · Marketing & Growth · Multi-location & Franchise · Partners) × 5 levels (Beginner → Master) = 25 lessons, each linked to a real /docs category, with client-side (localStorage) progress tracking — per-track progress bars, check-off, and a "next lesson" pointer. Pure curriculum model src/lib/academy/curriculum.ts (getAcademyTrack / lessonsForLevel / trackProgress / nextLesson; 4 tests) + AcademyClient + an SSR page (crawlable; added to the sitemap + llms.txt). Deterministic, no AI. 185 tests. tsc 0, eslint 0, build ✓. Next: richer per-lesson content + quick-start videos + in-sandbox tasks.
    • Phase 15 — Demo Platform: DA-6 SEO / AI-search (knowledge-hub `llms.txt`). Additive over the Phase 31 Citation Hub (which already emits rich JSON-LD — DefinedTerm / HowTo / Dataset / Article / FAQ / Breadcrumb). The platform `llms.txt` now lists every published knowledge guide — grouped by category, one citable line each — so AI-search crawlers (ChatGPT / Gemini / Perplexity / Claude) can ground answers in each guide, not just the /learn index. New pure src/lib/citation-hub/llms.ts (renderKnowledgeHubLlms, category-name resolver injected; 3 tests). Deterministic, no AI. 181 tests. tsc 0, eslint 0, build ✓. Next: pillar/cluster internal links + Course schema (Academy-dependent).
    • Phase 15 — Demo Platform: DA-7 PLG funnel analytics. Persistent lifetime counters (demoStats: created / claimed / expired) are bumped by the create (/api/public/demo), claim (/api/public/demo/claim), and cleanup-cron endpoints. New pure src/lib/demo/analytics.ts — bumpDemoStat (immutable, clamped) + computeDemoFunnel (claimRate, retainRate, current liveNow from the registry; 5 tests). Surfaced at GET /api/admin/demo-analytics (super-admin) — the headline PLG funnel for "read the demo→paid data". Deterministic, no AI. 178 tests. tsc 0, eslint 0, build ✓.
    • Phase 15 — Demo Platform: "Keep this site" CTA + guided first-run UX (on the demo storefront). The demo storefront (tenant layout demo branch) now greets prospects with a guided first-run overlay (DemoFirstRun — once per demo via sessionStorage, dismissible, reduced-motion-safe; orients them and points at the CTA) and a sticky "Keep this site" CTA (DemoKeepBar) that claims the demo for the signed-in user (→ /api/public/demo/claim), bouncing unauthenticated prospects to signup with a return to the demo. The claim endpoint now accepts the demo-access cookie as proof (in addition to the one-time demoKey), so a prospect viewing the demo can keep it directly. tsc 0, eslint 0, 173 tests, build ✓. Remaining demo UX: the in-dashboard explore (role pick/switch) needs a scoped demo dashboard session.
    • Phase 15 — Demo Platform: DA-2 "keep what you built" conversion. A PLG demo is a real tenant DB, so claiming it just flips the registry record — every dish, theme tweak and block the prospect built is preserved. New src/lib/demo/convert.ts — isConvertibleDemo (live, non-expired demos only) + pure applyDemoConversion (clears the demo markers, assigns the new owner, reactivates, extends expiry; 3 tests). New POST /api/public/demo/claim — gated on a signed-in session AND proof of the one-time `demoKey` (only whoever created the demo can claim it); starts an 'incomplete' subscription so the existing paywall prompts payment to publish — value before payment. Deterministic, no AI. 173 tests. tsc 0, eslint 0, build ✓. Next: the "Keep this site" CTA in the demo experience + the DA-1 guided first-run UX.
    • Phase 8 — Web Builder 2.0 CLOSED at 100% (buildable scope). The builder is now a restaurant growth platform: AI Website Auditor (completeness score + one-click fixes), 5 restaurant one-click blocks (Gift Card · WhatsApp Order · Loyalty · Catering · data-driven Daily Lunch), the AI SEO landing-page generator, and 5 restaurant-type full-site templates + AI copy-fill — all shipped, deterministic-first with budget-capped AI and the honesty rules enforced. The only remaining item — white-label custom-domain/SSL — is infra-gated (DNS + TLS cert provisioning) and graduates when that infrastructure is wired, not pre-built. (branch-locator remains an optional future data-driven block.)
    • Phase 8 — Web Builder 2.0: data-driven block (Today's Specials / Daily Lunch). The first data-driven restaurant block: on the live site it auto-shows the restaurant's featured dishes (DailyLunchSection fed real items + currency via the BlockRenderer switch, reusing toPreviewItem), and in the editor it shows representative sample items — the same dual-render pattern as the menu blocks (in SectionRegistry for the palette/preview, rendered with live data through the switch, deliberately not in REGISTRY_RENDERED_BLOCKS). New 'daily-lunch' WebsiteBlockType, in the builder's 'restaurant' palette. tsc 0, eslint 0, suite 170, build ✓. With this, Phase 8's buildable software scope is essentially complete — only the infra-gated white-label custom-domain/SSL remains.
    • Phase 8 — Web Builder 2.0: AI copy-fill for full-site templates. Completes the "AI restaurant-type full-site templates" item: POST /api/tenant/[tenantId]/site-templates/copy generates tailored copy for a chosen template — tagline, hero headline + subheadline, an about paragraph, three "why dine with us" feature cards, and a CTA label — as a review-only draft the builder maps onto the template's blocks. Pure prompt-builder + parser (src/lib/website-templates/copy.ts, 5 tests — caps features at 3, unescapes entities, defaults the CTA); the prompt enforces the honesty rules (no fabricated awards/ratings/stats) and writes in the tenant's locale. Reuses the budget-gated AI rails (resolveAIKey + getBudgetStatus + recordAIUsage) — cost-capped by the monthly budget. tsc 0, eslint 0, suite 170, build ✓.
    • Phase 8 — Web Builder 2.0: restaurant-type full-site templates (deterministic core). src/lib/website-templates/templates.ts — 5 curated, ordered block arrangements (Classic Restaurant · Pizzeria · Café/Coffee Shop · Fine Dining · Fast Casual/QSR), each a type-safe `WebsiteBlockType[]` (the compiler rejects unknown blocks), plus recommendTemplate(businessType) that picks the best fit (specific types beat the generic "restaurant" catch-all). Pure + server-safe (no React coupling), 4 unit tests; surfaced via GET /api/tenant/[tenantId]/site-templates. The builder expands a chosen template into real blocks using the SectionRegistry defaults and applies it — a great starting site in one click. (An AI copy-fill pass over a template can sit on top later, budget-capped.) tsc 0, eslint 0, suite 165, build ✓.
    • Phase 8 — Web Builder 2.0: three more restaurant one-click blocks (WhatsApp Order · Loyalty · Catering). Following Gift Card, three more theme-styled builder blocks in the 'restaurant' palette: WhatsApp Order (one-tap wa.me button built from the configured number + prefill text), Loyalty Club (join-and-earn CTA → the loyalty flow), and Catering & Events (inquiry CTA). Each is presentational and renders identically in the editor preview and on the live site (REGISTRY_RENDERED_BLOCKS); new 'whatsapp-order' / 'loyalty-cta' / 'catering' WebsiteBlockTypes. Four restaurant blocks now ship. tsc 0, eslint 0, suite 161, build ✓.
    • Phase 8 — Web Builder 2.0: AI SEO landing-page generator. POST /api/tenant/[tenantId]/seo-landing/generate generates SEO landing-page copy for a topic/keyword (optionally a location/cuisine) — title, meta description, H1, intro, sections, FAQs, CTA — as a review-only draft (never auto-published). The prompt-builder + JSON parser are pure + unit-tested (src/lib/seo-gen/landing.ts, 5 tests); the prompt enforces the honesty rules (no fabricated stats / awards / ratings) and writes in the tenant's locale. Reuses the budget-gated AI rails (resolveAIKey + getBudgetStatus + recordAIUsage), so it's cost-capped by the monthly AI budget. tsc 0, eslint 0, suite 161, build ✓.
    • Phase 8 — Web Builder 2.0: first restaurant-specific one-click block (Gift Card). A new gift-card builder block (GiftCardSection) — a polished, theme-styled "give the gift of a great meal" section whose CTA auto-links to the live gift-card flow (/gift-cards by default). Registered in SectionRegistry under the 'restaurant' palette and in REGISTRY_RENDERED_BLOCKS (so it renders identically in the editor preview and on the live site); new 'gift-card' WebsiteBlockType. The first of the planned restaurant blocks (daily lunch, buffet, events, chef, loyalty, catering, branch locator, …). tsc 0, eslint 0, suite 156, build ✓.
    • Phase 8 — Web Builder 2.0: AI Website Auditor. A deterministic website completeness score for a tenant's own Restora site (src/lib/website-audit/audit.ts): 13 weighted checks across essential / conversion / trust / SEO (hero, menu + items, opening hours, phone, address, online ordering, reservations, gallery, reviews, logo, SEO title + description) → a 0–100 score, a grade, the full checklist, and a worst-first "what's missing" list with one-click-fix hints. Pure + unit-tested (5 tests); surfaced via GET /api/tenant/[tenantId]/website-audit, which maps the builder blocks + settings onto the auditor. Extends the spirit of the external SEO health-scorer to the owner's own site; reuses the 22+ existing builder blocks. No AI in the scoring (the AI auto-fix/copy layer sits on top, budget-capped, later). 5 new unit tests (156 total). tsc 0, eslint 0, build ✓. Remaining Phase 8: restaurant-specific one-click blocks · AI full-site templates · AI SEO landing-page generator · the deferred white-label custom-domain/SSL.
    • Phase 15 — Demo Platform: DA-1 public demo creation (the "try in 60s" core). POST /api/public/demo provisions an isolated demo tenant with no signup and returns a one-time access key. Heavily abuse-gated: anonymous, per-IP daily rate limit (requireRateLimit) + a platform-wide live-demo cap, no AI / no cost. Reuses the proven provisioning pattern (createTenantDatabase + a TenantRecord flagged isDemo with demoPassword + demoExpiry 72h TTL) and the existing demo-access cookie path for entry; all public demos hang off one inert shared owner (unusable password, never logs in) so the DA-0 cleanup cron stays simple. Deterministic. tsc 0, eslint 0, suite 151, build ✓. Next: the DA-1 guided first-run UX (role pick/switch/explore-all + WOW #1 menu-PDF→site).
    • Phase 15 — Demo Platform: DA-0 demo lifecycle foundation. PLG demos are isolated tenants flagged isDemo with a demoExpiresAt TTL (markers that already exist on TenantRecord). New src/lib/demo/demo.ts carries the pure lifecycle — demoExpiry (72h TTL), isDemoExpired, selectExpiredDemos (the cleanup selector), and canCreateDemo (per-key daily quota gate) — and a CRON_SECRET-gated cleanup cron (/api/cron/demo-cleanup, daily 02:00, ≤100/run) reclaims expired demos (deleteTenantDB + registry removal). Real tenants are never touched (the selector requires isDemo). Deterministic, no AI. 4 new unit tests (151 total). tsc 0, eslint 0, build ✓. Next: DA-1 (public abuse-gated demo creation → <60s provisioning → guided first-run).
    • Phase 14 — External Menu Sync: EM-5 freshness monitoring + Phase 14 CLOSED at 100% (buildable scope). summarizeSources (src/lib/external-menu/sources.ts) rolls the queue up for monitoring — counts by freshness tier (fresh / aging / stale / never-synced) and by status, plus a worst-first stale worklist (sources not synced in > 30 days) — surfaced on GET /api/admin/external-menu. With EM-0 (ingestion model + permission/approval queue + legal gate), EM-1 (Schema.org/JSON-LD extractor), EM-2 (AI PDF/image extraction), EM-3 (permissioned robots-respecting crawl + change detection), and now EM-5, Phase 14 is closed at 100% on its buildable scope. The remaining EM-4 (enterprise chain connectors + GBP-via-API + multi-location sync) is integration-gated — it needs external API access (Google My Business OAuth, chain partner APIs/credentials), so it graduates when those integrations/partnerships are available rather than being pre-built. Deterministic throughout (only EM-2's extraction uses the budget-capped AI engine). 1 new unit test (147 total). tsc 0, eslint 0, build ✓.
    • Phase 14 — External Menu Sync: EM-3 permissioned crawl + change detection. A CRON-gated sync (/api/cron/external-menu-sync, daily 03:00, ≤25/run, stalest-first) re-fetches each owner-granted schema.org source — robots-respecting (src/lib/external-menu/robots.ts isPathAllowed, a dependency-free robots.txt parser) — re-extracts its menu, and detects a real change via an order-independent content hash (src/lib/external-menu/change.ts — reordering isn't a change; a price/item edit is). On a change it refreshes the stored items and drops a published/approved source back to pending-review (re-approval, per the EM-0 state machine); the first crawl just sets the baseline hash. New contentHash field on the source. Deterministic, no AI. 11 new unit tests (146 total). tsc 0, eslint 0, build ✓.
    • Phase 14 — External Menu Sync: EM-2 AI PDF/image extraction. POST /api/admin/external-menu/extract-ai runs the shipped menu-import engine (runMenuExtraction, text-only/cheap, detectPhotos: false) over uploaded menu photos / PDF pages and lands the result as a pending-review / pending-permission source carrying the extracted items — for the non-Restora restaurants whose menus aren't in structured data. Cost-capped by the global WoW daily AI budget + kill-switch (isWowGlobalBudgetExceeded / recordWowUsage), and it never auto-publishes (EM-0's legal gate governs). Reuses filterImageDataUrls + resolveAIKey. Email-to-menu / WhatsApp / QR submission channels are a thin intake layer over this route (follow-up). tsc 0, eslint 0, suite 135, build ✓.
    • Phase 14 — External Menu Sync: EM-1 Schema.org/JSON-LD menu extractor. The free/legal/accurate first tier: src/lib/external-menu/schema-extract.ts (extractJsonLdMenu) parses a restaurant page's public schema.org markup — Restaurant → hasMenu → Menu → hasMenuSection → MenuSection → hasMenuItem → MenuItem — tolerant of @graph wrapping, nested sections, object-or-array shapes, string-or-number prices, and malformed blocks. Pure + 5 unit tests. Wired to POST /api/admin/external-menu/extract, which fetches the page via the now-exported SSRF-guarded, redirect-revalidated fetchHtmlGuarded and lands a pending-review / pending-permission source carrying the extracted items — it never auto-publishes (the EM-0 legal gate still governs). No AI on the structured-data path. GBP-via-API is deferred to the EM-4 connector framework (needs Google My Business API access). 5 new unit tests (135 total). tsc 0, eslint 0, build ✓.
    • Phase 14 — External Menu Sync: EM-0 foundation (ingestion model + approval queue). The non-Restora onboarding pipeline now has its backbone: an ExternalMenuSource model (provenance + freshness + permission + approval) on a dedicated store (app_documents 'external-menu-sources' — getExternalMenuDB/mutateExternalMenuDB), a super-admin permission/approval queue API (/api/admin/external-menu [+/[id]]), and a pure src/lib/external-menu/sources.ts carrying the legal publish gate (canPublish = owner-granted permission AND human-approved — enforced server-side on the publish transition, so nothing is ever surfaced without permission), an approval state machine (pending-review → approved → published / rejected, with re-review on change), and freshness tiers (fresh ≤7d / aging ≤30d / stale >30d). Deterministic, no AI. 4 new unit tests (130 total). tsc 0, eslint 0, build ✓. Remaining Phase 14: EM-1 (Schema.org/JSON-LD + GBP extraction), EM-2 (AI PDF/image submission → queue), EM-3 (permissioned crawl + change detection), EM-4 (chain connectors + multi-location), EM-5 (freshness monitoring).
    • Phase 12 — Food Bazar Recovery: CLOSED at 100% (currency-consistency validation + search observability). Final pieces: an isKnownCurrency registry guard (src/lib/currency.ts) + a per-search `currencies` / `currencyConsistent` signal on /api/bazar/menu (a geo-gated search should be single-currency; a mix is logged as a warn data-quality signal), and structured search observability — every search emits a logger.info('bazar.search') event (query, total, zeroResults, geoApplied, radiusKm, didYouMean, currencies) so zero-result rate, geo usage, and did-you-mean hits are queryable. Mobile is covered by the same filter + geolocation flow. With search relevance + the 30 km geo radius gate + did-you-mean + the "within N km" note + this, Phase 12 is complete — all deterministic, no AI. 2 new unit tests (126 total). tsc 0, eslint 0, build ✓.
    • Phase 12 — Food Bazar Recovery: did-you-mean + "within N km" note. Zero-result searches now surface a did-you-mean correction — a deterministic Levenshtein suggester over a curated food/cuisine vocabulary (src/lib/bazar/suggest.ts) that turns typos into a clickable suggestion (pizzza→pizza, burgur→burger, itallian→italian), returned by /api/bazar/menu (didYouMean) only when a query yields nothing and rendered in the empty state. The marketplace results header now shows "📍 within N km" when the geo radius gate is active (the geo.applied/radiusKm flag), so a locally-constrained result set never reads as the whole world. No AI. 5 new unit tests (124 total). tsc 0, build ✓ (changes eslint-clean; the one pre-existing setState-in-effect finding remains). Remaining Phase 12: currency-consistency validation (largely covered by per-item currency + the local geo gate) + observability.
    • Phase 12 — Food Bazar Recovery: geo radius gate. When the shopper's location is known, marketplace search now applies a default 30 km radius (geoGate + DEFAULT_RADIUS_KM in src/lib/bazar/geo.ts): geocoded restaurants beyond the radius are gated out (no silent worldwide fallback), un-geocoded places fail open (never silently hidden — matching filterByRadius), and the haversine distance is fed into the relevance Quality Score (closer ranks higher) and drives a nearest-first sort when browsing without a query. /api/bazar/menu now accepts lat/lng/radius and returns a geo: { applied, radiusKm } flag; the bazar page plumbs the browser-geolocation coords from "Use my location" (and a manual city change clears the GPS gate, so searching another city isn't constrained to the old radius). No AI. 7 new unit tests (119 total). tsc 0, build ✓ (changes eslint-clean; one pre-existing setState-in-effect finding in the bazar page left untouched). Remaining Phase 12: currency-consistency validation + did-you-mean + a "within N km" UI note.
    • Phase 12 — Food Bazar Recovery: search relevance Quality Score. Marketplace search (/api/bazar/menu) was a naive haystack.includes(q) filter sorted only open-first. It now ranks by a deterministic relevance Quality Score (src/lib/bazar/relevance.ts): exact > prefix > whole-word > substring on the item name, then category/cuisine, then a curated synonym layer that widens recall (coffee→espresso, kebab→shawarma, pizza→margherita…), with featured popularity and proximity as small tie-breakers that can never outrank a textual match. Inclusion is gated on a real textual/synonym match (popularity/distance alone don't qualify), and results sort open-first then by score. No AI — pure + cheap. 9 unit tests (112 total). tsc 0, eslint 0, build ✓. Remaining Phase 12: the geo radius gate (default 30 km + distance ranking, no silent worldwide fallback), currency-consistency validation, and did-you-mean.
    • Phase 6 — Data Layer: 5d `supplier_orders` normalization (DB-verified). B2B supplier orders (in the singleton app_documents/'suppliers' doc) now mirror into an indexed supplier_orders table — opt-in via SUPPLIER_ORDERS_TABLE=1, reconciled inside the suppliers-write transaction (reconcileSuppliers, via the suppliers mutateDoc onPersist + writeDocWithReconcile), with querySupplierOrders for indexed per-supplier / per-tenant (buying restaurant) / by-status / recency reads, a db:backfill:supplier-orders script, and migration 006_supplier_orders.sql. Whole-table reconcile; additive + flag-reversible. Verified against a live Postgres: reconcile insert/update/delete correct, indexed reads correct. With 5a–5d done, all four hot entities (orders, stock movements, escrow holds/payouts, supplier orders) now have indexed mirrors. tsc 0, eslint 0, suite 103, build ✓.
    • Phase 6 — Data Layer: 5c escrow normalization (DB-verified). The platform escrow ledger (the singleton app_documents/'escrow' doc) now mirrors its holds + payouts into indexed escrow_holds / escrow_payouts tables — opt-in via ESCROW_TABLE=1, reconciled inside the escrow-write transaction (reconcileEscrow, wired through the escrow mutateDoc onPersist and a new generic writeDocWithReconcile blind-save path), with queryEscrowHolds / queryEscrowPayouts for indexed by-status / per-tenant / recency (payouts soonest-scheduled-first = settle-sweep order) reads, a db:backfill:escrow script, and migration 005_escrow.sql. Whole-table reconcile (one document owns all rows); additive + flag-reversible (off by default = the escrow path is unchanged; the document stays the source of truth). Verified against a live Postgres: holds+payouts reconcile insert/update/delete correct, indexed status/tenant/recency reads correct. tsc 0, eslint 0, suite 103, build ✓.
    • Phase 6 — Data Layer: 5b `stock_movements` normalization (DB-verified). The inventory stock ledger now mirrors from the tenant document into an indexed stock_movements table — opt-in via STOCK_MOVEMENTS_TABLE=1, reconciled inside the same row-locked tenant-write transaction as the orders mirror (the orders-specific path was generalized to reconcileTenantMirrors / writeTenantWithMirrors / anyTenantMirrorEnabled / deleteTenantMirrorRows), with queryStockMovements for indexed per-ingredient / per-type / recency reads, a db:backfill:stock script, and migration 004_stock_movements.sql. Additive + flag-reversible (off by default = the running app is unchanged; the document stays the source of truth). Verified against a live Postgres: reconcile insert/update/delete correct, indexed reads correct (recency uses idx_stock_movements_tenant_created). tsc 0, eslint 0, suite 103, build ✓.
    • Phase 9 — AI Theme Generation: closed at 100%. The flagship (screenshot / menu-PDF / logo → a complete editable Restora site) was already shipped via the Phase 25 engine, plus TG-4 (AI Design Report + score) and TG-5 (theme review/marketplace). The one thin spot — TG-3 "Watch it build" — is now real: a BuildReveal overlay (src/app/wow/[token]/BuildReveal.tsx) that narrates the actual build pipeline (reading your design/menu → palette & type → composing sections → polishing for mobile & SEO) then fades to reveal the live preview underneath. It's client-only and touches no preview/claim data, plays once per token (sessionStorage), is skippable, and is suppressed for prefers-reduced-motion. tsc 0, eslint 0, build ✓.
    • Phase 31 — AI Citation Hub (Marketing & Growth): closed at 100%. Shipped the last open piece — a Super-Admin Landing Pages CMS: create / edit / publish standalone marketing & campaign pages at /p/[slug] with a hero (badge / headline / subheadline), optional markdown body, FAQ accordion, a launch-list signup or button CTA, and the optional free site-health box — plus SEO metadata + WebSite/FAQPage JSON-LD and sitemap inclusion. Backed by MasterDB.landingPages + a requireSuperAdmin CRUD API (/api/admin/landing-pages [+/[id]], slug-unique, draft/publish toggle); the public renderer reuses MarkdownRenderer / FAQAccordion / NewsletterSignup and the self-contained coming-soon chrome, and records cookieless visitor analytics. New "Landing Pages" entry under Marketing & Growth. Programmatic city/location pages were deliberately split to a quality-gated SEO follow-up (to be built with real local content via the AI-draft+review flow, never thin doorway pages). tsc 0, eslint 0, suite 103, build ✓.
    • Phase 10 — Barcode & QR Ecosystem: closed at 100%. Shipped the remaining barcode/QR surfaces: equipment QR (/dashboard/equipment — a per-tenant Equipment registry + per-item QR that deep-links to a manual/warranty/service scan landing; TenantDB.equipment + CRUD API, inventory-gated, linked from Receiving), scan → AI reorder (a "Smart reorder" entry point from Receiving → the WA-4 supplier-refill engine — AI-ranked recommendations for low-stock items), and a hardware buying guide (scanners / label printers / QR stands). With the already-shipped scan-to-receive/waste/count, shelf/location QR, product + shelf labels, universal RESTORA-PROD- IDs, supplier shipment QR, and customer QR, the barcode/QR ecosystem is complete. Native/hardware items were split out honestly: central-warehouse WMS → Restora Webshop (WS-2), dedicated Bluetooth/Zebra/Honeywell scanners → Phase 32 (Native Apps & Hardware), full batch-recall consumption ledger → INV-1. tsc 0, eslint 0, suite 103, build ✓.

    Changed

    • Phase 6 — Data Layer (Postgres): closed at 100%. The JSON→Postgres migration's done-definition is met and verified — the JSONB document shim + row-lock concurrency (the lost-update fix), serverless/pooler hardening (load-tested ~3,900 ops/s, 0 lost), and all four hot entities normalized into indexed mirror tables (orders, stock movements, escrow holds/payouts, supplier orders), each DB-verified against a live Postgres — plus the GIN deferral resolved. The remaining doc-shrink cutover (stop double-storing the hot arrays in the document; hydrate from the tables) is a separate scale optimization that changes the source-of-truth on money/stock/escrow data — deliberately gated on measured doc-size need (the mirrors already deliver the indexed-read win), not part of the phase's done-definition.
    • Phase 6 — Data Layer: GIN-index deferral resolved (not needed). ADR-003 had deferred JSONB GIN indexes until hot entities were normalized. With 5a–5d done, the hot filtered reads use the mirror tables' btree-indexed columns, the remaining whole-doc tables are read by primary key, and a codebase scan finds no `doc->` / `@>` / jsonb-containment queries — so a GIN index on the doc columns would be unused write-amplification. None added; ADR-003 updated to record that entity normalization superseded the GIN need. The only remaining Phase 6 item is the (risky, deliberately-gated) doc-shrink cutover.
    • Phase 29 — Restora Catalog (Global Food & Ingredient Intelligence): closed at 100% (buildable scope). P0–P7+ are all shipped — the cross-tenant FoodCatalogDB canonical store with per-field provenance + hash-chained audit + deterministic dedup; the super-admin Control Center (review queues + two-key governed publish); tenant "select, don't type" linking with allergen roll-up + nutrition aggregation; the cost-governed deterministic Food Intelligence Agent + capped Haiku-first LLM enrichment; public /ingredient SEO pages (92 published, Food/NutritionInformation JSON-LD); supplier claim/RFQ; the monetization add-ons + sponsored auction; and a Food Catalog API. The remaining P8–P10 (scale / expansion / public API at scale) stay trigger-gated — they graduate on measured volume/breadth per the right-sizing/cost law rather than being pre-built — so the phase is closed at 100% on its buildable scope. No feature code changed.
    • Phase 25 — AI Website Cloning Engine: closed at 100%. The full pipeline is shipped — a Design-DNA AI engine (design-dna.ts, isolated + null-fallback) → a deterministic dna-to-blocks.ts mapper (reframes SaaS/agency sections into restaurant ones using the existing 27 builder blocks) → the WoW preview/claim/provision spine → a live, fully-editable restaurant site in <60s. Both the screenshot and URL engines work (DOM read + screenshot fallback, SSRF-guarded), plus the richer blocks (split-hero, feature-cards, masonry/bento, transparent-nav), a WOW-mode toggle, readable-accent (the readable-colour quality bar), design-report, clone-imagery, and the clone-learning loop (clone-learning.ts + admin /api/admin/wow/clones — records clones → curated + auto-learned correction rules). The remaining 4% was "ongoing fidelity tuning," which is continuous quality work driven by that shipped self-learning loop — not a discrete deliverable — so the engine is closed at 100%. Legal posture: inspiration-not-clone (abstract DNA only; never copies text/images/logos/code). No feature code changed.
    • Phase 24 — ⭐ WOW Acquisition Engine: closed at 100% (software scope). The full acquisition funnel was already built end-to-end across WA-0…WA-4 (pre-auth AI menu/URL/screenshot → 60s live branded site → magic-link claim → 30-day trial; free site Health Check + before/after rebuild + multilingual menu; agent Share&Earn + resell sites + designer before/after + white-label A/B/C; two-sided B2B — snap-invoice catalog, Instant Lead Wall, find-cheaper-suppliers, photo-to-quote RFQ, price-book→tiers, propose-only reorders, live TV-board demo) and is owner-toggleable via the wowFunnel feature flag (admin Feature Flags page) or WOW_FUNNEL_ENABLED=true. The only remaining piece — custom domains + SSL + reseller billing for white-label reselling (WA-3 white-label C) — is infrastructure, reclassified to Phase 8 (Web Builder 2.0) for custom-domain/SSL + the billing system for reseller billing. Go-live is now a business decision (review + enable the flag), not a build. No feature code changed.
    • Phase 5 — Scale & Ecosystem: closed at 100%. The full scale/ecosystem software scope is shipped (KDS, live order tracking, profit-margin/food-cost reports, feature flags, staff scheduling, white-label + Group HQ multi-location, docs center, modular billing marketplace + Suites, AI Briefings, AI usage analytics, Receiving Goods + mobile scanner). The two items that remained were native/hardware — a React Native mobile app + POS receipt-printer/peripheral integration — which need a separate codebase + physical devices, so they were split into a new Phase 32 — Native Apps & Hardware track (trigger-gated; an interim installable PWA + browser/thermal receipt printing can land first without the native lift). Roadmap + dashboard updated — no feature code changed.

    Added

    • AI Citation Hub — all 22 articles now native in 18 languages (full bodies). Completed the internationalization: every knowledge entry's full content (title, quick answer, key takeaways, sections, FAQs, summary + per-type fields) is now native in all 18 languages. The 16 additional locales (da/no/fi/de/fr/it/nl/pl/hu/ro/uk/be/sr/lt/lv/et) were native-written by a 352-agent Claude workflow (same honesty rules — no fabricated stats), merged into seed-entries.ts i18n. A visitor on /learn/…?lang=de (or auto-detected by country) now reads the entire article in German, not just the chrome. (seed-entries.ts ~4.2 MB, server-only generated data; tsc 6s, build 23s, 0 errors.) Advances Phase 31 95 → 98%.
    • AI Citation Hub — visitor analytics in Super Admin + flag language switcher. A new Super-Admin → Marketing & Growth → Visitors dashboard shows where visitors come from (countries + cities from geo headers), top pages, time on site, sign-ups + conversion %, "left without joining", languages served, link/visit counts, and the domains tested in the health-check (with scores + last-30-day trend) — all from a cookieless, aggregate-only beacon (/api/public/analytics/track + <VisitorTracker> on coming-soon/learn/site-check; sign-up + health-check events) that stores no raw IPs, in its own dedicated doc (kept out of the hot master doc so beacons never contend with core platform writes; rate-limited). The language switcher is a custom dropdown with real flag images (flag-icons SVGs — render cross-platform incl. Windows, unlike flag emoji) + native + English name; the admin Visitors country/city flags use the same. Advances Phase 31 92 → 95%.
    • AI Citation Hub — full internationalization (18 languages + country auto-detect). The hub + coming-soon now support 18 languages (English, Swedish, Danish, Norwegian, Finnish, German, French, Italian, Dutch, Polish, Hungarian, Romanian, Ukrainian, Belarusian, Serbian, Lithuanian, Latvian, Estonian) with a language-switcher dropdown (cookie-persisted) and automatic country detection — a visitor in Sweden sees Swedish by default. Precedence: ?lang → cookie → geo header (x-vercel-ip-country/cf-ipcountry) → Accept-Language → English; a COUNTRY_TO_LOCALE map handles non-language countries (Switzerland → German, Ireland → English, etc.). The full UI chrome + the entire coming-soon page (hero, capabilities, showcase, 7 FAQs, metadata) are translated into all 18 (16 via a translation workflow; en/sv hand-authored), with reciprocal hreflang across all locales. resolveRequestLocale (server) + LanguageToggle (client dropdown). The newsletter signup form is localized in all 18 languages (with locale-appropriate example emails). Article bodies are fully translated for English + Swedish; other locales fall back per-field to English until translated. Advances Phase 31 90 → 92%.
    • AI Citation Hub — Swedish multi-language (P5). The hub + coming-soon now speak Swedish (sv-SE) via ?lang=sv, with an EN | SV toggle, reciprocal hreflang alternates, and locale-aware metadata + JSON-LD. All 22 knowledge entries carry native Swedish (i18n.sv) — generated by a 22-agent workflow (idiomatic Swedish restaurant-tech terminology — kassasystem, lagerhantering, matsvinn — same honesty rules), shipped in-code — plus a fully Swedish /coming-soon (hero, capabilities, showcase, 7 FAQs) and a localized signup. localizeEntry applies per-field fallback to English; the i18n field was extended to cover term/shortDefinition/howto/comparison. Advances Phase 31 80 → 90%.
    • AI Citation Hub — P5 (partial): 8 substantive industry pages. Per-vertical guides — Pizzerias, Cafés & Coffee Shops, Food Trucks, Fine Dining, Bars & Pubs, Bakeries, Fast Casual & QSR, Cloud & Ghost Kitchens — each genuinely vertical-specific (not a templated name-swap), generated by an 8-agent workflow with the same honesty rules and shipped in-code (seed-entries.ts now 22 entries). This is the quality way to do programmatic industry SEO — substantive pages, not thin doorway pages. Advances Phase 31 70 → 80%. (Thin city-page generation at scale + full multi-language remain deliberately deferred — build them via the AI-draft+review flow, not auto-spam.)
    • AI Citation Hub — P3 (discovery) + P6 (Marketing & Growth modules). Published entries (seed + admin, deduped, DB-wins) now emit per-entry URLs in `sitemap.xml` for direct crawl/AI discovery. Lead capture is live (the newsletter/launch-list + the /site-check tool), so the Waitlist and Lead Management needs are served by the launch list + the Newsletter admin (subscribers + CSV). Added a Referrals link (existing sales-agent attribution) to the Marketing & Growth group. Advances Phase 31 55 → 70%. (Deliberately deferred to protect quality: programmatic location/industry pages at scale + full multi-language (P5) — built later with substantive per-page content via the AI-draft+review flow so they aren't thin pages that would undermine the SEO/citation goal; plus a standalone Landing Pages CMS and deep per-entry view-analytics.)
    • AI Citation Hub — P4: capped AI-draft assistant. A super-admin "✨ AI draft" button in the Citation Hub editor generates a full entry draft (meta description, summary, quick answer, key takeaways, FAQs, body sections) from a title via Claude — same honesty rules (no fabricated stats), returned to the editor for human review before saving (never auto-published). Guarded by requireSuperAdmin, a configured AI key, and a dedicated daily spend cap (MasterDB.citationHubUsage + isCitationHubBudgetExceeded, $5/day default, cloning the WoW global-budget pattern). Reuses resolveAIKey + the Anthropic SDK. Advances Phase 31 45 → 55%.
    • AI Citation Hub — P2 (per-type Schema.org + content) + a free site health-check tool. (1) 14 seed knowledge entries (6 articles, 4 definitions, 2 how-tos, 2 comparisons — generated by a 14-agent workflow; honest, no fabricated stats; AI-assisted + reviewed) ship in-code (src/lib/citation-hub/seed-entries.ts, like the Docs Center) so /learn launches with attention-grabbing content, merged with DB-authored entries by the store. (2) Per-type Schema.org JSON-LD (citation-hub/jsonld.ts): DefinedTerm (definitions/glossary), HowTo (guides), Dataset (statistics), ItemList (comparisons), Report, Article — chosen by entry type; the entry page now renders per-type UI (definition card, stat hero, comparison table, numbered how-to steps) + a `/learn/glossary` DefinedTermSet index. (3) A free public site health-check at `/site-check` (+ /api/public/site-check) — paste a URL → instant 0–100 score + per-signal analysis + "what Restora fixes", reusing the SSRF-guarded multi-page fetchSiteHealth; ungated, rate-limited, score-only, with a launch-list CTA. Linked from the coming-soon page + sitemap.xml + llms.txt; /learn forced dynamic to reflect live admin entries. Advances Phase 31 30 → 45%.
    • AI Citation Hub — public `/learn` knowledge pages (P1). The citable surface: a `/learn` index (categories + published entries; WebSite + CollectionPage JSON-LD) and `/learn/[category]/[slug]` entry pages that render the six AI-citation blocks (Quick Answer / Key Takeaways / Statistics / body / Summary / FAQs) + a "Data & sources" provenance block, emitting Article + BreadcrumbList + FAQPage JSON-LD via <JsonLd>. Reads published KnowledgeEntry from MasterDB (drafts never surface); reuses MarkdownRenderer + FAQAccordion; a self-contained shell (brand header + launch-list cross-link, no full-site nav). /learn added to sitemap.xml + llms.txt; source links hardened to http(s)-only. End-to-end author → publish → crawlable + AI-citable now works. Advances Phase 31 20 → 30%.
    • AI Citation Hub (Marketing & Growth) — public "coming soon" landing + newsletter capture. A server-rendered, SEO-optimised `/coming-soon` page (metadata + canonical/OG + WebSite + Organization + FAQPage JSON-LD so Google + AI engines index it) — a self-contained page (no site nav/login/footer links until launch): hero, value pillars, a 7-question FAQ (collapsible accordion, reusing the docs `FAQAccordion`), and a single launch-list signup. Public `POST /api/public/newsletter/subscribe` (per-IP rate-limited + disposable-email guarded + idempotent) stores to a new platform `MasterDB.newsletterSubscribers[]`; a Super-Admin Marketing & Growth → Newsletter page lists subscribers + CSV export. Registered in `sitemap.xml` + `llms.txt` for crawler/AI discovery. Reuses the landing Header/Footer, the SEO JSON-LD builders, isDisposableEmail, and MasterDB persistence — pulls Phase 31's P3 (lead capture) + P6 (landing) forward. Advances Phase 31 12 → 20%.
    • AI Citation Hub (Marketing & Growth) — foundation + admin backbone. A new integrated module (NOT standalone; designed reuse-first in docs/products/ai-citation-hub/ via a map+design workflow) to make Restora an AI-citable authority on restaurant technology. Shipped P0 + the admin backbone: a unified `KnowledgeEntry` model (src/lib/citation-hub/ — a type discriminant over article/definition/statistic/report/comparison/alternative/howto/resource/glossary/location/industry; the six AI-citation blocks (Quick Answer / Definition / Key Takeaways / Statistics / FAQs / Summary); source provenance; a review status machine; i18n) stored at MasterDB.knowledgeEntries[]; 23 seed knowledge categories; a Super-Admin Marketing & Growth sidebar group (linking the existing SEO Center + Analytics); and a super-admin Citation Hub authoring page + CRUD API (/api/admin/citation-hub [+ /[id]], requireSuperAdmin, Zod, slug-gen, draft → needs-review → published gate). Reuses the Docs-Center content model + the gated admin shell + MasterDB persistence + the SEO/JSON-LD builders. Next: public /learn pages + per-type Schema.org JSON-LD + sitemap/llms.txt (P1), then definitions/statistics/reports/comparisons/glossary, programmatic-SEO, a capped AI-draft assistant, multi-language, and citation tracking.
    • P10 Barcode & QR 2.0 — universal Restora product-ID scheme (completes BQ-0). Each ingredient can carry a stable, platform-wide RESTORA-PROD-… id (src/lib/inventory/product-id.ts — newRestoraProductId/isRestoraProductId, ambiguous I/L/O/U excluded; +2 tests). resolveByCode now matches it (scan a Restora-ID label → resolves, ahead of sku/id); IngredientItem.restoraProductId added; a one-tap idempotent backfill (POST .../inventory/ingredients/restora-ids, collision-guarded) assigns IDs to items missing one; and the label sheet prefers the universal ID. Reviewed by a 2-agent adversarial pass (3 findings fixed: the validator regex is now derived from the alphabet so it can't drift, dedup-on-generate in the backfill, and real feedback on the "Assign Restora IDs" button). Moves P10 70 → 72%.
    • P10 Barcode & QR 2.0 — printable product + shelf labels (BQ-0). A /dashboard/labels page with Products / Shelves modes: pick items → a print-ready grid of labels, each a title + a scannable QR. Products encode the item's resolvable code (barcode → SKU → ID — what resolveByCode reads, so scan-to-receive/waste/count recognise it); shelves encode the /dashboard/locations?loc=<id> deep-link (scanning opens that location's items). QR is rendered client-side (qrcode.toDataURL, the same lib as PaymentQR) so a full sheet makes zero server requests; @media print hides the controls. Product labels also offer a QR / Barcode toggle — a real Code128 (or EAN-13 when the code is a valid 13-digit) linear barcode via jsbarcode, so handheld/wedge scanners read them natively. Linked from the Receiving header. (Shipment labels are the supplier-side "mark shipped" shipment QR — not duplicated.) Moves P10 55 → 70%.
    • P10 Barcode & QR 2.0 — shelf/location QR. Name storage locations (shelf/bin/fridge) at /dashboard/locations, assign ingredients to each, and print a per-location QR (GET .../inventory/locations/[id]/qr — server-rendered SVG via the qrcode lib) that deep-links to /dashboard/locations?loc=<id> — scanning it opens that location's items. New StorageLocation (tenant-scoped) + IngredientItem.locationId + locations CRUD + an assign-items route (all gated on inventory-management) + the manager page; linked from the Receiving header. Moves P10 45 → 55%.
    • P10 Barcode & QR 2.0 — count-via-scan. A /dashboard/count page: scan/type a barcode → resolve via /inventory/lookup → enter the counted level → stock is set to it (a correction through the single applyStockMovement seam; a count of 0 routes via a full manual-remove, since the seam rejects a 0-quantity correction), with the system→counted variance shown live. New POST /api/tenant/[id]/inventory/count (gated on inventory-management) + linked from the Receiving header. Sibling to scan-to-waste; reuses the scanner + resolver + stock seam. Moves P10 40 → 45%.
    • P10 Barcode & QR 2.0 — scan-to-waste. A /dashboard/waste page lets staff scan or type a barcode (keyboard-wedge or camera — reuses the receiving BarcodeScanner) → resolve via /inventory/lookup → pick a reason (spoilage / expiry / prep-loss / overproduction / breakage / returned-dish / staff-meal) + quantity → log a waste StockMovement through the single applyStockMovement seam. New POST /api/tenant/[id]/inventory/waste (gated on inventory-management) + StockMovement.wasteReason. Reuses the shipped scanner + resolver + stock seam; linked from the Receiving header. Moves P10 30 → 40%.
    • WoW WA-4 — propose-only standing reorders (shelf-scan reorder, the safe model). Completes WA-4 (8/8). A restaurant sets up a standing reorder (supplier + items + cadence + a hard monthly cap); on schedule POST .../reorder-subscriptions/run DRAFTS a reorder and notifies — it never auto-places an order. The owner reviews the draft on the supplier-marketplace and confirms (one tap → a quote request via the existing engine; the supplier prices it, no money moves) or dismisses; over-cap drafts are blocked. New ReorderSubscription (tenant-scoped) + src/lib/supplier/reorder.ts (isDue/capRemaining/estimateCost/advanceNextDraft; +5 tests) + CRUD/run/confirm routes + a ReorderSubscriptionsPanel. Hardened after an adversarial multi-agent review (8 findings fixed): an atomic single-use confirm (claim→I/O→finalize, no double-quote/double-spend race), a live cap re-check at confirm, partial-estimate flagging (unmatched items no longer read as a free under-cap order), a suspended-supplier guard, and contactPhone pass-through. tsc 0, eslint 0, suite 100, build ✓.
    • WoW WA-4 — live Customer-TV board public demo. A no-auth acquisition showcase at /wow/tv-demo renders the shipped Live Orders Display (P10 CustomerDisplay) driven by a client-side simulated kitchen — fake order numbers cycling Accepted → Preparing → Ready (numbers only, no PII) — so a prospect sees the live pickup board come alive, with a "Build your site free" CTA into the funnel. Reuses the existing component (no backend). Remaining WA-4: shelf-scan reorder subscription (last item).
    • WoW WA-4 — PDF price-book → tiered storefront. The snap-a-price-list flow (/supplier/snap) now infers volume price-tiers from the extraction: inferPriceTiers parses variant labels like "10–49", "50+", "case of 24" into SupplierProduct.priceTiers (cheapest-per-quantity, ≤6, real-bulk-break only — size-only variants like Liten/Stor yield none), so a supplier uploading a price-book gets tiered pricing on their catalog/storefront in one scan. The review table shows a "+N bulk tiers" badge and the save carries priceTiers through the existing product-create path. +3 tests (suite 95). Remaining WA-4: shelf-scan reorder, live TV board.
    • WoW WA-4 — photo-to-quote RFQ. A restaurant snaps a photo of a shopping list → POST /api/tenant/[id]/photo-to-quote reads the line-items (vision extractor) + the best-matching suppliers (matchSuppliers, score normalised 0–100 vs the top match) → a "Photo to quote" panel on the supplier-marketplace lets them review items, pick suppliers, and fire a quote request to each via the existing POST /api/tenant/[id]/quotes (suppliers bid; compare at /dashboard/supplier-quotes). Hardened after an adversarial multi-agent review (12 confirmed findings fixed): a 20-item cap at both the route and panel (the quotes endpoint enforces items.max(20)), all-fail-silently guards on the photo-to-quote + snap-invoice save loops, name.min(2) alignment in the invoice mapper, a dropped cross-currency savings total, and http(s)-only validation on the supplier-profile logo/website URLs (public-storefront href). Remaining WA-4: PDF price-book→tiers, shelf-scan reorder, live TV board.
    • WoW WA-4 — find-cheaper-suppliers + 30-sec storefront. Two more WA-4 pieces. (1) Find cheaper suppliers for this menu (premium): for every ingredient the restaurant already buys (has a purchasePrice), findCheaperOptions (in refill.ts, reusing its token-matching + tier pricing) surfaces the cheapest matching product from another verified supplier that beats the current price, with the % saving — shown as a "Cheaper suppliers" table on the supplier-marketplace page (GET /api/tenant/[id]/cheaper-suppliers, gated on supplier-recommendations; +3 tests). (2) 30-sec storefront — suppliers get a /supplier/storefront setup page (new GET/PATCH /api/supplier/[id]/profile) to set logo + location + bio and publish their public /suppliers/[slug] storefront in one go. Remaining WA-4: photo-to-quote RFQ, PDF price-book tiers, shelf-scan reorder, live TV board.
    • WoW WA-4 (two-sided B2B) — snap-invoice → live supplier catalog. The supplier mirror of "snap your menu": a supplier uploads a photo/PDF of their price list or invoice at /supplier/snap → the vision extractor reads it → each line maps to a draft supplier product (name, price, unit, auto-inferred category + currency) → the supplier reviews/edits in a table and adds them to the catalog in one go, no typing. New src/lib/supplier/invoice-extract.ts (mapExtractedToProducts + inferCategory; +3 tests) + supplier-authed POST /api/supplier/[id]/products/from-invoice (reuses runMenuExtraction, per-supplier rate-limited) + the /supplier/snap review UI (saves via the existing product-create path) + a "Scan price list" sidebar entry. Starts WA-4 — the Instant Lead Wall (matchRestaurants → /supplier/leads) was already wired. Remaining: photo-to-quote RFQ, PDF price-book tiers, find-cheaper-suppliers, 30-sec storefront, shelf-scan reorder, live TV board.
    • WoW WA-3 (channel flywheel) — agent "Share & Earn" toolkit. A new /agent/share page turns the existing referral-attribution backend into a self-serve growth kit: the agent's share link (/?ref=CODE) with a QR + copy, plus lead-magnet links that carry their ref — the Free Website Health Check (/wow/health-check?ref=CODE) and the snap-your-menu demo (/wow?ref=CODE) — so a genuinely useful free tool doubles as attributed cold outreach. (RefCapture is mounted globally, so any ?ref= sets the 90-day cookie → signup credits the agent, reusing the existing self-referral guard + first-paid-invoice attribution lock.) Plus a deterministic earnings simulator (simulateAgentEarnings — restaurants × avg plan value × the agent's rate → monthly/annual; no AI). Adds commissionRate to the agent context, a "Grow → Share & Earn" sidebar entry, and +3 tests. Reuses ReferralCode / validateReferralCode + the WA-2 Health Check. Starts WA-3. Also adds a live view counter on shared previews — a per-session view beacon (POST /api/public/wow/preview/[token]/view, rate-limited) bumps WowPreview.views and shows a social-proof "👀 N views" badge, so a shared demo link feels alive. Plus a one-click resell site — a "Generate demo site" button spins up a polished sample-restaurant preview (reusing the /api/public/wow/demo generator) with the agent's ?ref baked in, so they can show a prospect what Restora builds before any menu is handed over. Plus designer before/after — a /designer/pitch page in the web-designer portal where a designer pastes a prospect's URL → the WA-2 rebuild produces a side-by-side before/after + a shareable link to send the client (owner-permissioned, inspiration-not-clone; reuses /api/public/wow/from-url). White-label (A) — designer-branded previews: a preview produced via the designer pitch is co-branded "Prepared for you by [Studio]" (validated designerId stored on WowPreview; the /wow/[token] page renders the designer's name + logo), so the designer looks like the creator — no disintermediation. White-label (B) — designer resell storefront: the public /designers/[slug] profile gains a branded "See your restaurant rebuilt — free, by [Designer]" CTA that funnels prospects into the WoW funnel carrying ?d=<designerId>; the Health Check threads it into the rebuild → a designer-branded preview (A). White-label (C, software slice) — brand-forward previews: an admin-controlled WebDesigner.whiteLabel flag flips a designer's attributed previews from co-branded ("Prepared by …") to brand-forward (their logo + name lead, "powered by Restora" minimised). The full custom-domain + wildcard-SSL + reseller-billing layer of C remains a separate infra phase (deferred). WA-3 channel flywheel complete (software scope).
    • WoW WA-2 — Multilingual menu (pre-auth, cached, budget-capped). The preview menu gains a language switcher (English · Svenska · العربية · Español · Deutsch · Français · Original). Picking a language calls a public POST /api/public/wow/preview/[token]/translate that Haiku-translates the menu strings (category + item names, descriptions, ingredients, notes) into an {original → translated} map the client applies (prices/brand untouched); cached per language on the preview (WowPreview.translations) so each language is translated once. Budget-capped via the global WoW daily USD ceiling + recordWowUsage, with a deterministic fallback — no AI key / budget spent / failure → empty map → the menu simply stays in its original language (never broken). Client-safe split: translate-langs.ts (language list + guard, dependency-free, imported by the client switcher) vs translate-menu.ts (server-only, lazy-imports the Anthropic SDK). +2 tests. This completes WA-2 (conversion).
    • WoW WA-2 — Before/After site rebuild + order-link carry-over. From the Free Health Check results, "See your site rebuilt — free" screenshots the owner's current site (the before) and runs the existing URL→Design-DNA clone pipeline to rebuild it as a branded, mobile, orderable Restora preview (the after), shown side-by-side in browser frames with an "Open full preview →". Crucially it carries the owner's existing online-ordering link — the Foodora/UberEats/Wolt/… link the Health Check detected — onto the rebuilt site, so they don't lose ordering when they switch. Additive: WowPreview.externalOrderUrl; the clone route (/api/public/wow/from-url) now returns beforeImage + an afterPreview thumbnail and accepts an orderUrl; the Health Check captures the third-party order URL (ordering.meta.orderUrl); and /wow/[token] renders an "Order online" button when one was carried over. Reuses screenshotUrl + the design-DNA clone — no new AI path. The rebuilt preview now also renders the imported menu (a URL-clone does import one — it was previously hidden behind the "cloned design" card, so "Open full preview" looked empty), and the restaurant name is taken from the page `<title>` / `og:site_name` (deriveSiteName, generic-word-aware) rather than the less-reliable AI-vision brand name. Advances WA-2 (before/after ✓); remaining there: the multilingual menu.
    • WoW WA-2 — Free Restaurant Website Health Check (pre-auth lead magnet). A new front door for owners who already HAVE a website: paste your URL at /wow/health-check → an instant, deterministic 0–100 site-wide health score + prioritised fixes — it crawls the homepage + a few same-origin sub-pages (not external links) and takes each check's best result site-wide, so a menu on /menu or contact on /contact counts, then finishes with a 2–3 line "why join Restora" summary → a "Build my better site — free" CTA into the snap-your-menu funnel. Checks: HTTPS, page title, meta description, true mobile-responsiveness (fetches the linked stylesheets and requires real @media breakpoints — a viewport tag alone isn't a pass), H1, Open Graph, Schema.org rich results, tap-to-call, a real searchable menu (flags PDF / image / Google-hosted menus), online ordering (warns + names the provider when orders route through a commission aggregator like Foodora/UberEats), an optional table-reservations check (never penalised), address/map, favicon and food photos — each failing check spells out what Restora ships instead. No AI (pure HTML/CSS parsing, no headless browser) and abuse-guarded: WoW feature-flag, per-IP/day + global rate limit, a strict SSRF guard re-checked on every redirect, sub-page and stylesheet, timeouts, size caps and HTML-only gates. New src/lib/wow/site-health.ts (scoreSiteHealth pure scorer + fetchSiteHealth crawler; 9 unit tests) + public POST /api/public/wow/site-check + /wow/health-check page + an entry link on /wow. Reuses validateExternalUrl + requireRateLimit. Advances WA-2; remaining: a full visual before/after redesign + multilingual menu.
    • P8 — Inventory intelligence (#6 / #4 / #7). All reuse-first on the existing scanner / supplier-matching / recipe stack. (#6) Scan-to-create ingredient — scan an unknown barcode/QR (handheld USB/Bluetooth keyboard-wedge, or the camera) and create the ingredient on the spot with a category guess; new from-scan API + a "Scan" button in Inventory. (#4) Smart Supplier Refill — new premium add-on (79 SEK/mo) that turns low stock into a ranked, costed refill plan: which supplier covers what you need, estimated cost, savings vs your current prices, delivery estimate and missing items (buildRefillRecommendations + gated API + a Smart Refill panel atop the marketplace). (#7) Recipe protection — a per-ingredient "hide from supplier matching" flag (keeps secret ingredients out of the anonymous supplier signal) + a runtime guard that blocks any supplier payload carrying recipe quantities/methods.
    • P7 — Offers & Campaigns (#14). Surfaced + completed the marketing stack (offers/email/loyalty already existed). (A) Storefront offers area — a new bold promo-banner block (image + discount + coupon code + CTA + "Ends …") spotlighting the featured active offer, the existing campaigns block now shows codes/end-dates in the tenant currency, and a public GET /api/public/[tenantId]/offers. (B) Scheduled & recurring campaigns now actually send — a CRON_SECRET-gated /api/cron/email-campaigns sweep sends due one-off + recurring campaigns and advances nextSendAt (they were stored but never fired). (C) Complete discounts at checkout — BOGO, free-item, and product/category-targeted coupons (was %/fixed only), with currency-correct amounts. (D) Plan-gating — coupon/loyalty/email-marketing write routes now enforce requireFeature (premium features were a billing leak); basic offers stay open. (E) Loyalty earn-on-checkout — points now earn automatically on a paid order (auto-enrolling the customer by email) + bonus-point campaigns (a multiplier window, e.g. double points). Deferred (UX only): page-level upgrade prompts on the gated dashboards.
    • Menu Import — AI image matching, validation, version history + undo, audit & staged progress. A 5-part upgrade to the menu-import flow (URL/photo/PDF import already shipped). (1) AI image matching — the import now finds a photo for every dish: an "Images" step matches each item against your media library → stock providers (Pexels/Unsplash/Pixabay) → category recommendation, with a deterministic 0–100 confidence, auto/recommend/suggest buckets, duplicate & wrong-category flags, bulk accept, and "choose your own" (gallery search/upload); accepted stock photos are imported (credited) to your library. (2) Validation layer — a non-blocking "Menu check" in the review step flags duplicate products/categories, missing/zero/suspicious prices, currency mismatch (vs your currency), thin descriptions, and missing photos/allergens. (3) Version history + Undo/Reset/Re-import — every import auto-snapshots the prior menu; a "History" panel undoes the last import or restores/deletes any earlier version (restores are themselves undoable); re-import gains a replace mode. (4) Audit — imports, re-imports and restores are recorded to the activity log (source + counts + user) and appear in the super-admin platform-activity view. (5) Staged progress — extraction shows a live stage checklist; importing shows real "saving photos X/N → building your menu" progress. New match-images + snapshots APIs; new image-match.ts, validate-menu.ts, menu/snapshots.ts libs. Reuses the existing extraction engine, stock search, media library, currency registry, activity log, and budget/auth guards — all deterministic, no extra AI cost. (Deferred: HTML/Schema.org DOM parsing + embedded provider APIs.)
    • Food Gallery reliability + Swedish search. Fixed broken gallery thumbnails: refreshed 18 dead demo image IDs and added a load-error safety net that auto-hides any image that fails to load (so a broken icon can never appear). Also fixed poor relevance for Swedish queries — stock libraries are English-indexed, so Swedish terms returned almost nothing; recognised Swedish food words are now auto-translated to English before searching (e.g. "Kyckling eller Kebab" → "chicken kebab"), working for both languages across all providers. (Live photos require a valid stock key in env — PIXABAY_API_KEY etc. — and a dev-server restart.)
    • Product editor — image picker + structured ingredients. The raw image-URL box is now a proper picker (your media library + food-photo search that auto-searches the product name + upload). The free-text ingredients line is now a structured list — one ingredient per line, autocomplete from a predefined catalog + your Inventory ingredients, with optional quantity & unit; new ingredients are added to your Inventory catalog and the list is saved as a recipe, so it automatically feeds the existing COGS / margin / inventory reports when quantities are filled. Existing free-text ingredients migrate into editable lines automatically. Saving ingredient lists/recipes is allowed on all plans (data entry); the paid Inventory analytics stay gated.
    • One home for menu appearance — Menu → Appearance & Layout. Menu controls were scattered (numbering in Settings → Menu; the whole layout/style was AI-only with no editor). New Appearance & Layout tab on /dashboard/menu consolidates everything: columns (single/double), price style (dotted-leader / right / inline), item dividers, category headers, item photos, a quick style preset, and numbering — with a live structural preview. The Settings → Menu tab was removed (numbering moved here). The settings PATCH now accepts menuLayout; the live /menu, WoW preview, and home menu-preview already read it, so changes apply immediately. Delivery/Google "sync menu" toggles stay with their integrations.
    • WoW — the website menu now matches the inspiration photo's PRESENTATION (not just colours). Uploading a menu photo already themed colours/fonts; now the AI also detects how the menu is laid out and the site renders it to match — the real "jaw-dropping" moment. A new menuLayout (style / columns / price style: dotted-leader vs right-aligned / divider / numbering / category header / image-board) is detected by the vision call, persisted on claim, and applied at the WoW preview, the live `/menu` (a text-list mode with leader/right prices + dividers + numbering + columns, gated so existing tenants keep their card layout), and the home menu-preview. showcase keeps image cards; the deterministic/no-AI path keeps today's standard layout. New shared src/lib/menu/menu-style.ts is the single source of truth. Colours/fonts are untouched.
    • Multi-page builder — theme + content now identical between the builder and the live site, + Contact page. Follow-ups after owner testing: (1) Theme/colour/background sync — the builder canvas was hardcoded white while the live pages are themed; the tenant theme is now scoped to the canvas (which also fixed a regression where the tenant theme leaked into and washed out the whole admin dashboard). (2) Content sync — the live Menu/Reservations/Blog pages now always render through the block renderer (default layout when the owner hasn't customised yet), so the live page matches the builder canvas exactly rather than only after the first save; Reservations' default keeps the wizard + hours + contact (as blocks). (3) Contact is now a real, block-editable page (/contact, default [heading, contact-info]), added to the builder page switcher and the default nav. (Account — the customer auth dashboard — is the one page still pending block-ification.)
    • Multi-page web builder — every page is now block-editable and stays in sync with the live site. The builder previously edited only the Home page; the live site was multi-page (Menu/Reservations/Blog auto-generated from data), so they diverged. Now: a page switcher (Home · Menu · Reservations · Blog) in the builder lets the owner edit every page as a block canvas. The data-driven content is exposed as dynamic blocks — menu-full (the live menu grid + cart), blog-list (published posts), reservation-form (the booking wizard) — so a page stays auto-synced with the owner's data (change a menu item → the Menu page updates) while remaining fully composable (add/reorder sections around it). Architecture: a shared BlockRenderer (_components/BlockRenderer.tsx) renders block arrays for all pages; settings.pageBlocks stores per-page layouts (Home stays on websiteBlocks); the builder state gained a pages model + SWITCH_PAGE; the builder API loads/saves per page with sensible default layouts. Backward-compatible: existing tenants keep their exact current pages until they edit a page in the builder (then it switches to block rendering).
    • WoW deep-QA hardening (batch 3) — builder parity, SEO settings, business-hours closures. (1) Builder→live parity: the Featured section vanished on the live site when no product was flagged "popular" (a fresh imported menu has none) — it now falls back to the first menu items so what the owner sees in the builder shows on "visit site". (2) SEO settings applied: the SEO tab's robotsPolicy (incl. noindex,nofollow for staging/private), social-share OG image, Twitter/X description, and canonical URL were saved but never emitted — home + menu generateMetadata now honour them. (3) Business hours / closures now reach the storefront: the home page shows a "Closed / Temporarily closed — <reason>" banner from the real status engine (special days + temporary closures, channel: 'website'); the order API now blocks ordering during a temporary closure (not just regular hours); and the Food Bazar Open now flag (list + detail) uses getOpenStatus instead of a naive hours-only check, so closed restaurants no longer show as open or rank open-first.
    • WoW deep-QA hardening (batch 2) — dark-theme contrast, mobile overflow, customer copy/currency. (1) Dark-theme contrast: the Newsletter "plain" style (forced white bg → invisible theme text), the home FAQ section's undefined bg-background-alt, and the menu out-of-stock chip now use theme tokens (bg-card/bg-background/bg-border/text-text-light). (2) Mobile (320px): the order-tracker action row no longer double-wraps (full-width cluster, labels hidden under sm, title truncates), the "match my brand" swatch row is now an even 5-col grid, and the contact phone number wraps instead of overflowing. (3) Customer copy/currency: checkout now shows the server's friendly message instead of raw codes (RESTAURANT_CLOSED/DEMO_MODE_ACTIVE), and the order tracker formats item prices, total, and refund with the tenant currency (formatMoney) instead of bare numbers.
    • WoW deep-QA hardening (batch 1) — security + funnel robustness + mobile/dark polish. From a multi-agent QA pass: (1) Security: the customer order-cancel endpoint had a dead ownership guard (read body.customerEmail, but the client sends body.email), so anyone with an order ID could cancel/refund another customer's order — a matching email is now mandatory (403 otherwise). (2) Magic-link lockout fixed: an email scanner/prefetch GET no longer permanently locks out the owner — verify is now idempotent (re-mints the same owner's session if the preview was already claimed by them). (3) Empty extraction guard: a non-menu photo used to create a blank dead-end preview — now returns a friendly 422 instead. (4) Rate-limit copy: 429s showed the raw RATE_LIMIT_EXCEEDED token — now a human message (token moved to code). (5) Mobile/dark: preview + storefront hero headlines no longer overflow at 320px, the printable QR pack no longer clips, FeaturedSection uses theme tokens (were near-invisible on dark), and the preview inherits a theme-aware text colour (fixes an invisible cart quantity).
    • WoW polish — trial banner, live-site menu numbering, builder theme sync. Three fixes from owner testing: (1) the dashboard now shows a "N days left in your free trial → add payment to keep your site live" banner for trialing tenants. (2) The website /menu page now honors the menu-numbering setting (settings.menuNumbering.mode = global / per-category) — MenuClient renders item numbers (respecting each item's showNumber); the setting was previously ignored on the public site. (3) The web-builder preview now applies the tenant's customColors (its API returns them + the canvas ThemeInjector uses them), so the builder matches the live site instead of showing only the base preset.
    • WoW claim — 30-day no-card free trial (was pay-up-front). Claiming a preview previously created the subscription as incomplete, so the magic link dumped the owner on /register/payment before they ever saw their dashboard — at odds with the "live site in 60 seconds" promise. WoW-claimed restaurants now get a 30-day free trial with no card: the claim sets the subscription to trialing (ends in 30 days), so they land straight in the full dashboard with a live site. The dashboard gate lets active trials through and only routes to payment once the trial lapses (claim.ts + dashboard/layout.tsx/dashboard/layout.tsx)). The general register flow (freeTrialEnabled) is unchanged. (Note: this is the business decision the WoW strategy deferred to the owner — no trial length was previously settled; the brief said 60d, the pricing doc recommended 14d, the shipped default was pay-now.)
    • WoW theme — the customer order tracker is now tenant-branded too. The live order tracker (/track/[tenantId]/[orderId]) lives outside the /tenant layout and was hardcoded to Restora's brand (#bf601d + gray/white). It now injects the tenant's theme (colours + font + radius) and its UI uses theme tokens (bg-background/bg-card/text-text/text-primary/text-secondary) — so a customer tracking their order sees the restaurant's brand, not Restora's. Semantic order-status colours (amber/blue/emerald/red) are kept. (The generic cross-tenant lookup at /track stays neutral — it has no single tenant.)
    • WoW theme — now applied to EVERY tenant page via the layout (no more default-theme leaks). A multi-agent audit found two use client pages with no ThemeInjector — `reservations/cancel` (the "cancel reservation" page that reverted to the red default) and `account` (post-login, incl. order history). Rather than patch each, the theme is now injected once in the tenant layout (it already loads settings for the radius), so colours + font + radius apply to every route — those two pages and any future one. The 7 correct per-page injectors are kept (they read the same settings.*, so no divergence). Fixes the reservation-cancel theme revert.
    • WoW hero — always a high-quality, cuisine-appropriate image (AI-chosen). Instead of a bare gradient when the owner's photo isn't hero-worthy, the storefront + preview always show a beautiful full-bleed hero. The AI judges the cuisine from the photo and the menu items and returns it; we map it to a verified, commercial-licensed Unsplash hero (hero-images.ts — pizza / burger / sushi / indian / thai / chinese / kebab / cafe / bakery / healthy / fine-dining + a restaurant-interior fallback; every URL curl-verified). A genuinely great upload (heroStyle: 'photo') is still used; otherwise the cuisine image — never a flat gradient.
    • WoW theme — full token cohesion + AI design tokens (radius personality). Completing the "designer-built" feel: ThemeInjector now derives the entire neutral family — card surface, borders, and muted text — from the theme background in the same hue, so cards, nav, hero panels and secondary text all sit in one cohesive palette (previously only background/primary/text were overridden, leaving the preset's clashing light borders/greys). And the AI stylist now also chooses a corner-radius personality (radius: sharp = modern/luxe, soft = balanced, round = casual/playful) applied across the storefront via a scoped override (settings.uiRadius) — so the feel, not just the colours, matches the restaurant. Contrast fix (UX): the storefront uses secondary as the on-primary text colour (every primary button/card is bg-primary text-secondary), so it's now a luminance-picked contrast colour (.on) instead of a darkened primary — fixing gold-on-gold buttons (Book Table / Checkout / Call Now / contact card). Token-alias fix: getStyleDeclaration now also emits --color-card-bg and --color-background aliases, so components reading those names (the FAQ accordion, hero) stop falling back to hardcoded white on dark themes. The FAQ accordion was also converted to the storefront's Tailwind theme tokens (bg-card/text-text/bg-background) so it's readable in any theme. Name sync: the re-sync now also updates the tenant's settings.name + record name to the current preview name, so the header logo matches the restaurant (fixing a stale name when the preview was renamed after the tenant was first created). The deterministic theme remains the fallback.
    • WoW live storefront — the generated theme now applies everywhere, with an AI-chosen hero. Testing showed the theme stopped at the home page and the live site had jarring defaults. Fixes: (1) the theme now applies on every storefront page — home, menu, blog, reservations, checkout, order-confirmation — via a new settings.customColors that each page's ThemeInjector reads (previously only the home page did, so subpages reverted to the red preset); (2) the hard-coded blue "Popular" badge now uses the brand colour (MenuPreviewSection); (3) the hero is AI-decided — heroStyle: 'photo' only for a genuinely hero-worthy image, otherwise an elegant on-brand gradient (HeroSection) instead of an awkward stretched upload. The dev "view live site" re-syncs the theme on every visit, so edits show immediately. Cohesion fix: the card/surface colour is now derived from the theme background (same hue, a step lighter/darker) instead of a hardcoded slate #1e293b — fixing the jarring two-tone where the nav, hero panels and cards clashed (slate-navy) with a dark-green/warm theme. The menu page also drops a redundant single-category pill and tightens its spacing.
    • WoW "match my brand" — AI theme stylist (capped) for a jaw-dropping first impression. On top of the deterministic engine, a best-effort AI vision pass (ai-theme.ts, claude-sonnet-4-6, 512px image + tiny JSON output) designs a studio-grade theme from the photo: a light or dark scheme matched to the venue's mood, a harmonious accessible palette, a font pairing from the curated set, and a vibe + hero tagline. It's applied across the whole preview — including real dark mode and typography — and carried into the claimed live site (colours + font + scheme; dark sites get a light text colour too). Cost-capped: it runs only within the funnel's existing global daily USD ceiling + per-IP rate limit, on a downscaled image with a ~700-token output; if the cap is hit, AI is disabled (WOW_AI_THEME=false), there's no API key, or the call fails, the deterministic theme is the guaranteed fallback — the funnel never breaks. The confirmation surfaces the chosen scheme, vibe and font. Net: the deterministic engine is the reliable floor; the AI pass is the "wow" ceiling.
    • WoW "match my brand" — full theme generation from a photo (the real WoW). Upload a photo and the entire preview re-themes to look designer-built — not just the buttons. A deterministic engine (brand-colors.ts, no AI) extracts a vibrant accent (the pop) plus the photo's overall tone (1×1 average) and builds a cohesive, accessible light theme: tinted page background, white cards, hue-tinted headings/body/borders, and the accent for CTAs/chips/links. It's applied across the whole preview via CSS variables — background, menu cards, hero, text, buttons, links all match the image (page.tsx + every preview component). Upload now shows a staged progress bar ("reading → extracting colours → designing your theme") and a confirmation card with the generated palette swatches (primary / background / surface / text / accent) so you see exactly what was created. The full theme carries into the claimed live site (customColors primary + secondary + tinted background), and a latent ThemeInjector bug that forced dark cards on any non-white background was fixed. Fixes the "colours barely change / doesn't match the image / no upload indication" feedback.
    • WoW funnel — smooth, forgiving back-and-forth (preview as many times as you want). Fixed a flow that dead-ended on "This preview has expired or was already claimed." after a dev peek. Root cause: the dev "view live site" shortcut consumed the preview (status → claimed), so returning to the tab to upload a brand photo or edit the health-check hit the status === 'preview' guard and failed. Now: (1) the dev peek is non-consuming — it provisions a throwaway tenant, records devTenantId, and leaves the preview editable; repeat clicks reuse it (claim.ts, dev route), and the link opens in a new tab. (2) A real (magic-link) claim now shows a celebratory "You've claimed this 🎉 → view your live site / log in" card instead of editable controls that 404 ([wow/[token]/page.tsx](src/app/wow/[token]/page.tsx)). (3) Edit routes return discriminated results (expired 404 vs claimed 409 with a clear message) instead of one conflated string (preview.ts WowEditResult). (4) `/login` now explains claim outcomes (email-exists / claimed / expired) and prefills the email, so reusing an email or a similar name is guided, not a silent dead-end (login/page.tsx + verify route passes &email=). (5) Hardening: a transient provisioning failure rolls the preview back to preview (no burned previews); prunePreviews/getWowConfig are NaN/0-safe (a bad TTL can never instantly expire a live preview); and a real claim trims the tombstone (drops menu + hero) to keep the hot master document small.
    • WoW acquisition funnel — "snap your menu → live restaurant" (pre-auth, flag-gated). The platform's flagship AI menu-import was buried behind a tenant login and the 199 kr paywall; this surfaces it as the public front door. On the landing hero (when the wowFunnel flag is on), a visitor drops a menu photo/PDF and gets a branded, shareable preview at /wow/[token] in ~60 s — no account. Claiming it is passwordless: one email → a 15-min magic link that provisions a real tenant, migrates the menu in, and signs them in ("keep what you built"); the tenant starts on Basic incomplete, so the existing paywall gate asks for payment to publish (value before payment). Built almost entirely by reuse: the Claude-vision extractor was lifted into a shared run-extraction.ts (the tenant ai-extract route now uses it too — identical behaviour), the preview is an ephemeral token-keyed record on the master DB (text-only menu, no base64 crops — wow/preview.ts + schema WowPreview), the menu→tenant migration mirrors the import conversion (import-to-tenant.ts), and provisioning reuses createTenantDatabase + the register pattern. Cost/abuse guards (no auth): the public extractor (extract route) is per-IP rate-limited + bounded by a configurable global daily USD ceiling (super-admin platformSettings.wow), runs text-only (Stage-2 photo detection skipped → ~half the cost), and is off by default (enable via the wowFunnel feature flag or WOW_FUNNEL_ENABLED=true). Expired previews are pruned daily (cron) + on each create; temp URLs are noindex. Implements WA-0 + all of WA-1 — the preview has an orderable demo cart ("this takes real orders → claim it"), a share button + scannable QR, Open Graph tags for shared "look what I built" links, a "✓ Google-ready" card with embedded Schema.org JSON-LD (Restaurant → Menu → MenuSection → MenuItem → Offer), and a printable QR pack (/wow/[token]/print — a table-tent sheet you print or save as PDF). Also: /wow is a standalone uploader entry, and the funnel is on by default in development (off in prod unless enabled) (strategy). Behind a flag on branch `feat/wow-acquisition`; tsc + production build clean. Follow-ups: before/after + Free Health Check lead-magnets; agent/designer channel; two-sided B2B (WA-2…4).
    • WoW demo content — "no menu handy? see a demo restaurant." Visitors without a menu to upload can one-click a ready-made sample restaurant — 10 dishes across Starters / Mains / Desserts / Drinks, each with a real, commercial-licensed Unsplash photo + ingredients + SEK price (demo-menu.ts; every image was curl-verified live and the IDs come from the in-app stock library — several library IDs had rotted, so they were checked). New `POST /api/public/wow/demo` seeds a preview (no AI, no cost); the landing + /wow widget shows a "No menu handy? See a demo restaurant →" button. The preview now renders item photos, and a claimed demo keeps the image URLs on the tenant's products.
    • WoW WA-2 — "match my brand" (storefront photo → theme). On a preview, upload a photo of your place, logo, or a signature dish and we derive — deterministically, NO AI (palette.ts) — both a brand accent colour and a small hero image from it (theme route). The photo becomes the preview hero and is carried into the claimed site's hero + banner; the accent persists and now actually applies on the live storefront. Remaining WA-2: Free Health Check on the owner's existing site + before/after, multilingual menu.
    • WoW claim fidelity — the live site now reflects the preview. Claiming a preview previously imported the menu into a generic template (default hero, empty "featured"). Now the claim sets the hero headline to the restaurant name + hero/banner image (uploaded brand photo, else first dish photo), the importer marks the first dishes `popular` so the home "featured" section showcases the menu, and the storefront's ThemeInjector now reads the tenant's customColors (it was hardcoded to nulls, so brand colours never applied to any tenant — fixed).
    • WoW WA-2 (conversion) — free health check + 60-second sandbox order. The preview now actively converts: a free, interactive site Health Check — a deterministic 0–100 completeness score whose details (name / opening hours / phone / address) can each be added, edited, or cleared inline, or filled with one-click "✨ Fill with demo info"; every change PATCHes the preview and re-scores instantly, and a wrong menu can be re-uploaded via "Start over" (wow/health.ts + PATCH preview route; complements the post-claim SEO health-scorer) — and a 60-second sandbox order: add dishes to the demo cart, tap Place test order, and get a mock confirmation ("this is exactly what your customers see — claim to take real orders"). Pre-auth, no payment, no account. Plus the setup wizard now auto-completes steps from real tenant data (`/api/dashboard/onboarding-status` — detects profile/categories/items/staff, only steps whose template default is empty so it never falsely marks "done") — a WoW-claimed restaurant lands with menu + profile already green, so onboarding feels mostly finished instead of a 65-minute checklist (benefits every tenant; merges with browser progress). Remaining WA-2: a Free Health Check on the owner's existing site + before/after redesign, multilingual menu, storefront-photo theme.
    • Business Hours — one page for all opening hours. New owner-portal page at /dashboard/business-hours (nav: between Menu & Customers, page/dashboard/business-hours/page.tsx)) — the single source of truth for hours. Edit regular weekly hours (bulk-apply: copy a day to all / weekdays / weekend), special dates (holiday/event overrides), and temporary closures (vacation/renovation date ranges with all / website / marketplace scope), with a live status preview. Saved via the existing settings PATCH API; every channel already reads the tenant doc live, so there is no sync engine — website, Food Bazar, reservations and search reflect changes instantly. (Deliberately did not build the spec'd event bus / sync workers / 4 SQL tables: nothing is duplicated to sync, and that fights the serverless no-event-bus design, ADR-003.) The shared status engine opening-hours.ts gained channel-scoped temporary-closure resolution → Temporarily closed + reopen label, and an Opens soon refinement; the marketplace restaurant profile + item page honour temporary closures in both the open/closed badge and the ordering gate. Data lives on TenantSettings (openingHours/specialDays already existed; temporaryClosures added). v1 is single-shift; split shifts, country-aware holidays, per-service hours, the remaining discovery-card "Open now" wiring, and the live Google Business API are deferred follow-ups.
    • TV pairing codes for the Live Orders Customer Display. Smart-TV browsers can't easily type the 64-hex display link, so a short, short-lived pairing code now bridges the gap. Owner/manager generates a code from the dashboard (Live Orders → Customer Display → TV code, TvPairingButton); on the TV you open `/tv` (page), type the code, and it hands off to the full display and remembers the connection (localStorage) so reboots auto-load. The code is a typeable pointer to the tenant's existing liveOrdersDisplayToken (which stays the real secret): generation (pairing-code route, owner-gated) mints a 6-char code from an unambiguous alphabet (no 0/O/1/I/L/U) into a small pruned global registry in the master DB (so /tv resolves code→tenant without scanning every tenant doc — schema `LiveOrdersPairing`); resolution (public pair route) is rate-limited (10/5min), codes expire in 15 min, and a bad/expired code returns 404 (no leak). Shared logic in tv-pairing.ts. The /tv page is hydration-safe (localStorage read only post-mount).

    Changed

    • Data layer: JSON files → PostgreSQL (Phase 1 — reversible JSONB document-shim). The whole-file JSON databases were last-writer-wins (no read-modify-write serialization) — a documented P0 with real corruption on disk (luigi-pizza.json.corrupt-*.bak). Phase 1 stands up PostgreSQL 16 (Docker locally via docker-compose.yml; a managed DATABASE_URL in prod) and backs every data accessor with a jsonb row, keeping all ~710 call sites untouched. Each accessor in db.ts branches on DATABASE_URL — unset = the exact previous JSON-file behavior, so this is a reversible feature flag, not a cutover. New row-locked helpers mutateTenantDB/mutateMasterDB (pg.ts, BEGIN; SELECT … FOR UPDATE; …; COMMIT) fix the lost-update races — proven: 50 concurrent writes lose 49 the old way, 0 with the lock. Includes an idempotent JSON→Postgres importer (scripts/db/import-to-pg.mjs, skips the .corrupt files), a half-migrated preflight guard, and the SSE inbox watcher + google-connections store made Postgres-aware. The stock-movement route is converted as the first proof hotspot. Full rationale + phased plan in ADR-003. Next (Phase 2): convert the remaining TOCTOU hotspots (addon billing, escrow/payouts, supplier orders, receiving/scan commits) through the lock — these are pre-existing races, off by default, no worse under Postgres.
    • Phase 2 — money-path TOCTOU hotspots converted through the row lock. The documented read-modify-write races are now serialized: 2a addon billing (activate/cancel/pause/resume/sweep + cron) → mutateMasterDB; 2c supplier B2B orders (placeOrder/confirmPayment/transition) → mutateSuppliersDB, with the on-delivery payout split so the async Stripe transfer runs outside the lock (+ a payout_<orderId> idempotency key against double-pay); 2d receiving + scan-session commits → mutateTenantDB; and 2b the escrow money path — createPaymentHold, payout schedule/release/hold, the refund engine, and customer/restaurant risk scoring → a new mutateEscrowDB (escrow-engine.ts, payout-engine.ts, refund-engine.ts, scoring.ts). Each conversion keeps the mutator synchronous — gateway/Stripe calls stay outside the lock and idempotency guards live inside it — and logs fire once on commit. Also hardened the shim itself: mutateDoc gained an optional race-safe `seed` (INSERT … ON CONFLICT DO NOTHING → re-SELECT … FOR UPDATE) so lazily-created singletons (escrow / suppliers / master) get the same first-write-creates behavior as the JSON files instead of throwing on a fresh Postgres deploy; mutateTenantDB passes no seed, so a missing tenant still errors. Each phase was adversarially reviewed (escrow: all hard invariants hold — no lock-across-await, idempotency intact, seed race-safe).
    • Phase 3 — last filesystem-direct subsystems moved onto Postgres. The two remaining stores that wrote straight to disk now branch on DATABASE_URL like everything else: the receiving scanner's per-session log (was an append-only JSONL sidecar) becomes an append-only `scan_events` table (002_scan_events.sql) — UNIQUE(tenant, session, scanEventId) makes a retried/replayed scan idempotent and a per-session pg_advisory_xact_lock serializes the monotonic seq so concurrent phone scans can't collide; and the Docs Center analytics document moves into app_documents('docs-analytics'). Both seams live inside their own modules (scan-log.ts, analytics.ts) so every call site is untouched and the fs path stays byte-identical when DATABASE_URL is unset. Verified live: 11 concurrent scan appends (5 distinct + 10 duplicate retries) yielded exactly 5 rows with the duplicate folded once and seqs monotonic 1–5.
    • Phase 4 — serverless / scale hardening. The shared pool (pg.ts) is now deployable behind a transaction pooler: PG_POOL_MAX (set 1–3 for serverless), PG_SSL (require/no-verify for managed TLS like Neon/Supabase), and connect/idle timeouts so a hung connection fails fast and idle backends are released. Two deliberate decisions, documented in ADR-003: LISTEN/NOTIFY is intentionally not used (it needs a pinned session a transaction pooler won't give — the SSE doorbell keeps polling docUpdatedAt, which is pooler-safe), and JSONB GIN indexes are deferred to Phase 5 (every read is a whole-document fetch by primary key, so a doc->… index can't be used until hot entities are normalized into their own tables). New load-test harness (load-test.mjs, npm run db:loadtest) sustains the row-locked write path under contention — measured ~3,900 ops/sec at 24 concurrent workers, 0 errors, p95 ≈ 23 ms, and zero lost updates (the per-row counters summed exactly to the write count), upgrading the migration's correctness proof from a single 50-shot burst to sustained load.
    • Phase 5a — orders normalized into an indexed table (staged, opt-in). The first hot entity is lifted out of the whole-document JSONB scan: an indexed `orders` table (status / createdat / paymentstatus) now mirrors each tenant's doc.orders, kept consistent inside the tenant-write transaction via reconcileOrders (a per-order content hash re-writes only changed rows; removed orders are deleted) — so the document and the mirror commit or roll back together. Heavy filtered reads use the indexed queryOrders (the orders-list API adopts it) instead of scanning every order in the document. Deliberately staged for safety: opt-in via ORDERS_TABLE=1, the JSONB document stays the source of truth, and getTenantDB is unchanged — so there's zero risk to the hottest read path and the flag flip is a clean rollback (a non-destructive npm run db:backfill:orders (re)syncs the mirror). The full doc-shrink cutover (read db.orders from the table, stop storing them in the doc) is the documented next step. created_at is stored canonical-UTC so the index sorts chronologically, matching the document path exactly. Adversarially reviewed (content can't diverge from the doc; reversible) and live-verified — count parity across every tenant plus add/update/delete/hash-skip reconcile. Kysely is deferred until 5b–5d add more tables (raw parameterized SQL is cleaner for one). See ADR-003.

    Fixed

    • Hydration mismatch on `<html>` (iPhone-only) + Food Bazar hydration audit. Root cause of the reported /bazar error: iOS Chrome / the Google in-app browser inject root attributes into <html> before hydration (e.g. __gcrremoteframetoken, __gcruniqueid) — Android Chrome does not, hence iPhone-only. The app renders <html> fully static, so this is third-party DOM mutation the app can't prevent; fixed with an element-scoped suppressHydrationWarning on <html> in layout.tsx (same documented pattern already used on <body> for Grammarly, FIX-012; element-scoped so it never masks descendant mismatches). Audited the whole Food Bazar render tree — it is hydration-clean (dynamic data is client-fetched, so pages render null/skeletons at hydration; the search bar + filter sheet are mount-gated). Found and fixed one genuine latent bug: [CountdownBadge](src/components/bazar/CountdownBadge.tsx) computed Date.now() in its useState initializer (runs on server and client → mismatched label) — now starts null and computes the live countdown only after mount. Pinned locale on item-count formatters (toLocaleString('en-US')) in /bazar/bazar/page.tsx) and /bazar/menu/bazar/menu/page.tsx). Added a reusable hydration auditor (scripts/check-hydration.mjs, npm run audit:hydration) that grades client-vs-server / render-vs-handler risk into diagnostics/hydration-report.md, plus a cross-device test checklist.
    • Food Bazar — search/filter polish (enhancement #15 follow-up). Removed the marketplace stats strip ("Average across N restaurants / Reviews total / Reviews last 30 days") and its marketplace-stats API per the redesign. The Food Court Finder (venue type + food-court/mall name + city) is now available in the mobile filter sheet, not just desktop. Added a Clear all filters control on desktop (mobile already had one). Added "Use my location" on both — browser geolocation reverse-geocoded to a city (free via Nominatim, no key; new reverse-geocode route) that pre-fills the city while keeping manual search editable.
    • TipTap editor: "Duplicate extension names found: ['link','underline']" (Priority-1). StarterKit v3.23 now bundles the link and underline extensions, but EmailEditor.tsx (the single rich-text editor, reused by the blog editor) also registered them separately. Disabled the bundled copies via StarterKit.configure({ link:false, underline:false }) so the custom-configured Link (openOnClick:false, rel="noopener noreferrer") and Underline remain the single source of truth, and added a dev-only guard that inspects the fully-resolved extension set and warns on any duplicate name so a future regression is caught early.

    Security

    • Path-traversal hardening of the tenant DB accessors. getTenantDB/saveTenantDB/createTenantDatabase (db.ts) now reject any tenantId that isn't a [A-Za-z0-9_-] slug, so a crafted id can never escape databases/tenants/. This closes a hole that the new unauthenticated mobile-scanner routes (which take [tenantId] straight from the URL) would otherwise have exposed — e.g. /api/public/..%2F..%2Fmaster/scan/… resolving to the platform master DB. Found and fixed during the feature's adversarial review.

    Fixed

    • Food Bazar — prices now render in each restaurant's own currency (enhancement #13). The marketplace surfaces hardcoded a $/USD prefix everywhere; they now format every price with the restaurant's resolved currency via formatMoney. The multi-currency foundation already existed (currency.ts — 18-currency registry, country→currency map, resolveCurrency; per-tenant currency/countryCode; a Settings → "Location & Currency" selector that auto-maps country→currency) — this closes the display gap. Currency is now plumbed through the cross-restaurant APIs (/bazar/menu, /bazar/sponsored, /bazar/orders/me, /bazar/orders/[orderId] — resolved per restaurant) and consumed by every customer surface: home, menu, item detail, checkout (line items + summary + place-order CTA), order detail, account order history, and the global cart drawer. CartItem now carries currency (captured at add-time across all add-to-cart entry points) so multi-restaurant carts render each line correctly. No cross-currency conversion — each restaurant prices in its own currency, as designed. Remaining: defaulting a brand-new tenant's currency from its country at signup needs a country field on the paid-signup form; today new tenants default to the platform currency and owners set theirs in one click via Settings.

    Added

    • Food Bazar — personalized "Offers for you" (enhancement #17). Signed-in customers get a rail of discounted items matched to their preferences (dietary first, falling back to a preferred cuisine), powered by a new ?offers=true flag on the menu API that returns only items carrying an offerPrice. The rail renders with the standard food card and silently hides when signed out or when there's nothing relevant (signed-out users still see the generic Featured section). (bazar/page.tsx/bazar/page.tsx))
    • Food Bazar — Open-now & Delivery filters (enhancement #15). The marketplace gains two operational quick-filters — Open now and Delivery — available on both the desktop filter row and the mobile filter sheet. The menu API honours ?openNow=true / ?delivery=true (resolved per restaurant), and both filters participate in the active-filter count and URL state so results are shareable.
    • Food Bazar — dietary-aware recommendations + ranking order (enhancement #18/#21). Recommendations now factor a signed-in user's food/dietary preferences (set in P0): each restaurant's available dietary tags are matched against the user's preferences and boost its rank — a vegan or gluten-free diner sees suitable places first, even when the cuisine doesn't match — with a clear "why" reason, applied to both the personalised scorer and the cold-start popular fallback (recommendations route). The marketplace home is now ordered Sponsored → AI Recommendations → the rest, per the ranking spec (bazar/page.tsx/bazar/page.tsx)).
    • Food Bazar — Food Court Discovery + Finder (enhancement #11/#15). Restaurants can now record the venue they sit in — food court, food street, shopping mall, indoor market, or outdoor market — plus the venue name and city, from a new Location & Venue card in their Food Bazar settings (TenantBazarSettings.venue). The marketplace gains a Food Court Finder filter (venue-type chips + city) so diners can discover everything in one mall/court/market; the menu API filters by ?venue/?venueName/?city (resolved per restaurant) and returns the venue on each item, which now shows as a 📍 badge on the cards. First slice of the P6 Bazar-discovery track.
    • Customer Reviews — food-item reviews with photos + complaint resolution (enhancement #12). On top of the existing restaurant reviews (rating, text, owner reply, moderation, verified-purchase badge), the system now supports food-item reviews: a review can target a specific menu item (Review.productId/productName) and carry photos (images), submitted via a new public, rate-limited review-photo upload (upload route — type/size validated, slug-guarded, UUID filenames; submissions only accept URLs from that endpoint, so no external-URL injection). Reviews also record whether the customer ordered online or visited in person (visitType). Owners get complaint-resolution tools beyond replying — mark a review resolved with a goodwill gesture (a discount code or a free item) plus an optional note (reviewId PATCH) — surfaced in the dashboard reviews page/dashboard/reviews/page.tsx) alongside item badges, photo thumbnails, and an online/in-person tag. Customers leave and read item reviews from a new ItemReviews section on the Bazar item page (star summary, photo upload, owner replies). The Bazar review API gained a ?productId= item feed + per-item upsert. Bazar submission stays auth-gated; tenant-site reviews stay moderated.
    • Live Orders — ready-order sound, smart rotation, and display settings (10c, completes the Live Orders Display request). A ready-order chime plays when a new order enters the Ready column — a pleasant two-note Web Audio tone (no asset), armed by a one-tap "enable sound" (per browser autoplay policy), fired only for newly-ready numbers, with configurable volume. Smart rotation paginates each column by a configurable orders-per-column and auto-cycles pages (with a page indicator) so a busy queue never clutters or overflows. Owners configure it all — welcome message, orders-per-column, rotation cadence, sound on/off + volume — in a Display settings panel on the Customer Display view with live preview (display-config API + shared resolver); the public TV picks up the same settings via its projection.
    • Live Orders — public unattended-TV display + live updates (10b). The customer display can now run on an unattended waiting-area TV without a staff login: owners mint a revocable, display-only capability token (Live Orders → Customer Display → "TV link"; token route, stored as liveOrdersDisplayToken), which authorises a public [/display/[tenantId]/[token]](src/app/display/[tenantId]/[token]/page.tsx) page. That page polls a PII-stripped projection ([/api/public/[tenantId]/live-orders](src/app/api/public/[tenantId]/live-orders/route.ts)) every 5s — returning order number + status only (never names, phones, notes, payment, or totals), with constant-time token validation, 404-on-mismatch (no existence leak), and the slug-guarded tenant accessor. The display keeps the last good frame through transient network blips for 24/7 stability. On the authenticated page, the board + customer display now refresh live via the existing SSE doorbell (useTenantPushSignal, background refresh — no spinner). No persistent sockets (pooler-safe per ADR-003). The display component is shared between the in-dashboard view and the public TV.
    • Live Orders — Fullscreen + Customer Display mode (10a of the Live Orders Display request). The Live Orders page gains a view switcher (Operations / Customer Display), a Fullscreen toggle (native Fullscreen API — covers the screen and hides the dashboard chrome; ESC exits), and a customer-facing Customer Display view: three large columns (Accepted · Preparing · Ready) showing order numbers and status only — no names, phones, notes, payment, or staff actions — with the "Ready for Collection" column visually dominant (larger cards + gentle pulse), big high-contrast typography that scales up in fullscreen, and a live clock. Built within the authenticated dashboard for now; an unattended-TV public token endpoint with SSE real-time, ready-order sound, smart rotation, and owner config settings follow in 10b/10c. (orders page/dashboard/orders/page.tsx))
    • Live Orders — board windowed to the current shift + Delivered History (enhancement #2). The sidebar entry is renamed Orders → Live Orders. The board now shows the open pipeline (Incoming · Accepted · Preparing · Ready) plus a Delivered Today column instead of an ever-growing "Delivered" list. The orders API windows delivered orders to today server-side (using deliveredAt, which is stamped when an order is marked delivered), so the operational board reflects the current shift and never loads the full delivered backlog. A new Delivered History panel on the orders page/dashboard/orders/page.tsx) loads older delivered orders on demand with a date-range filter (or all), newest-first, capped at 200. The legacy ?status= query is preserved for back-compatibility.
    • Legal & Compliance pages (enhancement #5). A single legal documents registry drives 9 placeholder legal documents — Terms & Conditions, Privacy Policy, Cookie Policy, Data Processing Agreement, Marketplace Agreement, Supplier Agreement, Restaurant Agreement, Escrow Terms, and Refund Policy — rendered at `/legal/[slug]` (statically generated, each with its own SEO metadata) plus a `/legal` index grouped by category. The landing and Food Bazar footers now link to the real pages (previously dead # links). Content is professional plain-language placeholder, clearly flagged for the operator's counsel to finalise.
    • Escrow & payout education (enhancement #10). A reusable, plain-language EscrowExplainer — a collapsible "30-second" card on the Payouts & Escrow page — explains why escrow exists (buyer + seller protection, when funds release, using the tenant's real payoutDelayHours/grace-period policy) and how payouts work (settlement flow, processing time, fees), linking to the full Escrow Terms. Designed to be understood at a glance.
    • Owner dashboard — Operations Center is now a configurable layout (enhancement #1). The Operations Center widget grid gains a column selector (1–4 columns) and drag-to-reorder widgets (via the existing @dnd-kit deps, keyboard-accessible through KeyboardSensor). Both the column count and the widget order are remembered per tenant (localStorage, consistent with the sidebar's persistence). Mobile stays single-column regardless; drag-and-drop is gated behind mount so SSR matches the first client render (no hydration mismatch — see ADR-002). Feature-gated widgets that are toggled on later append in natural order rather than disappearing. (OperationsCenter.tsx)
    • Owner dashboard — keyboard shortcut to collapse/expand the sidebar (enhancement #3). The sidebar already supported collapse/expand (with a desktop icon-rail), localStorage persistence, a mobile drawer, smooth animations, and aria labels; this adds the one missing piece from the spec — Ctrl/Cmd + B toggles the desktop sidebar (the familiar editor convention). (SidebarNavigation.tsx)
    • Food Bazar — food/dietary preferences for logged-in customers (enhancement #20/#18). The Bazar account Preferences tab gains a Food Preferences card: dietary tags (Halal, Vegan, Vegetarian, Gluten-Free, Dairy-Free, Nut-Free, Organic) + a spice-level selector (mild → extra-hot, tap-to-clear). Persisted on the Bazar user via new dietaryPreferences + spiceLevel fields on BazarSubscriptionPrefs (schema.ts), with bounded server-side sanitization in PATCH /api/bazar/user/preferences (dedup, length caps, spice-level enum check; clearing spice persists via an explicit null). Fields are optional + default [], so existing accounts are unaffected; they'll feed search + recommendations in a later phase. The per-restaurant account page already supported dietary editing — this closes the gap on the marketplace account.
    • Food Bazar — Popular This Week is collapsible + remembered (enhancement #19). The home recommendation rail can now be collapsed/expanded from its header. It defaults to collapsed in cold-start mode ("Popular this week") and expanded for personalised recommendations, and the user's choice is remembered (localStorage). The rail only ever renders for signed-in users (the recommendations API returns an empty list when signed out), keeping the front page tidy for returning customers. (bazar/page.tsx/bazar/page.tsx))
    • Receiving Goods — Phase 6: AI smart receiving + report export. The receipt view gains an "AI review" panel (POST /inventory/receiving/ai-suggest): rule-based flags are always computed on the device — cost anomaly (unit cost >25% above the ingredient's usual purchasePrice), still-below-reorder-point after the delivery, and short shipments — and a budget-gated Claude call (claude-haiku-4-5, max 220 tokens) adds a 1–2 sentence advisory summary, gracefully falling back to a rule-based summary when there's no key / the tenant is over budget / on any AI error. AI spend is metered under a new `'receiving'` feature (analytics.ts → "Smart Receiving"), so it shows up in the super-admin AI-usage breakdown like every other AI feature. Separately, a CSV export (GET /inventory/receiving/export, "Export CSV" header button) streams one row per receipt line, filterable by date range / status / supplier (print-to-PDF from the sheet covers the PDF case — no PDF dependency, per the codebase convention). Reuses the proven AI budget pattern (resolveAIKey/getBudgetStatus/recordAIUsage); additive, tenant-scoped, feature-gated. Completes the Receiving 3–6 track.
    • Receiving Goods — Phase 5: discrepancy resolution + supplier scorecard. Receipts can now carry discrepancies — short shipments, damage, wrong/poor goods — each with a type, severity, free-text description, and photo evidence (uploaded through the existing media route), plus a resolution. They're logged + resolved from an "Issues & discrepancies" section in the receipt view (discrepancies route) and are allowed on committed receipts (the stock stands; this tracks the supplier issue). A new supplier scorecard (GET /inventory/supplier-performance) is derived on read from committed receipts — per supplier: receipts, units received vs missing/damaged, an accuracy %, and open issues, worst-first — shown as a panel on the receiving page so owners see which suppliers under-deliver. Additive, tenant-scoped, feature-gated.
    • Receiving Goods — Phase 4: shipment QR → one-click receive. When a B2B supplier marks an order shipped, the order gets a signed 256-bit shipmentToken and the supplier's orders page shows a printable/copyable Shipment QR (ShipmentQrModal) that deep-links the buyer's dashboard. The buying restaurant — logged into its own dashboard — opens it and the receiving page claims it via POST /supplier-orders/claim-shipment: the token is validated (constant-time) and, crucially, the order must belong to that tenant, so a code only ever works for its real buyer. The claim marks the order delivered (settling the supplier payout — exactly the trust of the existing "Confirm received" button) and auto-drafts a Goods Receipt, opened for review in one tap. Reuses the existing transition, draftReceiptFromSupplierOrder (idempotent), and the qrcode renderer. Adversarially reviewed: no cross-tenant claim and no payout without an authenticated buyer session.
    • Receiving Goods — Phase 3: batch & expiry tracking. Receipt lines can now carry a batch/lot number and expiry date (optional per-line inputs in the receive modal). On commit, a `ProductBatch` record is created for each batch/expiry line — createBatchesFromReceipt, reconciled inside the same row-locked commit transaction and idempotent by receiptId (a re-commit never duplicates batches, mirroring the stock-movement guard). A new GET /inventory/batches returns the expiry watchlist (soonest-first, each tagged expired / critical ≤7d / soon ≤30d / ok), and the receiving page gains a color-coded "Batches & expiry" panel. Purely additive — lines without batch/expiry behave exactly as before. (Per-batch FIFO depletion on order is a later step.)
    • Operations Center — 4 more widgets (the extensible shell pays off). Building straight on the reusable OperationsWidget, the command center gains four data-driven cards that each render only when the tenant actually has the relevant data (so the grid stays relevant per restaurant): 📦 Inventory Alerts (low/out-of-stock ingredients with supplier + reorder point, "Receive" → goods-in), 🚚 Supplier Requests (B2B marketplace quotes awaiting your decision + orders in transit / ready for pickup), 🧑‍🍳 On Shift Today (derived from the schedule — who's on now is highlighted live, plus today's other shifts and approved time-off), and ✨ AI Recommendations (the latest daily briefing's top actions by priority + opportunities). All four extend the same single [GET /api/tenant/[tenantId]/overview](src/app/api/tenant/[tenantId]/overview/route.ts) aggregation (the supplier widget adds a parallel, non-fatal read of the shared B2B doc — a failure there degrades only that card). The endpoint now takes the viewer's local date so "today" agrees with the local clock used to mark who's on shift. No new round-trips, same real-time refresh.
    • Restaurant dashboard "Operations Center" — a true command center on the Overview page. Directly below the (now visually-polished) KPI cards sits a new responsive 4-widget grid (OperationsCenter) that lets an owner see everything needing attention in ~3 seconds: 📅 Upcoming Bookings (next reservations with status pills), 🔔 Notifications (derived operational alerts — low/out-of-stock, failed payments, reviews to answer, reservations to confirm — each click-through to the right page, with "Mark all read"), 🧾 Live Orders (active orders with status colors + amounts, plus a "Kitchen" shortcut to the KDS), and ✉️ Inbox (customer contact-form messages — previously collected but never surfaced anywhere). All four are fed by a single aggregation endpoint [GET /api/tenant/[tenantId]/overview](src/app/api/tenant/[tenantId]/overview/route.ts) (one tenant-DB read, one round-trip) and refresh in real time off the existing SSE push signal with a 60s poll fallback — no page refresh. Built on a reusable [OperationsWidget](src/components/dashboard/overview/OperationsWidget.tsx) shell (icon tile, tone-coded badge count, quick action, "View all", loading + empty states) so future widgets (inventory, suppliers, staff attendance, AI recommendations) drop in without redesigning the page. Also adds a Messages page + nav entry (/dashboard/messages/dashboard/messages/page.tsx)) backed by [GET/PATCH /api/tenant/[tenantId]/inquiries](src/app/api/tenant/[tenantId]/inquiries/route.ts) to triage those customer enquiries (reply, mark read/replied, delete). Premium light-glass styling (rounded-2xl, soft shadows, hover lift, semantic status colors). The shared inbox mark-read endpoint was moved onto the row-locked mutateTenantDB while it gained a second concurrent caller.
    • Staff scheduling — Phase 2: time-off, conflict detection, copy-week & shift swaps. The weekly scheduler (/dashboard/scheduling/dashboard/scheduling/page.tsx)) grows from a plain grid into a real planning tool. Time off: log a date-range block per staff member (vacation / sick / unavailable / other) with a pending→approved/denied workflow; approved time off renders in the grid and blocks scheduling. Conflict detection: a shared pure engine (conflicts.ts) flags shift overlaps (same staff, same day) and shifts that clash with approved time off — surfaced as red badges + tooltips on the grid and a week conflict count. Copy-week: one click clones a whole week's shifts into the next, preserving each shift's day-of-week and times while skipping inactive staff, approved-time-off dates, and exact duplicates (idempotent — safe to re-run, never double-books). Shift swaps: from any shift, request a one-way cover (reassign to another member) or a mutual swap (trade two shifts); approving it in the new Requests panel applies the reassignment. New StaffTimeOff + StaffSwapRequest models on TenantDB, and routes /time-off, /shift-swaps, and /shifts/copy-week — all writing through the row-locked mutateTenantDB. Manager-managed for now (staff have no self-service login); the request records carry `requestedBy`/`reviewedBy` so a future staff portal can plug straight in.
    • Super-admin AI usage analytics dashboard. A new /admin/dashboard/ai/usage page (sidebar AI Usage, plus a cross-link from AI Management) turns the per-restaurant tokenomics into a platform view: a rolling 12-month AI-spend trend (click any bar to drill into that month), an 8-tile KPI row (month spend, requests, input/output tokens, active AI tenants, tenants over budget, top-ups purchased, all-time spend), a spend-by-feature breakdown (AI assistant / menu photo import / social posts / email marketing / daily briefings), and a top-consumers table — per-tenant spend, tokens, and a budget-utilization bar with an over-budget badge. Backed by a pure aggregator analytics.ts (buildAIUsageAnalytics) and a super-admin route GET /api/admin/ai/usage (?month=). To power the feature breakdown, TenantAIMonthUsage gained an additive, optional `byFeature` map and recordAIUsage a feature tag (all six AI call sites — chat, menu extract, social, email, briefings — now attribute their spend; months recorded before this still roll up correctly, shown as an "attributed %"). Costs are displayed in USD (Anthropic bills in USD and the per-tenant budget is a USD figure — no misleading SEK conversion). While there, recordAIUsage + addAITopupUsd were moved onto the row-locked mutateTenantDB, closing a lost-update race on the AI-spend path (concurrent calls could undercount spend and slip past budget). Scale note: the aggregate reads every tenant's usage doc (O(tenants)) — fine for an admin-only report; folds into the planned hot-entity normalization (migration Phase 5) at larger scale.
    • Mobile Scanner Pairing — turn any phone into a wireless barcode scanner (WhatsApp-Web-style). On the Receiving page, “Connect phone scanner” opens a session and shows a pairing QR (PairingModal, QR rendered client-side via qrcode). Scanning it opens a public, login-free scanner page ([/scan/[tenantId]/[token]](src/app/scan/[tenantId]/[token]/ScanClient.tsx) — full-screen, dark, big confirmation + haptics) that reuses the camera BarcodeScanner. Every scan streams into the desktop’s live cart (~1s cursor polling — zero new infra, matches the existing KDS/tracker pattern) and commits via the existing GoodsReceipt engine. Architecture: raw scans are append-only `ScanEvent`s in a per-session JSONL sidecar (scan-log.ts) so concurrent writes append (O(1)) instead of racing the tenant JSON; the desktop derives the cart by folding events. The pairing token is capability-scoped (scan-only, bound to one session + the first device, 20-min expiry, revoked on commit) — never account access. Commit is idempotent (requestId), validates ingredients still exist, and the session log is cleaned up on commit. Engine in scan-session.ts; routes under /inventory/receiving/sessions (desktop) + [/api/public/[tenantId]/scan/[token]](src/app/api/public/[tenantId]/scan/[token]/route.ts) (phone). MVP scope: single phone, online-only, one active session per tenant. Next: multi-scanner merge, SSE doorbell, supplier shipment QR, offline queue + PWA, AI assist. Honest scale: real for single-restaurant receiving; “thousands of simultaneous scanners” needs the planned SQL/Redis migration.
    • Receiving Goods (Ta Emot Varor) — phase 2: barcode/QR scan-to-receive. The Receive flow now has a scan bar: a keyboard-wedge input (works with any USB/Bluetooth handheld scanner + manual typing — Enter = +1) and a live phone-camera decoder (BarcodeScanner via html5-qrcode, mounted on demand, debounced). Each scan resolves the item through a new GET /inventory/lookup (match priority barcode → SKU → id → name) and bumps or appends a line in the receiving cart; repeat scans increment quantity. A no-match scan offers to link that barcode to an ingredient on the spot (self-learning — next scan just works). IngredientItem gained sku/barcode, editable in the inventory item modal and accepted by the ingredient POST/PATCH. Hardened after an adversarial review: the camera stream is torn down cleanly even when toggled off mid-startup (no zombie stream), barcode-linking checks the save result (no false "Linked" / no cart bump on a failed save), and duplicate barcodes are rejected (409) across the tenant's ingredients.
    • Receiving Goods (Ta Emot Varor) — phase 1: manual & marketplace-linked receiving. A new Ta Emot Varor page (receiving/page.tsx/dashboard/receiving/page.tsx), under Inventory, gated on the inventory-management add-on) for receiving deliveries fast: pick supplier / invoice / delivery-note reference, add lines with received / missing / damaged quantities + unit cost, then Receive — every mapped line stocks the ingredient and writes an auditable StockMovement. Introduces a first-class `GoodsReceipt` model (draft → committed, soft-deletable) on TenantDB.goodsReceipts, and a single shared `applyStockMovement()` seam (extracted from the manual-adjust route so receiving can't drift). Confirming a B2B marketplace order as delivered now auto-drafts a receipt (draftReceiptFromSupplierOrder, idempotent per order, auto-matching lines to ingredients by SKU/name) so a delivery actually updates inventory in one review-and-receive step — closing the long-standing gap where received marketplace orders never touched stock. API: GET/POST /inventory/receiving + [GET/PATCH/DELETE /inventory/receiving/[receiptId]](src/app/api/tenant/[tenantId]/inventory/receiving/[receiptId]/route.ts). Hardened after an adversarial review: commits are idempotent by construction (a receipt whose lines already produced movements never stocks twice), commit validates mapped ingredients still exist (409 instead of silently under-stocking), the skipped-line count is surfaced (banner + audit), the optimistic draft-delete now reverts + reports on failure, and the soft-delete route gained the feature gate. Next phases: barcode/QR scan-to-receive, batch & expiry, shipment QR, discrepancy/supplier-performance, AI smart receiving.
    • AI platform — scheduled daily briefings, email/SMS delivery, and 7/30/90-day forecasting. The AI Executive Briefing now generates and delivers itself: a new GET /api/cron/daily-briefings (CRONSECRET-protected) sweeps every AI-enabled tenant, generates the day's briefing if missing (no wasted AI spend — idempotent per day), and **delivers** it to owners who opted in. Generation is factored into a shared [service](src/lib/ai/briefing/service.ts) used by both the cron and the on-demand route (no drift). **Delivery** ([delivery.ts](src/lib/ai/briefing/delivery.ts)) sends a polished HTML+text [briefing email](src/lib/email/briefing-email.ts) to the owner and a short **SMS** summary via the tenant's text settings — opt-in per channel from the **AI Briefings** page (new email/SMS toggles), persisted on `TenantDB.briefingDelivery` ([briefings PATCH](src/app/api/tenant/[tenantId]/briefings/route.ts)). **Forecasting:** the [snapshot](src/lib/ai/briefing/snapshot.ts) now projects **next 7 / 30 / 90 days** of revenue & orders (trailing-average + 7-day momentum), shown as a Forecast card in-app and in the email, and stored on each briefing (`Briefing.forecast`). Next: supplier / agency / sales-agent briefings._
    • Stripe Connect for real B2B supplier payouts (test mode). Suppliers can now connect a Stripe Express account from their dashboard (Integrations → Payouts via StripeConnectCard): [GET/POST /api/supplier/[supplierId]/connect](src/app/api/supplier/[supplierId]/connect/route.ts) creates the account, returns a hosted onboarding link, and refreshes the mapped status (pending / active / restricted / rejected) from Stripe (connect.ts). On delivery, the order engine settles the payout (payout-engine.ts): when Stripe is configured, the supplier is active, and the order was paid by a real charge, it creates a Stripe Transfer of supplierNet to the connected account and keeps the 1%+1% commission; otherwise it records the payout as simulated (dev / no real funds yet) or pending (onboarding incomplete) — never blocking delivery. New SupplierOrder.payout (status/amount/transferId), surfaced as a payout chip on the supplier orders list. Note: the restaurant side of B2B is still dev-confirmed, so transfers stay simulated until real B2B charges are wired — the transfer path itself is live.
    • Quick Links phase 2 — folders, drag-and-drop reorder, and a collapsible sidebar. The Quick Links page/dashboard/quick-links/page.tsx) now supports custom groups (folders) — create / rename / delete groups and file any bookmark into one via the per-card folder menu (deleting a group returns its links to Ungrouped, never deletes them). Each section (every group + the ungrouped list) is independently drag-and-drop reorderable via @dnd-kit (wrapped in ClientOnly per ADR-002 to avoid the React-19 hydration counter bug). Groups are per-user-per-restaurant, stored on TenantDB.quickLinkGroups with a new route-guarded groups API (create / rename / reorder / delete); QuickLink gained an optional groupId, and the main Quick Links PATCH now also moves a link between sections. Separately, the desktop sidebar (SidebarNavigation.tsx) gained a collapse toggle → a slim icon rail (preference persisted in localStorage); collapsed group icons expand the sidebar straight into that group.
    • Trial → paid-Basic conversion cron. New GET /api/cron/base-billing sweeps subscriptions and converts an ended 14-day all-access trial (trialing + endsAt < now → incomplete); the dashboard layout already gates incomplete to /register/payment, so trial access is revoked and the 199 SEK/mo Basic payment is required once the trial ends. CRON_SECRET-protected, mirroring the add-on billing cron.

    Changed

    • Reseeded the approved SEK add-on price card. registry.ts now carries the approved SEK figures (clean 9 / 19 / 29 / 39 / 49 / 99 monthly tiers) instead of the legacy USD numbers, and the price comments read SEK. Suites recompute their savings off these registry prices, so the marketplace and bundle math follow automatically — resolving the earlier $/SEK inconsistency.

    Added (earlier)

    • AI Executive Briefings — daily business intelligence (MVP). A new AI Briefings page (under Analytics & AI) gives restaurant owners a daily executive briefing — intelligence, not a raw report: an executive summary, KPI grid (revenue/orders/AOV with week-over-week deltas), trends, needs-attention alerts (low stock, revenue drops, unanswered reviews), recommended actions (prioritised), and opportunities. A server-side snapshot builder computes the KPIs from real data (orders, inventory, reviews, customers, offers); the generator turns it into the briefing via Claude (Haiku) when an AI key is configured and the tenant is within budget, and falls back to a genuinely useful rule-based briefing otherwise (dev / over-budget) — so the feature always works. Gated behind the AI add-on + the per-tenant AI budget (tokenomics), metered on each generation. Briefings are stored per-day on TenantDB.briefings (API: GET history + budget, POST generate). Next: scheduled daily auto-generation + email/WhatsApp/SMS delivery, supplier/agency/sales-agent briefings, and 7/30/90-day forecasting.
    • Quick Links — bookmark shortcuts for the owner portal (MVP). A new Quick Links nav item (under Overview) plus a ⭐ in the top bar that bookmarks the current page on any dashboard screen. The Quick Links page/dashboard/quick-links/page.tsx) shows Pinned Favourites, your links (pin / remove / go), search across your links + every page, and one-click add from the full page catalog. Bookmarks are per-user-per-restaurant, stored in TenantDB.quickLinks via a route-validated API (add / remove / pin / reorder). The dashboard sidebar was refactored onto a shared nav catalog (nav-catalog.ts) — one source of truth for ~45 pages, used by both the sidebar and the bookmark system (no drift). Next phases: custom groups + drag-and-drop, recommended/most-visited (AI), and the desktop sidebar collapse toggle.
    • Modular billing — module marketplace (on/off), Suites, Stripe test checkout, and B2B commission. Turns the existing feature-registry/add-on engine into a self-serve marketplace (dashboard/billing/dashboard/billing/page.tsx)):

    - Pay-on-activation, on/off model. Turning a module on charges a 30-day paid period (no per-module trial). Within the period the owner can pause/resume freely (off = hide access, no refund, clock keeps running; on = free) via PATCH /billing/addons; at period end the cron renews (if on) or lapses (if paused). New pauseAddon/resumeAddon + renew/lapse sweep in subscription.ts; paused/stripeSessionId added to AddonSubscription. - 14-day all-access trial. While the base subscription is trialing, the resolver unlocks every purchasable module (resolver.ts); signup now creates the trial sub as trialing (not active) so the gate actually fires. - Suites (suites.ts) — Growth / Delivery / Operations bundles priced as a discount off the members' à-la-carte sum. Activating a suite is one subscription that grants all members (fan-out grants via bundledFeatureIds); pause/resume/cancel apply to the bundle. Marketplace shows suite cards with savings; suite-covered modules are hidden from the à-la-carte list. - Stripe (test mode). Activation redirects to hosted Checkout (stripe.ts); the return path verifies payment_status==='paid' + session↔tenant/feature metadata and is idempotent on the session id. Dev-sim fallback for local; production refuses to free-activate without a real key. - B2B supplier marketplace commission — 1% restaurant + 1% supplier (commission.ts). Recorded on every supplier order (order-engine.ts): restaurantPays = total + 1%, supplierNet = total − 1%, platform keeps 2%; rates are super-admin-configurable (clamped 0–100%). Shown in the restaurant + admin supplier-order views. Charge/transfer execute when Stripe Connect onboarding lands (Phase C-2). - Hardened after a 33-agent adversarial review: blocked suite⇄individual overlap (no double-billing / no over-revoke of separately-paid modules), made Checkout confirmation idempotent (replay/concurrency safe), fixed the trial-gating status mismatch, and closed a production free-activation path. - Strategy + decisions recorded in PRICING_STRATEGY.md.

    • AI: super-admin–managed API key + per-restaurant tokenomics

    - System-wide API key, managed from the admin UI (/admin/dashboard/ai): the Anthropic key can now be set/replaced/removed by a super-admin and is stored AES-256-GCM encrypted (AISettings.apiKeyEncrypted); only the last 4 chars + a hasApiKey/aiLive flag are ever returned. Key precedence is admin DB key → `ANTHROPIC_API_KEY` env, resolved per-request so changes take effect with no restart. All AI routes refactored to use the shared resolveAIKey. - Per-restaurant monthly AI budget (USD) — default $1/restaurant/month, set by the admin. Token usage is metered per tenant per month (recordAIUsage); cost is estimated from per-model rates (pricing.ts). Metering wired into all four AI routes (menu extract, chat, email, social), including capturing usage from the streaming chat. - Upsell when exhausted (per your choice): the menu photo import hard-blocks with 402 `ai_budget_exceeded` and the import modal shows SEK-priced top-up packages (ai-topup); buying one (dev-simulated, mirroring signup-payment; Stripe one-time checkout in prod) grants USD allowance for the month and retries. Auxiliary AI tools (chat/email/social) degrade gracefully to their existing mock/template output when over budget (no cost, no hard block). - New [POST /api/tenant/[tenantId]/billing/ai-topup](src/app/api/tenant/[tenantId]/billing/ai-topup/route.ts) (GET packages + budget status; POST buy). Budget tracked in TenantDB.aiUsage keyed by YYYY-MM. - Verified in dev: encrypted key round-trip (never leaked), 402 gate + reset date, upsell packages, graceful restore. 0 TypeScript errors.

    • AI menu import — snap or upload a photo, auto-build the menu (dashboard/menu/dashboard/menu/page.tsx))

    - New "Import from photo" action on the menu page. Restaurant owners can take photos on mobile (capture="environment") or upload up to 6 images (multi-page menus). Images are downscaled client-side (~1568px JPEG) before upload. - New [POST /api/tenant/[tenantId]/menu/ai-extract](src/app/api/tenant/[tenantId]/menu/ai-extract/route.ts): Claude vision (claude-sonnet-4-6) with forced tool-use returns structured { categories: [{ name, items: [{ name, description, price, ingredients }] }] }. Output is normalised/clamped server-side. DEV MODE (no ANTHROPIC_API_KEY) returns a realistic sample so the flow is fully testable offline — mirrors the existing /ai route pattern. - New [POST /api/tenant/[tenantId]/menu/ai-import](src/app/api/tenant/[tenantId]/menu/ai-import/route.ts): bulk-creates the reviewed menu in a single atomic DB write — dedupes categories by name (case-insensitive) against existing ones, appends items with correct ordering. Defensive caps (50 categories / 300 items). Leaves dietary tags & allergens empty (edited manually later, per spec). - New MenuPhotoImportModal: camera/upload → cost-estimate confirm step → AI extract → editable review grid (all items pre-selected; fix names/prices/descriptions/ingredients, deselect, remove) → one click adds to the menu. Mobile-friendly full-screen modal. - Pre-flight cost estimate: before extraction runs, a confirm step shows an estimated AI cost range for the selected photos (computed from the shared per-model rates) and whether it's metered against the monthly allowance (live) or free (sample mode). The top-up GET now also returns aiLive. - Menu card fix: product cards gave the price its own protected lane (shrink-0 + gap + name min-w-0 flex-1 truncate) so long dish names no longer collide with the price. (menu/page.tsx/dashboard/menu/page.tsx)) - Category management fix: the decorative three-dots on each category (no handler) is replaced with working Rename + Delete hover actions wired to the existing PATCH/DELETE endpoints. Delete cascades to the category's items with a count-aware confirm and re-selects another category. (CategoryList.tsx) - Multi-currency by country (super-admin managed) — restaurant owners + Bazar. Each restaurant now displays prices in its own currency (no conversion). A super-admin Currency panel (admin/dashboard/settings) manages the supported currencies, the country→currency map, and the platform default (persisted in PlatformSettings); a restaurant's currency resolves as owner override → its country's mapped currency → platform default → SEK (resolveCurrency). Owners pick their country + currency in Settings → General (choosing a country auto-fills the matching currency, overridable; new TenantSettings.currency/countryCode). New currency library (currency.ts) with an 18-currency registry + formatMoney() (locale-correct: 199,00 kr / 199,00 € / £199.00 / $199.00); formatSEK kept as a back-compat wrapper. Display wired into the owner dashboard menu and the Bazar restaurant page (which previously hard-coded $ — now shows each restaurant's real currency via a page-level currency context). Payment-gateway charge currency is a deliberate follow-up; this ships the display + configuration. - Move items between categories (single or bulk) + empty-category highlight. Product cards now have a selection checkbox (plus a header "Select all"); selecting one or more items shows a "Move N to [category]" bar that re-homes them in one atomic write. New move: { ids, categoryId } op on PATCH /menu/products (validates the target, appends to its order); the UI updates optimistically and reloads on error. The category sidebar now shows a per-category item count, and empty categories are flagged red (red left-accent, red name, "empty" badge) so owners can spot a category that needs items or removal. (menu/page.tsx/dashboard/menu/page.tsx), CategoryList.tsx) - Import — Phase A (smarter extraction + PDF): the extraction prompt now treats menus that use options/sections/set-meals instead of named categories as categories, and keeps near-duplicate items distinct (different size/portion/variant/price → separate items with the distinction baked into the name; only true cross-photo duplicates are merged). PDF menus are now accepted and sent to Claude as a native document block (no extra dependency); the picker accepts image/* + application/pdf and shows a PDF tile. - Import — Phase B (best-effort auto item-images): extraction now returns a per-item imageBox (0–1000 normalized region + source attachment index); the modal crops each item's photo client-side from the source image (canvas), shows it in the review grid with an AUTO badge + one-click remove, and on import uploads kept crops via the media endpoint and stores the URL on the product. Missing/low-confidence → no image; PDF-sourced items aren't auto-cropped (sent as documents, by design). CreateProductSchema.imageUrl was relaxed to accept the platform's own root-relative /uploads/... URLs. Hardened after an adversarial review: unparseable attachments are rejected so source indices stay aligned; imageBox is bounds-checked (reject out-of-range / overflow / bad source — no silent garbage crops); a shared safeImageUrl now also rejects protocol-relative //host and is applied to the product PATCH route (previously unvalidated); crops upload in parallel; retries reuse prior uploads. - Import — auto item-photos: full product image, no wrong matches, + lightbox. Review thumbnails are click-to-zoom (full-size lightbox + item name; closes on ×/backdrop/Esc) so owners can verify each AI match. The crop now captures the item's whole product photo as printed — including a drink/garnish/price baked into the graphic (no edge-trim, which previously clipped them). To stop wrong-item matches (e.g. one item getting a neighbour's photo + caption), the model also reports imageBoxText + imageConfidence, and the server drops any box whose inside-text is a bled-in caption (an NN. Name item label or long text) or whose confidence is low — those items get no image instead of the wrong one. Geometry backstop loosened to only reject egregious boxes (>85% of page, >4:1 aspect, tiny). (ai-extract, MenuPhotoImportModal) - Import — Phase C (URL screenshot import): new [POST/GET /api/tenant/[tenantId]/menu/url-capture](src/app/api/tenant/[tenantId]/menu/url-capture/route.ts) drives the system's installed Edge/Chrome via Playwright's channel option — no bundled-browser download, no external/paid screenshot API. It runs as an in-memory background job the client polls (progress bar in the modal), screenshots the page (no crawling) and slices it into ≤6 viewport-height JPEGs that feed the normal extract flow. Security/robustness (post adversarial review): an SSRF guard blocks loopback/private/link-local hosts incl. IPv6 brackets, IPv4-mapped ::ffff:, and trailing-dot forms; a request interceptor blocks subresources/redirects to private literals + a post-navigation final-URL recheck (DNS-rebinding to a private IP remains a documented residual); an overall 75s job deadline, explicit page/context/browser teardown, per-tenant concurrency cap, and TTL pruning on both POST and GET. (Caveat: the detached job needs a long-lived process — local/persistent server; a serverless deploy would need a real job queue.) - Import — two-stage server-side photo extraction (sharp). Reworked auto item-photos to mirror a proven reference pipeline: a separate vision pass per image locates each dish's printed photo and returns a fractional `[0,1]` bounding box (photo-detect.ts), which is then cropped server-side with `sharp` from the near-original bytes — no more client-canvas crops off a downscaled copy. Decoupling text extraction (Stage 1) from photo detection (Stage 2) and matching crops back to items by name is what stops a box from landing on the wrong dish or bleeding into a neighbour's caption. New image-ops.ts bakes EXIF rotation once, tiles very long/tall menus into overlapping full-res tiles (splitLongImage), fits a copy under Claude's ~3.6 MB vision cap (fitImageForVision, crops still taken from the original), and emits 480px web-JPEG crops (cropRegion). The modal now sends near-originals and consumes the server's imageDataUrl directly (client-side cropImage removed). Added `sharp` 0.34.5. Hardened after an adversarial review (8 confirmed findings): untrusted images go through a pixel-limited loadSharp (50 MP limitInputPixels) to defuse decompression-bomb DoS; per-upload byte cap (~12 MB base64) before any decode; embedded base64 crops capped at 60 so the JSON response stays small; detector item-names deduped; and a warning is logged when all Stage-2 detection calls fail (text still returned, photos degraded gracefully). (ai-extract, MenuPhotoImportModal) Supersedes the earlier client-side `imageBox` crop approach. - Import — uniform white-square item thumbnails. Auto-cropped item photos are now composited onto a fixed 450×450 square canvas: the dish photo is fit-centred (aspect preserved, never stretched) and the remainder is filled white (cropRegion). This makes every tile a consistent size and turns any caption/neighbour bleed around a photo into white padding rather than stretched junk. Small detections keep their native resolution (withoutEnlargement) so they stay crisp instead of being upscaled-and-blurred. The detector prompt was tightened to stop the box at the picture edges — it must exclude the printed item-number/name line (e.g. "04. Kebabtallrik inkl. pommes & läsk") and any neighbouring dish, and omit an item rather than return a loose box ("a missing photo is better than a wrong one") (photo-detect.ts). A whole-frame detection (box ≥92% of the image in both dimensions) is now rejected as a page-level miss. Verified empirically (white padding + crisp small crops) and via an adversarial review (1 reported finding was a false positive from a degenerate square→square test; the white-pad is proven correct for non-square crops). - Import — 50 MB PDF support + system-side URL download (no AI for fetching). PDF/upload limit raised 12 MB → 50 MB (modal + the ai-extract data-URL cap → ~70 MB base64). Because a vision request can't exceed ~32 MB / 100 pages, large PDFs are now page-split server-side with pdf-lib (pdf-split.ts): each batch stays under the cap and is extracted independently then merged (reuses the per-unit extract+merge); a normal small PDF passes through untouched. Validated: a 10-page PDF splits into 3 valid batches summing to 10 pages. URL import now downloads the file directly — new url-fetch fetches the URL server-side (SSRF-guarded via the shared url-safety.ts, 50 MB streaming cap, redirect re-validation) and, when it's a PDF or image, hands the bytes straight to extraction — no browser, no AI tokens, no screenshot. Only a real HTML page falls back to the existing Playwright screenshot capture. The SSRF guard was extracted from url-capture into a shared module so both paths share one hardened implementation. - Import — manual "choose the photo" control (owner crops from the original, zero AI tokens). Auto-cropping can't reliably separate a dish photo from its printed caption when they're fused in one menu cell (proven: bold colored captions are statistically indistinguishable from food in row stats — trimming the caption also clips the food). So every item in the review screen now has an always-present photo control: a crop button on an existing photo ("wrong crop? pick the area") and a "+ Photo" slot on items with none. It opens MenuItemCropPicker — a full-screen picker over the original uploaded/captured image(s) (multi-source selector, mouse + touch drag-to-select, "Whole image") that crops the chosen region client-side into a white-padded square and attaches it to the item. No server call, no AI tokens. PDFs are excluded as sources (can't render in <img>). This is the reliable fallback for any scenario the AI box gets wrong (caption bleed, wrong item, logo, missing photo) and the foundation for a future correction-memory layer that remembers these picks per menu so re-imports apply them automatically. - Import — accuracy engine ported from the reference pipeline (variants, verbatim prices, per-image merge). After a deep cross-repo study, the real quality gap was Stage-1 extraction, not photos. Ported the reference's richer extraction into extract-core.ts + ai-extract: the save_menu tool + prompt now capture price variants (multi-price items like "Pitabröd / Rulle / Tallrik" → one item with {label, price} variants instead of name-mangled duplicate rows), verbatim prices (preserved as printed), and category/menu notes + restaurant info; output cap raised 8000 → 16000 tokens with a truncation warning. Multiple uploads are now extracted independently and merged (mergeExtractedMenus, detail-scored dedup) instead of one attention-splitting/truncation-prone combined call — which also yields cleaner item names and therefore better photo name-matching. On import, variants map onto the existing `optionGroups` radio system (no new data model / migration): basePrice = cheapest variant, each option priceAdjustment = price − base, so they render and price correctly through the current editor, public menu, and cart. The review grid shows variants as chips + a "N sizes" badge. Locale-aware price parsing (parsePriceNumber) handles 12,50, 1.234,56, 1,234.56, 85:-, etc. Validated by adversarial review (variant→cart compatibility + auth/tokenomics dimensions clean; 3 price-parsing findings fixed). URL text-import + PDF page-split + note/info persistence land next. - Import — photo detection now runs on the WHOLE image (matches the reference pipeline). Comparing against the menu_generator reference revealed our crops were worse because we ran photo detection on the text-extraction tiles with an over-constrained prompt. Realigned to the reference's proven shape: tiling is now used only for Stage 1 text reading; Stage 2 detects photos on the whole baked image per upload (new photoSources) and crops from it, so the model sees the full layout + complete item list and attaches each photo to the correct item instead of guessing from a fragment. The detector prompt was reverted to the reference's minimal version (plus one light "one dish per box, don't grab the caption" clause). Stage 1 text extraction is unchanged. Validated by adversarial review — no regressions; remaining divergences from the reference are deliberate (white-square output, EXIF baked up-front which also fixes a latent rotated-crop misalignment the reference has). (ai-extract, photo-detect.ts)

    Fixed

    • Large PDF import failed with "Invalid JSON body". A menu PDF downloaded from a URL (e.g. 16 MB → ~22 MB base64 body) was truncated by Next.js's default 10 MB request-body limit (applied because the app runs a proxy/middleware), so req.json() threw. Fixed by setting experimental.proxyClientMaxBodySize: '80mb' in next.config.ts (covers a 50 MB PDF's ~67 MB encoded body). Hardened alongside (from an adversarial review): MAX_DATA_URL_CHARS raised to 80 M so a true 50 MiB file (~70 M chars) isn't silently filtered; a `MAX_UNITS` fan-out cap (16) so a many-page PDF can't spawn dozens of Claude calls / overshoot the budget; bounded extraction concurrency (4 at a time); recursive re-split of any oversized PDF batch (uneven/heavy pages) in pdf-split.ts; the URL-download timeout now also covers body streaming (slow-drip servers can't hang the request); and the modal warns before sending an over-limit payload instead of letting it truncate.
    • Website builder: editor preview and live site were out of sync (different renderers). Root cause: the builder editor renders blocks with the builder/sections/* components (via SectionRegistry), but the public site ([tenant/[tenantid]/page.tsx](src/app/tenant/[tenantid]/page.tsx)) re-rendered every block with its own hand-written JSX — so the two drifted (different markup, theming, defaults, field-mapping). Most visibly on the Hero: the live renderer mapped the button link (/order) as a second button's text, injected a businessType eyebrow and a bannerUrl background, hard-coded the CTA to /menu, and used different colors than the editor. Fixed by rendering the live hero through the same `HeroSection` component the editor uses (single source of truth) — content fields are resolved (legacy names + i18n → strings) and passed in, the CTA now uses the owner's actual button link (basePath-resolved), and there's a single correct button. HeroSection's empty-image helper is now builder-only.

    - Builder ↔ live: presentational blocks unified through `SectionRegistry`. The live site now renders heading, paragraph, image-full, cta-banner, stats, video, faq, announcement through the same registry components as the editor (via a content i18n-resolver), eliminating per-block drift — and newsletter, team, and instagram-feed, which the live renderer didn't handle at all (they appeared in the editor but vanished on the published site), now render too. Data-bound blocks (featured, menu-preview, reviews, gallery, opening-hours, contact, maps) still use their data-aware live rendering — wiring those through the shared components with real data + currency is the remaining parity step. - Builder ↔ live: ALL remaining data-bound blocks unified (testimonials, special offers, gallery, opening hours, contact info, map, reservation, delivery). Completes the single-source-of-truth migration: the public site no longer hand-writes JSX for these eight blocks — it renders the *same `builder/sections/ components the editor uses**, fed with real data resolved server-side, so the builder preview now mirrors the live site for every block. Each component reproduces the live markup (theme-token styled) and accepts real-data props: **Testimonials** (approved reviews), **Special Offers** (active offers + formatDiscount label + per-card menu link), **Gallery** (menu photos), **Opening Hours** (weekly hours + "today" highlight), **Contact Info** (phone/email/address/socials resolved as block-content → restaurant settings, preserving the #contact/#about anchors and the working contact form), **Google Maps** (the configured map link), **Reservation** (the working booking form, #book anchor), **Delivery** (enabled delivery platforms + showcase photos, honoring owner-configured images then falling back to menu photos). The builder editor fetches /menu/products, /settings, /reviews, /offers and feeds the same data; interactive forms render as the real client islands on live and as non-interactive skeletons in the editor. DELIVERY_PLATFORMS moved to a shared [delivery-platforms.ts](src/lib/delivery-platforms.ts) used by both sides. **Hardened after an adversarial review (3 confirmed customer-facing regressions fixed):** demo placeholders are now strictly **editor-only** (isPreview-gated) — an unconfigured restaurant can no longer leak a fake phone/email/address, fake opening hours, fake delivery platforms, or "img N" placeholder tiles to live customers; contact fields are type-guarded so a stray non-string value can't crash the page. - **Builder ↔ live: Featured Dishes + Menu Preview now show the real menu (data-bound unification).** Previously the editor rendered these two blocks as **skeleton placeholders / demo dishes with $0.00** while the live site showed the real menu — so owners couldn't see images or prices in the builder. Both [FeaturedSection](src/components/builder/sections/FeaturedSection.tsx) and [MenuPreviewSection](src/components/builder/sections/MenuPreviewSection.tsx) now accept real items + currency + an optional menuHref, and the **editor fetches the live menu + resolved currency** ([builder/page.tsx](src/app/(dashboard)/dashboard/builder/page.tsx)) so the preview matches the published site (real photos via <img>, locale-correct prices, badges). Empty-state is safe: the live site returns null when there's no data (no demo leak), the editor shows a neutral skeleton / representative demo so the layout is still designable. **Hardened after an adversarial review (parity gaps fixed):** the editor now **sorts menu items by order** (matching the live ordered list) and **caps Featured at 3** (matching live popularProducts); currency is resolved **server-side** via a new additive resolvedCurrency field on [GET /settings](src/app/api/tenant/[tenantId]/settings/route.ts) (same resolveCurrency chain the live page uses → no editor/live drift); the Featured "View Full Menu" CTA now renders in the editor too (as an inert span); the id="menu-preview" anchor was restored on the shared component; and the live page's **duplicate getMasterDB()` load was collapsed to one**.

    • "Visit site" opened an unreachable placeholder domain. The dashboard header built the link as https://<customDomain>/, and the seed tenant's customDomain is the reserved-TLD placeholder luigis-pizzeria.example, so it errored; the subdomain fallback (<sub>.localhost:3001) also didn't resolve since there's no subdomain middleware. TopHeader.tsx now uses a custom domain only when it's actually resolvable (rejects .example/.test/.invalid/.localhost), and otherwise links to the reachable storefront route <origin>/bazar/restaurant/<subdomain> (the public page resolves a restaurant by its subdomain slug; works in dev and prod with no DNS/middleware). Verified the target returns 200.
    • Concurrent tenant-DB writes could corrupt the JSON file ("Tenant DB Read Error"). writeJsonAtomic used a shared ${file}.tmp for every write, so two concurrent writes to the same tenant (e.g. the menu photo-import uploading several item-image crops at once, or background polling writing read-state during another write) interleaved into a corrupt file. Fix: unique temp filename per write + a short rename retry for Windows EPERM/EBUSY contention (writes now serialize instead of failing) — eliminates the corruption class platform-wide; proven with a 25-way concurrent-write test (0 failures, 0 corruption, 0 orphan temps). The AI menu import now also uploads crops sequentially. A corrupted luigi-pizza.json from this bug was recovered losslessly from its valid prefix (corrupt copy preserved as .bak).

    - Both routes guarded by requireTenantAccess. Verified end-to-end in dev (extract 200, import 201 with correct fields, empty→400, cascade-cleanup restores baseline). 0 TypeScript errors.

    Changed

    • Super-admin shell — light/dark theme refresh + access reference

    - Removed the third "Medium" theme mode. The admin theme switcher is now a clean Light / Dark toggle. Any persisted admin-theme = "medium" is migrated to dark on load. (AdminThemeProvider.tsx, AdminTopBar.tsx) - Refreshed both palettes for a cohesive look, applied as scoped [data-admin-theme] overrides (no per-page rewrites): Dark uses a refined midnight-slate (panels lift off the shell for depth); Light uses crisp white cards on a soft slate canvas, with a border + soft shadow so cards no longer dissolve into the background. Accent system: orange = brand (logo, active nav, highlights), indigo = interactive controls (theme toggle). - Light-mode polish: solid gray surfaces render as elevated white cards while /40–/50 alpha variants stay subtle insets; dark-mode colored chips/badges (bg-blue-900/40, text-green-400, …) are remapped to light tints with readable, darker text across blue/green/emerald/amber/yellow/orange/red/purple/indigo; stronger borders and slightly darker muted text. - Added a "Super Admin Access" card to the overview page with the local sign-in URL, email, and password, each with copy-to-clipboard. Local-development only — gated on process.env.NODE_ENV !== "production" so it is tree-shaken out of production builds. (admin/dashboard/page.tsx) - Verified in dev: /admin/login 200, super-admin login succeeds, /admin/dashboard renders 200 with the themed shell. 0 TypeScript errors.

    • Paid signup — direct Basic subscription (199 SEK/month), free trial disabled (reversible)

    - New restaurants now subscribe to Basic at 199 SEK/month at signup instead of getting a free trial. The free trial is disabled by default but re-enableable via a flag — set platformSettings.freeTrialEnabled = true to restore the previous 14-day trial behavior. - Schema (additive): SubscriptionRecord.planId gains 'basic-monthly'; status gains 'incomplete' (account created, first payment not yet collected); PlatformSettings.freeTrialEnabled?: boolean (default false). - New src/lib/billing/signup-plan.ts: BASIC_MONTHLY_PLAN (199 SEK/mo, 30-day period) + isFreeTrialEnabled(). - Registration (/api/auth/register): when trials are off, creates the subscription as incomplete on basic-monthly (no trial window) and returns requiresPayment: true. The tenant stays isActive: true so the owner can log in to pay. When freeTrialEnabled is on, the old 14-day basic-annual trial path is used unchanged. - New POST /api/billing/signup-payment: collects the first charge. DEV simulates and activates immediately; PROD creates a Stripe Checkout Session (subscription mode, 199 SEK/mo) and the webhook activates on completion. Idempotent. - Dashboard gate (dashboard/layout.tsx/dashboard/layout.tsx)): a tenant-owner whose subscription is incomplete is redirected to /register/payment until the subscription is active. - Stripe webhook (stripe/webhook): subscription period is now plan-aware (basic-monthly → +1 month, annual → +1 year) and sets the paid amount when activating a pending subscription. - UI: register page routes to the new `/register/payment` step (Basic 199 kr/mo summary → "Pay 199 kr & activate"); copy updated from "free trial" to the paid plan. - Verified end-to-end in dev: signup → incomplete → dashboard gated (307 → payment) → pay (simulated) → active/199 SEK/30-day → dashboard accessible; flipping freeTrialEnabled reverts to the trial path. 0 TypeScript errors.

    • Standardized the platform on a single currency: SEK (multi-currency support to be layered on later)

    - Motivation: the codebase mixed USD and SEK — feature-registry/plan prices and many display formatters defaulted to USD while monetization/add-ons/escrow were already SEK, and customer storefronts hard-coded a $ symbol. - New src/lib/currency.ts: single source of truth — PLATFORM_CURRENCY = 'SEK', formatSEK() / formatSEKWhole() using Intl.NumberFormat('sv-SE', { currency: 'SEK' }) (renders 99,00 kr). - Customer storefront: every hard-coded $ price now routes through formatSEK — home (page.tsx), MenuClient, CartDrawer, CartButton, CheckoutForm, ProductCustomizeModal, ReservationWizard, StripeCardForm, PayPalCheckout, and the customer account page. - Dashboards: admin + owner formatters switched from en-US/USD to sv-SE/SEK (analytics, customers, scheduling, home, subscriptions, gift-cards, table-bills, reports, billing invoices). - Code defaults flipped USD → SEK: subscription PLANS (billing/route.ts), plan builders (admin + owner), food-bazar default settings (db.ts), and per-feature fallbacks (table-pay, gift cards, table bills, profit-margin report, Stripe webhook, admin plans API). - Payments: Stripe PaymentIntent currency: 'usd' → 'sek' (amount math unchanged — SEK is also a 100-minor-unit currency); PayPal SDK + order currency_code USD → SEK. - Integrations / SEO: Schema.org currenciesAccepted + menu priceCurrency, and Google Business sync currency, now default to SEK. - Seed generators: designer/agent commission builders + restaurant/builder defaults emit SEK; burger-flash scenario set to SEK. - Data migration: new idempotent scripts/migrate-currency-to-sek.js flipped 21 stored currency/currency_code USD → SEK across databases/ (designers 9, sales-agents 9, master 1, food-bazar 1, flash-burgers 1). Re-run is a no-op. - Intentionally NOT changed: the B2B Supplier Marketplace stays multi-currency (GBP/EUR per international supplier — databases/suppliers.json excluded from the migration); the currency: 'USD' | 'SEK' | 'EUR' schema union is kept so the data model is already forward-compatible for real multi-currency later. Existing numeric prices were treated as SEK values (no FX conversion). - 0 TypeScript errors; storefront verified rendering kr with no $NN.NN leaks.

    Added

    • Phase 77: Fix Phase 76's `enabled: false` default that hid tenants from the bazar

    - Reported by the owner — immediately after Phase 76 made the Launch campaign flow succeed, the Sponsored section disappeared from the bazar landing entirely. - Root cause: Phase 76's per-field normalisation used enabled: rawBazarSettings?.enabled ?? false. On a tenant whose bazarSettings was {} (no enabled field), the normalised object got enabled: false. The handler then wrote that back to the tenant DB during the campaign save. Every reading endpoint (/api/bazar/menu, /api/bazar/search, /api/bazar/sponsored) has if (bazarSettings.enabled === false) continue; — so the tenant vanished from every bazar surface as soon as the first sponsored item was launched. - Why this regressed only after Phase 76 — the previous bug threw before saving, so the bad default never persisted. Phase 76 fixed the throw but didn't fix the wrong default. - Reading endpoints all use opt-out semantics — they default enabled to true when missing, so an approved tenant who never explicitly opts out stays visible. The Phase 76 normalisation broke that convention. - Fix ([src/app/api/tenant/\[tenantId\]/food-bazar/sponsored/route.ts](src/app/api/tenant/[tenantId]/food-bazar/sponsored/route.ts)): - Changed enabled: rawBazarSettings?.enabled ?? false → enabled: rawBazarSettings?.enabled ?? true to match the rest of the bazar reads. - All other fields keep their schema-aligned defaults (items: [], customBadges: [], etc.) — only enabled was wrong. - Repaired `luigi-pizza.json` — flipped bazarSettings.enabled from false (Phase 76 corruption) back to true. Other seeded tenant DBs untouched (their bazarSettings field was already {} and never went through the Phase 76 save path). - No other endpoints affected — every read uses bazarSettings.enabled === false as the strict-false test, so an enabled: true (or missing) field always passes.

    • Phase 76: Fix "Launch campaign — Network error" + unmask future server errors

    - Reported by the owner — clicking Launch campaign after configuring a bazar promotion returned a generic "Network error. Please try again." message regardless of what actually went wrong. - Root cause ([src/app/api/tenant/\[tenantId\]/food-bazar/sponsored/route.ts](src/app/api/tenant/[tenantId]/food-bazar/sponsored/route.ts)): - The POST handler initialised bazarSettings with ((tdb).bazarSettings ?? { ...defaults }). - `??` only triggers on `null`/`undefined`, not on the empty object {} — which several seeded tenant DBs contained (luigi-pizza.json: "bazarSettings": {}). - So bazarSettings was the literal {}, then bazarSettings.items.findIndex(...) threw TypeError: Cannot read properties of undefined (reading 'findIndex'). - Next dev returned an HTML 500 page; the client's res.json() threw on the non-JSON body; the catch block surfaced its generic "Network error" message. - Server fix: Replaced the ?? initialisation with a per-field normalisation: ``ts const rawBazarSettings = (tdb).bazarSettings; const bazarSettings = { enabled: rawBazarSettings?.enabled ?? false, cuisineTypes: rawBazarSettings?.cuisineTypes ?? [], items: rawBazarSettings?.items ?? [], // ...etc, every field defaulted individually }; ` Now an empty object, a partially-populated object, and a missing field all converge on the same shape. bazarSettings.items is always a valid array. - **Three sibling routes audited** (menu, search, public sponsored). All three already use (bazarSettings.items ?? []) per-access — bug was isolated to this one file. - **Client fix** ([src/app/(dashboard)/dashboard/food-bazar/page.tsx](src/app/(dashboard)/dashboard/food-bazar/page.tsx)): - **Defensive response parsing** — read res.text() first, then try-parse. A non-JSON body (HTML 500) no longer throws; we fall through and show Failed to launch campaign (HTTP 500) instead of masking with "Network error". - **Real network errors** still hit the catch, but now include the underlying err.message so future debugging starts with a real signal rather than a generic string. - **Why this happened** — the ?? pattern was *defensive in intent* (handle missing settings) but assumed missing meant undefined. The seed scripts initialised tenants with bazarSettings: {}` as a placeholder, which silently bypassed every fallback. Per-field normalisation is the only shape that survives all three states (missing / empty / partial).

    • Phase 75: "Closed now" badge on bazar aggregator cards

    - Follow-up to Phase 73 — Phase 73 demoted closed restaurants to the bottom of the cuisine / city / combo aggregator lists, but the cards themselves carried no visual indicator. Users scrolling past well-rated open restaurants and landing on closed ones at the bottom had no signal why the order was that way. - Adds a small gray "Closed now" pill to the header row of each aggregator card when listing.isOpenNow === false. Sits next to the rating chip (so the visual weight is similar — small, paired). Card itself gets opacity-70 so closed entries read as visually subordinate without disappearing. - Files (cuisine/bazar/cuisine/[cuisine]/page.tsx), city/bazar/city/[city]/page.tsx), combo/bazar/cuisine/[cuisine]/in/[city]/page.tsx)): - Same three-line JSX addition per file: {listing.isOpenNow === false && (...)} with the Closed pill, wrapped in a flex container with the existing rating chip. - Card-level opacity-70 conditional className mirrors the FoodCard pattern from the bazar landing. - Why a pill instead of dimming alone — opacity tells the eye "less important" but doesn't say why. The text "Closed now" answers the implicit question. - Why not also disable navigation — restaurant detail pages render fine even when closed (menu visible, order CTAs disabled by the detail page's own logic). Users may still want to browse the menu of a closed restaurant for tomorrow. Demotion + label is the right level of friction.

    • Phase 74: Bazar landing failsafe-timeout bump 10 s → 45 s (fix "frozen, no items" UX)

    - Reported by the owner — bazar landing showed "no items" / "frozen" state on first hit. Audit traced it to a dev-only false trip of the page's 10 s failsafe timeout, not a real outage. - Diagnosis: - next build + tsc --noEmit clean — no code-level bug. - Fresh dev server probe of GET /api/bazar/menu?per_page=5&page=1: - First call: 200 in 60 s (next.js: 15.5 s, proxy.ts: 14.0 s, application-code: 30.4 s). - Second call: 200 in 85 s (next.js: 15.2 s, proxy.ts: 21.8 s, application-code: 48 s). - The API was returning the correct data; the landing's hard 10 s setTimeout was flipping the page to the offline state before the response arrived. - Underlying slowness is Turbopack dev compile + per-request module-evaluation cost (Next 16 / Turbopack first-hit). The bazar landing's import graph is wide enough that a cold compile blows past 10 s easily. Production builds aren't affected — the production bundle is pre-compiled. - Fix (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)): - Bumped the bazar landing's failsafe timeout from 10 s to 45 s. - The timeout's original purpose (catch a genuinely stuck mobile fetch and surface the "Try again" screen) still works — 45 s is still well below any reasonable user patience window — but it no longer false-trips on Turbopack cold-compile. - No API change — /api/bazar/menu was returning correct results; the issue was purely client-side timing. - Why not just remove the timeout — a stuck mobile connection on a flaky network really does need the fallback screen so the user knows to retry. The "Try again" button is a real piece of UX. Removing the timeout would leave them on the skeleton forever. - Why not optimise the menu API instead — the API itself isn't slow; cold Turbopack compile is. Production doesn't pay that cost. Adding caching or other optimisations to dodge a dev-only symptom would add complexity for no real-world gain.

    • Phase 73: Open-first sort extended to cuisine + city + combo aggregator pages

    - Sweep continued from Phase 70/71 — three more bazar SEO pages had the closed-restaurants-interleaved problem: /bazar/cuisine/[slug], /bazar/city/[slug], and /bazar/cuisine/[cuisine]/in/[city]. All three sorted by rating desc → name asc with no open/closed signal. - Files (cuisine/bazar/cuisine/[cuisine]/page.tsx), city/bazar/city/[city]/page.tsx), combo/bazar/cuisine/[cuisine]/in/[city]/page.tsx)): - ResolvedListing grows isOpenNow?: boolean (defaults to true when hours are missing — a misconfigured tenant isn't quietly demoted). - canAcceptOrders + WeekHours + SpecialDay imported per page (same modules the menu / sponsored / recommendations routes use). - openNow computed in the same tdb fetch that already pulled reviews for rating — no new fan-out reads per listing. - Sort upgraded from rating → name to open → rating → name. V8 stable sort preserves rating-then-name as the within-group tiebreaker. - SEO consideration documented — the comment on the cuisine page previously read "stable for SEO". Phase 73 acknowledges that the displayed order now varies through the day as opens/closes flip, but the canonical URL, <title> / <meta description>, and the ItemList JSON-LD stay byte-stable across hour boundaries. Search engines see the same listing set; only the rendered card order shifts. - Coverage — every bazar surface that shows a list of restaurants or food items now sorts open-first: - Sponsored rail / Featured rail / Browse All Food (Phase 70 — via /api/bazar/menu + /api/bazar/sponsored). - Recommendations rail (Phase 71 — both signal + cold-start modes). - Restaurants directory list (Phase 71 — via /api/bazar/restaurants). - Cuisine aggregator, city aggregator, cuisine × city combo aggregator (Phase 73).

    • Phase 72: Bazar landing — Featured rail + fix click-through on items with options

    - Reported by the owner — two adjustments after Phase 70/71: (1) the "Featured" section between Sponsored and Browse All wasn't appearing at all, and (2) clicking on a food card with customisation options did nothing (no navigation, no add to cart). - Featured section ([src/app/(bazar)/bazar/page.tsx](src/app/(bazar)/bazar/page.tsx)): - Filter changed from i.offerPrice != null && i.offerPrice < i.price to i.featured === true. The previous filter required a discounted price; a bazar with no active discounts (which is the steady state when no operator is running a sale) silently hid the entire section. The new filter surfaces whatever items operators have explicitly flagged as featured. - Section relocated from between Search and Browse All to between Sponsored and Search — matching the owner's specified rhythm of Sponsored → Featured → Show All. - Heading and copy reworded from "Today's Best Offers" / "Save big — these deals won't last long" / "Limited Time" to "Featured" / "Hand-picked highlights from restaurants on the bazar" / "Curated picks". Same visual treatment otherwise; gradient softened from red-500 → orange-500 to amber-500 → orange-500 so it doesn't read as a sale flag. - "See all" button retained but now points at ?featured=true, which surfaces the same items the rail shows. - Filtered Browse-All heading updated from 🔥 Best Offers to ⭐ Featured to match the new naming. - Item click navigation fix: - The bug — the FoodCard quick-add button's onClick called e.preventDefault() before the if (item.hasOptions) return guard. For items with options the handler ran preventDefault() (killing the parent <Link>'s navigation) and then returned (skipping the addItem call). Result: clicking the "+" on a customisable item did absolutely nothing. The comment in the code claimed "The parent <Link> still navigates to the detail page on its own unless we eat the click — let it through" but that was contradicted by the preceding line. - The fix — invert the order: if (item.hasOptions) return; first (no preventDefault → the parent Link navigates normally to the picker on the detail page); preventDefault + addItem only on the no-options path. - Other paths unchanged — items without options still quick-add silently; closed items still render the "Closed" pill instead of a button. - Verification — tsc --noEmit clean, next build green. Both rail visibility and item click-through covered by the same single file change.

    • Phase 71: Open-first sort extended to recommendations + restaurants list

    - Follow-up to Phase 70 — the same closed-restaurants-interleaved problem the owner flagged on the bazar landing's three rails also exists on adjacent surfaces. Phase 71 sweeps the same principle (open first; prior ranking is the tiebreaker) into the recommendation rail and the paginated restaurants directory. - `/api/bazar/recommendations` (src/app/api/bazar/recommendations/route.ts): - `Recommendation.isRestaurantOpen: boolean` added to the response shape so the sort doesn't need a second pass and any future "Closed" badge in the rail card can read the flag directly. - Both modes covered: - Signal mode (user has favourites / cuisine prefs) — open-first becomes the first sort key; score → rating → name preserved as the existing tiebreakers within each open/closed group. - Cold-start mode (no signals — "Popular this week") — same change: open-first first, then existing recent-order count / rating / name chain. - DB read folded — openNow is computed from the same tdb fetch that already pulled reviews for rating. No new fan-out reads per listing. - Defensive default — isRestaurantOpen = true when tdb can't be read or openingHours is missing. A misconfigured tenant isn't quietly demoted off the rail. - `/api/bazar/restaurants` (src/app/api/bazar/restaurants/route.ts): - One-line stable sort on the valid array before the pagination .slice — identical pattern to the Phase 70 fix on /api/bazar/menu. - Same V8 stable-sort guarantee preserves whatever prior ranking the directory had (featured-first by default). - The endpoint already computed openNow per listing in Phase 7a; Phase 71 just uses that existing field. - Now sorted consistently across every bazar surface: Sponsored rail (Phase 70), Today's Best Offers (Phase 70 inherited via items.filter), Browse All Food (Phase 70), Recommendations rail (Phase 71), Restaurants directory (Phase 71). A customer never encounters a closed restaurant ahead of an open one without intentionally filtering for it.

    • Phase 70: Closed restaurants demoted in all bazar landing sections

    - Reported by the owner — in the bazar landing's three rails (Sponsored, Today's Best Offers / Featured, Browse All Food), closed restaurants were interleaved with open ones in every section. A customer scrolling the front page kept hitting items they couldn't order without first going elsewhere. - Section order is already correct as the owner specified (Sponsored → Featured → Show All) — the issue was strictly the within-section sort. - Three sections, two APIs, one principle — open restaurants always rank above closed ones; the previous ordering acts as the tiebreaker within each group. - Why server-side, not client-side — the Browse All grid is paginated (24 items / page). Sorting on the client would only re-order the current page, so closed items from page 1 would still displace open items into page 2. The fix has to happen before the .slice(start, start + perPage) call. - `/api/bazar/menu` (src/app/api/bazar/menu/route.ts): - One-line stable sort after the results loop and before the pagination math: (b.isRestaurantOpen === false ? 0 : 1) - (a.isRestaurantOpen === false ? 0 : 1). - Closed → 0, open / unknown → 1, so open sorts first. - Covers both the Browse All Food rail and the derived Today's Best Offers rail (offerItems is computed client-side as items.filter(i => i.offerPrice != null), so it inherits the same order). - `/api/bazar/sponsored` (src/app/api/bazar/sponsored/route.ts): - New per-candidate isRestaurantOpen field computed via canAcceptOrders(openingHours, specialDays) — the same helper the menu API uses, so the open/closed signal is consistent across surfaces. - Sort upgraded from "bid + relevance score only" to "open first, then by score". A closed restaurant with a higher bid never displaces an open one in the rail; bid still wins among open restaurants and again among closed ones. - Imports canAcceptOrders and the related WeekHours / SpecialDay types — same shape as the menu route. - V8 sort is stable (Node 12+ / Chrome 70+ — both since 2019). The relative order among open items and among closed items is preserved within each group, so existing scoring (popularity, bid amount, geo) still drives the within-group order. - Defensive default — items with no isRestaurantOpen field are treated as open. The field is set today on every code path, but this guards against a future listing surface forgetting to populate it.

    • Phase 69: Per-language SDK examples docs article

    - Closes more of the Phase 3 docs deferred — "...per-language SDK examples (Node / Python / PHP)" of the original "Postman collection download, downloadable OpenAPI spec, additional webhook events, per-language SDK examples" checklist. Developers had to translate the cURL examples in api/orders by hand into their target language; Phase 69 ships paste-ready snippets for the four most-common stacks. - Article (src/lib/docs-center/articles.ts) — new slug: 'sdk-examples' in the api category. - Scope — single endpoint (POST /api/public/{tenantId}/orders) shown four ways. Picking one canonical endpoint kept the article focused; pattern transfers immediately to any of the other public routes. - Languages covered: - cURL — the universal baseline; matches the syntax used elsewhere in the docs. - Node.js (fetch) — Node 18+ native fetch, no dependencies. Async/await with explicit 429 handling reading Retry-After. - Python (requests) — requests 2.31, Python 3.10+. Uses raise_for_status() for the happy path and explicit 429 handling on top. - PHP (cURL) — ext-curl (shipped with every PHP install), no Composer dependency. Explicit RuntimeException on 429 / 4xx so the example is copy-paste safe into an existing app. - Each snippet sends the same payload — different idiom, identical body — so a developer comparing two languages can spot the structural identity immediately. - FAQ answers the three obvious follow-ups: where the tenant ID comes from (subdomain), why we don't ship language-native SDKs (openapi-generator-cli is downstream), and whether there's a sandbox (no — demo tenants reject orders with 403 DEMO_MODE_ACTIVE). - Next steps section points at idempotency, status polling, and rate-limit back-off — the three things a developer asks about within minutes of the first successful POST. - Cross-references — listed under relatedSlugs for api/orders, api/getting-started, api/idempotency, and api/rate-limits. Reverse links from those articles' related-slugs will pull the article into the recommended-reading rail.

    • Phase 68: Bazar auth endpoints in the Postman + OpenAPI exports

    - Closes the Phase 65/66 scope gap — the first cuts covered the public order / reservation / menu / payment / gift-card surface but skipped the bazar customer-auth flow. Developers integrating against the marketplace needed register → login → me → logout to even get a session cookie before any of those public endpoints worked end-to-end. - Postman collection (src/app/api/docs/postman-collection.json/route.ts): - New "Bazar auth" folder at the top of the collection — Register, Login, Current user, Logout. - Each request carries the realistic body template (Jane Doe / strong password placeholder / gdprConsent: true) and a description noting the cookie-session model, password length floor, and the Phase 67 rate-limit ceilings on login. - Folder description spells out the no-token-header / bazar_user_session cookie model so developers don't waste time looking for an Authorization header. - OpenAPI 3.0 spec (src/app/api/docs/openapi.json/route.ts): - New `Bazar auth` tag added to the tag list (registered at the top so generated docs render it first). - `BazarRegisterRequest` and `BazarLoginRequest` components — the register schema enforces gdprConsent: true via enum: [true] so generated clients know the server treats false as a 400. - Four new paths (/api/bazar/auth/register|login|me|logout) — each carries tags, summary, description, and the relevant response codes. Login wires in the RateLimited shared response (Phase 67's stacked cap referenced in the description text). - Why both formats stay in sync — same hand-authored TypeScript source-of-truth in each route. Future API additions land in both files together so the Postman and OpenAPI downloads never drift.

    • Phase 67: Stacked global rate-limit ceiling on high-risk endpoints

    - Closes the Phase 24 deferred — "No global limit — only per-(route, principal). Worst-case a botnet from 1000 unique IPs each hitting the report endpoint 5 times pre-trips no limiter. Mitigation: cuts off at the auto-hide threshold (Phase 18) anyway. Future polish: stacked global cap." Phase 24 protected against single-attacker spam; Phase 67 adds the distributed-attack ceiling that catches coordinated abuse. - Limiter (src/lib/rate-limit/limiter.ts): - `RateLimitOpts.globalLimit?: number` + optional `globalWindowSec` (defaults to windowSec when omitted). When set, the limiter runs the per-principal check first AND a second check against a shared __global__ principal bucket. Both must pass. - Bucket-sharing math — the global bucket reuses the same check() machinery as the per-principal one, just with principal = '__global__'. Hits get recorded in both buckets when both pass, so the global counter reflects every accepted hit regardless of which IP/user made it. - Response on 429 picks the tighter limit (whichever bucket actually tripped) for Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining. New header `X-RateLimit-Scope` = principal or global so monitoring can distinguish single-attacker spam from distributed flood. Response body also gains scope for the same reason. - Backwards compat — endpoints that don't set globalLimit behave identically to the Phase 24 limiter. - High-risk endpoints now stacked: - bazar/review/report: per-IP 5/10min + global 200/10min. A botnet of 1000 IPs each at 5 hits would trip the global cap long before it caused real damage. - bazar/review/submit: per-user 10/hour + global 500/hour. Coordinated bot army faking unique accounts gets caught. - bazar/auth/login: per-IP 8/15min + global 300/15min. Common-password lists across many IPs trip the global before per-IP catches each. - Logging — when a request trips the limit the warn log now includes the scope field. Operations can grep on scope: 'global' to see whether they're looking at distributed abuse versus a noisy single client. - Known limitations / deferred (still open): - In-process global bucket — same single-Node-process constraint as the per-principal limiter. The "deferred" entry for cross-instance state (Redis / Upstash) still applies. A multi-pod deployment would need to share both buckets; documented in the Phase 24 limiter file.

    • Phase 66: OpenAPI 3.0 spec download alongside the Postman collection

    - Closes more of the Phase 3 docs deferred — Phase 65 shipped the Postman v2.1 collection; Phase 66 publishes the same surface as an OpenAPI 3.0 document so Swagger UI / ReDoc / Stoplight / openapi-generator / swagger-typescript-api users get the same one-clicker. - Why both formats — Postman (collection v2.1) and OpenAPI (3.0) are the two dominant consumption shapes; tools can usually round-trip between them, but starting from a native artefact is always cleaner than converting. - API (src/app/api/docs/openapi.json/route.ts): - GET /api/docs/openapi.json — returns an OpenAPI 3.0.3 document with Content-Disposition: attachment; filename="restora-public-api.openapi.json". Browser Save As fires automatically. - `components.schemas` — typed Error, OrderItem, OrderRequest, ReservationRequest. The Error envelope matches the runtime shape ({ error, message? }); OrderRequest documents the optional customerCoords field the Phase 55 radius gate consumes. - `components.parameters` — reusable TenantId / OrderId / ReservationId path params; reused via $ref across every route's parameters array. - `components.responses` — RateLimited (with Retry-After header) and ValidationError. Both reused via $ref. - 5 tags, 12 paths — same coverage as the Postman collection (Orders submit/get/cancel, Reservations config/availability/create/cancel, Menu filters, Payments intent/coupon, Gift cards purchase/lookup). Each path carries description text that calls out auth, rate limits, and OUTSIDE_RADIUS 409 when relevant. - 5-minute browser cache mirrors the Postman endpoint. - Docs UI ([src/app/docs/\[category\]/page.tsx](src/app/docs/[category]/page.tsx)): - The Phase 65 gradient banner gains a second download button (OpenAPI) next to the Postman button. Orange-filled for Postman (primary), white-on-orange-ring for OpenAPI (secondary) — both equally functional, just different shapes. - Banner copy reworded from "Postman collection" to "API references — download" to cover both downloads. - flex-wrap on the banner so the title block + buttons reflow cleanly on narrow viewports.

    • Phase 65: Postman v2.1 collection download for the public API

    - Closes part of the Phase 3 docs deferred — "Postman collection download, downloadable OpenAPI spec, additional webhook events (`reservation. / giftcard.*` / `commission.*`), per-language SDK examples..."* Developers integrating with the public API previously had to copy-paste endpoint URLs from individual docs articles into Postman by hand. Phase 65 makes the import a one-clicker. - **Why Postman v2.1 over full OpenAPI 3.0** — same import target (Postman, Insomnia, Hoppscotch all consume v2.1), smaller hand-written surface, doesn't need full response schemas. Postman exports OpenAPI from the collection if downstream conversion is needed. - **API** ([src/app/api/docs/postman-collection.json/route.ts](src/app/api/docs/postman-collection.json/route.ts)): - `GET /api/docs/postman-collection.json` — returns the collection as a JSON document with `Content-Disposition: attachment; filename="restora-public-api.postmancollection.json" so browser File Save dialogs fire automatically. - **Variables** ({{baseUrl}} / {{tenantId}} / {{orderId}} / {{reservationId}}) so importers can swap environments without editing every request body. - **Default baseUrl** points at https://restora.io; tenant ID defaults to YOURTENANTID placeholder. - **Cache-Control: public, max-age=300** — the collection is hand-authored and doesn't change per request; 5-minute browser cache is enough. - **Coverage** — five folders, ten endpoints across the most-documented surfaces: - **Orders** — Submit order, Get order, Cancel order. - **Reservations** — Get config, Check availability, Create reservation, Cancel reservation. - **Menu** — Get menu filters (allergen + dietary tag taxonomy). - **Payments** — Create payment intent, Validate coupon. - **Gift cards** — Purchase gift card, Look up gift card balance. - **Each request** carries realistic body templates (Jane Doe / +44 7700 900123 / sample products) and a description field with auth notes, rate-limit limits, and the OUTSIDE_RADIUS 409 callout (Phase 55) so the collection is self-documenting on hover in Postman. - **Docs UI** ([src/app/docs/\[category\]/page.tsx](src/app/docs/[category]/page.tsx)): - New gradient banner at the top of the **API** category landing — cat.id === 'api' only. Heading + tagline + amber Download button. - Banner uses native <a download>` so the browser handles the save dialog; no JS framework dependency. - Other category landings (Getting Started / Restaurant / etc.) are unaffected.

    • Phase 64: Cross-links from cuisine + city parents to top combo destinations

    - Closes the Phase 20-era deferred — "Cross-links on parent cuisine/city pages don't yet point at the top combo destinations — would require expensive DB scans on every parent page load to compute 'which combos have listings'. Future polish could precompute this offline." Parent pages had generic chip strips ("Other cuisines" / "Other cities") but no signal about which /bazar/cuisine/<slug>/in/<city> combo pages actually had listings — wasting both the user's navigation and Google's crawl budget on dead ends. - Helper (src/lib/bazar/combo-index.ts): - `buildIndex()` walks every approved listing once: reads its tenant DB to extract the city via extractCity(address), then iterates MARKETPLACE_CATEGORIES matching by slug or keywords. Tally (cuisineSlug, citySlug) → count map. The walk is genuinely expensive (one tenant DB read per listing) so the result feeds the cache rather than running per-request. - 5-minute global module-memory cache — same TTL pattern as Phase 60's marketplace-stats. Every parent page hit during the window reuses the index; a stale read of up to 5 minutes is well within acceptable SEO latency. - `topCombosForCuisine(slug, limit)` and `topCombosForCity(slug, limit)` — filtered slices over the shared index. Both return { cuisineSlug, cuisineName, cuisineIcon, citySlug, cityName, count } sorted by count desc with name asc tiebreaker (deterministic for crawlers). - Cuisine page ([src/app/(bazar)/bazar/cuisine/\[cuisine\]/page.tsx](src/app/(bazar)/bazar/cuisine/[cuisine]/page.tsx)): - New "Popular {cuisine} cities" chip strip above the existing "Other cuisines" strip. Each chip links to /bazar/cuisine/<slug>/in/<city> with the listing count badge. - Orange-tinted (vs the gray neutral chips below) so the eye is drawn to the higher-intent destinations first. - Conditional rendering — when the index returns zero combos (new bazar with no approved listings), the existing "Other cuisines" section regrows the top divider it normally borrows from this new section. Avoids an awkward double-divider gap. - City page ([src/app/(bazar)/bazar/city/\[city\]/page.tsx](src/app/(bazar)/bazar/city/[city]/page.tsx)): - New "Popular cuisines in {city}" chip strip above the existing "Other cities" + "Browse by cuisine" sections. Same visual treatment as the cuisine-page strip. - Uses the cuisine icon as the chip glyph so the visual scan reads as cuisine choices first, city later — matches the "I'm in {city}, what should I eat?" mental model. - SEO value — these chips materially raise the internal-linking density toward the combo pages without producing 404 paths. Every link points to a route that already exists and has approved restaurants behind it. - Cache invalidation — none, by design. A new approved listing waits up to 5 minutes to appear in the combo index. Acceptable for an SEO surface; a webhook-driven invalidation would be premature for the file-DB topology.

    • Phase 63: Dry-run preview for bulk feature-grant campaigns

    - Closes the Phase 15-era deferred — "No undo / dry-run — once a campaign runs, the only way to roll back is another campaign with `enabled: false`. A dry-run preview ('would affect 12 tenants — these are the IDs') would be a Phase 16 polish." Campaigns touch dozens of tenants in one click; a typo in the tenant cohort or feature ID was painful to walk back. Preview makes the impact visible before the writes happen. - API (src/app/api/admin/feature-grants/bulk/route.ts): - New dryRun?: boolean body field. Runs the full validation + selection pipeline (featureId lookup, tenant ID resolution, dedupe, skip-list) but short-circuits before the masterDB write and before the per-tenant activity logs. - Response splits the affected tenant IDs into willReplace[] (existing grant for the same featureId about to be upserted) and willCreate[] (fresh grant). The admin can see "this would replace 4 existing comps and create 8 new ones" before committing. - Returns the same grants[] objects the live run would have written, so the inspector can verify the exact payload (expiresAt, addonPriceMonthly, notes) without a second probe. - Live runs keep their exact existing response shape — no breaking change for anyone scripting against the endpoint. - Admin UI (src/app/admin/dashboard/features/page.tsx): - Preview button sits next to Run as a 1:2 grid (small Preview, large Run). Dark-purple outline vs. solid purple keeps the visual hierarchy correct — Preview is the safer, secondary action. - Impact panel appears above the buttons after a successful preview — three stat tiles (Would apply / Would replace / Would create) with explicit colour treatment (amber for replace, emerald for create) so the destructive vs. additive split is read at a glance. - "Nothing has changed yet" banner copy and Sparkles icon make the no-commitment state obvious. Once the admin clicks Run, the preview state clears so the post-run success card replaces it. - Skipped count also shown in the preview panel so unknown tenant IDs surface before the live run rather than as a quiet "skipped: N" in the success message. - Disable interlock — Preview disables while Run is in flight and vice versa, so an admin can't issue both at once.

    • Phase 62: Super-admin backfill for verified-purchase badges on bazar reviews

    - Closes the Phase 21 deferred — "No backfill — existing reviews stay unverified even if the reviewer has orders. We could run a one-off script that re-scans every bazar review against its tenant's order history, but the false-negative rate from email-mismatch makes it a low-priority polish." The false-negative problem from email mismatch was largely closed by Phase 22 (FK at checkout) and Phase 39 (opportunistic email backfill on read) and Phase 46 (manual claim). Phase 62 now sweeps legacy reviews against that strengthened pipeline. - API (src/app/api/admin/bazar/reviews/backfill-verification/route.ts): - GET — coverage stats. Walks every approved listing's reviews, counts bazar-source reviews already verified, unverified, and upper-bound flippable (those whose reviewer could match an order). No mutation. - POST — runs the backfill. Same two-tier match logic as Phase 21/22 at submit time: 1. Strong FK — Order.bazarUserId === Review.bazarUserId + paymentStatus === 'paid'. 2. Email fallback — Order.customerEmail case-insensitive match against the bazar user's email (when looked up via Review.bazarUserId). - Bounded per run (default 50, cap 200). One save per tenant DB with dirty changes — partial-progress tolerant; a save failure on one tenant doesn't break the loop. - Returns summary.remaining so the operator sees how many more runs they need. - Admin UI (src/app/admin/dashboard/food-bazar/page.tsx): - New "Bazar review verification" panel in GeoTab, below the Customer saved addresses panel. Same UX pattern: percentage bar, totals, "Verify N now" button (label reflects whatever the actual flippable count is, capped at 50). - Results card after each run: emerald Verified / gray No match / amber Remaining. Amber "run again to continue" hint when more work remains. - Conditional render — panel only appears when there's at least one bazar review on the platform. A fresh deployment with zero reviews keeps a clean tab. - Composition with the verification tiers: - Phase 21: submit-time check — new reviews flip on the spot when the order is already there. - Phase 22: FK at checkout — every new order ships with a strong link. - Phase 39: opportunistic email backfill on read — closes the FK gap for legacy orders the user has touched. - Phase 46: manual claim — covers the cross-email case. - Phase 62: super-admin batch sweep — closes whatever's still stale at scale.

    • Phase 61: Discard-changes Cancel button on bazar item-detail edit mode

    - Closes the Phase 45 deferred — "No 'discard changes' path beyond browser back — the customer cancels by hitting Back. A dedicated Cancel button could be a future polish." Browser Back works but is unobvious on desktop and varies on mobile (swipe vs. button vs. gesture); a visible Cancel button is self-documenting. - UI ([src/app/(bazar)/bazar/restaurant/\[slug\]/item/\[productId\]/page.tsx](src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx)): - Cancel button added to the right edge of the Phase 45 edit-mode banner. White background, orange ring, uppercase label — visually subordinate to the orange "Update cart" CTA so users don't accidentally tap Cancel when they mean Update. - Click handler calls router.back() — same path as the post-Update navigation, so the user lands wherever they came from (cart drawer → cart drawer position; checkout summary → checkout summary). - Title attribute spells out "Discard changes and return to where you were" for hover and screen readers; the button label itself stays one-word for tap-target speed. - Banner layout kept the same — text on the left, Cancel on the right, ShoppingCart icon unchanged. No height change, no layout shift.

    • Phase 60: Cross-restaurant marketplace aggregate review stats

    - Closes the Phase 7a deferred — "Reviews aggregation is per-tenant only; cross-restaurant marketplace aggregate review surfacing (e.g. 'average ⭐ across all listings') is a future polish." A new bazar customer landing on /bazar had no immediate signal that the marketplace as a whole was trustworthy — only per-restaurant ratings once they drilled in. Phase 60 surfaces the headline numbers up-front. - API (src/app/api/bazar/marketplace-stats/route.ts): - GET /api/bazar/marketplace-stats — anonymous-safe; returns { restaurants, ratedRestaurants, totalReviews, averageRating, recentReviews30d, generatedAt }. - Weighted (unweighted) mean — every approved + non-hidden review counts once, not once-per-restaurant. A merchant with 200 reviews carries the math more than one with 3, which matches what a "marketplace average" should mean. - 30-day recency window computed alongside so the strip can show "activity now," not just lifetime totals. - 5-minute in-module cache (let cached: { at, payload }). A landing-page hit during the TTL window reuses the cached payload — repeated visits don't fan out to N tenant DBs per render. A stale read ≤ 5 min is well within the "trust strip" accuracy budget. - Fail-soft — bazar marketplace disabled or DB load failure returns zeros, not an error. The client renders nothing in that case. - UI (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)): - New MarketplaceStatsStrip component placed at the top of the landing's main column, just above the recommendation rail. - Three-pill layout — Avg rating (amber, Star icon) · Total reviews (rose) · Reviews last 30 days (emerald, TrendingUp icon). Gradient background (amber → orange → rose) keeps it visually distinct from the rest of the page without screaming. - `MarketplaceStatsStrip` returns null when totalReviews === 0 or the rating is null. A brand-new bazar deployment with zero reviews doesn't get a "0.0 ★" embarrassment on its hero page — the strip simply doesn't render. - Mobile-responsive — text scales from text-2xl on small screens to text-3xl on sm: and up; labels truncate via uppercase + tracking. No horizontal scroll on small viewports.

    • Phase 59: "Why am I seeing this?" deep-dive on recommendation chips

    - Closes the Phase 28 deferred — "No 'Why am I seeing this?' deep-dive — the chip's one-liner is the explanation. A future polish could let the user click for 'Because you liked Anna's pick and Sakura, plus your preferences include Japanese'." Recommendations stay opaque without it; users wonder why the model picked something and either trust it blindly or ignore the rail entirely. - API (src/app/api/bazar/recommendations/route.ts): - `Recommendation.reasons?: string[]` — full breakdown of contributing signals. The existing one-line reason stays as the headline; reasons is the click-to-expand detail. - Signal-based mode lists, in priority order: matched preferred cuisines (from subscriptionPrefs.preferredCuisines), matched favourite cuisines (from listings the user already saved), and a "strong rating" call-out when avg ≥ 4.0 and review count ≥ 3. - Cold-start mode is explicit that the signals are marketplace-wide rather than personal: paid-order count this week, strong rating, and a closing hint that personalised picks unlock once the user sets prefs or saves a favourite. - No new DB reads — every signal is already in scope at the scorer level. - UI (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)): - Chip is now a button, not static text. Clicking expands an indigo-tinted panel below the card with a bullet list of the reasons. Clicking again collapses. - `openWhy` state tracks a single open card at a time so the rail stays scannable (closing the previous opens the new). - Click event eats `preventDefault` + `stopPropagation` so opening the panel doesn't navigate to the restaurant page (the rest of the card is still a Link). The panel itself also stops propagation so an accidental tap inside doesn't navigate either. - `?` suffix on the chip (subtle, normal-case, indigo-400) hints that more is available without shouting. Title attribute also reads "Why am I seeing this?" for hover. - `aria-expanded` mirrored on the button for screen-reader navigation. - Cards without `reasons` still render the chip but with no ? and no panel — graceful degrade for any stale cached payload.

    • Phase 58: SMS deep-link beside tel: on bazar restaurant phone CTAs

    - Closes the Phase 25 deferred — "`tel:` only — no `sms:` for SMS — could be added on a long-press or 'More' menu in a future polish." "I want to text the restaurant about my order" is one tap shorter than tap-and-hold; this exposes it as a sibling button. - Bazar order detail ([src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)): - The lone tel: chip becomes a two-button group (Call + SMS). MessageSquare icon for SMS so the visual distinction is immediate. Both buttons strip whitespace from the phone identically (digits-only target). - title attribute on Call now spells out the number; SMS reads "Send SMS to the restaurant" so screen readers / hover users don't get a bare icon. - Bazar restaurant client ([src/app/(bazar)/bazar/restaurant/\[slug\]/RestaurantClient.tsx](src/app/(bazar)/bazar/restaurant/[slug]/RestaurantClient.tsx)): - The "Quick Actions" grid (Call / Directions / Share) gains an SMS tile next to Call. Matches the visual treatment of the existing actions (icon over label, hover tint). - Reuses the existing MessageSquare import — no new icon dependency. - Why both protocols even on desktop — desktop browsers handle sms: inconsistently (some dispatch to an installed app like iMessage on macOS, some no-op, some prompt to pick an app). The cost of exposing a non-functional link on desktop is essentially zero — the button is a visual hint, not a contract — and the mobile win is significant. - Discoverability vs. clutter — the alternative (long-press or "More" menu from Phase 25's deferred suggestion) hides the action behind a gesture nobody knows about. A second visible button costs ~24px of space and is self-documenting. - Stale deferred swept — also reviewed the Phase 20 deferred "`MARKETPLACE_CATEGORIES` is food-type-focused — `/bazar/cuisine/italian` is a 404"; the Phase 11 ethnicity aliases (italian / japanese / french / mediterranean / korean / vietnamese) shipped in src/lib/bazar/categories.ts already close it. No action needed.

    • Phase 57: Anonymous geocode-preview for guest checkout

    - Closes the Phase 56 leftover — "Anon checkout still falls through. Customers ordering without a bazar account can't hit the preview endpoint (401)." Without this fix, the entire Phase 55 radius gate was invisible to guest orders — the server fell back to fail-open whenever the body lacked customerCoords, and guests never had a way to populate it. - API (src/app/api/bazar/geocode-preview/route.ts): - Two-tier rate limit — auth tier keeps its 20/5min per-user budget; anon tier gets a stricter 5/5min per-IP budget on a separate bazar.geocode.preview.anon bucket. - The auth tier earns the higher budget because we have an account to suspend if it abuses; the anon tier sits behind the same Phase 50 Nominatim 1.1s throttle either way, so the cumulative impact on the upstream provider is bounded. - No body / payload changes — the endpoint signature stays identical, so the client just stops getting 401s. - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - Dead 401 branch removed — the manualGeoStatus = 'idle' special-case for 401 responses is now unreachable. A non-OK response (429 rate-limit, 5xx server error) falls into the same 'miss' branch — the user sees the existing fail-open message and the order proceeds without coords. - Composition with the rest of the chain: - Saved addresses with stored coords (Phase 42 / 49 / 54) → still the strongest signal. - Authenticated manual entry (Phase 56) → 20/5min preview budget. - Anon manual entry (Phase 57) → 5/5min preview budget. - All paths populate customerCoords in the order POST → server-side Phase 55 gate fires the same way. - Why per-IP for anon, per-user for auth — a single shared-NAT household sharing one bazar account isn't penalised by another tenant's typo; an IP isn't penalised twice for the same machine logging in. The buckets are independent (route field differs) so a logged-out user who later signs in starts fresh on the auth tier.

    • Phase 56: Geocode preview for manually-entered checkout addresses

    - Closes the Phase 53 + 55 leftover — "No equivalent for the inline 'new address' path — distance only renders for saved addresses. Manually typed addresses can't be measured client-side without an extra geocode call. Deferred." First-time customers who type an address at checkout without saving it were invisible to the Phase 55 radius gate; the server fell back to fail-open. Phase 56 closes that gap with a debounced preview call. - API (src/app/api/bazar/geocode-preview/route.ts): - POST /api/bazar/geocode-preview — body { addressText, postcode? }, returns { coords: { lat, lng } | null }. - No persistence. The endpoint geocodes and returns; nothing is written. Saved-address geocoding still goes through the Phase 42 / 49 / 54 persistence paths. - Auth: bazar session required (the geocoder costs money / shared community resources). 401 anon. - Rate limit: 20 attempts / 5 minutes per user via requireRateLimit. Combined with the client's 800ms debounce + the Phase 50 Nominatim 1.1s throttle, well within polite usage and a hard stop on scripted abuse. - Reuses `geocodeSavedAddress` — same 3-second hard timeout, same provider chain (Google → Nominatim). - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - `manualCoords` + `manualGeoStatus` state — 'idle' | 'resolving' | 'hit' | 'miss'. Resets to idle when the user picks a saved address (then the saved address's stored coords win). - Debounced effect — 800ms after the last deliveryAddress or postcode keystroke, fires the preview call. Conditional on delivery mode + manual-entry path + address text ≥ 3 chars. Cancellation token guards against in-flight responses landing after the user typed more. - `activeCoords` picks the picked saved address's coords first, then falls back to manualCoords. The downstream pickedDist + outsideRadius derived state work unchanged — same gate applies to both paths. - `customerCoords` submit payload — now sends pickedAddr?.coords ?? manualCoords for delivery orders. The server treats both paths identically (Phase 55 enforcement). - Inline status indicator below the postcode field, manual-entry only: - 'resolving' → "Resolving location…" (gray). - 'hit' → "{distance} from {restaurant}" with map-pin icon, blue when within range, amber when outside. - 'miss' → "Couldn't locate this address — delivery will be attempted anyway." (gray). This is the fail-open path: the server-side check would also skip if coords are absent. - Composition: - Saved addresses → distance from stored coords (Phase 42 / 49 / 54). - Manual entry → distance from preview-geocoded coords (Phase 56). - Both paths → server-side radius check (Phase 55) → 409 OUTSIDE_RADIUS when over. - Known limitations / deferred: - Anon checkout still falls through. Customers ordering without a bazar account can't hit the preview endpoint (401). The radius gate only fires when they pick a known restaurant + their bazar session signs them in. Anonymous-safe geocoding would need its own rate-limit and is a future polish.

    • Phase 55: Hard delivery-radius enforcement at bazar checkout

    - Closes the load-bearing half of the Phase 42 / 53 deferred — "No hard radius enforcement at submit — checkout still doesn't refuse orders outside a radius. Needs a `deliveryRadiusKm` field on tenant settings + a server-side check in `/api/public/[tenantId]/orders`. Future phase." Phases 42 / 49 / 53 / 54 built the geocoding pipeline + distance display; Phase 55 turns it into a real gate. - Schema (src/lib/db/schema.ts): - TenantSettings.deliveryRadiusKm?: number — straight-line cap in km. Absent or 0 → no enforcement (default for existing tenants; nothing breaks for anyone who hasn't opted in). - Documentation comment makes the fail-open contract explicit so future readers don't introduce a surprise hard block when the data isn't there. - Validation (src/lib/validation/schemas.ts): - UpdateSettingsSchema accepts deliveryRadiusKm clamped to [0, 500]. 500 km handles even bicycle-delivery ambition without typos breaking the DB. - CreateOrderSchema accepts an optional customerCoords: { lat, lng }. Required for the server-side enforcement to fire — absent → fail-open. - Server-side enforcement ([src/app/api/public/\[tenantId\]/orders/route.ts](src/app/api/public/[tenantId]/orders/route.ts)): - Inside the POST handler, after auth + closed-check + body parse + before item validation, computes haversineDistanceKm between restaurant and customer coords when all three sides are present (type === 'delivery', deliveryRadiusKm > 0, restaurant has coordinates, body has customerCoords). - 409 `OUTSIDE_RADIUS` with { distanceKm, radiusKm, message } payload when over. Client renders the message verbatim — no info-leak concern since this is a customer's own destination distance. - Fail-open everywhere else — missing coords on either side, no radius set, pickup order, manual address entry without coords → the gate is invisible. - API surfaces ([src/app/api/bazar/restaurant/\[slug\]/route.ts](src/app/api/bazar/restaurant/[slug]/route.ts) + [src/app/api/tenant/\[tenantId\]/settings/route.ts](src/app/api/tenant/[tenantId]/settings/route.ts)): - Bazar restaurant detail returns deliveryRadiusKm alongside coordinates (Phase 53). - Tenant settings PATCH allows-list now includes deliveryRadiusKm. - Operator UI (src/app/(dashboard)/dashboard/delivery/page.tsx/dashboard/delivery/page.tsx)): - New "Delivery radius" card at the top of the Delivery Zones tab — separate from per-zone pricing because the cap applies to the whole restaurant. Number input (0–500 km, 0.5 step), Save button, inline error / saved-confirmation chip. - Loads current value via GET /api/tenant/[id]/settings in the same load() cycle as the zones list. - Explanatory copy spells out the fail-open behaviour ("Only enforced when both the restaurant and the customer have geocoded coordinates") so operators know radius=0 isn't an error state. - Bazar checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - ResolvedRestaurant.deliveryRadiusKm typed. - `outsideRadius` derived state — true only when delivery + radius set + picked saved address has coords + distance > radius. - Amber warning panel above the Place Order button when outsideRadius. Messages: "{restaurant} only delivers within X km. Your selected address is Y km away. Switch to pickup or choose a closer saved address." - Place Order button disabled via the existing canSubmit chain — so the client refuses just like the server would, but with a calmer UX. - Submit payload now includes customerCoords when delivery + a saved address with coords is picked. Manual entries omit them (fail-open path). - Composition with the prior geo phases: - Phase 42: save-time geocoding writes coords. - Phase 49: customer Verify button geocodes per-address. - Phase 54: super-admin batch backfill closes the legacy gap. - Phase 53: checkout shows the distance hint. - Phase 55: checkout uses the distance to decide whether to allow the order.

    • Phase 54: Super-admin batch backfill for bazar user saved-address geocoding

    - Closes the Phase 49 + Phase 42 follow-up deferred — "No batch verify — the operator-side super-admin batch backfill is still missing. Customer-side per-address verify covers the visible cases; a cron is a future polish." Phase 49 gave customers a per-address Verify button; Phase 54 gives platform admins the same power across every user at once. - API (src/app/api/admin/food-bazar/geocode/users/route.ts): - GET — returns coverage: total users, total addresses, geocoded count, missing count, coverage percentage, plus a per-user breakdown sorted by missing DESC so admins can spot heavy-tail outliers. Caps the response at 50 users. - POST (body: { limit?: number }) — walks bazar users in order, runs geocodeSavedAddress against every saved address without coords, stops at limit processed (default 25, clamped to [1, 100]). - On success: stamps coords, geoHash, geocodedAt, bumps updatedAt. - On failure: records 'failed' in the per-address result; the address is not skipped permanently — the next run retries. - One save per batch (dirty flag) — not one save per address. - Bounded runtime: 25 × ~1.1 s Nominatim throttle ≈ 28 s worst case. Google-keyed deployments hit Google's higher cap and finish much faster. - Returns summary.remaining so the operator sees how many more runs they need. - Existing restaurant geocoder unblocked (src/app/api/admin/food-bazar/geocode/route.ts): - The hard if (!GOOGLE_MAPS_API_KEY) return skipped gate from before Phase 50 is now too strict — geocodeAddress falls through to Nominatim. Phase 54 swaps the gate for one that only bails when both Google and Nominatim are disabled (GEOCODER_NOMINATIM=off). The "noprovider" reason replaces the legacy `noapi_key. - **Admin UI** ([src/app/admin/dashboard/food-bazar/page.tsx](src/app/admin/dashboard/food-bazar/page.tsx)): - **New "Customer saved addresses" panel** in the GeoTab, below the per-restaurant table. Shows percentage bar, total / verified / missing counts, "Backfill 25 now" button (label reflects whatever the actual cap is when fewer than 25 remain). - **Results card** after each run: green Geocoded / red Failed / gray Skipped / amber Remaining. Amber "run again to continue" hint when more work remains. - **Provider-chain banner**: the legacy "Google Maps API key not configured" red banner is reworded to a blue info panel that explains the active provider (Nominatim fallback) and how to switch / disable. Reflects the post-Phase-50 reality where the system works without a Google key. - **Restaurant geocode button no longer hard-disabled** when Google is unset — only blocked when Nominatim is also off (the matched-server-side new gate). - **Composition with the per-tier model**: - **Strong-link path**: addresses geocoded on save (Phase 42). - **Customer-controlled path**: per-address Verify button (Phase 49). - **Platform-controlled path**: super-admin batch backfill (Phase 54). - All three persist into the same coords / geoHash / geocodedAt` triple, so downstream consumers (Phase 53 distance hints, future radius enforcement) don't care which path the data came from.

    • Phase 53: Distance hints on bazar checkout's saved-address picker

    - Partial close on the Phase 42 follow-up deferred — "Delivery-radius validation at checkout still uses the user-level coordinates / address text. Switching the checkout zone check to prefer the picked saved-address coords is a Phase that touches the order-submit handler and is left as a follow-up." Full radius enforcement still needs a deliveryRadiusKm schema field, but Phase 53 ships the visible UX that turns the Phase 42 storage into something customers actually see: each saved address shows distance-from-restaurant. - Why distance hints first, not enforcement — even without a hard radius rule, "this address is 1.2 km away" tells the customer which option to pick when they have several saved addresses (Home / Work / Mom's place). The data, math, and UI surface land now; enforcement reuses everything and ships in its own phase. - API ([src/app/api/bazar/restaurant/\[slug\]/route.ts](src/app/api/bazar/restaurant/[slug]/route.ts)): - Restaurant detail response now exposes coordinates: { lat, lng } from the bazar listing (Phase 2 geo). Missing when the listing wasn't geocoded — the client falls back to a coordless picker. - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - Distance badge — every saved address with coords gets a small badge next to its label: 120 m under 1 km, 1.2 km otherwise. Reuses the existing haversineDistanceKm helper from @/lib/bazar/geo. - Sort nearest-first — the picker re-orders saved addresses by distance ascending, with the default address pinned to the front so a one-click pickup still hits the user's intended address. Addresses without coords fall to the end. - Badge style — blue pill when the button is unpicked; semi-transparent white when the address is selected (matches the orange picked state without losing contrast). Title attribute shows the exact distance for hover/screen-reader. - Backwards compat — addresses without coords (legacy, or saved before Phase 42) simply render without the badge. No UI break for users who haven't run the Phase 49 Verify pass. - Type updates: ResolvedRestaurant and SavedAddress interfaces in the checkout page grew the optional coordinates / coords fields. No schema change required — both already exist in the underlying DB. - Known limitations / deferred (still open): - No hard radius enforcement at submit — checkout still doesn't refuse orders outside a radius. Needs a deliveryRadiusKm field on tenant settings + a server-side check in /api/public/[tenantId]/orders. Future phase. - No equivalent for the inline "new address" path — distance only renders for saved addresses. Manually typed addresses can't be measured client-side without an extra geocode call. Deferred.

    • Phase 52: Sitemap image `title` + `caption` for richer image SEO

    - Closes the unfinished half of Phase 41's image-extension polish. Phase 41 emitted <image:image><image:loc> only — Google's sitemap-image schema 1.1 also supports <image:title> (shown in image-rich result snippets) and <image:caption> (descriptive context for the crawler). Adding both significantly improves the per-image signal without changing the entry count. - Library change (src/app/sitemap.xml/route.ts): - `SitemapImage` union — string | { loc, title?, caption? }. Plain-string callers (Phase 41 platform branch) keep working unchanged; the new object form opts into title + caption. - `clip(text, max)` helper trims to whole-word boundaries with a trailing ellipsis: title capped at 160 chars, caption at 240. The Google docs don't publish a hard length limit but rich-result snippet rendering truncates around that range; clipping here keeps the XML lean and the snippets clean. - `xmlEscape` already handles the entity-sensitive characters (&, <, >, "), so user-supplied restaurant names with apostrophes/ampersands flow through safely. - buildXml now emits the inner elements as a single line (<image:image><image:loc>…</image:loc><image:title>…</image:title></image:image>) so the diff between Phase 41 and 52 stays minimal in inspector tools. - Tenant sitemap (subdomain branch): - Home — logo gets title: "{restaurantName} logo", banner gets "{restaurantName} interior"; both carry the restaurant description as caption (when set). - Menu — per-product image gets the product name as title and the product description as caption. Multi-locale fields (Record<string, string>) are resolved via the existing getProductText helper (en → first non-empty fallback). - Blog index + per-post — hero image gets the post title + excerpt. heroOf() checks both imageUrl (current schema) and heroImageUrl (forward-compat for the deferred Phase 52-like rename) so neither path silently drops the hero. - Platform sitemap: - Each tenant home (under /tenant/<id> or customDomain) — logo / banner get the same name + description treatment as the tenant subdomain branch. - Each bazar restaurant (/bazar/restaurant/<slug>) — same. The image-rich result snippet on a bazar discovery query now shows the restaurant's name + tagline instead of a raw URL. - Backwards compat — every Phase 41 string-form images callsite continued to work via the string | object union; no force-migration of existing entries. - Type fixes during integration: - Blog title / excerpt are Record<string, string>, not strings. Plumbed through getProductText() to extract en (or first non-empty) for title/caption. - Restored unintended dependence on a non-existent BlogPost.heroImageUrl field that crept into the first draft.

    • Phase 51: Volume control for bazar status alerts (3-step off / soft / loud)

    - Closes the Phase 43 follow-up deferred — "Volume control — the gain is fixed (peak 0.18). A future polish could add a slider; today users control via OS volume." Phase 51 ships discrete steps rather than a slider — three taps cover the actual UX needs (mute, polite, audible) without the precision-vs-discoverability tradeoff of a continuous control. - Why discrete over continuous — a slider on a tiny header pill is fiddly on touch, hard to discover, and forces the user to find their preferred level by experimentation. Three named steps with audible feedback (chirp at the new level on each tap) get the user to "good enough" in 0-2 clicks. - Library (src/lib/bazar/status-alerts.ts): - New types: AlertLevel = 'off' | 'soft' | 'loud'. - New API: getStatusAlertsLevel(), setStatusAlertsLevel(level), cycleStatusAlertsLevel() (advances off → soft → loud → off and returns the new value), playLevelChirp(level). - Backwards compat: - Legacy 'on' localStorage values upgrade silently to 'loud' on read — existing users don't lose their preference. - getStatusAlertsEnabled() / setStatusAlertsEnabled(boolean) kept as shims that map to the level API. - playEnableChirp() retained as @deprecated; delegates to playLevelChirp('loud'). - Gain math: GAIN_MULT.loud = 1.0, GAIN_MULT.soft = 0.35. Soft is ~35% of loud — quiet enough not to startle someone with earbuds, loud enough to hear over a laptop speaker at moderate room volume. - Haptic scaling: soft mode uses a shorter vibration pattern ([30] instead of [50, 30, 50] for positive; 80 instead of 150 for negative). Mirrors the audio softening so the feel matches. - `bazar-status-alerts-changed` event payload changes from { on: boolean } to { level: AlertLevel }. Internal-only event; the order detail page is the only consumer. - Order detail page ([src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)): - Pill cycles through the three states. Each tap calls cycleStatusAlertsLevel() + playLevelChirp(next) — the user hears the level they just selected. - Visual mapping: Off → BellOff icon, gray pill; Soft → Bell at 70% opacity, amber pill; Loud → Bell solid, emerald pill. Label inside the pill flips through "Alerts: Off / Soft / Loud" so the state is also readable. - Title attribute spells out what the next click does so the cycle is discoverable on hover. - AudioContext init still happens on the first user-gesture tap (soft or loud both qualify).

    • Phase 50: Nominatim (OpenStreetMap) fallback geocoder

    - Closes the Phase 42 + Phase 49 follow-up deferred — both phases noted that "No Nominatim / OSM fallback — when `GOOGLE_MAPS_API_KEY` is unset, geocoding is a no-op." Phase 50 wires a free fallback so saved-address geocoding (Phase 42), user-triggered Verify (Phase 49), and the legacy /api/bazar/user/location endpoint actually return coordinates out of the box, with zero billing setup. - Why Nominatim and not another free provider — it's the canonical OSM geocoder, predictable, free for low-volume use, and doesn't require an account. The TOS is well-documented and compliance is mechanical. - Implementation (src/lib/bazar/geo.ts): - `geocodeViaNominatim` — fetches https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=<address>. Single result, parsed to { lat, lng }. Returns null on miss / network error / unparseable response. - `geocodeViaGoogle` — extracted from the old geocodeAddress body. No behavioural change. - `geocodeAddress` — now picks the provider based on env. Google when GOOGLE_MAPS_API_KEY is set (no Nominatim fallback in that case — a Google miss stays a miss, so we don't leak search volume cross-provider). Nominatim otherwise. Set GEOCODER_NOMINATIM=off to disable the fallback entirely. - TOS compliance: - User-Agent: required by Nominatim's usage policy. Defaults to Restora/1.0 (admin@restora.io); override with NOMINATIM_USER_AGENT env var. Operators running their own deployment should set their own contact email. - Rate limit: Nominatim caps at 1 request/second. throttleNominatim() is a process-local mutex that delays each call so the next request is no closer than 1100 ms after the previous one. Sufficient for our scale; no Redis / distributed lock needed. - No bulk geocoding: their policy prohibits it. Per-user-action calls (verify a saved address, save a new address) are well within "interactive" patterns. - `Accept-Language: en` — gets back English-formatted results for consistency with our existing logic. - Composition with Phases 42 + 49: - The new Verify button on saved addresses now resolves successfully even without a Google key. - geocodeSavedAddress (Phase 42's 3-second-timeout wrapper) races the Nominatim call against the timeout — slow OSM responses still bail out cleanly without blocking the save handler. - requireRateLimit on the verify endpoint (8/5min per user) plus the in-process 1 req/sec throttle gives belt-and-braces protection against accidentally overloading the OSM service. - Known limitations / deferred: - Accuracy — Nominatim is good for street addresses in well-mapped regions but can miss new buildings or rural areas. For production deployments in delivery-radius-sensitive markets, Google is still the recommended provider. - Process-local throttle — multi-process / multi-pod deployments could in theory exceed 1 req/sec collectively. Sufficient for the file-DB topology; revisit when we shard.

    • Phase 49: Verified-location pill + user-triggered re-geocode for saved addresses

    - Closes the Phase 42 follow-up deferred — "No backfill of pre-Phase-42 saved addresses — existing entries stay without coords until the user edits them (which triggers a re-geocode). A super-admin batch script could rip through unindexed addresses in one pass; deferred." Rather than an admin-side batch, Phase 49 puts the control in the customer's hands: a Verify button on each saved address, and a Verified pill that surfaces once coords land. - API ([src/app/api/bazar/user/addresses/\[addressId\]/geocode/route.ts](src/app/api/bazar/user/addresses/[addressId]/geocode/route.ts)): - POST /api/bazar/user/addresses/[addressId]/geocode — re-runs the configured geocoder against the stored addressText + postcode. No body required; the address ID is the only input. - Persistence model: on success, overwrites coords / geoHash / geocodedAt and bumps updatedAt. On miss (no key, timeout, no match), keeps existing coords if any and returns 422 GEOCODE_UNAVAILABLE so the UI can surface the failure without nuking previously-good data. Same policy PATCH uses on edits. - Rate limit: 8 attempts / 5 minutes per user via requireRateLimit. A user verifying half a dozen saved addresses in a row succeeds; a script pattern is throttled. - UI (src/app/(bazar)/bazar/account/page.tsx/bazar/account/page.tsx)): - Verified pill on the address card when coords is present — blue with a MapPin icon. Tooltip shows the date + the lat/lng to 4 decimals for transparency. - Verify / Re-verify button in the row's action group. Label flips based on whether coords already exist; both verbs hit the same endpoint. - Inline error surfacing — 429 / 422 / 5xx each get their own user-facing message ("Too many verify attempts", "Could not verify this location right now", "Could not verify location"). The 422 wording deliberately avoids blaming the user for a missing API key (a platform-level config concern). - `SavedAddress` interface in the account page grows the coords and geocodedAt fields used by the pill render. - Composition with Phase 42 — the helper from Phase 42 (geocodeSavedAddress) is reused unchanged. POST → PATCH → Phase 49's geocode-only endpoint are now the three paths into the same geocoder: save, edit, refresh. - Known limitations / deferred: - Still no provider fallback — when GOOGLE_MAPS_API_KEY is unset, every verify hits a 422. The verify button stays useful as a "try again later" surface once the platform-admin configures a key. A Nominatim fallback is still on the deferred list for a future phase. - No batch verify — the operator-side super-admin batch backfill is still missing. Customer-side per-address verify covers the visible cases; a cron is a future polish.

    • Phase 48: Edit options from the bazar checkout summary

    - Closes the Phase 45 follow-up deferred — "No edit affordance from `/bazar/checkout` directly — the cart edit button only lives in the header drawer." Customers reviewing their order on the checkout page could see the option chips ("Pepperoni · Thin crust") but had to back out to the cart drawer to change anything. Phase 48 adds the same pencil button to each checkout line. - Implementation (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)): - Pencil button added to each <li> in the "Your order" section, only rendered when the line has `selectedOptions` (matches the header drawer pattern from Phase 45 — plain items have nothing to edit). - Reuses Phase 45's edit-mode picker — clicking the pencil pushes /bazar/restaurant/<slug>/item/<productId>?edit=<optsKey>&qty=<n>, just like the cart drawer's edit handler. The item page replays the cart line's selections, the CTA flips to "Update cart", and router.back() after save returns the customer right back to checkout. - No new API, no new state, no new picker code — the third consumer of the same edit-mode contract. - Layout: the price moved into a wrapper with the pencil so both share the right-aligned shrink-0 column. Plain items keep the original look (price alone, no extra spacing). - Consistency — the cart drawer (Phase 45) and the checkout summary (Phase 48) now offer identical edit affordances; a customer who scrolls past the drawer to checkout can fix toppings from either surface.

    • Phase 47: Live push for the full-screen `/dashboard/inbox` page

    - Closes the Phase 40 follow-up deferred — "The full-screen `/dashboard/inbox` page still polls on visit only. It's a destination page, not background UI — adding SSE there is a future polish." Phase 40 shipped real-time push to the header bell + TopHeader notifications via a shared SSE stream; the destination page operators land on when they click the bell still loaded once and stayed frozen. Phase 47 wires it onto the same signal. - Implementation (src/app/(dashboard)/dashboard/inbox/page.tsx/dashboard/inbox/page.tsx)): - Reuses `useTenantPushSignal` (Phase 40) — no new endpoint, no new connection logic. The browser's HTTP/2 multiplexing folds the inbox page's SSE subscription into the same stream already opened by InboxBell / TopHeader. One TCP connection, three consumers. - Silent re-fetch — load({ silent: true }) skips the loading spinner so a server-side change doesn't blank the screen mid-scroll or wipe the operator's filter selection. The Refresh button still flashes the spinner (explicit user intent). - `hasLoadedRef` guard — the silent push handler only fires after the initial mount-time load has completed. Without this, an SSE change event arriving before the first load() finishes could double-fetch and clobber the in-flight result. - Refresh button typing fix — onClick={load} previously coerced the React MouseEvent into load's new opts?: { silent?: boolean } parameter; switched to onClick={() => load()} so the click handler discards the event and calls load with no opts (loud mode). - Latency: A new urgent inbox item now appears on the page within ~2s of the underlying file mtime change — same window as the bell badge.

    • Phase 46: Manual order claim for cross-email linking

    - Closes the final mile of the Phase 16 deferred — "Email-based matching is the weak link — a customer who orders with john@gmail.com but reviews from a bazar account using john.work@example.com won't show as verified. The proper fix is a `bazarUserId` foreign key on `Order` set at checkout when a bazar user is logged in." Phase 22 (FK stamp at checkout) and Phase 39 (opportunistic email-match backfill on read) closed the easy cases. Phase 46 adds the user-controlled escape hatch for the case where the email used at checkout doesn't match the bazar account's email at all. - API (src/app/api/bazar/orders/claim/route.ts): - POST /api/bazar/orders/claim with body { orderNumber, email }. - Walks every approved tenant's orders[] for a match on (orderNumber, customerEmail) case-insensitively. Order numbers are tenant-scoped but the email pair disambiguates cross-tenant. - Strong claim rules: - Match must be on both order number and the email it was placed under — knowing only one isn't enough. - Refuses to overwrite an existing bazarUserId on a foreign account → 409 Already claimed. A customer with two bazar accounts has to use the right one; we never let account A evict account B's claim. - Same-user re-claim is idempotent (alreadyLinked: true). - Same 404 Order not found shape for "no such order" and "order belongs to a different account" so the response never confirms whether a number exists for someone else. - Rate limit via requireRateLimit: 5 attempts per 10 minutes, keyed on the user ID (not IP) so a shared-NAT household isn't collectively locked out by one person's typo. Returns 429 with Retry-After. - Side effect: stamps bazarUserId on the matched order. From this point on, the order surfaces normally in /api/bazar/orders/me, detail, cancel, reorder — and the review verification badge logic (Phase 22 two-tier check) finds the strong FK match. - UI (src/app/(bazar)/bazar/account/page.tsx/bazar/account/page.tsx)): - Collapsible "Missing an order?" form at the bottom of the Orders tab (and on the empty-state when the user has no claimed orders yet). - Two inputs (order number + email) + Claim button. Inline error / success messages with tone-coded styling (rose on miss, emerald on success). - On success the orders list reloads from page 1 so the newly-claimed order surfaces immediately. The user doesn't have to switch tabs or refresh. - Auto-handled responses: 429 → "Too many attempts — try again in a few minutes"; 409 → "This order is already linked to a different account"; 404 → "No matching order found. Double-check the number and email." - Composition with existing tiers: - Strong FK (Phase 22 stamp at checkout) → most orders go through here, no manual claim needed. - Email backfill (Phase 39 read-time match) → catches orders placed before account creation when emails happen to match. - Manual claim (Phase 46) → covers everything else. - Once stamped, all three paths converge on the same bazarUserId semantics for verification + history + ownership. - Why not auto-detect by name match or phone number — high false-positive rate + privacy concerns. Order number is high-entropy enough that requiring it as proof of possession is the right level of friction.

    • Phase 45: Edit options on a bazar cart line

    - Closes the Phase 37 deferred polish — "No 'edit options' on a cart line — to change the options of a line in the cart, the customer removes it and re-adds from the detail page. Adding inline edit would require lifting the picker into the drawer or a modal; a future polish." Phase 45 picks a third path that's smaller than either: route to the existing detail page in edit mode, prefill the picker from the cart line's choices, and replace-on-save. - Why route instead of modal-in-drawer — the picker on the item detail page already has the full validation (required, min, max), localised group/option names, isDefault seeding, live price math, and the option-error inline hint. Lifting it into the drawer would duplicate that logic + need its own modal mount + close handling. Routing reuses every line of existing picker code. - Edit button on cart line (src/components/bazar/BazarHeader.tsx): - Pencil icon, only rendered when the line has `selectedOptions` (plain items have nothing to edit; the existing remove + re-add path is fine). - Click handler closes the drawer + navigates to /bazar/restaurant/<slug>/item/<productId>?edit=<optsKey>&qty=<n>. optsKey is the line's stable options signature; qty is the quantity (so a "3 × Pepperoni" line that becomes 3 × Margherita after edit doesn't degrade to qty 1). - Item detail page ([src/app/(bazar)/bazar/restaurant/\[slug\]/item/\[productId\]/page.tsx](src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx)): - `useSearchParams` reads edit and qty. isEditMode = editOptsKey !== null. - Seed effect — in edit mode, looks up the matching cart line by (productId, restaurantSlug, optsKey) and replays its selectedOptions into selectedByGroup instead of seeding from isDefault. If the cart line vanished (multi-tab edit, refresh after clear), gracefully falls through to the normal defaults seed. - Initial quantity picked up from the qty param. - Edit-mode banner above the picker — orange pill with ShoppingCart icon: "Editing your cart selection — Changes replace the line on Update." Removes the ambiguity of the renamed CTA. - CTA label flips — "Add to Cart" → "Update cart"; "Added!" → "Updated!". Price math unchanged. - Save logic — handleAddToCart removes the old cart line by its original editOptsKey before adding the new variant. This means a no-op edit (user opened, didn't change anything, hit Update) still ends at the original quantity rather than quantity + qty. After save we router.back() so the customer ends up back in checkout / account / wherever they came from. - Hide cartEntry stepper in edit mode — if the user's edits land on the same optsKey they came from, the page would normally render the in-cart qty stepper instead of the Add button (cartEntry !== undefined). In edit mode we always render the Add/Update button so the replace-on-save flow is unambiguous. - Merge semantics — if the user edits Line A's options to match an existing Line B (same product, different options that resolve to B's options), the remove-then-add cycle merges quantities into B. Two variants collapse to one with the combined qty — which is the only correct outcome. - Known limitations / deferred: - No "discard changes" path beyond browser back — the customer cancels by hitting Back. A dedicated Cancel button could be a future polish. - No edit affordance from `/bazar/checkout` directly — the cart edit button only lives in the header drawer. Checkout shows its own line summary; adding the pencil there is a small future polish.

    • Phase 44: Inline PATCH for saved campaign templates

    - Closes the Phase 36 deferred polish — "PATCH deliberately omitted — editing means re-saving under the same name (delete + create). Inline edit deferred as a future polish; not load-bearing for the use case." Admins iterating on a recurring campaign now click an Edit pencil on the chip; the existing template values load into the form and the Save bar flips to "Update", PATCHing the same record so createdAt and createdBy survive. - API ([src/app/api/admin/feature-grants/templates/\[templateId\]/route.ts](src/app/api/admin/feature-grants/templates/[templateId]/route.ts)): - New PATCH handler — same requireSuperAdmin gate as DELETE. - Every body field is optional. null clears an optional field (addonPriceMonthly, expiresInDays, notes); undefined leaves it alone. Zod doesn't have built-in "optional + nullable" so we spell the union explicitly. This pattern lets the UI distinguish "user emptied the input" from "user didn't touch that input." - featureId revalidates against FEATURE_REGISTRY when changed — a stale or renamed ID can't slip in during an edit. - createdBy / createdAt immutable. updatedAt bumped to the current ISO timestamp on every successful PATCH, which the chip-row sort uses (newest first) so an edit bubbles the template to the top of the row. - 404 on missing template ID (idempotent — mirrors DELETE). - UI (src/app/admin/dashboard/features/page.tsx): - Edit pencil button added to each saved-template chip, between the load button and the delete ×. Clicking loads the template values into the form (same path as loadTemplate) AND switches the Save bar into edit mode by stashing the template ID in new editingTemplateId state. - Save bar sprouts an "Editing" pill on the left + the action button label flips from Save to Update. Cancel exits edit mode without touching the form values, so the operator can keep their edits live without persisting them. - Chip highlight — the row being edited gets a purple ring so the operator knows where their changes will land if they hit Update. - POST vs PATCH branching — saveCurrentAsTemplate is one function that picks the verb + URL from editingTemplateId. Optional fields use null in edit mode (to clear) and undefined in create mode (to omit from the body the POST handler doesn't accept null). - Delete-while-editing — if the operator deletes the template currently being edited, the UI drops out of edit mode automatically so the next Save creates a fresh template instead of PATCHing a record that no longer exists.

    • Phase 43: Sound + haptic alerts on bazar order status change

    - Closes the Phase 35 deferred polish — "No in-app sound or vibration on transition — the toast is silent visual only. A user-controlled 'sound on status change' preference could be a future polish but adds permissions / mute complexity." The visual flash from Phase 35 already surfaced status transitions, but customers leaving the tab open in the background got nothing. Phase 43 adds an opt-in audio cue + haptic buzz that fires alongside the toast. - Helper (src/lib/bazar/status-alerts.ts): - `fireStatusAlert(tone)` — plays a synthesised tone via Web Audio API. Positive tone (accepted / preparing / ready / delivered): 1175→1568 Hz triangle, 180ms — a bright "ding" with a small uptone making it feel like progress. Negative tone (rejected / canceled): 220→165 Hz sine, 320ms — low and sober. - No asset files. Oscillators synthesised on the fly. No network round-trip, no bundle weight, no licensing concern. - Lazy `AudioContext` — instantiated on first call after the user gesture (the Alerts toggle). ensureRunning resumes from 'suspended' state each play, which is what Safari + mobile Chrome require. - Vibration via navigator.vibrate — [50, 30, 50] for positive, 150 for negative. Silently no-op on iOS Safari + most desktops. - Opt-in by default (bazar_status_alerts localStorage key, missing → off). A surprise beep on someone else's phone is a bad first impression. - `playEnableChirp()` — short confirmation tone when the user flips alerts ON. Doubles as the user gesture that lets the AudioContext be created in the first place. - Cross-tab sync via a bazar-status-alerts-changed CustomEvent so the toggle stays consistent if the user has the order open in multiple tabs. - Order detail page ([src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)): - Alerts toggle pill appears next to the existing Live indicator in the header card. Bell icon when on (emerald), BellOff when off (gray). Title attribute explains the state. - Visible only while `isLive` — once the order reaches a terminal state (delivered / rejected / canceled), no more transitions are coming, so the toggle disappears rather than misleadingly hanging around. - Fire alongside the toast — the existing status-diff useEffect now also calls void fireStatusAlert(copy.tone) when a transition happens. Fire-and-forget; audio failure (autoplay policy block, missing AudioContext) cannot prevent the visual toast from rendering. - Why an explicit user gesture toggle, not "ask permission once" — the Web Notifications API needs a permission grant for cross-tab alerts; sound + vibration only need a user gesture. Skipping the Notifications API kept this phase tight + zero browser-permission UX. - Known limitations / deferred: - Tab-hidden autoplay — modern browsers may pause AudioContext when the tab is fully backgrounded. We resume on each play but can't override the policy. Users who minimise the tab and switch away may miss the sound; the haptic still fires on mobile. - No native push notification — for "you closed the tab but want to know your order is ready," the Push API + a service worker would be the right fix. Out of scope for a polish phase. - Volume control — the gain is fixed (peak 0.18). A future polish could add a slider; today users control via OS volume.

    • Phase 42: Geocoding on bazar saved addresses

    - Closes the Phase 33 deferred polish — "No geocoding — saved addresses store free text. Distance / 'is in delivery zone' checks at checkout still rely on the legacy `addressText` + coords. Geocoding-on-save is a future polish that would feed delivery-radius validation per saved entry." Saved addresses now carry coordinates from the moment they're created so downstream features (radius validation, distance-based sorting, geo-hash bucket queries) can move off the user-level fallback. - Schema (src/lib/db/schema.ts) — BazarSavedAddress grows three optional fields: - coords?: { lat, lng } — geocoded coordinates. - geoHash?: string — precision-5 cell (~4.9 km) for fast radius-bucket queries via the existing radiusGeoHashSet helper. - geocodedAt?: string — ISO timestamp of last successful geocode. - All three move together — if coords is present, the other two are too. Optional means legacy saved addresses + geocode-failure cases stay valid. - Helper (src/lib/bazar/geocode-saved.ts) — geocodeSavedAddress(addressText, postcode): - Composes address + postcode into a single query string (postcode improves geocoder accuracy materially; the upstream geocodeAddress only takes a single string). - 3s hard timeout via a Promise.race. A slow / unconfigured geocoder cannot block the user's save. Timeout returns null. - Reuses the existing `geocodeAddress` (Google Maps via GOOGLE_MAPS_API_KEY) — single source of truth for the provider. No new third-party dependency added. - Returns `null` when no key is configured, on timeout, or on any API failure. Callers persist the address without coords; radius checks fall back to user-level coordinates the way they did pre-Phase 42. - POST `/api/bazar/user/addresses` (route.ts) — calls the helper after zod-parse + before persist. If coords resolve, they're stored alongside the new address; the response carries them too so the UI can show "Verified location" immediately. - PATCH `/api/bazar/user/addresses/[addressId]` (route.ts) — re-geocodes only when `addressText` or `postcode` actually changed. Editing the label / notes / default flag does not trigger an external call. On geocode failure during edit we keep the previous coords rather than blanking — a transient API blip shouldn't strip good data that may still be valid. - Known limitations / deferred: - No Nominatim / OSM fallback — when GOOGLE_MAPS_API_KEY is unset, geocoding is a no-op. Adding a public-service fallback to the save path would require User-Agent compliance + throttling (Nominatim caps at 1 req/sec) — out of scope. - Delivery-radius validation at checkout still uses the user-level coordinates / address text — Phase 42 lands the storage. Switching the checkout zone check to prefer the picked saved-address coords is a Phase that touches the order-submit handler and is left as a follow-up. - No backfill of pre-Phase-42 saved addresses — existing entries stay without coords until the user edits them (which triggers a re-geocode). A super-admin batch script could rip through unindexed addresses in one pass; deferred.

    • Phase 41: Sitemap image extensions for SEO

    - Closes the second half of the Phase 29 deferred — "Tenant sitemaps stay 3 URLs deep — no product detail pages (route doesn't exist yet), no `<image:image>` extensions (Phase 29 deferred holds)." Product detail routes still don't exist (out of scope for this phase), but the <image:image> extension half is shippable today: tenant sitemaps + platform sitemap now expose brand and product imagery to Google's image-rich result pipeline. - Sitemap image schema 1.1 (src/app/sitemap.xml/route.ts): - SitemapEntry.images?: string[] — optional list, capped at 10 per URL (Google indexes up to 1000 but the SEO marginal value drops off well before that). - buildXml lazily adds the xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" namespace only when at least one entry has images. Sitemaps without images stay byte-identical to before — no behaviour change for tenants who haven't uploaded branding. - New absolutiseImage(url, base) helper resolves relative paths (/uploads/foo.jpg) against the canonical host. Google silently drops relative image:loc values; this guards against that footgun. Data URIs (data:) are also dropped (not crawlable). - Tenant subdomain sitemap — tenantSitemap() now attaches: - Home `/` → logo + banner from tdb.settings. - Menu `/menu` → up to 10 product images, sorted by popular flag, filtered to available !== false. - Blog index `/blog` → hero image of the latest published post (representative). - Each blog post `/blog/<slug>` → that post's hero image. - Platform sitemap — platformSitemap() now attaches: - Each tenant home `/tenant/<id>` (or `customDomain`) → logo + banner. - Each bazar restaurant `/bazar/restaurant/<slug>` → logo + banner. - Per-product images at the platform level are intentionally skipped — the platform sitemap is already large (every tenant + designer + supplier + bazar listing + docs article), and the per-tenant subdomain sitemap already carries the menu image detail. Avoids doubling the platform sitemap byte count. - Read-cost optimisation — the bazar restaurant loop previously did one getTenantDB per restaurant for city extraction. Phase 41 was about to add a second read per restaurant for images; folded both into a single read instead. No regression vs. Phase 29; better with images included. - Empty-case safety — entries with no resolvable images get no image:image block, not an empty one. Spec compliance + cleaner XML. - Known limitations / deferred: - Product detail routes still don't exist — /tenant/<id>/menu/<productId> is unbuilt. Adding them is a Phase that touches the rendering tree, not the sitemap. The bazar already has detail routes (Phase 7-ish) which are listed. - Image freshness vs. CDN caching — image:lastmod is supported by the schema but we don't emit it. Tenants typically upload to a stable URL and overwrite, so the parent URL's <lastmod> suffices as a recrawl hint.

    • Phase 40: Real-time push for inbox + dashboard notifications

    - Closes the Phase 19 and Phase 20 deferred items together — both said "No real-time push — page polls on visit / refresh. Adequate for v1; could wire SSE in a future phase if the operator's expectation tightens." Operators had to wait up to 30s for a new order to surface in the header bell or for inbox urgency to refresh. Phase 40 collapses that to ~2s. - SSE endpoint (src/app/api/dashboard/inbox/stream/route.ts): - GET /api/dashboard/inbox/stream?tenantId=... — server-sent events with event: hello on connect, event: change when watched files mutate, event: keepalive every 25s to defeat proxy idle timeouts. - Watched paths: databases/tenants/<tenantId>.json (orders / reservations / tenant reviews / feature-grant log) and databases/food-bazar.json (bazar reviews + listing-side changes that surface in the inbox). - Change detection via fs.stat mtime poll every 2s rather than fs.watch — fs.watch is unreliable on Windows network drives and Docker bind mounts (our deployment surface). A stat-per-tick is negligible cost and the 2s ceiling is well below the prior 30s poll. - Why pings instead of payloads — the stream doesn't push data; it just says "something changed, re-fetch." Aggregation logic stays in the existing /api/dashboard/inbox and /api/tenant/[id]/notifications handlers (single source of truth for shape + auth filtering). The new endpoint adds zero duplication. - Auth gated by requireTenantAccess(tenantId). Tenants can't tap each other's signal streams. - Clean teardown — req.signal.addEventListener('abort', ...) clears both intervals + closes the controller when the browser tears down EventSource (tab close, navigation, network drop). - Shared client hook (src/hooks/useTenantPushSignal.ts): - useTenantPushSignal(tenantId) → { pushedAt: number, connected: boolean }. pushedAt is an epoch that ticks every time the server fires event: change. - Browser HTTP/2 multiplexing folds duplicate EventSource connections to the same URL onto a single TCP stream, so even though both InboxBell and TopHeader open their own connections, the network sees one. No shared-context plumbing needed — premature optimization. - Auto-reconnect comes for free from EventSource (browser handles transient disconnects). - Server-side guard: empty tenantId → no connection (used during initial mount before tenant context resolves). - InboxBell (src/components/dashboard/InboxBell.tsx) — subscribes via the hook and re-fetches on pushedAt change. Falls back to the existing setInterval poll (now 60s, was 30s) as the safety net when SSE is errored / unsupported. Visibility-change listener kept for cold-tab returns. - TopHeader notifications (src/components/dashboard/TopHeader.tsx) — same pattern. Optimistic local-read set preserved so the badge never bounces during in-flight PATCH writes. - Latency, before → after: new order placed → header bell badge updates ~30s → ~2s in the happy path. Inbox urgency counter same window. - Cost profile: one long-lived SSE connection per operator tab (multiplexed by HTTP/2). 2s file-stat per connection on the server. No external broker (Redis, NATS) introduced — keeps the deployment surface unchanged. - Known limitations / deferred: - Cross-process invalidation not handled — if the JSON-DB write happens in a different Node process (or a future Postgres-backed deployment), the file mtime won't change. Out of scope for the current file-DB topology; the SSE shape is forward-compatible with a pub/sub backend swap. - The full-screen `/dashboard/inbox` page still polls on visit only. It's a destination page, not background UI — adding SSE there is a future polish.

    • Phase 39: Opportunistic `bazarUserId` backfill for legacy orders

    - Closes the Phase 22 deferred polish — "Legacy orders stay unlinked — they were placed before `bazarUserId` existed. A future polish could backfill `bazarUserId` on legacy orders by matching `customerEmail` against current bazar users (low-priority)." Customers who created a bazar account after their first order saw an empty "My orders" page even though their email matched orders sitting in tenant DBs. - Read-time backfill, not a batch script — every bazar-side order endpoint (history list, detail, cancel, reorder) does the email match inline and stamps bazarUserId on the matched order. No admin button to forget; no cron to monitor; no migration to run. Once a customer logs in and lands on /bazar/orders, every legacy order they touch becomes a strong-linked, first-class order on the way out. - Files: - src/app/api/bazar/orders/me/route.ts — history list. For every tenant scanned, the loop now also matches customerEmail.toLowerCase() === user.email.toLowerCase() when bazarUserId is missing, stamps the link in memory, and persists one save per tenant DB at the end of that tenant's loop. Best-effort persistence — a saveTenantDB failure logs nothing (intentionally — the in-memory match already produced a correct response; next read retries). - [src/app/api/bazar/orders/\[orderId\]/route.ts](src/app/api/bazar/orders/[orderId]/route.ts) — detail. Same ownership-leak guard (404 on mismatch) but the gate also accepts an email match when bazarUserId is absent, then writes the link back. - [src/app/api/bazar/orders/\[orderId\]/cancel/route.ts](src/app/api/bazar/orders/[orderId]/cancel/route.ts) — cancel. Same gate. Stamping happens before the delegated processCustomerCancel call, so subsequent cancel side-effects (refund posting, risk scoring) see a fully-linked order. - [src/app/api/bazar/orders/\[orderId\]/reorder/route.ts](src/app/api/bazar/orders/[orderId]/reorder/route.ts) — reorder. Same gate. - Why this is safe: - Email ownership is proven by the verified bazar session cookie (getBazarSession) — we don't trust the request body's email, only the database-bound session. - Bazar user emails are unique (the bazar register endpoint enforces it), so an email match resolves to exactly one user. - The 404-over-403 ownership pattern stays intact — a mismatch still returns 404 with no enumeration leak. - No retro-stamping of strong-linked orders — orders that already have bazarUserId (even a different one) are untouched. The fallback only fires when bazarUserId is absent. - Performance: the email comparison happens during the tdb.orders loop the API already runs. Zero extra DB reads. One additional saveTenantDB per tenant only when there are matches to persist (dirty flag gates the save).

    • Phase 38: Bazar source filter on operator reviews dashboard

    - Closes the Phase 17/20 deferred polish — "Bazar reviews link to `/dashboard/reviews` — that page already exists for tenant reviews; the bazar source filter inside it is a future polish." Reviews from both surfaces (the tenant's own website and the Food Bazar marketplace) have always landed in the same db.reviews[] array tagged with source: 'tenant' | 'bazar', but the dashboard treated them as one undifferentiated pile. Operators couldn't see "just my marketplace reviews" to answer "how is Bazar treating me?" - API ([src/app/api/tenant/\[tenantId\]/reviews/route.ts](src/app/api/tenant/[tenantId]/reviews/route.ts)) — new source query param (all / tenant / bazar). Legacy reviews without a source field are treated as tenant (the only review surface that existed pre-Phase 16). Counts response gains a bySource: { tenant, bazar } block. Counts are unconditional on the status filter so the per-source totals don't jump when the operator switches between Pending / Approved. - UI (src/app/(dashboard)/dashboard/reviews/page.tsx) — new segmented control to the right of the status filter: All sources / Site (Globe icon) / Bazar (Store icon). Each tab carries its own count badge. - Conditional visibility — the source filter is hidden when bySource.bazar === 0. Tenants that don't list on the marketplace see exactly the UI they had before; the polish only surfaces once it's relevant. No "Bazar (0)" tab cluttering the dashboard for self-hosted-only restaurants. - Status + source compose — picking "Pending" + "Bazar" lists Pending bazar reviews only; counts stay stable across switches.

    • Phase 37: Options-aware bazar cart + reorder

    - Closes the Phase 26 and Phase 32 deferred items in one stroke — "Reorder doesn't carry selected options" (Phase 26) and "No options-aware re-submission — `selectedOptions` aren't passed because the bazar cart hook doesn't track them. Closing both needs an upgrade to `useBazarCart`." (Phase 32). Both deferreds blocked the same root cause: the bazar CartItem was option-blind. Phase 37 upgrades the hook and threads the data through every surface that touches it. - Cart hook upgrade (src/hooks/useBazarCart.ts): - New SelectedCartOption interface (groupId, groupName, optionId, optionName, priceAdjustment) — stores enough to render chips, compute totals, and rebuild the API payload without re-fetching the product. - CartItem.selectedOptions?: SelectedCartOption[] (optional — backwards compatible with carts already in localStorage). - Stable option signature via optionsKeyFor(opts): sort groupId:optionId pairs and join. Two pizzas with the same toppings merge into one line at quantity 2; the same pizza with different toppings lives as two separate cart lines. The sort is what makes this robust — clicking checkboxes in a different order doesn't fork two identical lines. - Line identity is now productId + restaurantSlug + optionsKey (lineKey). The legacy two-arg lookup (productId + slug) still works for option-less items by defaulting optsKey = '', so no caller breaks if they aren't option-aware. - New helpers: optionsAdjustmentTotal(opts) and unitPriceWithOptions(item) so every consumer computes the same effective price. - API exposure ([src/app/api/bazar/restaurant/\[slug\]/route.ts](src/app/api/bazar/restaurant/[slug]/route.ts) + src/app/api/bazar/menu/route.ts): - Restaurant detail API now flattens each product's optionGroups into picker-friendly shape (localised name, kept type / required / min / max, options with id / name / priceAdjustment / isDefault). Translation happens server-side so every client gets the same view. - Landing / menu aggregator adds hasOptions: boolean (computed from product.optionGroups?.length). This is enough for the landing card to flip its "+" CTA without paying for the full picker payload on every grid card. - Item-detail picker ([src/app/(bazar)/bazar/restaurant/\[slug\]/item/\[productId\]/page.tsx](src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx)): - New selectedByGroup: Record<string, Set<string>> state per group. - Seeded defaults: any option flagged isDefault is pre-checked on load (this is the "removable ingredients" convention — onions start checked, customer unchecks them). For required radio groups with no default, the first option is pre-picked so customers don't see a red error before clicking. - Validation: every required group must have at least min (or 1) selections; no group may exceed max (when > 0). Errors surface inline above the add button with the specific group name. Add button is disabled while any error stands. - Live price includes option adjustments — activePrice = basePrice + optionAdjust, and "Add to Cart · $X.XX" reflects the running total per quantity. - Radio with `required: false` allows deselect (single-click toggles off); strict required radios swap selection but never go empty. - Restaurant menu CTA ([src/app/(bazar)/bazar/restaurant/\[slug\]/RestaurantClient.tsx](src/app/(bazar)/bazar/restaurant/[slug]/RestaurantClient.tsx)) — items with option groups can no longer be quick-added from the list (would silently skip required choices and undercharge for paid extras). Clicking "+" now routes to the detail page where the picker lives. Option-less items still quick-add inline. - Landing card (src/app/(bazar)/bazar/page.tsx/bazar/page.tsx)) — same treatment: hasOptions=true flips the "+" icon to a chevron and lets the parent <Link> navigate to the detail page instead of eating the click + quick-adding. - Cart drawer chips (src/components/bazar/BazarHeader.tsx) — each cart line surfaces a ·-separated list of option names under the product. Quantity controls now thread the line's optionsKey so bumping the +/− on a Pepperoni line doesn't accidentally walk a Cheese line's quantity. Undo-clear restores selectedOptions so a recovered cart isn't degraded. - Checkout (src/app/(bazar)/bazar/checkout/page.tsx/bazar/checkout/page.tsx)) — order summary shows option chips and uses unitPriceWithOptions for the per-line total + subtotal. POST payload translates the cart's SelectedCartOption[] into the Record<groupName, optionLabels[]> shape the public order route already expects (/api/public/[tenantId]/orders). Server is the source of truth for pricing — it re-resolves labels against the product's live optionGroups and recomputes adjustments, so a stale localStorage cart can't undercharge. - Reorder ([src/app/api/bazar/orders/\[orderId\]/reorder/route.ts](src/app/api/bazar/orders/[orderId]/reorder/route.ts) + [src/app/(bazar)/bazar/orders/\[orderId\]/page.tsx](src/app/(bazar)/bazar/orders/[orderId]/page.tsx)) — past-order selectedOptions get re-resolved against the product's current optionGroups by matching groupName + optionName (the historical OrderItem only stores labels, not IDs). Successfully matched options come back as a structured array ready for addItem. Anything dropped (merchant removed the group or renamed the option) surfaces in a new warnings[] array, and the toast says "N with changed options" so the customer reviews before checking out. - Pricing trust model: client computes prices for display only. The server (/api/public/[tenantId]/orders and the payment-intent / PayPal cousins) re-resolves option labels against the product's current optionGroups and recomputes unitPrice = baseUnitPrice + optionAdjustment authoritatively. A user editing localStorage to drop a $5 "extra cheese" adjustment will still be charged for it. - Known limitations / deferred: - Tenant menu page is unchanged — this phase only touches the bazar (marketplace) surface. The tenant-direct menu (/tenant/[id]/menu) already had its own option-aware flow; that path is unchanged and stays separate. - Cart migration — carts already in localStorage from before Phase 37 stay valid (no selectedOptions means an empty options key, which matches the legacy "no options" line). No reset / clear required. - No "edit options" on a cart line — to change the options of a line in the cart, the customer removes it and re-adds from the detail page. Adding inline edit would require lifting the picker into the drawer or a modal; a future polish.

    • Phase 36: Saved campaign templates for bulk feature grants

    - Closes the explicit Phase 15 deferred item — "No saved campaign templates — recurring campaigns must be reconfigured each time." Recurring grants (annual "Holiday Promo" upsell, monthly "free Premium trial for new signups", etc.) previously meant picking the same feature + setting the same expiry + reselecting the same tenant cohort from scratch every cycle. Phase 36 lets admins save the whole campaign config as a named, reloadable template. - Schema (src/lib/db/schema.ts): new CampaignTemplate interface and MasterDB.campaignTemplates?: CampaignTemplate[] array. Optional field — backwards compatible with existing master DB files (no migration needed). - Stored fields: featureId, enabled (grant vs revoke), isAddon, addonPriceMonthly, expiresInDays, notes, and targetPreset (all / basic / premium / enterprise / no-plan / active-only / custom). - Relative expiry, not absolute — templates store expiresInDays (e.g. 30) rather than a fixed date. When loaded next quarter, the campaign expires 30 days from that day, not 30 days from when the template was first saved. Without this, a "30-day trial" template saved in January would create already-expired grants when reused in May. - Target by preset, not by tenant ID — templates store the preset name (e.g. premium) instead of the resolved tenant ID list. A "Premium upgrade" template saved when there were 10 Premium tenants automatically picks all 14 when reloaded next month. Custom selections fall back to whatever the operator has currently checked. - API (src/app/api/admin/feature-grants/templates/route.ts + [\[templateId\]/route.ts](src/app/api/admin/feature-grants/templates/[templateId]/route.ts)): - GET — returns templates sorted by updatedAt desc. - POST — creates a template. Validates featureId against FEATURE_REGISTRY (refuses unknown features so a registry rename doesn't leave dangling templates). Hard cap of 50 templates per platform. - DELETE /[templateId] — removes. Idempotent — missing template returns 404 so the caller knows something was off. - PATCH deliberately omitted — editing means re-saving under the same name (delete + create). Inline edit deferred as a future polish; not load-bearing for the use case. - Auth: all three endpoints gated by requireSuperAdmin — verified via smoke probes (GET=401, DELETE=401 anon). - UI (src/app/admin/dashboard/features/page.tsx) — CampaignBuilder component grows three new surfaces: - Save Template button in the header (BookmarkPlus icon). Toggles an inline name input. Disabled until both a feature is picked and a name is entered. - Saved templates row appearing above the preset bar. Each template is a chip showing the name; hover surfaces the feature ID, preset, and notes via title attribute. Clicking re-applies preset → fills feature, enabled, isAddon, addon price, expiry (computed from expiresInDays), and notes into the form. Per-chip × delete button. - Preset tracking — clicking a preset button (All / Basic / Premium / etc.) now stamps lastPreset with the preset key; toggling any individual tenant checkbox stamps lastPreset = 'custom'. Save reads from lastPreset, so a template saved after manual edits replays as "custom" (preserves current selection rather than replacing with a stale preset).

    • Phase 35: Status-change toast on bazar order detail

    - Closes the explicit Phase 27 deferred item — "No 'your order is ready!' toast — status change between polls is currently silent. A diff-against-previous render could surface a one-time success burst when status flips." Phase 27 shipped live polling but customers never saw the change; the badge just flipped while they were reading something else. Phase 35 surfaces the transition as a celebratory (or honest, for bad news) flash at the top of the page. - Status diff logic: a useRef carries the last observed status across renders. The first observation primes the ref without firing — we don't want a "your order is accepted!" toast on the initial page load when the order already moved past that state. Only real transitions surface. - Curated copy per status: - accepted → "Order accepted! The restaurant is on it." (positive) - preparing → "It's being made now / Your food is in the kitchen." (positive) - ready → "Your order is ready! / Pickup time, or the driver is en route." (positive) - delivered → "Delivered — enjoy 🎉 / Tell us how it was when you can." (positive) - rejected → "Order rejected / The restaurant couldn't accept this order." (negative) - canceled → "Order cancelled / You'll see any refund in your account." (negative) - Unmapped statuses don't trigger — keeps a future enum addition from spawning a generic flash. - Toast UI: - Fixed positioning at top-center, max-w-md, z-50, pointer-events isolated to the toast itself so the page below stays interactive. - Two tones: emerald for positive transitions, rose for negative. - Bell icon + headline + sub copy + a dismiss X button. - Auto-dismiss after 6s via a separate useEffect (so manual dismiss still works during the timer). - `role="status" aria-live="polite"` so screen readers announce the transition non-disruptively. - Slide-in animation via Tailwind's arbitrary animate-[fadeInDown_200ms_ease-out] (animation keyframes already in the Tailwind config from earlier polish). - Verification: - TypeScript: 0 errors (clean rebuild after one transient cached .next/dev/types/routes.d.ts glitch) - next build: ✅ green - HTTP probe: /bazar/orders/ord_x → 200 ✓ - Known limitations / deferred: - No in-app sound or vibration on transition — the toast is silent visual only. A user-controlled "sound on status change" preference could be a future polish but adds permissions / mute complexity. - No persistence across reloads — if the user closes the tab between two polls, the transition happened but they won't see the toast on the next visit. Acceptable: the live status badge tells the current truth. - No stack of toasts — if two rapid transitions happen between polls (unlikely with 30s cadence but possible after a tab-focus re-poll), only the latest fires. Trade-off for visual simplicity. - Copy isn't localised yet — English hardcoded. Lives alongside the rest of the bazar surface which is also single-language for now.

    • Phase 34: Bazar checkout — coupons + gift cards + saved-address notes pass-through

    - Three tight polishes closing the deferred items from Phases 32 and 33. The public order route already accepted couponCode, giftCardCode, and notes — Phase 34 just builds the UI that uses them. - Coupon input on /bazar/checkout: text field with BadgePercent icon. Auto-uppercases as the user types ("welcome10" → "WELCOME10"), 50-char cap. Submitted as couponCode on the order body. Server resolves via the existing resolveCoupon engine; invalid coupon silently drops without rejecting the order (matches the existing public-route behaviour). - Gift card input with Gift icon, same uppercase-on-type + 40-char cap. Submitted as giftCardCode. Server validates via getCardByCode + previewApply; invalid gift card returns 400 and surfaces in the existing setSubmitError toast — copy is honest about this asymmetry: "Invalid coupons silently drop; invalid gift cards block submission — clear the field if you don't want to use one." - Saved-address notes pass-through (Phase 33 deferred item) — when checkout uses a saved address that has notes (doorbell / floor / etc.), those are prepended to the user-entered notes with a · separator before submit. So a kitchen ticket sees "Apt 4B, ring twice · No coriander please" instead of losing the saved hint when the user types their own one-off note. Picking "+ New" or editing the address manually opts out of the pass-through automatically (the picker flips to 'new'). - No client-side validation: the page doesn't preview discounts or gift-card balances. The server is authoritative and the response shows the resolved totals once the order lands. This keeps the checkout page fast and trust the existing order-creation pipeline. - Verification: - TypeScript: 0 errors - next build: ✅ green - HTTP probe: /bazar/checkout → 200 ✓ (form renders; backend submission flow unchanged since Phase 32) - Known limitations / deferred: - No client-side discount preview — the user types a coupon code and only sees the discount after submit succeeds. A POST /api/public/[tenantId]/coupons/preview would let us show "Applies $X off" inline. Polish. - Gift card balance not displayed — same reason. Could surface "$25 remaining" beside the code once we expose a balance check endpoint. - No coupon code from URL — campaigns often send ?coupon=BLACKFRIDAY query params; we don't auto-fill yet. One-line addition. - Notes merger is naive — uses a simple · join. Doesn't dedupe if the user types the same note as the saved one or handles multi-line saved notes gracefully. Acceptable for v1.

    • Phase 33: Bazar saved address book

    - Closes a long-standing real-world gap mentioned across Phases 22 and 32. Bazar users had a single addressText field for life — fine for cold-start onboarding, broken the moment anyone orders to both Home and Work. Phase 33 gives them a managed address book with default-selection invariant. - Schema (BazarUser.savedAddresses?: BazarSavedAddress[]): { id, label, addressText, postcode?, notes?, isDefault, createdAt, updatedAt }. The legacy addressText stays as the geocoded address-of-record for radius queries; saved addresses are the customer-facing ones. - `GET /api/bazar/user/addresses`: returns the authenticated user's list (empty array for users who haven't added any yet). 401 anon. - `POST /api/bazar/user/addresses`: creates a new address. Body: { label, addressText, postcode?, notes?, isDefault? }. First address auto-defaults; subsequent isDefault: true atomically clears the previous default to preserve the "at most one default" invariant. Limit 10 per user. - `PATCH /api/bazar/user/addresses/[addressId]`: partial update. Setting isDefault: true clears the previous default; setting isDefault: false on the only default-bearing entry returns 400 CANNOT_REMOVE_DEFAULT (the invariant requires exactly one default when the list is non-empty). - `DELETE /api/bazar/user/addresses/[addressId]`: removes the entry. If the removed entry was the default and others survive, the most recently-updated remaining entry is promoted to default automatically — so the invariant always holds without surprising the user. - Ownership opacity: missing-ID and not-yours both return 404 (never 403) so probes can't enumerate other users' address IDs. - UI on `/bazar/account` (Profile tab): new <AddressBook /> block below "Saved Location". Lists each saved address with label, full address, optional notes, and a "Default" badge on the active one. Buttons: Default (when not), Delete, and an + Add button at the header that opens an inline form. - UI on `/bazar/checkout`: when the bazar session resolves, the page now ALSO fetches the saved address list. Default is auto-selected and prefills the delivery fields. A pill row above the address inputs lets the customer swap between saved entries or pick + New (which clears the fields and unlocks the manual inputs). Any manual edit auto-flips the picker to New so the saved entry isn't silently mutated. - Anonymous + new-user paths intact — no saved addresses → picker is hidden, the manual fields render as before. Phase 32's flow degrades cleanly when the user has no signal yet. - Verification: - TypeScript: 0 errors - next build: ✅ green; both new routes registered - HTTP probes: GET → 401 without bazar session (auth gate ✓); POST/PATCH/DELETE all share the same auth-required pattern - Known limitations / deferred: - No geocoding — saved addresses store free text. Distance / "is in delivery zone" checks at checkout still rely on the legacy addressText + coords. Geocoding-on-save is a future polish that would feed delivery-radius validation per saved entry. - Notes field isn't passed through to the order — saved-address notes (doorbell / floor) don't propagate into the order's notes field on checkout. One-line fix during the submit handler; left as a Phase 34 polish so this phase stays tight. - No address-merging on duplicate add — submitting two identical addresses creates two rows. Acceptable for v1. - No reorder of entries — they're shown in insertion order with the default first. Drag-to-reorder is polish.

    • Phase 32: Bazar checkout flow — completes the customer order arc end-to-end

    - Closes a real broken state that none of Phases 22-27 caught: the "Proceed to Checkout" button in the bazar cart drawer had no `onClick` handler. Customers could fill a cart but had no way to actually submit an order. Every UX surface we polished after that point (My Orders, order detail, live polling, cancel, reorder) was reachable only by directly editing seed data. Phase 32 wires the loop closed. - `/bazar/checkout` page (new) — single-restaurant cart submission. - Reads cart via `useBazarCart`, groups items by restaurantSlug (cart can hold items from multiple restaurants), picks the active group from ?slug= param or auto-selects when there's only one. - Multi-restaurant interstitial: when the cart spans multiple restaurants AND no ?slug=, renders a "Order from one restaurant at a time" picker with per-restaurant subtotals; each row links to /bazar/checkout?slug=…. Other restaurants stay in the cart after the active one is checked out. - Bazar-session prefill: /api/bazar/auth/me populates name + email + saved address (when signed in) so logged-in users see one-tap checkout. - Restaurant resolution: /api/bazar/restaurant/[slug] fills in delivery fee, min order, openNow, delivery vs pickup availability. Open/closed and min-order warnings render before the customer can hit submit. - Form: order type (Delivery / Pickup, disabled when restaurant doesn't offer it) → customer name/email/phone → delivery address + postcode (only when Delivery) → notes → payment method (Cash / Card). - Submit POSTs to the existing /api/public/[tenantId]/orders route. Phase 22's getBazarSession inside that handler stamps bazarUserId on the new order, so the order automatically threads into Phase 22's My Orders, Phase 25's detail page, Phase 27's live polling, and Phase 21's verified-purchase signal. - Cart cleanup after success: only items from the just-checked-out restaurant are removed; the rest stay so a multi-restaurant cart can be handled one order at a time. Persists via the same bazar_cart localStorage key the hook uses and fires the bazar-cart-update event to refresh the header badge. - Redirects to `/bazar/orders/[orderId]` — the Phase 25 detail page picks up with the live status badge (Phase 27) already polling. - Cart drawer wiring: the "Proceed to Checkout" button in BazarHeader.tsx is now a <Link href="/bazar/checkout"> that closes the drawer on click. (Single-line change with multi-phase impact — the whole arc from Phases 22-27 now reachable from the cart.) - Verification: - TypeScript: 0 errors - next build: ✅ green; /bazar/checkout registered - HTTP probe: /bazar/checkout → 200 (client-rendered cart-empty / multi-restaurant / form states branch correctly) - Known limitations / deferred: - No coupon / gift-card UI on the bazar checkout. The underlying API supports both — wiring the inputs is a future polish. - No address book (Phase 22 deferred holds) — single saved address from BazarUser.addressText only. - Stripe payment is "Card" placeholder — submits with paymentMethod: 'stripe' but no actual Stripe Elements integration. The escrow + hold logic on the public order endpoint runs as if payment succeeded; production would need the real Stripe redirect dance. Acceptable for the multi-vendor marketplace prototype. - No options-aware re-submission — selectedOptions aren't passed because the bazar cart hook doesn't track them. Aligns with the Phase 26 deferred item ("Reorder doesn't carry selected options"). Closing both needs an upgrade to useBazarCart. - No tax calculation on the bazar side — tax falls through to the public order route's existing logic.

    • Phase 31: Cold-start recommendations + Verified-only review filter

    - Closes one deferred item from Phase 28 and one from Phase 21 in a single bazar-side polish. - Cold-start fallback for `/api/bazar/recommendations` (Phase 28 deferred — "No cold-start fallback — new users with no preferences and no favourites get no recommendations"). - When the signal-based scorer (preferredCuisines + favouriteCuisines) returns zero matches, the API now falls back to "Popular this week" — every approved listing ranked by paid-order count in the last 7 days. - Reason chip on each card switches from "Matches your love of X" to "5 orders this week" (or "New on the marketplace" when zero). - Excludes restaurants the user has already favourited or reviewed (same gate as signal mode), so the surface stays fresh. - Response now carries a mode: 'signal' | 'cold-start' discriminator. - `RecommendationRail` UI: reads the mode field and swaps both the title ("Recommended for you" → "Popular this week") and the subtitle so users see why the picks changed. Visual identity stays indigo — same rail, signaled state. - Verified-only review filter (Phase 21 deferred — "No verified-only filter chip on the public review list yet"). - GET /api/bazar/restaurant/[slug]/reviews now accepts ?verified=1 (also true / yes). Filters the returned reviews[] to those with verifiedPurchase === true. - Headline summary always reflects the unfiltered approved set so the star average doesn't bounce when users toggle the chip. Response shape extended with summary.verifiedCount and filter.verifiedOnly so the client renders chip labels without a second round-trip. - BazarReviews UI: two-button toggle (All / Verified only) above the review list, with the verified count on the button. Pagination preserves the filter across pages. Toggle is hidden when `verifiedCount === 0` so we never advertise a filter that resolves to empty. - Verification: - TypeScript: 0 errors - next build: ✅ green - HTTP probes: - Anonymous GET /api/bazar/recommendations → { recommendations: [] } (still anonymous-safe, mode field omitted on empty) - GET /api/bazar/restaurant/luigi/reviews?page=1 → 2 reviews (1 legacy tenant verifiedPurchase: false, 1 bazar true) - GET /api/bazar/restaurant/luigi/reviews?page=1&verified=1 → 1 review (only Tom's bazar review with verifiedPurchase: true) — confirms the filter ✓ - Known limitations / deferred: - "Popular this week" rolls over every tenant DB to count orders + ratings — same scale caveat as Phase 28's signal mode. A precomputed (tenantId, weekStart) → orderCount index would help past ~1000 listings. - "This week" hardcoded to 7 days — could be a per-tenant or platform setting. - Cold-start ignores rating quality at the score level — a brand-new restaurant with 1 order outranks a 4.9-star one with 0 recent orders. Conscious choice: the rail is about momentum, not popularity-all-time. The rating still appears on each card so users can judge. - No "Verified only" filter on the operator-side reviews dashboard — would mirror the bazar chip on /dashboard/reviews. Polish.

    • Phase 30: Custom-domain support for tenant sitemap + robots.txt

    - Closes Phase 29's most concrete deferred item: "Custom-domain tenants (e.g. www.luigis-pizzeria.com) still hit the unknown-subdomain branch and serve a blocked robots." A premium tenant whose merchant points DNS at their own domain now gets a proper tenant-scoped robots + sitemap on that domain — same shape as the subdomain branch but anchored on the custom hostname. - `src/lib/seo/host-resolver.ts` (new): single shared resolver consumed by both /sitemap.xml and /robots.txt. - PLATFORM_HOSTS constant moves here (was duplicated in both routes + /llms.txt). - resolveHostToTenant(host, tenants) returns a tagged union: { kind: 'platform' } | { kind: 'tenant', tenant, matchedVia: 'customDomain' | 'subdomain' } | { kind: 'unknown' }. - Lookup priority: custom-domain exact match (case-insensitive, scheme stripped, trailing-slash stripped, with bi-directional www. matching so an operator who stores the bare apex still matches a www. request and vice versa) → subdomain match against leftmost host label → unknown. - `/sitemap.xml` and `/robots.txt` refactor to delegate host resolution to the shared helper. Behaviour: - Custom-domain match → same tenant-scoped sitemap (home + menu + reservations + blog when present) and tenant-scoped robots, but with <loc> and Sitemap: lines anchored on the custom domain. - Unknown host → empty sitemap + Disallow: / robots (unchanged from Phase 29). - Platform / subdomain branches unchanged. - Seeder update (scripts/seed-everything.js): Luigi gets customDomain: 'luigis-pizzeria.example' so the Phase 30 smoke test exercises the new branch. - Verification: - TypeScript: 0 errors - next build: ✅ green - HTTP probes with spoofed Host: luigis-pizzeria.example: - /sitemap.xml → 3 URLs anchored on luigis-pizzeria.example (not luigi.localhost) ✓ - /robots.txt → tenant-scoped allow + disallow + sitemap reference pinned to the custom domain ✓ - Previous-phase probes still pass: platform 163 URLs; nobody.localhost blocked; luigi.localhost → 3 URLs. - Known limitations / deferred: - No multiple custom domains per tenant — tenant.customDomain is a single string. Migrating to an array would handle apex + www + locale variants (.com, .co.uk, etc.). Polish. - No domain verification flow — the seeded luigis-pizzeria.example is trusted by-definition. Production needs the operator to prove DNS ownership before customDomain is honoured. Lives elsewhere in the codebase (tenant settings UI), not in scope here. - AI crawler allow-list still duplicated between platform + tenant variants of robots.txt. Small DRY follow-up. - Tenant sitemaps stay 3 URLs deep — no product detail pages (route doesn't exist yet), no <image:image> extensions (Phase 29 deferred holds).

    • Phase 29: Per-tenant subdomain `/sitemap.xml` + `/robots.txt`

    - Closes a real SEO discovery gap — both files previously served the same monolithic platform-wide content regardless of host, so luigi.restora.app/sitemap.xml returned 163 URLs that mostly didn't belong to that host (designers, suppliers, docs, bazar listings on the platform host, etc.). Google + AI crawlers treated this as cross-host noise rather than canonical declarations. - Convention switch: replaces Next's MetadataRoute sitemap() + robots() static-generation convention with explicit host-aware route handlers at app/sitemap.xml/route.ts and app/robots.txt/route.ts. Same pattern Phase 6's /llms.txt already uses. Old .ts files removed. - Host detection mirrors Phase 6: a PLATFORM_HOSTS set (localhost:3001, restora.io, restora.app, etc.) + naive subdomain extraction from the Host header. The proxy treats files with dots as static assets and skips rewrites, so the route self-detects. - `/sitemap.xml` branches: - Platform host → unchanged from Phase 11 + Phase 20: static routes + designers + suppliers + chains + tenants + bazar surfaces (cuisine + city + combo + restaurant). 163 URLs after the Phase-everything seeder. - Known tenant subdomain → home (/) + menu + reservations + (when blog feature has published posts) the blog index + per-post URLs. 3 URLs for Luigi today; grows with content. - Unknown subdomain OR demo/inactive tenant → empty <urlset>. Explicit "nothing canonical here" signal rather than a fallback to the platform list (which would be wrong). - `/robots.txt` branches: - Platform host → unchanged disallow list (admin / API / checkout / tracking / dev paths) + 7 explicit AI crawler allowances + Sitemap: ${BASE}/sitemap.xml + Host: ${BASE}. - Known tenant subdomain → tenant-scoped disallow (/checkout, /order-confirmation, /account, /api/, /_next/) + same AI crawler allowances + sitemap pointing at the tenant's own subdomain sitemap. So luigi.restora.app/robots.txt advertises Sitemap: luigi.restora.app/sitemap.xml, not the platform sitemap. - Unknown subdomain OR demo/inactive tenant → Disallow: / (full block) so we never accidentally leak demo content into Google's index. - DB read failure falls back to the platform robots rather than blocking everything — defence against transient infra issues. - Verification: - TypeScript: 0 errors - next build: ✅ green; both /sitemap.xml and /robots.txt registered as dynamic functions (ƒ) - HTTP probes with spoofed Host header: - restora.io/sitemap.xml → 163 <loc> entries (platform rollup) ✓ - luigi.localhost/sitemap.xml → 3 entries (home + menu + reservations) ✓ - restora.io/robots.txt → platform allow + disallow list ✓ - luigi.localhost/robots.txt → tenant-scoped + tenant-sitemap reference ✓ - nobody.localhost/robots.txt → Disallow: / (unknown subdomain block) ✓ - Known limitations / deferred: - Custom-domain tenants (e.g. www.luigis-pizzeria.com) still hit the unknown-subdomain branch and serve a blocked robots. Need a customDomain lookup in addition to the subdomain match — small follow-up that touches both files. - Tenant sitemaps don't include offer / product detail pages yet. The current per-tenant list is intentionally conservative (3-4 URLs); expand once we have public product URLs to index. - No `<image:image>` extensions on the tenant sitemap — could surface banner + product photos for richer image search. Polish. - Cache TTL is 10 minutes (max-age=600) — fine for SEO crawl frequency but a tenant changing their hours won't see the lastmod tick for up to 10 min.

    • Phase 28: Bazar "Recommended for you" rail driven by cuisine signals

    - Closes a real product gap — the bazar collected subscriptionPrefs.preferredCuisines during onboarding and favoriteRestaurantIds from the Heart button (already wired on the restaurant detail page), but never used either signal to recommend anything. Phase 28 turns those data points into a visible benefit on the marketplace landing. - `GET /api/bazar/recommendations` (new): blends two signals to rank approved listings the user hasn't already engaged with. - Weight 2 per overlap: preferredCuisines — the explicit list typed during onboarding. - Weight 1 per overlap: cuisine tags of restaurants the user has already favourited ("you liked X, here's another like X"). - Excludes restaurants in favoriteRestaurantIds (don't recommend things they've already saved) AND restaurants they've already reviewed (they know). - Per-result reason string ("Matches your love of Italian" / "Similar to a restaurant you saved") — transparent recommendation rationale instead of black-box "AI says so". Rendered as a chip under each card. - Unauthenticated callers get `200 { recommendations: [] }` not 401 — the marketplace landing can call this from the public surface; the rail self-hides on empty. - Sorted by score desc, then rating value desc, then name asc. Caps at 8 results. - Best-effort rating attachment from per-tenant tdb.reviews (filters out hidden via Phase 18 logic). - `<RecommendationRail />` on /bazar: - Self-contained, optional component — fetches recommendations on mount and renders nothing when none come back, so the landing stays clean for anonymous + new users. - Sits just above the Sponsored section so logged-in users see relevance before paid placement. - 4-up grid on desktop, single column on mobile. Each card shows name, rating pill, top 3 cuisine tags, and the indigo reason chip. - Indigo theme to differentiate from the orange marketplace primary and the amber Sponsored block. - Heart button audit: confirmed the restaurant detail page Heart toggle already POSTs /api/bazar/user/favorites correctly (Phase 28 audit caught this before duplicating work). No UI changes needed there — the existing infrastructure feeds the new signal directly. - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/bazar/recommendations registered - HTTP probe: anonymous → 200 { recommendations: [] } ✓ (rail hides silently); authenticated paths exercised via the seeded bazar users (each has 2 preferred cuisines + 2 favourites) - Known limitations / deferred: - Two signals only — no order history, no recently-viewed, no time-of-day patterns yet. The score scaffolding makes adding weights trivial. - No "Why am I seeing this?" deep-dive — the chip's one-liner is the explanation. A future polish could let the user click for "Because you liked Anna's pick and Sakura, plus your preferences include Japanese". - No cold-start fallback — new users with no preferences and no favourites get no recommendations. Could swap in "Popular this week" when score-based results are empty. - Reads every tenant DB to score + rate — fine at our scale (low single-digit ms per listing); a precomputed cuisine→[tenantId] index would help past 1000 listings. - Not personalised per device — recommendations follow the bazar user account, not the browser. Acceptable.

    • Phase 27: Bazar order live status polling + click-to-call

    - Closes two Phase 25 deferred items: "No live status push — page re-fetches only on mount" and "Restaurant phone is shown raw — no click-to-call deep link on mobile yet". - Live polling on `/bazar/orders/[orderId]`: - 30s interval while the order is in flight. Stops automatically once status ∈ {delivered, rejected, canceled} — no point burning cycles on completed orders. - Re-polls on tab focus via visibilitychange so a backgrounded customer who comes back gets fresh state without manual refresh. - Silent mode: load({ silent: true }) skips the loading spinner and error banner — keeps the page calm while the badge stays solid. Initial load + user-triggered refresh still surface loading + error states. - `isLive` derived from order status — single source of truth feeds both the polling effect and the UI badge. - UI affordances: - `<Radio /> Live` badge (rose pill with animate-pulse) next to the status pill, only while polling is active. Tooltip: "Auto-refresh every 30s while in flight". - "Updated Xs ago" hint below the status row. A 1Hz tick state forces a re-render each second so the counter doesn't freeze on the value it landed at during the last fetch. aria-live="polite" so screen readers announce updates non-disruptively. - Click-to-call: restaurant phone in the strip is now a <a href="tel:…"> chip (Phone icon + number on ≥sm, "Call" label on xs). The phone string has whitespace stripped before being passed to tel: so messy formatting in tenant settings doesn't break iOS dialer URLs. - Restaurant strip refactor: splitting the row into a name-link + separate phone-chip resolves the "two tap targets, one link" UX problem — taps on the row now reliably hit the link they aimed at. - Verification: - TypeScript: 0 errors - next build: ✅ green - HTTP probe: /bazar/orders/ord_x → 200 (page renders; client polling fires once auth lands a real order in state) ✓ - Known limitations / deferred: - 30s cadence is hardcoded — same trade-off discussed for Phase 19's inbox bell. Could be configurable per tenant or per user. - No SSE / WebSocket push — real-time push remains the proper upgrade path; polling is enough for current scale. - No "your order is ready!" toast — status change between polls is currently silent. A diff-against-previous render could surface a one-time success burst when status flips to ready or delivered. - `tel:` only — no `sms:` for SMS — could be added on a long-press or "More" menu in a future polish.

    • Phase 26: Bazar cancel + reorder — completes the customer order-lifecycle loop

    - Closes both Phase 25 deferred items ("No cancel from this page" and "No reorder button"). - `src/lib/order/customer-cancel.ts` (new): shared customer-cancellation service extracted from the existing public route. Single source of truth for eligibility checks, refund creation, escrow status updates, and risk scoring. Callers handle auth + ownership BEFORE invoking — the service trusts the caller has already proven ownership. - `POST /api/public/[tenantId]/orders/[orderId]/cancel` refactored to call the shared service. Email-based ownership check stays at the route layer (unchanged behavior for tenant-direct flow). - `POST /api/bazar/orders/[orderId]/cancel` (new): bazar wrapper. - Auth: getBazarSession → 401 if missing. - Walks approved bazar listings to find the order's tenant. - Ownership gate: bazarUserId === user.id; mismatch returns 404 (not 403) to avoid order-existence enumeration. - On pass, delegates to processCustomerCancel so refund + risk scoring matches the public path bit-for-bit. - Body: { reason?: string } propagated to the audit timeline. - `POST /api/bazar/orders/[orderId]/reorder` (new): cart-ready payload builder. - Same auth + ownership gates as cancel. - Re-resolves every line against the current menu — product must still exist and available !== false. Skipped items land in skipped[] with reason 'unavailable' | 'removed' so the UI can surface "X items skipped". - Current `basePrice` is returned, not the historical one — better to surprise the customer with a higher checkout total than to charge a stale price and lose money on the merchant side. - Current `imageUrl` is returned (the detail-API doesn't carry it — needed for the cart visual). - Doesn't mutate cart server-side — cart lives in localStorage via useBazarCart. Client takes items[] and calls addItem per line, fanning out by quantity so the cart's existing add semantics (bump existing line, insert new) just work. - UI on `/bazar/orders/[orderId]`: - Action row above the timeline with two buttons: - Reorder (orange) — always available. Calls reorder API, fans items into the cart, surfaces an info toast when items were skipped, redirects to the restaurant page after 1.2s for review + checkout. - Cancel order (rose outline) — only shown when order.status ∈ {pending, accepted, preparing}. Prompts for an optional reason, POSTs to the cancel endpoint, shows the server's refund-percent message in the success toast. - Inline action message (success/error/info toast) renders above the action row. Success toast hides the action buttons. - Loading spinners per-button via actioning state. - Verification: - TypeScript: 0 errors - next build: ✅ green; 3 routes registered: bazar cancel, bazar reorder, and the refactored public cancel - HTTP probes: bazar cancel → 401 without session ✓; bazar reorder → 401 without session ✓; public cancel still returns 404 for a missing order (proves the shared service wiring is intact) ✓ - Known limitations / deferred: - No client-side eligibility hint — the Cancel button shows on pending|accepted|preparing but the server may still 409 if escrow policy says no. Acceptable: the error toast surfaces the reason. - Reorder doesn't carry selected options — past-order line options aren't replayed. Phase 27-ish polish; needs useBazarCart to grow option-aware semantics. - No bulk reorder ("reorder my last 3 orders") — single-order only. Trivial extension when warranted. - No reorder gate on closed/inactive restaurants — the reorder endpoint doesn't check listing.status or ordering-status. Could lead to a friendly cart push then an immediate "restaurant closed" toast at checkout. Acceptable.

    • Phase 25: Bazar order detail page

    - Closes Phase 22's deferred item ("No order detail page for bazar customers — the card surfaces summary fields but there's no /bazar/orders/[id] deep link yet"). With the seeder now populating 10 bazar-linked orders, the detail page has real data to render from day one. - `GET /api/bazar/orders/[orderId]` (new): returns the full detail for a marketplace order owned by the authenticated BazarUser. - Ownership gate: the order must have bazarUserId === user.id. Mismatched ownership returns 404, never 403, so the response shape doesn't leak whether the order exists. - Walks every approved tenant's orders[] looking for the ID; stops on first hit. - Strips internal/sensitive fields: paymentHoldId, stripePaymentIntentId, escrowStatus, refundId. Surfaces a coarse paymentLabel ("Card" / "Cash on delivery") instead of the raw method. - Builds a synthesised timeline from explicit timestamps (createdAt / acceptedAt / preparingStartedAt / preparedAt / deliveredAt / cancelledAt) — the richer underlying order.timeline[] (an operational log) isn't exposed. - Tolerant of two OrderItem shapes: legacy productName/price/selectedOptions AND the seeder's name/subtotal/options. Both render uniformly. - `/bazar/orders/[orderId]/page.tsx` (new): receipt-style customer detail page. - Header card: order number + placed-at + status pill + paid pill + delivery/pickup pill + clickable restaurant strip with logo + phone. - Timeline section: step list with the synthesised events; last step highlighted in orange. - Receipt section: per-item line with quantity × name + options + unit price + subtotal; totals breakdown including subtotal / delivery / discount (with coupon code if present) / tax / total. - Delivery + payment side-by-side cards; gift-card credit displayed when applied. - Notes section when the order had any. - Loading / 404 / 401 (sign-in) states all handled cleanly. - `/bazar/account` Orders tab: each card is now a single <Link> wrapping the full content (resolves the nested-link HTML issue) that opens the detail page. The restaurant-name link moved into the detail page's restaurant strip — clearer UX, one tap-target per card. - Seeder update: seed-everything.js now writes each order item with BOTH the canonical (productName/price/selectedOptions) AND the shorter alias (name/subtotal/options) fields so both reader paths (legacy tenant order surfaces + Phase 25 bazar detail API) see consistent data. - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/bazar/orders/[orderId] + /bazar/orders/[orderId] registered - HTTP probes: API → 401 without bazar session ✓; page → 200 ✓ - Known limitations / deferred: - No reorder button — would clone the items into a fresh cart on the source restaurant. One-line addition once useBazarCart exposes a replaceAll action. - No cancel from this page — would call the existing /api/public/[tenantId]/orders/[orderId]/cancel endpoint with bazar-session auth. The cancel flow already exists for the tenant-direct path; bazar wrapper is a Phase 26-ish item. - No live status push — page re-fetches only on mount. For "is my order ready yet?" the polling cadence from Phase 19 could be reused. - Restaurant phone is shown raw — no click-to-call deep link on mobile yet (tel: href would be a one-line addition).

    • Phase 24: Rate-limiting middleware on the four highest-risk public endpoints

    - Closes Phase 18's explicit deferred concern ("no rate limiting on the report endpoint — a determined actor could flag-bomb") and proactively gates three other high-risk public endpoints that previously had zero throttling: bazar review submit, public order POST, bazar login. - `src/lib/rate-limit/limiter.ts` (new): in-memory sliding-window counter. - Principal derivation (priority order): explicit principal override (auth-resolved tenant/user IDs) → first IP in cf-connecting-ip → x-real-ip → x-forwarded-for → literal 'anon' (so a misconfigured proxy degrades to a shared bucket, never a bypass). - Sliding window: each bucket stores hit timestamps capped to the active window. Periodic sweeper evicts buckets cold for >30 minutes so memory doesn't grow unbounded under churn. Sweeper timer is unref'd so Node still exits cleanly. - `requireRateLimit(req, opts)` helper mirrors our existing auth-gate ergonomics ({ ok: true } | { error: Response }). - 429 response includes Retry-After (seconds), X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers so well-behaved clients can back off intelligently. - `inspectRateLimit` for read-only checks (no hit consumed) — useful for tests or quota indicators. - `_resetRateLimitForTests` escape hatch for unit tests. - Warn-level log on every 429 so abuse waves stand out without burying signal in normal traffic. - Endpoints gated: - /api/bazar/restaurant/[slug]/reviews/[reviewId]/report — 5 reports per IP per 10 min. Closes the Phase 18 flag-bomb vector. - /api/bazar/restaurant/[slug]/reviews POST — 10 review writes per BazarUser per hour (keyed by user.id not IP — review is per-user-per-restaurant upsert, but spammy edits to a single review are still abuse). - /api/public/[tenantId]/orders POST — 10 orders per IP per 10 min, scoped per tenant (route: 'public.order.submit:${tenantId}') so flagging one tenant doesn't lock out another. Generous enough that a household placing back-to-back orders is fine, tight enough that scripted probing trips immediately. - /api/bazar/auth/login POST — 8 attempts per IP per 15 min. Stalls credential-stuffing without blocking a typo + retry. - Verification: - TypeScript: 0 errors - next build: ✅ green - Live abuse simulation with x-forwarded-for: 198.51.100.7: - 6 rapid POSTs to the report endpoint → first 5 land (return 404 because Luigi isn't on bazar — proves rate-limit fires before DB lookup), 6th returns 429 with `Retry-After: 600` ✓ - 9 rapid POSTs to login with wrong credentials → first 8 return 401, 9th returns 429 ✓ - Documented trade-offs: - In-memory storage = single-process scope. Deployed across multiple Node instances (Vercel serverless, multi-pod K8s), the counter doesn't share state — each instance has its own budget. For our current scale this is acceptable; the proper upgrade path is Redis/Upstash with the same requireRateLimit signature. - Per-IP can hurt CGNAT/corporate users sharing an egress IP. The user-ID override path (used on review submit) mitigates this for authenticated endpoints. For unauthenticated endpoints we accept the small collateral. - No global limit — only per-(route, principal). Worst-case a botnet from 1000 unique IPs each hitting the report endpoint 5 times pre-trips no limiter. Mitigation: cuts off at the auto-hide threshold (Phase 18) anyway. Future polish: stacked global cap. - Known limitations / deferred: - In-memory only — see above re Redis upgrade path. - Limits are hardcoded — no admin UI to tune them yet. Editing them requires a code change. Acceptable for security defaults. - No exemptions — there's no allowlist for trusted IPs (e.g. internal monitoring). Easy to add via the principal override when we need it.

    • Phase 23: Super-admin platform activity feed

    - Closes Phase 14's deferred item ("Activity log type feature-grant only appears on the tenant's own activity page — there's no platform-wide super-admin activity feed yet"). Every tenant has its own activityLogs[] but a super-admin couldn't see "what happened across every restaurant in the last 7 days" without checking each tenant individually. Phase 23 builds the rollup. - `GET /api/admin/activity` (new): walks every tenant's activityLogs[], enriches each entry with the tenant record, applies optional filters, returns sorted newest-first. - Query params: days (clamped [1, 90], default 7), type (single ActivityType), tenant (substring match against tenant name OR subdomain), limit (clamped [1, 500], default 200). - Pre-filters tenants by substring before loading their DBs so a search for "luigi" doesn't read 100 tenant DBs we'll throw out. - Response includes: items array, counts (total, returned, byType, byTenantId), window (fromIso, toIso, days), knownTypes list. - Super-admin auth gate (requireSuperAdmin). - `/admin/dashboard/platform-activity` (new page): - Three-tier filter row: (1) time window — 24h / 7 days / 30 days / 90 days, (2) activity type chips (All + every known ActivityType with live count), (3) debounced tenant search (300ms) matching name or subdomain. - Summary tiles: Events count + returned subset, Active tenant count, Window in days, current type filter. - "Most active tenants" panel: top 5 tenants by event count in the current window, each clicks through to the tenant detail (filtered by name). - Per-event card: color-coded type icon + pill, tenant name with external-link to detail, relative time, action headline, details body, subdomain + truncated userId + full timestamp in the footer. - Loading + empty + truncated-results states all handled. - Distinct from existing `/admin/dashboard/logs` — that page surfaces super-admin audit actions (suspensions, deletions, tenant changes). Platform activity surfaces the inverse: what's happening inside each tenant. They live as siblings. - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/admin/activity + /admin/dashboard/platform-activity registered - HTTP probes: API → 401 without super-admin ✓ (both base and filtered variants); page → 307 (admin login redirect ✓) - Known limitations / deferred: - Scans every tenant's full activityLogs[] in-memory — fine at current scale (the existing MAX_ACTIVITY_LOGS = 500 cap per tenant means worst case is N_tenants × 500 entries). Worth indexing or shipping to a real DB if we cross 10k+ tenants. - No real-time push — page refreshes on filter change + manual Refresh button. Adequate for an admin tool. - No CSV export of the filtered set. Easy add when needed. - No multi-type filter (one type at a time only). The "All" chip + per-type counts is the workaround. - `tenant` filter is exact-string substring — no fuzzy match. Acceptable for an admin tool.

    • Phase 22: Bazar order linkage + customer order history

    - Closes Phase 21's "email matching is the weak link" deferred item and unlocks a proper "My orders" surface for bazar customers. - Schema (Order): adds optional bazarUserId?: string. Backwards-compat with every legacy order (defaults to undefined). - Stamp at checkout: POST /api/public/[tenantId]/orders now reads the bazar session cookie via getBazarSession(req) at the top of the handler. When present, the resolved user.id lands on Order.bazarUserId alongside the existing customerEmail etc. Wrapped in a try/catch so a bazar-DB hiccup never blocks the order from going through — the link is best-effort, the order is sacred. - Upgraded verification logic in POST /api/bazar/restaurant/[slug]/reviews: the verified-purchase check now matches on either (a) Order.bazarUserId === user.id (Phase 22, rock-solid) OR (b) customerEmail case-insensitive match (Phase 21, fallback for legacy + guest checkouts). Either path still requires paymentStatus === 'paid'. - `GET /api/bazar/orders/me?page=1&perPage=10` (new): authenticated bazar customers can now pull their cross-restaurant order history. - Iterates approved bazar listings, scans each tenant's orders[] for matching bazarUserId. - Sorts newest-first. Strips per-restaurant sensitive fields (escrow IDs, payment intent IDs, hold IDs) — only customer-visible fields make it out. - 401 without a bazar session ✓. - `/bazar/account` page: new Orders tab added between Profile and Favourites with the ShoppingBag icon. Each order card shows order number, status pill, "Paid" pill (when applicable), restaurant name (linked to the bazar restaurant page), item count + type, total, and date. Pagination at the bottom when total > 10. Empty state links to /bazar. - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/bazar/orders/me registered - HTTP probes: /api/bazar/orders/me → 401 without bazar session ✓; /bazar/account → 200 ✓ - Behavioral migration: - Legacy orders stay unlinked — they were placed before bazarUserId existed. Phase 21's email fallback still catches them. A future polish could backfill bazarUserId on legacy orders by matching customerEmail against current bazar users (low-priority). - Tenant-direct orders (placed via the tenant's own ordering surface, not bazar) generally won't carry bazarUserId because the customer isn't logged into the marketplace — they'd order as a guest or via the tenant's own auth. Acceptable: verified-purchase is a marketplace signal, not a tenant signal. - Known limitations / deferred: - No order status sync from tenant-side updates — if the tenant operator marks an order delivered, the bazar Orders tab reflects it on next reload but doesn't auto-refresh. The same polling cadence as the operator dashboard (Phase 19) could be added later. - No order detail page for bazar customers — the card surfaces summary fields but there's no /bazar/orders/[id] deep link yet. Tenant order-tracking surfaces already exist (/track/[tenantId]/[orderId]) and would be the natural target. - `/api/bazar/orders/me` walks every approved tenant DB — fine at our scale. A precomputed bazarUserId → [orderIds] index would help at 10k+ listings.

    • Phase 21: Verified-purchase badge for bazar reviews

    - Closes Phase 16's social-proof gap — bazar reviews previously had no credibility signal beyond rating + comment. Reviewers who actually ordered now carry a "Verified" badge so the social-proof loop is honest. - Schema (Review): adds optional verifiedPurchase?: boolean. Backwards-compat with every legacy review (defaults to undefined, rendered as not-verified). - Verification check at submission time — POST /api/bazar/restaurant/[slug]/reviews now scans tdb.orders for any customerEmail (case-insensitive) matching the bazar user's email with paymentStatus === 'paid'. If found, the new review is created with verifiedPurchase: true. When upserting an existing review the flag is sticky — once you've verified, future edits keep the badge. - API echo: the public review listing (GET .../reviews) and the submit response both include verifiedPurchase so clients render the badge without a second round-trip. - UI badge: BazarReviews review cards on /bazar/restaurant/[slug] show an emerald Verified pill (BadgeCheck icon) between the rating and the source pill when the flag is true. Tooltip explains: "This reviewer placed a paid order on Food Bazar." - Verification: - TypeScript: 0 errors - next build: ✅ green - HTTP probe: public reviews API returns 404 for an unlisted restaurant (correct gating ✓); API shape intact for listed restaurants. - Known limitations / deferred: - Email-based matching is the weak link — a customer who orders with john@gmail.com but reviews from a bazar account using john.work@example.com won't show as verified. The proper fix is a bazarUserId foreign key on Order set at checkout when a bazar user is logged in. Deferred to a future phase that touches the order-submit flow. - No backfill — existing reviews stay unverified even if the reviewer has orders. We could run a one-off script that re-scans every bazar review against its tenant's order history, but the false-negative rate from email-mismatch makes it a low-priority polish. - No verified-only filter chip on the public review list yet. Could be added if AI / Google Search starts surfacing verified counts. - Bazar users with no orders ever still review freely — verification is a positive signal, not a gate. Acceptable for marketplace discovery use cases.

    • Phase 20: Cuisine × city combo aggregator pages

    - Combines Phase 10's cuisine pages and Phase 11's city pages into the long-tail SEO surface that captures the highest-volume restaurant search queries ("best italian restaurants in stockholm", "japanese restaurants london", etc). - New route /bazar/cuisine/[cuisine]/in/[city] — server-rendered combo aggregator. - 404 gating is two-axis: unknown cuisine slug OR unknown city slug both return 404. Valid combos with zero listings still render (with a "check back soon" message + cross-links to the parent surfaces). - Restaurant filter uses the same listingMatchesCuisine keyword logic the single-axis cuisine page uses, plus extractCity for city resolution — so combos surface the exact same listings their parents would. - Listings sorted by rating desc, then name asc (deterministic for crawlers). - Hidden review filter applied here too (Phase 18) — hidden reviews don't contribute to the rating shown on cards. - Metadata: title "Best ${Cuisine} restaurants in ${City} — Food Bazar", description with live count, canonical URL, OpenGraph + Twitter cards. - JSON-LD is the headline upgrade — three blocks: - CollectionPage with both about: Thing (the cuisine concept) AND spatialCoverage: Place → PostalAddress (the city + country). Lets Google + AI ground geo+cuisine queries on a single structured page. - BreadcrumbList showing the 4-level path: Home → Food Bazar → Cuisine → "In City". - ItemList (when listings present) reusing buildBazarItemListLd. - Cross-links at the bottom: - "${Cuisine} in other cities" — 8 KNOWNCITIES other than the current. - "Other cuisines in ${City}" — 8 MARKETPLACECATEGORIES other than the current. - Builds dense internal link graphs between combos, parent cuisine pages, parent city pages, and individual restaurants — Google's crawl budget gets put to work. - Twin back-links at the top — both parent surfaces (cuisine page + city page) so operators and crawlers can navigate up either axis. - Sitemap depth (/sitemap.xml): - Each approved listing's tenant DB is now scanned for BOTH city AND cuisine matches. Combos with ≥1 listing get an entry at priority: 0.5 (lower than single-axis 0.6 — they're long-tail targets, not main hubs). - Combos with zero listings stay out of the sitemap entirely — no "page exists but it's empty" pollution. - 0 combo URLs in sitemap currently because no bazar listings are approved yet — correct conditional behavior. Becomes ~10× larger once listings exist (26 cuisines × 25 cities = 650 potential combos, but only those with active listings get emitted). - Verification: - TypeScript: 0 errors - next build: ✅ green; /bazar/cuisine/[cuisine]/in/[city] registered - HTTP probes: /bazar/cuisine/italian/in/stockholm → 200 ✓; /bazar/cuisine/pizza/in/florence → 200 ✓; unknown cuisine → 404 ✓; unknown city → 404 ✓ - JSON-LD probes: CollectionPage + spatialCoverage + about + correct title all present ✓ - Known limitations / deferred: - Cross-links on parent cuisine/city pages don't yet point at the top combo destinations — would require expensive DB scans on every parent page load to compute "which combos have listings". Future polish could precompute this offline. - Sitemap loop now scans every approved tenant DB for cuisine matches — fine at our scale (low single-digit ms per listing), but if we ever cross 10k+ listings we'll want a precomputed (cuisine, city) index on the bazar DB. - Combo URL format is `/bazar/cuisine/X/in/Y` — readable but not the "italian-in-stockholm" SERP-pretty form. The current URL still parses cleanly in headlines; revisit if SEO data justifies the migration cost. - No combo-page admin UI — there's no way for super-admins to highlight or de-emphasize specific combos. Acceptable: ranking is implicit from listing count + ratings.

    • Phase 19: Inbox header bell — click-through preview from any dashboard page

    - Closes Phase 17's "no header bell badge" deferred item — the unified inbox shipped at /dashboard/inbox was a destination operators had to remember to visit. Phase 19 makes it impossible to miss with a top-bar bell that polls live, badges urgent items red, and reveals a 3-item quick preview from any dashboard page. - `src/components/dashboard/InboxBell.tsx` (new): self-contained client component. - Polls /api/dashboard/inbox?tenantId=… on mount + every 30s + on visibilitychange (so returning to a backgrounded tab gets a fresh count without manual refresh). - Badge tone: red when urgent > 0, blue when only unread > 0, hidden when zero. Caps display at "9+". - Dropdown panel: top 3 unread items with per-type icon (rose AlertTriangle for sync, amber MessageSquare for urgent reviews, blue HelpCircle for Q&A, orange MessageSquare for bazar reviews, indigo Puzzle for grants). Each card shows title + body excerpt + urgent badge when applicable + relative time. - Per-item click: optimistically marks read (single batch POST to /api/dashboard/inbox/mark-read), closes the panel, navigates to the item's href. Falls back gracefully — next poll reconciles if the request fails. - "Mark all read" button in the header (only shown when unread > 0); "Open inbox" link in the footer. - Portal-rendered panel so it always sits above page content regardless of dashboard stacking contexts. - Closed on: outside click, ESC key, scroll, and the close (X) button. - TopHeader wiring: InboxBell mounts in src/components/dashboard/TopHeader.tsx immediately before the existing orders/reservations Bell. Coexists cleanly — both surfaces have their own state, their own polling intervals, and their own dropdowns. - Verification: - TypeScript: 0 errors - next build: ✅ green - HTTP probes: /dashboard → 200 (bell renders) ✓; /dashboard/inbox → 200 ✓ - Known limitations / deferred: - 30s poll cadence is fixed — could be configurable per tenant. Real-time SSE/WebSocket push would eliminate the latency entirely but adds infrastructure. - The InboxBell renders for all tenant contexts — even tenants on plans that exclude Google features still see the bell. Items from gated sources just won't appear, so the bell stays empty for them. Acceptable. - No badge animation when count changes — a subtle pulse would help draw attention. CSS-only addition when warranted. - No keyboard shortcut to open the inbox bell (Cmd+I etc.). Could be added when we wire up a global hotkey system.

    • Phase 18: Bazar review reporting + moderation queue

    - Closes Phase 16's deferred safety gap — bazar reviews previously auto-approved with no abuse mitigation. This phase wires a three-layer moderation surface (public report → operator hide → super-admin reinstate/delete). - Schema (Review): adds hidden / hiddenBy / hiddenAt / hiddenReason for soft-deletion and reportCount / reportReasons[] for the public flag counter. All optional, all backwards-compat with existing reviews. - `POST /api/bazar/restaurant/[slug]/reviews/[reviewId]/report` — public, anonymous endpoint. - Body: { reason?: string } coerced to spam | abuse | off-topic | fake | other. - Increments reportCount and appends to reportReasons (capped at 10 most recent so a flagging campaign can't bloat the record). - Auto-hide threshold: at 3 reports the review flips hidden: true with hiddenBy: 'auto-moderation'. Drops out of the public listing + aggregate rating immediately — no human in the loop required for the obvious cases. - Returns { ok, reportCount, autoHidden } so the UI can show "Thanks — we'll review" plus a one-time success state. - `PATCH /api/tenant/[tenantId]/reviews/[reviewId]` extended with hidden / hiddenReason fields — operators can hide a review they consider unfair without escalating to admin. Sets hiddenBy: auth.session.userId for audit. - Hidden filter applied across every public surface that aggregates ratings: /api/bazar/restaurant/[slug] (rating header), /api/bazar/restaurant/[slug]/reviews (paginated list), /bazar/cuisine/[cuisine], /bazar/city/[city], /bazar/restaurant/[slug] server metadata. The underlying review record still exists for admin retention. - Super-admin moderation queue: - `GET /api/admin/bazar/moderation` — returns every review that is either hidden: true or has reportCount > 0. Enriched with tenant name + subdomain + report-reason histogram so the UI renders without an extra round-trip. Sorted: hidden first, then by report count desc, then newest first. - `POST /api/admin/bazar/moderation/[tenantId]/[reviewId]` with { action: 'reinstate' | 'delete' | 'clear-reports' }. Reinstate unhides and clears reports (false-positive recovery). Delete removes the review entirely. Clear-reports keeps the review visible but resets the counter (for coordinated-abuse situations). - Admin page at /admin/dashboard/bazar/review-moderation: summary tiles (Total / Hidden / Flagged only), filter chips, per-review card showing rating + bazar/tenant badge + hidden badge + report-count badge + reason histogram + the full review body + Reinstate / Clear-reports / Delete actions. - UI updates: - Bazar review card (/bazar/restaurant/[slug]) gets a small Flag icon button. Click opens an inline form with 5 reason chips (spam, abuse, off-topic, fake, other) + Submit / Cancel. Post-submit the card flips to "Thanks — our moderators will review." - Operator reviews page (/dashboard/reviews) gets a new EyeOff / Eye action per row. EyeOff hides the review from the bazar; Eye toggles back. Headers now show source pill (Bazar / Tenant), Hidden badge, and a Flag badge with the public report count. - Verification: - TypeScript: 0 errors - next build: ✅ green; 4 new routes (report, admin moderation GET, admin moderation/[tenant]/[review] POST, admin moderation page) registered - HTTP probes: public report → 404 for an unlisted restaurant (gating ✓); admin queue → 401 (super-admin gate ✓); admin action → 401 ✓; admin page → 307 (admin login redirect ✓) - Known limitations / deferred: - Anonymous reports — no IP / fingerprint / rate limiting on the report endpoint. Trade-off for low-friction reporting; a determined actor could flag-bomb. Mitigated by the admin reinstate flow. Rate-limiting middleware is a Phase 19-ish polish. - Auto-hide threshold is hardcoded at 3 reports. Should become a per-tenant or per-platform admin setting once we have data on what threshold catches abuse without false-positives. - No reporter notifications — anonymous reports stay anonymous so we can't tell them "we acted on your report." Acceptable. - Hide doesn't notify the reviewer either — by design (no spam vector + no harassment of legitimate negative-review authors). Tenants can still reply publicly; that's the recourse for "I disagree." - Aggregate rating recomputes on every fetch (still — same as Phase 16). Worth caching if it becomes a hot path.

    • Phase 17: Operator unified inbox

    - Problem closed: across Phases 6-16 we built so many surfaces (sync failures, urgent Google reviews, unanswered Q&A, bazar customer reviews, admin-driven feature grants) that an operator has to check 5 different pages to know what needs attention. This phase consolidates them into a single /dashboard/inbox triage view. - Aggregator engine — src/lib/inbox/build-inbox.ts: - buildInbox(tenantId) returns an InboxBundle with a unified InboxItem[] ordered newest-first, plus headline counts: { total, unread, urgent, byType }. - Sources today (each bounded so a noisy source can't drown the others): 1. Failed Google syncs — last 14 days · cap 10 · severity: high|normal 2. Urgent Google reviews — sentiment=urgent only · cap 10 · always severity: urgent 3. Unanswered Q&A — questions without ownerAnswer · cap 10 · severity: high 4. Recent bazar reviews — source==='bazar' AND within 30 days · cap 20 · low-rating + unreplied auto-promotes to urgent 5. Recent feature grants — activityLog.type==='feature-grant' within 14 days · cap 10 · severity: normal - Stable item IDs encode the type + source identifier (urgent-review:<reviewId> etc.), so read-state survives across reloads. - Pure-ish: only the two parallel data loads (tenantDB + Google connection) are I/O; the build is deterministic. No randomness, no time-of-day dependence beyond inWindow. - Read state lives on the existing tenantDB.readNotifIds: Record<string, string> (no schema additions). An item is read iff its ID is keyed in the map. - APIs: - GET /api/dashboard/inbox?tenantId=… — returns the bundle. - POST /api/dashboard/inbox/mark-read with { tenantId, itemIds[] } — upserts each ID into readNotifIds with the current timestamp. Idempotent (re-marking is a no-op). Capped at 500 IDs per call to bound the worst-case write. - Dashboard page at /dashboard/inbox: - Summary tiles: Unread / Urgent / Total / Failed syncs+Q&A. - Filter chips (composable): status (Unread, Urgent, All) + type (per-source). Per-type chips are hidden when count is zero. - Item cards: severity-coded left border (rose · amber · gray), type pill, urgent badge, relative timestamp, title, body excerpt, "Open" link to the relevant fix surface (/dashboard/settings/google for sync failures, /dashboard/settings/google/inbox for reviews/Q&A, /dashboard/reviews for bazar, /dashboard/features for grants), "Mark read" button. - Click-through marks read automatically — opening the fix surface flips the item's read state so the operator doesn't have to do it twice. - Mark all read button in the header when unread > 0. - Empty state: "All caught up" with a green flash icon when nothing matches. - Optimistic local update on mark-read so counts/UI bump without a full reload. - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/dashboard/inbox + /api/dashboard/inbox/mark-read + /dashboard/inbox registered - HTTP probes: both APIs → 401 without session ✓; dashboard page → 200 ✓ - Known limitations / deferred: - No real-time push — page polls on visit / refresh. Adequate for v1; could wire SSE in a future phase if the operator's expectation tightens. - No header bell badge — the page is a destination, but there's no count badge on the dashboard nav yet. One-line addition to the layout when we want it. - No mute / snooze — operators can mark read but not "remind me later". A snoozeUntil: string on the read map would handle it. - Bazar reviews link to `/dashboard/reviews` — that page already exists for tenant reviews; the bazar source filter inside it is a future polish. - Mark-read race condition — concurrent mark-read calls could overwrite each other's readAt values, but the IDs themselves still land (the merge is shallow). Tolerable.

    • Phase 16: Bazar customer reviews — closes the marketplace social-proof loop

    - Problem closed: Phase 10 added aggregate rating pills to the marketplace UI but there was no way for marketplace customers to actually leave reviews — the loop was half-built (we surfaced ratings but only collected them via tenant sites). - Schema (Review): adds optional source: 'tenant' | 'bazar' + bazarUserId?: string. Backwards-compatible — undefined source reads as 'tenant'. Public listing endpoints redact email and bazarUserId. - `GET /api/bazar/restaurant/[slug]/reviews?page=1&perPage=10` — public paginated review feed. Only approved reviews are returned; both legacy tenant-site reviews and Phase 16 bazar reviews surface in one list, sorted newest first. Response includes summary: { value, count } so the UI can render the star average without recalculating. - `POST /api/bazar/restaurant/[slug]/reviews` — bazar-authenticated submission. - Auth: requires a BazarUser session (getBazarSession), 401 otherwise. - Body validation (zod): rating 1–5 integer, comment 3–2000 chars, optional displayName 1–80 chars. - One review per (BazarUser, restaurant) — re-submitting upserts in place rather than spawning duplicates. The bazarUserId field is the dedupe key. - approved: true by default — bazar reviews are public-marketplace content, not tenant-moderated (tenant owners can still respond via the reply field). - Writes to tenantDB.reviews[] so the aggregate rating + restaurant detail page automatically pick up the new review without extra plumbing. - UI on `/bazar/restaurant/[slug]`: - New `BazarReviews` section appended below the menu column on both mobile and desktop. - Section header shows star average + review count + a "Leave a review" / "Edit your review" CTA. The CTA flips to "Edit" when we detect the current bazar user already has a review on this restaurant. - Inline form with StarRating picker (1–5, hover-scale interaction), textarea (char counter, 2000 max), display-name override (defaults to user's account name), Cancel + Post buttons. Form is hidden until the CTA is clicked — keeps the section visually quiet by default. - Auth-required hint when an unauthenticated visitor clicks the CTA — shows a sign-in link that round-trips back to the restaurant page via ?redirect= so they don't lose context. - Review cards show author name + star rating + relative time (fmtRelative — "Yesterday" / "3 days ago" / "2mo ago") + a "Bazar" badge for source disambiguation + tenant owner replies in a left-bordered quote block. - Pagination — Prev/Next when total exceeds the per-page cap. - Empty state: "No reviews yet — be the first to review {name}." - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/bazar/restaurant/[slug]/reviews registered - HTTP probes: GET → 404 for an unlisted tenant (correct gating ✓), POST → 401 without bazar session (auth gate fires first ✓), POST → 401 even with invalid body (auth gate fires before validation ✓) - Known limitations / deferred: - No verified-purchase flag — we'd love to mark "Reviewed after ordering" but we don't yet correlate bazar review submissions with marketplace order history. Future polish once the bazar order-history table grows. - No spam / abuse moderation — bazar reviews auto-approve. Tenant owners can reply but can't hide; super-admins can edit master.json if needed. Should add a soft-flag + admin queue once we have meaningful volume. - No edit-after-reply guard — operators replying don't prevent the reviewer from editing the review afterwards. Acceptable for v1 (no public confusion since the reply is shown verbatim). - Aggregate rating recomputes on every fetch — fine at our scale (low single-digit ms even with 1000 reviews per restaurant), but worth caching on the tenant record if it ever becomes a hot path.

    • Phase 15: Bulk feature grants (Campaign mode) + Expiring-soon panel

    - Closes the ops pain deferred from Phase 14 — granting one feature to 20 tenants used to be 20 manual POSTs. Now it's one Campaign run. - Bulk grant API — POST /api/admin/feature-grants/bulk accepts { featureId, tenantIds[], enabled?, isAddon?, addonPriceMonthly?, expiresAt?, notes? }. Per-tenant upsert (existing grant for the same feature is replaced). Single masterDB write so the whole batch is atomic. Capped at 500 tenants per call. Unknown tenant IDs are silently skipped and returned in the skipped array so admins can see exactly which inputs didn't land. One feature-grant activity-log entry written per affected tenant with a "campaign" verb so the audit trail shows the action wasn't a one-off. - Expiring-soon API — GET /api/admin/feature-grants/expiring?days=30 returns every grant whose expiresAt falls in the next N days (or is already past), sorted ascending by expiry. Enriched with tenant name + subdomain + feature name + daysLeft + expired flag so the UI renders without an extra round-trip. days clamped to [1, 365]. - Campaign mode UI at /admin/dashboard/features: - Top-of-page toggle between Browse (existing per-tenant grant editor) and Campaign (new bulk-grant builder). - 3-step builder: (1) pick feature with inline search + plan-inclusion hint, (2) pick tenants with checkbox list + preset selectors (All, Basic, Premium, Enterprise, No plan, Active only) + search by name/subdomain/plan, (3) configure the grant (enabled/revoke toggle, paid-add-on toggle + monthly price, expiry date, campaign note). - Live count in the Run button: "Run campaign — google-auto-sync → 12 tenants" so admins see what's about to fire. - Result toast showing applied count + any skipped tenant IDs. - Expiring Soon panel sits above the tenant browse list. Shows count of expired + count expiring within the chosen window (7 / 14 / 30 / 90 days). Each row is clickable — jumps to that tenant in Browse mode and scrolls them into view so an admin can extend or remove the grant inline. Hidden entirely when no grants are expiring. - Verification: - TypeScript: 0 errors - next build: ✅ green; both new API routes registered - HTTP probes: bulk POST → 401 (super-admin gate ✓); expiring GET → 401 (super-admin gate ✓) - Known limitations / deferred: - No undo / dry-run — once a campaign runs, the only way to roll back is another campaign with enabled: false. A dry-run preview ("would affect 12 tenants — these are the IDs") would be a Phase 16 polish. - No saved campaign templates — recurring campaigns (e.g. monthly Q&A access for beta cohort) must be reconfigured each time. Could store and replay later. - No CSV export of expiring grants for downstream tooling. Easy add when needed. - `onApplied` doesn't auto-refresh the per-tenant Grant Manager — if a campaign affects a tenant that's currently expanded in Browse mode, the manager keeps showing pre-campaign state until the user closes + reopens that card. Trade-off for keeping the data-flow simple.

    • Phase 14: Super-admin Feature Grant Manager — audit log + effective catalog view + expiry picker

    - Closes the deferred admin gap from Phase 13 — Phase 13 surfaced the resolved feature catalog for operators but the admin console only had a raw grant editor (showed override rows, not the effective truth). Comp grants and refund-flow revokes were possible but invisible until you reloaded the operator's screen. - Activity log integration — POST /api/admin/tenants/[tenantId]/features and DELETE /api/admin/tenants/[tenantId]/features/[featureId] now write a per-tenant writeActivityLog entry on every grant / revoke / removal. New activity type feature-grant added to both src/lib/audit/activity.ts ActivityType and src/lib/db/schema.ts ActivityType (kept in sync). Operator-facing /dashboard/activity page gets a new Features row with a Puzzle icon (indigo) for the type filter. - Effective catalog endpoint — new GET /api/admin/tenants/[tenantId]/features-effective (super-admin scoped) returns the same shape as the operator's /api/tenant/[tenantId]/features so the admin UI and operator UI render from a single source of truth. Adds explicitGrants count so admins can see at a glance whether the tenant is on a vanilla plan or has custom overrides. - Admin UI (/admin/dashboard/features): - Summary tiles atop each tenant's expanded panel: Active features X/Y, From plan count, Override rows count. - Existing override rows now show a expires DD/MM/YYYY chip (red when past) so admins notice stale grants. - Expiry date picker added to the Add Grant form — leave blank for permanent grants, set a date to scope a beta/comp grant. - "All effective features" collapsible below the override list shows every feature in the registry with a source pill (In plan · Granted · Add-on · Revoked · Not active) + status icon — useful when an admin needs to answer "does this tenant have feature X?" without reading three different lists. - Empty state copy fixed — was "No custom feature grants" (misleading: implied tenant has nothing); now "No explicit override grants — features come from the plan only." - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/admin/tenants/[tenantId]/features-effective registered - HTTP probes: features API → 401 (super-admin gate ✓); features-effective API → 401 (super-admin gate ✓); admin page → 307 (admin auth redirect, expected) ✓ - Known limitations / deferred: - No PATCH endpoint for in-place grant updates — operators changing just the expiry must remove and recreate (the POST endpoint does an upsert by removing any existing grant for the same tenant+feature). For low-churn admin actions this is fine; revisit if it becomes friction. - No bulk-grant flow — granting the same feature to 20 tenants requires 20 POST calls. A "Promotional campaign" UI would be a Phase 15-ish item. - Activity log type `feature-grant` only appears on the tenant's own activity page — there's no platform-wide super-admin activity feed yet. Existing admin logs page covers other admin actions but doesn't ingest tenant activities.

    • Phase 13: Plan→Feature resolver + Tenant feature catalog

    - Closes the explicit gap from Phase 12 — requireFeature previously only checked featureGrants rows, so even an Enterprise tenant needed an explicit grant for every feature their plan tier should include. This phase wires plan inclusion into the gate logic, so plans cascade automatically. - `src/lib/features/resolver.ts` (new): pure resolveFeature(feature, sub, grants) that merges the three sources of truth: 1. Plan inclusion — FEATURE_REGISTRY[].planInclusion describes which tier includes a feature in its base price. Tier hierarchy is inclusive: Premium covers Basic; Enterprise covers Premium covers Basic. 2. Explicit grant — FeatureGrant with enabled: true adds access on top of plan inclusion (typical add-on purchase). isAddon: true distinguishes purchased add-ons (source addon) from comp grants (source grant). 3. Explicit revoke — FeatureGrant with enabled: false removes access even if the plan would include it (compliance / abuse / refund scenarios). Wins over plan inclusion. - Returns { granted, source: 'plan' | 'grant' | 'addon' | 'denied' | 'none' } so callers can show why a feature is or isn't active. - addon planInclusion is never granted by plan alone — only by explicit grant. none is never granted at all. - Pure: takes pre-loaded data so callers control I/O. - Companion: resolveAllFeatures(sub, grants) returns the full catalog; grantedFeatureIds() returns a Set for fast membership checks. - `gate.ts` refactor: tenantHasFeature and tenantHasFeatures now load the tenant's active subscription + grant rows in a single shared loadGateContext() call and delegate to the resolver. Same external API — backwards-compat is preserved across all the Phase 12 call sites. - `GET /api/tenant/[tenantId]/features` (new): returns the full effective catalog including the tenant's current plan, every feature with { granted, source, planInclusion, expiresAt, addonPriceMonthly, dependencies, badge }, plus a summary { total, granted, byTier } count for headline tiles. - `/dashboard/features` (new page): operator-facing feature browser. - Top tiles: current plan badge (Basic / Premium / Enterprise) + status, active feature count vs total, count of Premium-tier features, count of Enterprise-tier features. - Filters: status (All / Active / Available / Revoked) + category. Filters compose. - Grouped by category with collapse/expand per group. Each feature row shows status icon, name, source pill (plan-included / granted / add-on / revoked / not active), description, pricing line, expiry, dependencies, and a "Get it" CTA linking to billing when the feature is available but not yet active. - Upgrade-URL fix: Phase 12 set upgradeUrl: '/dashboard/settings/billing' but that path doesn't exist — the real billing page is /dashboard/billing. Fixed in gate.ts 402 response + the two UI components that linked to it (audit-log paywall + UpgradeRow in the Google settings page). - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/tenant/[tenantId]/features + /dashboard/features registered - HTTP probes: features API → 401 without auth (auth gate first ✓); /dashboard/features → 200; /dashboard/billing → 200 ✓ - Behavioral migration: - Existing Phase 12 gates now respect plan tier without any per-feature backfill. A tenant on enterprise-annual automatically gets google-auto-sync; a tenant on premium-annual automatically gets google-sync-diff + google-audit-log. Explicit grants still stack on top for add-ons or upgrades outside their tier. - Cron auto-sync in tickAutoSync already calls tenantHasFeature — so platform-wide ticks now correctly process tenants whose Enterprise plan includes the feature, not only ones with explicit grants. - Known limitations / deferred: - No expiry enforcement on plan-included features — only explicit grants honour expiresAt. If a tenant's subscription expires the feature evaporates via isActiveSub (status === 'active' / 'trialing' required), but partial expiry of a single included feature isn't a thing. - `addon` planInclusion is exclusively grant-driven — operators can't self-serve purchase from this page yet. The "Get it" CTA links to /dashboard/billing where the existing add-on flow lives. - No super-admin "What does this tenant have?" view in the admin console yet — the catalog API supports it but the UI is operator-facing only. Likely Phase 14.

    • Phase 12: Plan gating for Google integration premium features

    - Problem closed: Phases 8-9 shipped a stack of premium-feeling Google features (auto-sync scheduler, audit log, sync diff preview) but they were available to every connected tenant regardless of plan — a revenue leak. This phase wires those features into the existing feature-grant gating system. - Feature registry — src/lib/features/registry.ts extended with 3 new feature defs: - `google-auto-sync` — Enterprise tier (or $19/mo add-on). Set-and-forget scheduling. Depends on google-business. - `google-sync-diff` — Premium tier ($5/mo add-on). Side-by-side preview before push. Depends on google-business. - `google-audit-log` — Premium tier ($5/mo add-on). Extended chronological log with filters + 50-entry retention. - Gate helper — src/lib/features/gate.ts extended: - requireFeature(tenantId, featureId) returns { ok: true } or { error: Response } mirroring requireTenantAccess ergonomics. The error response is a ready-to-return 402 Payment Required with the full feature metadata + a upgradeUrl so clients can render an upgrade prompt without an extra lookup. - tenantHasFeatures(tenantId, featureIds[]) returns Record<id, boolean> for batch checks in UI-feeder routes. - API gates applied: - GET /api/google/auto-sync → requires google-auto-sync - PATCH /api/google/auto-sync → requires google-auto-sync - POST /api/google/auto-sync/tick with { tenantId } → requires google-auto-sync (cron-secret path stays open for platform-wide ticks) - GET /api/google/sync/diff → requires google-sync-diff - /api/google/quota stays open — it's a hard-limit observability surface, not a feature - /api/google/sync (manual sync) stays open — covered by existing google-business grant via the gateway - Tick engine gating — tickAutoSync() in src/lib/google/auto-sync.ts now filters out tenants without the google-auto-sync feature grant in the platform-wide cron path (so even if their config still has enabled: true from before the rollout, the platform doesn't burn API quota on their behalf). Tenant-scoped "Run now" path stays pre-gated at the route. - Feature echo on status — GET /api/google/status response now includes features: { autoSync, syncDiff, auditLog } boolean map so the UI can render plan-gated controls without an extra round-trip. - UI gating (/dashboard/settings/google + /dashboard/settings/google/audit): - New <UpgradeRow> component replaces the AutoSyncCard / Audit log link with a locked-state visual + plan badge (Premium / Enterprise) + "Upgrade" CTA when the feature isn't granted. - Preview buttons on every sync row are conditionally omitted (not just disabled) when features.syncDiff === false — keeps the row clean. - Audit log page renders a paywall card when accessed directly without the grant, with link back to the 12-event recent-activity strip on the main settings page (so the free experience isn't a dead end). - Verification: - TypeScript: 0 errors - next build: ✅ green; all 13 Google routes registered including the gated ones - HTTP probes: all 4 gated endpoints correctly return 401 without session (auth-gate fires first, then feature-gate); audit page renders 200 (client decides what to show after the status fetch returns features) ✓ - Migration / known limitations: - No automatic backfill — existing tenants currently using auto-sync in dev won't have feature grants. Production rollout would need a one-time script granting google-auto-sync + google-sync-diff + google-audit-log to every tenant whose subscription tier already includes them (per planInclusion). Dev tenants need a grant inserted manually. - No plan-tier inference yet — requireFeature only checks featureGrants not the underlying plan. So even an Enterprise-tier tenant needs an explicit grant row. Should be wired through the plan→features resolver in a follow-up. - Quota tracking stays free — intentional; it's a safety surface, not a value-add. Could be re-evaluated as a Premium "extended quota analytics" feature with longer history.

    • Phase 11: Marketplace sitemap depth + City aggregator + Cuisine aliases

    - Sitemap depth — the marketplace surfaces shipped in Phase 7a/10 weren't yet enumerated in /sitemap.xml. Now they are, so crawlers can discover every leaf URL without relying solely on internal cross-links. - Static: /bazar + /bazar/menu (kept from earlier). - Cuisine (/bazar/cuisine/<slug>): one entry per MARKETPLACE_CATEGORIES slug (26 today: 20 food-type + 6 ethnicity aliases). - City (/bazar/city/<slug>): only included when at least one approved listing resolves to that city, so empty city pages don't poison the index. - Per-restaurant (/bazar/restaurant/<slug>): every approved listing, lastModified from the listing record. - Existing tenant/designer/supplier/chain/docs URLs still included unchanged. - Cuisine aliases — MARKETPLACE_CATEGORIES extended with 6 ethnicity-anchored slugs (italian, japanese, french, mediterranean, korean, vietnamese) so URLs like /bazar/cuisine/italian work for the search-query style users actually type. Aliases match the same cuisineTypes keyword index the existing slugs use, so they share the listing pool. - City aggregator pages (/bazar/city/[city]) — SEO landing per known city with the same shape as the cuisine pages. - `src/lib/bazar/cities.ts` (new): KNOWN_CITIES curated dictionary (Stockholm / Gothenburg / Malmö / London / Manchester / New York / Paris / Berlin / Rome / Tokyo / Sydney etc — 25 cities across 11 countries). extractCity(address) walks the address text against a sorted keyword index (longest-first for greedy match), returns the matching KnownCity or null. findCityBySlug(slug) for 404 gating. - Server page at src/app/(bazar)/bazar/city/[city]/page.tsx. Resolves slug → known city → 404 when unknown. For each approved listing, reads tenant.settings.address and matches against the city via extractCity(). Listings sorted by rating desc then name asc (deterministic for crawler). - `generateMetadata` emits title "Best restaurants in $City — Food Bazar", description with count, canonical URL, OG + Twitter cards. - JSON-LD: CollectionPage with spatialCoverage: Place → PostalAddress (addressLocality + addressCountry) so Google + AI can geo-anchor the page. BreadcrumbList + ItemList (when listings present). - UI: hero with city name + country, restaurant cards (same shape as cuisine page), two cross-link strips (other cities + browse-by-cuisine) for internal link graph density. - Verification: - TypeScript: 0 errors - next build: ✅ green; /bazar/city/[city] + /bazar/cuisine/[cuisine] + /sitemap.xml registered - HTTP probes: /bazar/cuisine/italian → 200 (alias works ✓); /bazar/cuisine/pasta → 200; /bazar/city/stockholm → 200; /bazar/city/florence → 200; /bazar/city/nowhere-xyz → 404 ✓; /sitemap.xml → 200, contains 26 cuisine URLs (20 food-type + 6 ethnicity aliases) ✓ - 0 city + restaurant URLs in sitemap currently — expected, no approved bazar listings exist yet. The conditional logic correctly suppresses empty city pages. - Known limitations / deferred: - City extraction is dictionary-based — addresses without a known city in the text won't surface anywhere. Adding more cities is an additive 1-line edit to KNOWN_CITIES. Future: optional reverse-geocoder fallback using tenant.settings.coordinates. - No cuisine × city combo pages yet — /bazar/italian-in-stockholm long-tail SEO not built. Worth doing once we have enough listings to make the combo pages meaningful. - `canonicalUrl` is relative (/bazar/city/stockholm) — Next.js resolves to absolute against the site URL. If we add per-tenant subdomains for the marketplace, revisit. - No per-restaurant `lastModified` on the bazar restaurant URL beyond listing.updatedAt — would benefit from rollup that includes menu/photo edits. Polish.

    • Phase 10: Operator FAQ overrides + Cuisine aggregator pages + Marketplace ratings

    - Operator-curated FAQs — closes the polish deferred from Phase 6. Restaurant owners can author their own Q&A pairs that override the heuristic generator. - Schema: new TenantFaqEntry interface + TenantSettings.faqs?: TenantFaqEntry[]. Each entry has { id, question, answer, order, updatedAt }. Capped at 50 per tenant. - `deriveFaqs()`: when settings.faqs is non-empty, the heuristic generator is skipped entirely — operators get exactly what they wrote (sorted by order, filtered to non-blank entries). - Validation (UpdateSettingsSchema): zod validator accepts an array of FaqEntry shape; max 50; question 1-300 chars; answer 1-2000 chars. - API: existing /api/tenant/[tenantId]/settings PATCH route now accepts faqs (added to allowlist + array semantics replace-wholesale). - UI: new "FAQs" tab on /dashboard/seo between Meta & Tags and AI Generator. Inline editor: add / reorder (up/down arrows) / delete. Incomplete entries (blank question or answer) excluded from save with a warning chip. Save button shows valid count. - Visible FAQ block on the tenant home page (/tenant/[tenant_id]/page.tsx) — accordion of Q&A rendered alongside the existing FAQPage JSON-LD, so the on-page UX mirrors what search engines + AI Overview engines see. - Cuisine aggregator pages — /bazar/cuisine/[cuisine] SEO landing pages that rank for "best $cuisine restaurants" queries. - Server page at src/app/(bazar)/bazar/cuisine/[cuisine]/page.tsx. Resolves the slug against MARKETPLACE_CATEGORIES; 404s for unknown slugs. Matches restaurants by exact cuisineTypes slug AND keyword substring (handles "Pizzeria" → 'pizza' variants). - `generateMetadata` emits dynamic title ("Best $Cuisine restaurants — Food Bazar"), description with count, canonical URL, OG + Twitter cards. - JSON-LD: CollectionPage + BreadcrumbList + ItemList (when listings present). CollectionPage uses isPartOf to link back to the bazar WebSite entity for crawler entity-graph cohesion. - UI: hero with cuisine icon + count, restaurant cards sorted by rating desc then name asc (deterministic for SEO), and a cross-link section to other cuisines for internal link graph density. - Cross-restaurant aggregate ratings — per-restaurant avg ⭐ + count surfaced on the marketplace. - `/api/bazar/restaurant/[slug]` response now includes rating: { value, count } computed from approved reviews. - `RestaurantClient` header shows a rating pill (⭐ value + count) next to the open/closed status pill. - Cuisine aggregator cards show the same rating pill, drive the sort order. - Verification: - TypeScript: 0 errors - next build: ✅ green; /bazar/cuisine/[cuisine] registered as a dynamic route - HTTP probes: /bazar/cuisine/pizza → 200 with 4 LD-JSON blocks (WebSite + 2x BreadcrumbList + CollectionPage), title "Best Pizza restaurants — Food Bazar" ✓; /bazar/cuisine/nonsense-xyz → 404 ✓; /dashboard/seo → 200 ✓ - Known limitations / deferred: - `MARKETPLACE_CATEGORIES` is food-type-focused (pizza, burger, pasta) not ethnicity-focused — so /bazar/cuisine/italian is a 404 even though listings tag themselves "Italian". A future polish would add ethnicity aliases (italian → maps to pizza+pasta listings). - No /bazar/city/[city] aggregator yet — only cuisine, not geo. City pages need either an explicit city field on listings or a reverse-geocoder for the coords we already store. - FAQ block always appears at the bottom of the tenant home — operator can't choose placement yet. Should become a website block in the builder eventually. - No translation surface for operator-curated FAQs — single-language (whatever the operator wrote in). Multi-lingual restaurants will need to author per-language or wait for an AI-translation pass.

    • Phase 9: Sync Diff Preview (Google integration)

    - Operator trust feature — before clicking Sync, operators can click Preview to see exactly what will change on Google. Side-by-side before/after diff per resource. Catches typos in hours, accidentally-removed menu items, and stale phone numbers pre-flight. - Diff engine (src/lib/google/sync-diff.ts): - buildSyncDiff(tenantId, resource) produces a DiffReport with summary { added, modified, removed } + flat changes: Change[] array. - For each resource fetches the current state from Google (real API or mock store) and the proposed state via the same buildGbpPayload the sync engine uses — so the diff is faithful, not estimated. - Per-resource strategies: - location: recursive value-equality diff restricted to the sync-mask fields (title / phone / categories / hours / address / etc). - menu: full FoodMenu tree diff (sections → items). - offers / media: match-by-key diffs — offers by summary, media by sourceUrl (mirrors sync-engine dedupe logic). Sync engine never deletes, so before-only items are shown as unchanged not removed. - place-actions: keyed by placeActionType — added when new, modified when URI changes. - Returns fetchError when current state read fails (live mode without GBP approval), but still includes the proposed payload as after so operators can review. - Returns buildError when payload assembly fails (e.g. unreadable tenant DB). - New API client accessors: - getLocation(tenantId, resourceName) — reads current location state via GET /v1/{location}?readMask=…; mock returns a stub from MOCK_LOCATIONS. - getFoodMenu(tenantId, locationResourceName) — reads current menu via GET /v4/{location}/foodMenus; mock returns whatever upsertFoodMenu last wrote. - API route GET /api/google/sync/diff?tenantId=…&resource=location|menu|offers|media|place-actions — tenant-scoped, returns the DiffReport. Read-only — no mutations on Google. - UI: - Preview button added to every sync row on /dashboard/settings/google (left of Dry-run / Sync). Loading spinner per resource. - DiffModal — full-screen on mobile / centered on desktop. Header shows resource label + Mock badge + summary count. Body groups changes by top-level path prefix with color-coded ChangeRow (green/Plus for added, rose/Minus for removed, amber/Edit3 for modified). Modified entries show before/after side-by-side. Footer offers Cancel + "Apply changes" — Apply closes the modal and runs the real sync for that resource. - Empty state: when noChanges is true, modal shows "Google's copy already matches — no sync needed." - Build error: shown as a rose warning banner; Apply disabled. - Fetch error (live mode without GBP approval): shown as an amber banner with "Showing proposed only" caveat; Apply still enabled. - Refactor: regionCodeOf() in sync-engine exported so sync-diff can reuse the same address → region mapping. - Verification: - TypeScript: 0 errors - next build: ✅ green; /api/google/sync/diff registered alongside /api/google/sync - HTTP probes: ?tenantId=…&resource=location → 401 (auth required ✓); missing resource → 400 (zod validation ✓) - Known limitations / deferred: - No "remove from Google" semantics for offers/media — sync engine never deletes existing posts/media (Google's API doesn't expose dedicated delete for those v4 surfaces). Diff reflects this by marking before-only items as unchanged, not removed. Real removal needs an explicit "purge" surface in a future polish. - Live mode requires GBP API approval to read current state. Until then, fetch returns 403 and the modal shows the "proposed only" warning banner — still useful (the operator sees what would be pushed). - No nested diff for deeply nested arrays — positional comparison works for our flat lists but won't elegantly handle reordering. If the operator re-orders categories, the diff shows N modifications instead of a single move. Acceptable for v1.

    • Phase 8: Auto-sync scheduler + Audit log + Quota tracking (Google integration)

    - Auto-sync engine — most operators won't remember to click "Sync now" after editing their menu / hours / photos. Per-tenant config lets them set-and-forget the cadence + which resources to sync. - Schema: GoogleConnection.autoSync: GoogleAutoSyncConfig with enabled / frequency (hourly / every-6h / daily / weekly) / resources (location, menu, offers, media, place-actions, reviews, qa) / lastRunAt / lastRunStatus / lastRunError / nextRunAt. - `src/lib/google/auto-sync.ts` — tickAutoSync({ onlyTenantId? }) iterates connections whose nextRunAt is past, runs each configured resource through the sync-engine functions, advances nextRunAt on the chosen cadence. Errors don't disable the schedule — they're recorded and retried next tick. setAutoSyncConfig(tenantId, patch) updates the per-tenant config and recomputes nextRunAt when enabling/changing frequency. - API routes: - GET /api/google/auto-sync?tenantId=… — returns current config + connection state. - PATCH /api/google/auto-sync — { tenantId, enabled?, frequency?, resources? } updates the config. - POST /api/google/auto-sync/tick — two modes: { tenantId } runs that one tenant out-of-band (dashboard "Run now"); {} runs the platform-wide tick gated by x-cron-secret header matching CRON_SECRET env (or Vercel's x-vercel-cron-signature). - UI: new auto-sync card on /dashboard/settings/google with toggle, frequency picker (4 buttons), per-resource selector (7 chips), "Run now" button, "Next run" + "Last run" timeline. - Audit log — full chronological record of every sync that ran, who triggered it, and what was pushed. - Schema: GoogleSyncEvent extended with actor: 'manual' | 'auto' | 'webhook', userId?, triggerSource?: 'dashboard' | 'auto-sync' | 'api' | 'webhook'. recordSync() retention bumped 20 → 50. - `SyncAuditContext` threaded through every sync-engine entry point (syncLocationToGbp / syncMenuToGbp / syncOffersToGbp / syncMediaToGbp / syncPlaceActionsToGbp / syncAllToGbp). Defaults to { actor: 'manual', triggerSource: 'dashboard' } when omitted so legacy callers keep working. - `/api/google/sync` route now passes the authenticated user's auth.session.userId into the audit context. - New page /dashboard/settings/google/audit — stat tiles (Total / OK / Failed / Auto / Manual), actor + status filters, per-event card showing resource pill, actor pill, dry-run / mock badges, message, counts, masked userId, timestamp. - Quota tracker — per-tenant per-day API call counter so operators see "X/Y calls used" before Google's hard quota kicks in. - Schema: GoogleConnection.quotaUsage: GoogleQuotaUsage with { date, totalCalls, byApi: Record<string, number> }. Date is UTC YYYY-MM-DD; resets implicitly when date changes. - `src/lib/google/quota-tracker.ts` — incrementQuota(tenantId, apiId) is fire-and-forget (best-effort persist, never throws). classifyQuota(usage) returns { status: 'ok' | 'warn' | 'block', percent } with thresholds 80% warn / 95% block. assertQuotaAvailable(tenantId) is a throws-by-design guard for hot paths. - `gbp-api-client.ts` httpJson() now calls incrementQuota(ctx.tenantId, apiIdFromUrl(url)) after every real Google call. URL-prefix lookup attributes each call to the right API bucket (account / business-info / food-menus / local-posts / media / place-actions / reviews / qa). Mock calls don't increment. - API route GET /api/google/quota?tenantId=… returns the classified status. - UI: progress bar on the connection card shows today's usage with green / amber / red tinting; only renders when there's been at least one real API call. - Verification: - TypeScript: 0 errors - next build: ✅ green (335 pages); /api/google/auto-sync, /api/google/auto-sync/tick, /api/google/quota, /dashboard/settings/google/audit all registered - HTTP probes: all 3 new API routes correctly return 401 without auth; tick with cron-secret missing correctly returns 401; audit page renders 200 - Deployment: to enable scheduled ticks in production, set CRON_SECRET env var and configure Vercel Cron (or external scheduler) to POST /api/google/auto-sync/tick every 5-15 minutes with the matching x-cron-secret header. - Known limitations / deferred: - Auto-sync v1 shares one frequency across all resources — wanting "menu daily but media weekly" requires running two ticks with different configs. Operator could work around by enabling only one resource per cadence. - Quota counter is per-tenant, not per-project. When we add multiple tenants on the same GCP project, the project-wide quota is the sum — currently each tenant's UI shows only their own slice. Aggregated platform-wide quota dashboard is a future polish. - Tick endpoint is best-effort, not exactly-once — if two ticks fire concurrently for the same tenant, both could process before nextRunAt advances. Acceptable for v1 (Google's API calls are idempotent for our shapes); revisit with a per-tenant lock if it becomes a problem.

    • Phase 7a: Food Bazar marketplace SEO + Tenant SEO Health Scorer

    - Marketplace structured data — Schema.org JSON-LD across the public /bazar surface so AI Overviews and crawlers can ground cross-restaurant searches in our actual ranked list. - `buildBazarWebsiteLd` (new in src/lib/seo/structured-data.ts): emits WebSite + SearchAction (sitelinks searchbox) anchored at /bazar#website. - `buildBazarItemListLd`: ItemList of up to 20 approved listings — each item carries the canonical /bazar/restaurant/<slug> URL so search engines can de-reference the full Restaurant entity. - `buildBazarRestaurantLd`: per-listing Restaurant schema with isPartOf link back to the marketplace WebSite, plus aggregateRating, priceRange (derived from menu avg), servesCuisine, postalAddress. - `/bazar` layout (src/app/(bazar)/bazar/layout.tsx) becomes an async server component that: - Loads the food-bazar DB + master DB and emits WebSite + (when listings exist) ItemList + BreadcrumbList JSON-LD in the SSR'd HTML. - Sets richer metadata (OpenGraph + Twitter card) so social share previews are populated. - `/bazar/restaurant/[slug]` page split — what was a pure client component is now a server wrapper (page.tsx) + client implementation (RestaurantClient.tsx): - generateMetadata({ params }) returns per-restaurant title, description, OG image (bannerUrl), and canonical URL. - Server wrapper emits Restaurant + BreadcrumbList JSON-LD inline, then renders the existing client UI unchanged. - 404 via notFound() when listing absent / not approved (instead of an empty client shell). - `/llms.txt` platform branch now mentions Food Bazar with the live tenant count, points crawlers at the marketplace landing + per-restaurant URL pattern (/bazar/restaurant/<slug>). - Tenant SEO Health Scorer — broader 0-100 scorer covering the entire restaurant surface, distinct from the existing settings-only score on /dashboard/seo. - `src/lib/seo/health-scorer.ts` (new): pure scoreSeoHealth({ db, bazarListing?, hasGoogleConnection? }) function. 10 checks × 100 total points across 6 categories (brand, contact, menu, seo, reach, social-proof). Each check returns earned + max + status (pass/partial/fail) + detail + fixUrl linking to the dashboard page that resolves it. Returns grade A-F and top-3 prioritised recommendations. - `/api/seo/health` (new GET route): tenant-scoped, requires requireTenantAccess. Loads tenant DB + food-bazar listing + Google connection status in parallel and returns the scored report. Read-only, no side effects. - `/dashboard/seo` gets a new Health tab between Overview and Meta & Tags: - Headline score card with circular progress + grade letter (A-F) + recompute button. - Top 3 recommendations card with direct Fix links. - Per-category breakdown showing every check with progress bar + detail + per-check Fix this link. - Footer note distinguishing the broader health score from the legacy SEO-settings-only Overview score. - Verification: - TypeScript: 0 errors - next build: ✅ green (332 pages); /api/seo/health, /bazar, /bazar/restaurant/[slug] all registered - HTTP probes: /bazar → 200, emits WebSite + BreadcrumbList JSON-LD blocks ✓; /llms.txt → 200, mentions Food Bazar (N restaurants) with live count ✓; /api/seo/health without auth → 401 (correct) ✓; /bazar/restaurant/<unlisted> → 404 (correct gating) ✓ - Known limitations / deferred: - `/bazar/menu` SEO-wise relies on layout — page is a client-rendered filtered browse view; not a stable canonical URL so we don't split-server-wrap it. - ItemList currently empty in dev because no restaurants are yet approved on the marketplace. Listing tenants via /dashboard/marketplace will populate it automatically. - Reviews aggregation is per-tenant only; cross-restaurant marketplace aggregate review surfacing (e.g. "average ⭐ across all listings") is a future polish. - Reserve with Google end-to-end feed format (the partner-program half of Phase 7) is not in scope here — that requires Google's partner application and separate spec compliance work.

    • Google integration — Phase 6: Order with Google redirect + AI optimisation

    - Place Action Links sync — pushes the URLs behind Google's coloured CTA buttons (Reserve a table · Order online · Pickup). Order with Google's end-to-end ordering was deprecated July 2024, so the FOOD_ORDERING URL we set here is where every "Order online" click is routed by Google now — the largest single commercial lever in the GBP surface area. - `gbp-api-client.ts` adds listPlaceActionLinks(tenantId, locationResourceName) / upsertPlaceActionLink(tenantId, { locationResourceName, link }) / deletePlaceActionLink(tenantId, name) against mybusinessplaceactions.googleapis.com/v1. Mock store keeps state across calls; mirrors the "at most one preferred per placeActionType" demotion contract. - `sync-engine.ts` adds syncPlaceActionsToGbp({ tenantId }). Lists existing actions, dedupes + updates in-place by placeActionType, carries Google-assigned name resource identifiers for PATCH. Records counts: { created, updated } history entry with resource: 'place-actions'. - Umbrella `syncAllToGbp` extends to include place-actions; SyncAllResult.parts gains a placeActions? field. - API route /api/google/sync accepts resource: 'place-actions' (Zod enum extended). - Schema: GoogleSyncEvent.resource enum extends with 'place-actions'. - UI: new "Reserve · Order · Pickup buttons" sync row on /dashboard/settings/google with one-line description; surfaces what's being set. - FAQ JSON-LD — AI Overview citation magnet. buildFaqLd(siteUrl, faqs) + deriveFaqs(db) in src/lib/seo/structured-data.ts. The heuristic generator produces 4-7 reasonable FAQs from existing tenant settings (reservations, delivery, hours, dietary options, payments, address) — no schema additions needed. Wired into the tenant home page (/tenant/[tenant_id]/page.tsx) so it renders alongside Restaurant + WebSite + Breadcrumb JSON-LD. Gemini / ChatGPT / Perplexity / Apple Intelligence / Google AI Overviews all heavily favour FAQPage structured data for "Do they take reservations?" / "Are they open?" / "Do they have vegan options?" answer-engine queries. - `/llms.txt` route (new) — emerging convention for LLM crawlers (spec: https://llmstxt.org). Markdown content that tells LLMs the canonical, authoritative pages for grounding their answers. - Platform mode: returns Restora-level overview with links to top resources (docs, designers, suppliers, agents). - Tenant mode: returns merchant-specific guidance — name + cuisine + today's hours + phone + address + popular dishes (top 5 by popular flag) + canonical page tree (home / menu / reservations). - Subdomain detection is via host-parse against a known platform-hosts set, not the proxy's x-tenant-base header — llms.txt is treated as a static asset by the proxy and skips rewrite. - Verification (live, mock-mode): - TypeScript: 0 errors - ESLint: 0 errors on changed files (8 pre-existing warnings on tenant page <img> tags + 1 unused-var, all out of scope) - next build: ✅ green; 8 Google routes + new /llms.txt registered - HTTP probes: curl http://luigi.localhost:3001/llms.txt returns Luigi-specific markdown with cuisine + today's hours + 5 popular dishes ✓ - curl http://localhost:3001/llms.txt returns platform-level file ✓ - curl http://luigi.localhost:3001/ shows the new FAQ JSON-LD block with 6 Q&A entries derived from tenant data ✓ - /api/google/sync with resource:'place-actions' correctly returns 401 unauth ✓ - Known limitations / deferred: - No operator-curated FAQs yet — deriveFaqs is purely heuristic. A future polish would let operators add custom FAQs via tenant.settings.faqs[]. - AI visibility tracking (do we appear in ChatGPT / Gemini / Perplexity citations?) needs an external monitoring service — deferred indefinitely. - No per-Place-Action overrides — operator can't yet set a different URL for, say, DoorDash redirect vs Restora checkout. Phase 8 polish. - Real production smoke test still requires GBP API approval (~60 day Google application).

    • Google integration — Phase 5: Reviews + Q&A inbox

    - Schema additions: - GoogleReview — name, reviewId, reviewer, starRating ('ONE'–'FIVE'), comment, createTime, updateTime, reply ({ comment, updateTime }), internal sentiment ('positive' | 'neutral' | 'negative' | 'urgent') + isRead flag - GoogleQuestion — name, questionId, text, author, upvoteCount, totalAnswerCount, topAnswers[], plus internal ownerAnswer cache + isRead flag - GoogleAnswer — name, answer text, author (MERCHANT | REGULAR_USER | LOCAL_GUIDE), upvoteCount, timestamps - GoogleConnection gains reviews? + questions? (capped 50/20) + lastInboxPullAt + lastInboxPullError - GoogleSyncEvent.resource enum extends with 'reviews' | 'qa' - `gbp-api-client.ts` extends with four review/Q&A endpoints: - listReviews(tenantId, locationResourceName) — GET /v4/{location}/reviews - replyToReview(tenantId, reviewName, comment) — PUT /v4/{review}/reply - listQuestions(tenantId, locationResourceName) — GET /v4/{location}/questions - answerQuestion(tenantId, questionName, text) — PUT mybusinessqanda.googleapis.com/v1/{question}/answer (Q&A uses a separate mybusinessqanda API) - In-process mock fixtures: 5 realistic reviews (mix of ⭐⭐ — ⭐⭐⭐⭐⭐ with one reply already in place + one urgent 2★ recent complaint) + 2 Q&As (one answered, one still open about gluten-free options) - `src/lib/google/inbox-engine.ts` (new, ~210 LOC): - pullInbox(tenantId) — list reviews + questions from Google, merge against cache by resource name (preserves isRead + sentiment), persists on the connection, caps at 50/20 - classifySentiment(review) — heuristic: 1-2★ + <3 days old + no reply → urgent; 1-2★ otherwise → negative; 3★ → neutral; 4-5★ → positive. Real LLM sentiment is Phase 8 polish - postReviewReply(tenantId, reviewName, comment) — validates length (1-4000 chars), calls Google, updates cached review with reply - postQuestionAnswer(tenantId, questionName, text) — same pattern for Q&A; ownerAnswer cache updated - markRead(tenantId, { reviewIds, questionIds }) — set isRead flags - getInbox(tenantId) — read-only accessor for API routes - unreadCounts(conn) — { reviews, questions, urgent } for badge counts - API route /api/google/inbox: - GET ?tenantId=…[&pull=1] — return cached state, optionally trigger a Google pull first - POST with discriminated union body — action: 'pull' | 'reply' | 'answer' | 'markRead'. All requireTenantAccess-gated. - Operator UI /dashboard/settings/google/inbox/page.tsx (new, ~350 LOC): - Mock-mode banner when active - 5 stat tiles: Total · Urgent · Unreplied · Replied · Avg ★ - 5 filter chips: All · Urgent · Unreplied · Replied · Questions - Review cards: avatar + reviewer name + 5-star display + sentiment chip (Urgent in rose) + body. Card border tinted by sentiment (rose/amber/gray/emerald). Inline reply textarea + Send button. Once replied, shows the response in a teal-bordered nested block. - Question cards: blue HelpCircle icon + asker name + upvote count + body. If owner already answered, shows it in teal block. Otherwise shows up to 2 top community answers + an "Answer as the business" textarea. - Empty state with friendly explanation when filter has no items - Pull-from-Google button at top (re-merges incoming data) - Settings page gets a new "Reviews + Q&A inbox" entry above the sync rows that links into the inbox page. - Verification: - TypeScript: 0 errors - ESLint: 0 errors / 0 warnings - next build: ✅ green; 8 Google routes registered (added inbox) - HTTP probes: all auth-gated routes return 401 unauth ✓; invalid action returns 400 ✓ - Mock fixtures persist across pulls — reply to a review and a re-pull preserves the reply - Known limitations / deferred: - Sentiment is heuristic, not LLM — Phase 8 polish. Currently maps stars + age to one of 4 buckets. - No Q&A delete — Google supports DELETE /v1/{answer} but we don't expose it yet. - No review deletion (reviewers can edit/delete their own; merchants can flag-not-delete via separate "report" API). - No bulk reply / template responses — operator types each reply manually for now. - Real production smoke test still requires GBP API approval.

    • Google integration — Phase 4: Menu / Offers / Media sync

    - Schema: GoogleSyncEvent gains resource: 'location' | 'menu' | 'offers' | 'media' | 'all' and an optional counts: { created?, updated?, skipped? } field so per-resource history entries explain how many records moved. - `gbp-api-client.ts` extends with three v4 resource families: - upsertFoodMenu(tenantId, { locationResourceName, menu }) — single menu per location; POST → PATCH semantics under the hood - createLocalPost(tenantId, { locationResourceName, post }) + listLocalPosts() — create-then-dedupe pattern (no upsert on Google's side; we POST new posts) - uploadMedia(tenantId, { locationResourceName, media }) + listMedia() — uses sourceUrl (since our images are already hosted) - In-memory mock fixtures keep state across calls (_mockMenus / _mockPosts / _mockMedia) so demos build up over time - httpJson extended to support POST in addition to GET/PATCH (LocalPosts + Media use POST for creates) - `sync-engine.ts` expands with three new sync functions sharing one preflight(tenantId) bootstrap: - syncMenuToGbp({ tenantId }) — runs offline shape validation gated to foodMenus[*] issues, pushes the menu (1 per location). Records counts.updated = itemCount. - syncOffersToGbp({ tenantId }) — composes posts from payload.localPosts + active tenant.offers (with couponCode honoured), dedupes by summary against existing posts, returns counts.created + counts.skipped. - syncMediaToGbp({ tenantId }) — composes from payload.media, dedupes by sourceUrl, returns counts.created + counts.skipped. - syncAllToGbp(opts) — umbrella that runs location → menu → offers → media in sequence. Records a single resource: 'all' history event with partial-OK semantics (status: 'partial' if any child failed). - Original Phase 3 syncTenantToGbp preserved as backwards-compat alias for syncLocationToGbp. - validateOnly caveat: only the location resource supports server-side validation (FoodMenus / LocalPosts / Media v4 endpoints don't have it). The engine silently skips validateOnly for those — callers see validateOnly: false on returned events. - API route /api/google/sync accepts resource: 'location' | 'menu' | 'offers' | 'media' | 'all' (defaults to location for back-compat). Each resource gets its own switch arm; invalid values return 400. - Operator UI on /dashboard/settings/google: - Single Sync everything action button moved to the card header - Four per-resource rows (Hours + info / Menu / Offers / Photos) with one-line descriptions of what each pushes + their own Sync button. Location row keeps the Dry-run button. - Recent activity collapsed into <details> — shows resource type as a small chip per entry - Verification: - TypeScript: 0 errors - ESLint: 0 errors / 0 warnings on all changed files - next build: ✅ green; all 7 Google routes preserved (callback, connect, disconnect, link-location, locations, status, sync) - HTTP probes: { resource: 'menu' | 'offers' | 'all' } all return 401 unauth ✓; invalid resource returns 400 ✓ - Known limitations / deferred: - No deletion sync — removed products / cancelled offers won't disappear from Google until manual cleanup. Phase 8 work. - LocalPosts dedupe is by summary text, not stable ID. If an operator edits an offer title, a new post will be created (old one stays for 7 days per Google's TTL). - Media dedupe is by `sourceUrl`. Re-hosting an image at a new URL re-uploads it. - Real production smoke test still requires GBP API approval (~60 day Google application).

    • Google integration — Phase 3: Location picker + hours/business info sync

    - `src/lib/google/gbp-api-client.ts` — thin HTTP wrapper around mybusinessaccountmanagement.googleapis.com + mybusinessbusinessinformation.googleapis.com. Auto-injects bearer token from getValidAccessToken(), handles JSON response shape, branches to mock fixtures (1 mock account + 3 mock locations) when the connection is in mock mode. Exposes listAccounts(), listLocations(accountResourceName), patchLocation({ locationResourceName, body, updateMask, validateOnly }). Returns a typed CallResult<T> with ok / status / data / errorMessage / isMock. - `src/lib/google/sync-engine.ts` — syncTenantToGbp({ tenantId, validateOnly }) end-to-end. Loads tenant DB → builds Location payload via the existing buildGbpPayload builders → runs offline validateOffline to catch shape issues before any network call → PATCH against Google with validateOnly=true to surface server-side issues → if valid AND validateOnly !== true, runs the real PATCH → records every attempt on the connection's syncHistory[] (capped at 20). Sync masks cover all Phase 3 fields: title, phoneNumbers, categories, storefrontAddress, websiteUri, regularHours, specialHours, moreHours, serviceArea, latlng, openInfo, profile. - Schema additions on GoogleConnection: lastSyncAt, lastSyncStatus ('ok' | 'partial' | 'failed'), lastSyncError, syncHistory: GoogleSyncEvent[]. New GoogleSyncEvent type with { at, status, updateMask?, validateOnly, isMock, message? }. - 3 new API routes under /api/google/: - GET /api/google/locations?tenantId=… — returns { accountName, isMock, locations[] } for the picker. Auto-fetches the first account if none cached on the connection yet. - POST /api/google/link-location — binds { locationResourceName, displayName, accountResourceName? } to the tenant's connection. The displayed link survives until the operator unlinks or reconnects. - POST /api/google/sync — { tenantId, validateOnly? }. Returns { ok, validateOnly, isMock, message?, offlineIssues?, status? }. 200 OK on success, 400 on failure with structured details. - Operator UI at /dashboard/settings/google extended: - Location picker card (amber "action needed" framing) appears when connected but no location is bound. "Load listings" pulls from /api/google/locations; tapping a location calls /api/google/link-location and refreshes. - Sync card appears once a location is bound. Shows Last synced X min ago (or "Not yet synced"), Dry-run button, Sync now button (teal CTA), and a recent-history list (last 8 attempts) with ✓/✗ status pills, MOCK/DRY tags, and timestamps. Sync errors render their truncated message inline in rose-tinted text. - Status fetch + new fields (locationResourceName, lastSyncAt, lastSyncStatus, lastSyncError, syncHistory) returned by extended /api/google/status. - Verification: - TypeScript: 0 errors - ESLint: 0 errors / 0 warnings on all changed files - next build: ✅ green; 7 Google routes registered (callback, connect, disconnect, link-location, locations, status, sync). - HTTP probes: /api/google/sync, /api/google/locations, /api/google/link-location all return 401 without auth ✓ - Mock mode end-to-end: list returns 3 synthetic locations (Luigi's Pizzeria / Södermalm branch / Bar Bianchi cafe); picking one binds; sync writes both validateOnly and real PATCH paths to the history. - Phase 3 known limitations / deferred: - No auto-sync triggers — currently only manual "Sync now" or "Dry-run" buttons. Settings-save hook + nightly reconciliation cron land in Phase 8. - No bi-directional pull — we PATCH our values into Google but don't read Google's current state to detect operator edits made on Google's UI. Conflict resolver lands in Phase 8. - Updatable masks are coarse — every sync pushes every field. Future polish: diff against last-known-Google-state, only PATCH changed fields. - Real GBP API access required for `--live` runs — application takes ~60 days, so no production smoke test yet. Mock mode is end-to-end functional.

    • Google integration — Phase 2: OAuth + GBP connection

    - Schema additions (GoogleConnection, GoogleConnectionsDB in src/lib/db/schema.ts) — per-tenant connection record. One tenant ↔ one connection (reconnecting overwrites in place). Refresh tokens encrypted at rest as iv:tag:ciphertext hex tuples (AES-256-GCM). Mock placeholder MOCK so consumers can branch on synthetic case without trying to decrypt. - `src/lib/google/encryption.ts` — AES-256-GCM helpers (encrypt / decrypt / isMockToken). Key source priority: GOOGLE_TOKEN_ENC_KEY env (32-byte hex) → dev fallback (SHA-256 of a static label, logged as insecure). Production reads throw if the env var is absent. - `src/lib/google/connections-db.ts` — flat-file storage at databases/google-connections.json mirroring the suppliers/designers DB pattern. Atomic writes via tmp + rename. findConnectionByTenant / upsertConnection / deleteConnection. - `src/lib/google/oauth.ts` — full OAuth 2.0 flow with PKCE + state CSRF: - buildAuthorizeUrl({ tenantId }) → authorize URL with code_challenge (S256), signed-HMAC state carrying tenantId + 10-min expiry + the PKCE verifier - verifyState(state) → constant-time HMAC check + expiry validation - exchangeCodeForTokens({ code, codeVerifier }) → POST to oauth2.googleapis.com/token - refreshAccessToken(refreshToken) → silent renewal - revokeToken(token) → best-effort revocation on disconnect - decodeIdToken + fetchUserInfo → populate googleEmail - Mock mode auto-engages when GOOGLE_OAUTH_CLIENT_ID + _SECRET are absent OR GOOGLE_OAUTH_MOCK=1 — all functions return synthetic tokens prefixed mock_at_/mock_rt_ so the operator UI works end-to-end in dev/demo without a real Google Cloud project. - Default scopes: business.manage + userinfo.email + openid. - `src/lib/google/google-business-service.ts` — single facade over the OAuth + storage layer. Tokens never leak. Public lifecycle: startConnect / completeConnect / disconnect / getStatus / getValidAccessToken (refreshes silently, bumps lastRefreshAt, records lastRefreshError on failure). linkLocation for binding a specific GBP locations/{id} resource later. - 4 API routes under /api/google/: - POST /api/google/connect — requireTenantAccess-gated, returns { url, state, mode } for the redirect - GET /api/google/callback — OAuth callback; exchanges code, persists connection, redirects to /dashboard/settings/google?connected=1&email=… (or ?error=…) - POST /api/google/disconnect — revoke + delete (idempotent) - GET /api/google/status?tenantId=… — safe-to-leak connection metadata, includes oauthMockMode flag - Operator UI at /dashboard/settings/google/page.tsx: - Connection status card (Connected / Not connected) with mode pill (Live / Mock) - Connect / Disconnect buttons with confirm + loading state - Mock-mode banner explaining what's happening when no real credentials are configured - Success / error callouts from the OAuth callback redirect - Detail rows: Google account, linked location, granted scopes (with googleapis.com/auth/ prefix stripped), connected-on, last refresh, last refresh error - "What we'll be able to do" preview when not connected, with explicit "we never post on your behalf without confirmation, never read Gmail" assurance - Smoke tests passed: - Encryption round-trip: encrypt → decrypt matches original - State signing: verified payload returns tenantId; tampered state rejected - Mock token exchange returns mock_at_* / mock_rt_* prefixes - HTTP probes: /api/google/connect returns 401 without auth ✓; /api/google/disconnect returns 400 on invalid body ✓; /api/google/callback redirects (302) on missing state ✓ - next build succeeds, all 4 routes + settings page registered - Env vars consumed: - GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET — real-mode credentials (absent → mock mode) - GOOGLE_OAUTH_REDIRECT_URI — defaults to http://localhost:3001/api/google/callback - GOOGLE_OAUTH_STATE_SECRET — HMAC secret for state signing (required in production) - GOOGLE_TOKEN_ENC_KEY — 32-byte hex AES key (required in production) - GOOGLE_OAUTH_MOCK=1 — force mock mode even with live credentials present - Known limitations / deferred: location picker (after connect, operator needs to choose which GBP listing to link from a list — Phase 3); silent token-refresh cron (right now we refresh on demand); audit log of every API call against the connection (Phase 8); per-tenant rate-limiting against quota (Phase 8).

    • Google integration — GBP payload + validation toolkit (research-grade scaffolding)

    - `src/lib/google/gbp-types.ts` — TypeScript types matching the wire format for the full GBP surface: Location (categories / address / hours / latlng / openInfo / serviceArea), RegularHours + SpecialHours + MoreHours, AttributeValue (BOOL / ENUM / REPEATEDENUM / URL), `PlaceActionLink` (DININGRESERVATION / FOODORDERING / FOODTAKEOUT / FOODDELIVERY / APPOINTMENT), `FoodMenu` → `FoodMenuSection` → `FoodMenuItem` → `FoodMenuItemAttributes` with `DietaryRestriction` / `Allergen` / `Spiciness` / `PreparationMethod` enums, `LocalPost` (OFFER / STANDARD / EVENT / ALERT), `MediaItem` (COVER / EXTERIOR / INTERIOR / FOODANDDRINK / MENU). - **`src/lib/google/gbp-mock-payload.ts`** — `buildGbpPayload(input)` generates one fully-loaded `GbpFullProfile` from a tenant's existing DB. Covers: primary + additional categories (mapped per businessType — `pizzeria` → `Italian Restaurant` + `Pizza Restaurant` + `Catering Service`), 7-day regular hours with past-midnight closing, 1 holiday `specialHours` entry, three `moreHours` blocks (`DELIVERY` close 30min before kitchen close, `TAKEOUT` matching regular, `HAPPYHOUR Mon-Thu 16-18), 17 service/amenity attributes (dine-in / delivery / takeout / reservations / wheelchair accessibility / pet policy / payments / atmosphere / dietary), service area with 5km radius, three preferred Place actions, one full FoodMenu with cuisine enum per business type and dietary restriction + allergen + spiciness + preparation method mapping from product labels, two LocalPosts (OFFER + STANDARD), four media items (COVER / EXTERIOR / INTERIOR / MENU) plus per-product FOOD_AND_DRINK photos. - **src/lib/google/gbp-validator.ts** — two modes: validateOffline(profile) (default — no network, walks payload field-by-field with severity-tagged issues) and validateLive(profile, { accessToken, accountId, locationId }) (calls mybusinessbusinessinformation.googleapis.com/v1/{locationId}?validateOnly=true&updateMask=… with proper bearer auth). Only Business Information API supports validateOnly; FoodMenus / PlaceActions / Posts / Media fall back to offline-only. Returns a ValidationReport with errors / warnings / info bucketed by severity. - **src/lib/google/gbp-visual-mapping.ts** — renderVisualMapping(profile, report) emits a terminal-readable explainer of how each field renders on Google Search Knowledge Panel + Maps card. 8 sections: header, contact, hours (with per-hoursTypeId surface descriptions), attributes (with per-attribute UX surface labels), place actions, food menu (sample items with diet/allergen/heat/prep tags), local posts (carousel slots), media (per-category photo tabs). Summary footer with validation report. - **scripts/google/validate-gbp.mjs** — Node 24 ESM CLI using --experimental-strip-types to load the TypeScript engine directly. Reads tenant DB from disk, derives regionCode from address text, builds payload, runs offline or live validation, prints visual mapping. Usage: node --experimental-strip-types scripts/google/validate-gbp.mjs <tenantId> [--json | --live]. Live mode requires GOOGLEACCESSTOKEN + GBPACCOUNTID + GBPLOCATIONID env vars. - **npm scripts** added: google:validate and google:validate:json. - **Verified against the seeded ecosystem**: all 6 restaurants produce 0-error / 0-warning validation reports with distinct cuisine enums (ITALIAN+PIZZA, JAPANESE+SUSHI+ASIAN, VEGETARIAN, MEDITERRANEAN+TURKISH, CAFE+BREAKFAST+BRUNCH, AMERICAN+HAMBURGER). - **Honest scope notes**: no OAuth flow yet (Phase 2); live mode is plumbed but currently untested because we have no approved GBP API access nor a real locations/{id} resource. --live will surface the documented validateOnly=true request and Google's response. FoodMenus / PlaceActions / LocalPosts / Media endpoints do not support validateOnly per Google docs — those remain offline-only. - **Research correction**: the original brief asked for ONLINEDISPOSALDELIVERY as a moreHours type; the actual hoursTypeId per categories.batchGet(view:FULL) for restaurants is DELIVERY`. Verified via Google Business Profile API documentation (2026).

    • Google integration — Phase 1: Structured data + per-tenant SEO foundation

    - `src/lib/seo/structured-data.ts` — pure builders that emit Schema.org JSON-LD from existing tenant DB shape, zero schema changes: - buildRestaurantLd() — full Restaurant entity with name / description / phone / email / cuisine / price range / opening hours spec / aggregate rating / reviews / address / geo / hasMenu / acceptsReservations / currency / payment methods / sameAs social URLs - buildMenuLd() — Menu → MenuSection[] → MenuItem[] tree with offers (price + currency + InStock) and suitableForDiet mapped to Schema.org VeganDiet / VegetarianDiet / GlutenFreeDiet / HalalDiet / KosherDiet IRIs - buildBreadcrumbLd() — standard BreadcrumbList for any path stack - buildWebsiteLd() — minimal WebSite entity, anchors Knowledge Panel - Internal helpers: cuisineFor() (businessType → canonical cuisine label), dietsFor() (labels → diet IRIs), openingHoursSpec() (our hours map → Schema.org spec including past-midnight closing), priceRangeFor() ($, $$, $$$, $$$$ from menu average), aggregateRatingFor(), reviewsFor() (tolerant of both author/comment and legacy customerName/body field names), postalAddress() (only uses last segment as country when ≥3 comma parts to avoid false country values), geo(), sameAsFrom() - `src/components/seo/JsonLd.tsx` — stateless component, takes one object or an array → emits one or more <script type="application/ld+json"> blocks. Used by every page that wants Schema.org markup. - `src/lib/seo/site-url.ts` — resolveSiteUrl() resolves canonical site URL with priority: tenant.customDomain > detected subdomain host > ${NEXT_PUBLIC_SITE_URL}/tenant/<id> platform fallback. - Tenant home page (/tenant/[tenant_id]) now emits: - Restaurant, WebSite, and BreadcrumbList JSON-LD - generateMetadata with title / description / OG / Twitter Card / canonical pulled from tenant.settings.seo (operator-overridable) with sensible fallbacks - Tenant menu page (/tenant/[tenant_id]/menu) now emits: - Menu and BreadcrumbList JSON-LD - generateMetadata distinct from the home page so Google indexes /menu separately for dish-level queries - `src/app/sitemap.ts` extended to surface every active, non-demo tenant (home / menu / reservations) plus /bazar + /bazar/menu. 6 tenants × 3 routes = 18 new tenant URLs in the index. - `src/app/robots.ts` (new) — disallows operator/admin/API surfaces and transactional pages (/tenant/*/checkout, /tenant/*/order-confirmation, /tenant/*/account); explicitly opts every major AI crawler in (GPTBot, Google-Extended, PerplexityBot, ClaudeBot, anthropic-ai, CCBot, Applebot-Extended); points at sitemap.xml. - Smoke test (live): curl http://luigi.localhost:3001/ returns three JSON-LD blocks with full Restaurant entity (name + telephone + 7-day opening hours + 3 reviews with named authors + 4.7★ aggregate + Stockholm postal address + lat/lng + hasMenu + acceptsReservations + SEK currency); curl /menu returns full Menu tree (6 sections, 18 items with prices in SEK and dietary IRIs); curl /sitemap.xml shows 18 tenant URLs + 2 bazar URLs alongside the existing partner/docs surfaces; curl /robots.txt returns the policy. - Phase 1 follow-ups deferred: FoodBazar SEO surfaces (need re-activated listings first), SEO Health Scorer, FAQ schema, llms.txt, per-subdomain sitemap/robots, marketplace aggregator pages. Each lands in its own session.

    • Phase 3b — supplier + designer + sales-agent ecosystem

    - `scripts/seed/lib/partners-builder.js` — pure builders for partner records (makeSupplierAndOwner / makeSupplierProduct / makeSupplierOrder / makeDesignerAndOwner / makeDesignerRestaurantLink / makeDesignerCommissionEntry / makeSalesAgentAndOwner / makeReferralCode / makeReferral / makeAgentCommissionEntry). All match live schemas in src/lib/db/schema.ts exactly so the runtime UI lights up without further work. - Orchestrator extension — now upserts into 4 DB files atomically (master.json + designers.json + sales-agents.json + suppliers.json), recomputes denormalised counts on every save (supplier.productCount/orderCount, designer.restaurantCount, agent.referralCount/activeRestaurantCount/totalCommissionEarned). - `reset.js` bug fix — was using the wrong field name commissionEntries instead of the actual commissionLedger on designers.json, leaving orphan commission entries on every reset. Now correctly cleans them. - 6 supplier scenarios covering all 5 coverage tiers needed by the geo-intel engine: - 01-nordic-foods — Stockholm wholesaler, eu-wide coverage, 8 products, 3 Luigi B2B orders - 02-verde-beverages — Stockholm distributor, international coverage, 7 beverages, 1 Brew Bros order - 03-pakka-green — Birmingham eco packaging manufacturer, eu-wide, 6 products, 1 Al Madina order - 04-cresta-farm — Florence farm, local-only coverage, 6 fresh-produce items, pickup-focused - 05-clean-kitchen-supply — Malmö cleaning supplies, regional Sweden coverage, 5 products - 06-plant-pure-co — London plant-based distributor, eu-wide, 7 products, 2 Green Earth orders - 3 designer scenarios covering the brief's archetypes: - 01-anna-freelancer — solo featured freelancer, 2 clients (Luigi + Sakura), 4 commissions - 02-marco-agency — Berlin premium agency, 2 clients (Brew Bros + Green Earth), 4 commissions - 03-priya-marketplace — Mumbai new designer, 1 client (Al Madina), 1 pending commission - 4 sales-agent scenarios with status diversity: - 01-anna-top — Stockholm top performer, 3 converted + 1 lead, 5 commissions ($78.90 earned) - 02-marco-steady — Milan steady-tier, 2 converted + 2 in pipeline, 2 paid commissions - 03-tom-new — NYC new agent, 1 lead, 0 conversions, 0 commissions yet - 04-priya-suspended — Mumbai agent suspended for compliance review, 1 converted + 1 rejected, 1 held commission (tests the suspended-account UX) - Ecosystem totals after re-seeding: 6 tenants + 20 users (1 superadmin / 6 owners / 6 suppliers / 3 designers / 4 agents); 39 supplier products + 7 B2B orders; 5 designer↔tenant links + 9 commission entries; 11 referrals + 9 agent commissions. - Login matrix (all password123): suppliers orders@nordicfoods.test / sales@verdebeverages.test / orders@pakkagreen.test / sofia@crestafarm.test / orders@cleankitchen.test / orders@plantpure.test; designers anna@wd-demo.test / marco@wd-demo.test / priya@wd-demo.test; agents anna-agent@sa-demo.test / marco-agent@sa-demo.test / tom-agent@sa-demo.test / priya-agent@sa-demo.test. - Validation: TypeScript untouched (no app code changed). Build untouched. All FKs valid (no orphan references after the reset.js fix). Walkthrough recaptured — Luigi's dashboard KPIs still render real data after the partner-only run. - Known gaps deferred: 2 more suppliers + 2 more agents for the brief's 8/6 counts; 30 customer-ecosystem archetypes; supplier reviews + quote requests; compliance + escrow seed (Phase 4); other 6 restaurant archetypes (Phase 3a₂).

    • Phase 3a — 6 restaurant scenarios + seed orchestrator

    - `scripts/seed/lib/builder.js` — pure helpers for emitting records in the exact shape the runtime expects (makeTenantAndOwner, makeSettings, makeCategory, makeProduct, makeOrder, makeCustomer, makeStaff, makeReview, emptyTenantDB) plus i18n / ID / timestamp / password helpers. Mirrors databases/tenants/template.json exactly so the dashboard, KDS, orders pages, customer surfaces, and review surfaces all render with non-empty data. - `scripts/seed/scenarios/lib/order-mix.js` — shared 8-order mix generator used by every restaurant scenario. Produces a predictable status spread: 4 orders dated today (paid+delivered / pending / accepted in KDS / ready for pickup), 1 yesterday delivered, 1 rejected, 1 four-days-old, 1 week-old. Today-orders count + Pending Orders KPI always non-zero after seeding. - `scripts/seed/orchestrator.js` — CLI loader. --dry-run previews everything that would be written; --only PATTERN filters scenarios. Loads every scenarios/*/.*.js module, dedupes against existing master.json (idempotent re-runs), writes atomically via writeJsonAtomic. Replaces matching records on re-run instead of duplicating. - 6 restaurant scenarios (~1,500 LOC total, each ~250 LOC of full menu / customers / orders / staff / reviews / location-aware settings): - pizza-luigi — Luigi's Pizzeria, Stockholm. 18 products across pizzas/pastas/starters/salads/desserts/drinks. SEK currency. - burger-flash — Flash Burgers, NYC. 15 products, smash burger menu, late hours (Fri/Sat until 02:00). USD. - vegan-green-earth — Green Earth, London. 13 plant-based products with vegan/gluten-free labels. GBP. - sushi-sakura — Sakura, Tokyo. 17 products covering nigiri, maki, sushi sets, ramen, small plates. JPY. - cafe-brew-bros — Brew Bros Café, Melbourne. 16 products, AM-only café hours (07:00–15:00). AUD. - halal-al-madina — Al Madina Grill, Birmingham. 18 products including kebabs, mixed grills, curries, breads. Evenings-only (16:00–24:00). GBP. - Ecosystem totals after re-seeding: 6 tenants, 7 users (1 superadmin + 6 owners), 97 products, 33 categories, 48 orders (24 dated today, 6 pending), 21 customers, 16 staff, 18 reviews. Every dashboard KPI tile now renders with realistic numbers. - Backups taken: databases/backups/pre-phase3-reset-… before the reset; databases/backups/pre-reset-… auto-created by reset.js --yes. Roll back via node scripts/seed/reset.js --restore <name>. - Walkthrough recapture: docs/qa/pages/restaurant-dashboard.html regenerated with the new Luigi data — KPI tiles show $118.68 / 4 / 0 / 1 instead of all zeros, proving the ecosystem is alive end-to-end. - Login matrix (all password123): owner@luigi.com, owner@flash-burgers.com, owner@green-earth.co.uk, owner@sakura-japan.com, owner@brew-bros.cafe, owner@al-madina-grill.co.uk. - Known gaps deferred to next phase: No reservations seeded (Active Reservations KPI still 0 — easy to add). No suppliers/designers/agents/customers-as-VIPs (Phase 3b territory). Other 6 restaurant archetypes (fine dining / food truck / pickup-only / dine-in only / delivery-focused) follow the same pattern. Compliance + escrow still empty (Phase 4).

    • Visual QA explorer + walkthrough generator (foundation)

    - `scripts/explorer/capture-page.js` — Playwright-driven capture: programmatic login via /api/auth/login, session-cookie carry-over to the tenant subdomain, full-page screenshot + per-region cropped screenshots from a CSS-selector registry. Emits a manifest.json describing what was captured. - `scripts/explorer/generate-doc.js` — fuses the capture manifest with a hand-tuned annotation JSON into a single self-contained HTML document. Reads PNG dimensions from the IHDR chunk (no library dep), picks a layout per crop (normal stacked vs tall with max-height: 560px holder), inserts numbered dividers between regions (02-welcome, 03-kpis…), and emits a rich SEO/AI metadata block. - Sample walkthrough: docs/qa/pages/restaurant-dashboard.html — restaurant owner dashboard broken into 6 regions (attention bar, welcome strip, KPI cards, recent activity, onboarding checklist, sidebar nav). Each region carries what it is / what to look for / ✓ good / ✗ bad / 💡 pro tips. Real screenshots committed under docs/qa/screenshots/restaurant-dashboard/. - Lightbox viewing: native <dialog> overlay opens any screenshot at full resolution; Esc / backdrop / close button dismiss. No new-tab navigation on click. - Tall-image policy: images with aspect < 0.65 (e.g. the 480×1800 sidebar) scale to fit a 560px-tall holder so the whole element stays visible without dominating the page; click → lightbox shows actual resolution. - Unified divider rhythm: every region renders image-on-top + text-below, separated by a centered chip showing the full region ID (02-welcome) on a tapered horizontal line. Predictable reading pattern, no per-page tuning. - SEO + AI metadata baked in from the start: canonical URL, OpenGraph (type/title/desc/url/image/sitename + article:section + article:publishedtime), Twitter Card (summary_large_image), JSON-LD TechArticle + BreadcrumbList + HowTo (each region is a HowToStep), embedded <script type="application/json" id="restora-qa-data"> island with the full annotation payload for AI ingestion / offline tooling, Microdata via itemscope/itemtype on figures, semantic HTML throughout (<header>, <main>, <aside>, <article>, <section>, <figure>, <nav>, <footer>). - Environment-aware URL generation: docs/qa/environments.json declares dev / sandbox / prod with platformBaseUrl, tenantHostPattern, env colour, and warning copy. Switch via --env sandbox at gen time. Annotations carry tenantSubdomain + loginPath + pagePath (no more hardcoded localhost). When real sandbox/prod environments come online, only environments.json needs updating. - Rich top + bottom chrome: brand bar with env pill (teal DEV / purple SANDBOX / red PROD), sticky page-title strip with breadcrumb, env warning callout in non-prod, sticky TOC sidebar with anchor links to each region, footer with three columns (platform links / demo creds / walkthrough metadata) + generation provenance. - Print-friendly: @media print hides chrome + TOC + lightbox; region cards break-inside: avoid so PDF exports stay clean. - Accessibility: ARIA labels on lightbox triggers, <dialog> for native focus trap + Esc handling, role="region" semantics inherit from existing components, noindex in non-prod environments to keep dev/sandbox docs out of search indexes. - What's NOT here yet: mobile-viewport capture pass (script supports it via env var — needs a second run), multi-page index of all walkthroughs, LLM-draft path for the long tail of routes, automated diff-against-previous-capture for regression detection. Each lands in its own session.

    • Ecosystem seed-reset toolkit — Phase 2 (safe-reset infrastructure)

    - `scripts/seed/reset.js` — selectively wipes demo/seed data from databases/ with three modes: dry-run (default, never mutates), --yes (apply after auto-backup), --restore NAME (roll back to a snapshot). Default-safe: passing no flags prints the plan and exits. - `scripts/seed/backup.js` — point-in-time snapshot of all databases/*.json + databases/tenants/*.json into databases/backups/<label>-<timestamp>/ with a BACKUP.json manifest. Supports --list and --prune N. - `scripts/seed/lib/patterns.js` — single source of truth for what counts as "resettable". Only admin@platform.com is universally preserved; every other current account is treated as demo per the Phase 1 audit confirmation. Preserve-sets ready to grow as real production data appears. - `scripts/seed/lib/inventory.js` — read-only reporter that walks every DB and prints what's present + what would be reset. Used by both the dry-run and the post-apply summary. - `scripts/seed/lib/safe-io.js` — atomic writeJsonAtomic() (tmp + rename), copyDir(), humanBytes(), and friends. Mirrors src/lib/db/db.ts patterns. - FK cleanup, not orphaning: when a resettable designer/agent is removed, preserved tenants get their designerId / salesAgentId nulled instead of carrying dangling references. Same logic for organizationId on emptied chains. - Mandatory auto-backup before mutation — db:reset:apply always saves a pre-reset-<ts> snapshot first; skip via --no-backup only for advanced runs. - npm scripts: db:backup, db:backup:list, db:reset (dry-run), db:reset:apply, db:reset:list. - Baseline backup taken: databases/backups/baseline-2026-05-29T16-19-51-740Z/ captures the pre-toolkit state in case anything goes sideways during scenario regen later. - Dry-run validated against current state (34 tenants, 31 users, 7 designers, 7 agents, 5 suppliers, 19 bazar listings, 8 supplier orders) — output shows exactly what would change without touching disk. - What's NOT here yet: scenario seeders (Phase 3), accounts.html autogen (Phase 5), QA manual + tracker (Phase 6/7), Playwright visual explorer (Phase 8). Each lands in its own session after the reset infra is proven safe on the current dataset.

    • Supplier geo-intelligence engine — proximity, coverage, shipping fit (task 94 polish)

    - New geo engine (src/lib/supplier/geo-intel.ts) — explainable scoring layered over the Phase G matcher. Three layers stack: 1. Hard eligibility (isInScope) — drops suppliers who structurally can't serve a restaurant. A Stockholm local-only supplier no longer shows up in Tokyo. Coverage tier + country match + shipping cap drive eligibility. 2. Tier scoring (scoreGeoFit) — bucketises distance/relationship into 9 friendly tiers: local (<30km) / nearby (30-100km) / same-city / regional (100-500km) / same-region / national / adjacent (neighbouring country) / eu (both in EU/EEA bloc) / international. Each tier carries a UX label ("Local — 8 km away") and weighted points (20 → 2). 3. Shipping multiplier — pickup-only suppliers at distance get ×0.2 (visible but ranked well below capable shippers); the badge surfaces "Pickup only — long trip" so the buyer knows why. - `SupplierCoverage` enum on SupplierRecord: local-only / regional / nationwide / eu-wide / international. Optional latitude/longitude/shippingMaxKm. All optional — existing rows default to nationwide. - Country-adjacency matrix — hand-curated for ~50 high-traffic countries spanning Nordics, EU, British Isles, North America, Asia-Pacific. Neighbours are bidirectional even when listed once. Trade-practical (e.g. Swedish restaurant gets DK/NO/FI before US). - EU/EEA set — used for the "EU-wide" tier and the eu-wide coverage scope. Includes Norway, Iceland, Switzerland (trade-practical, not strictly EU). - Tenant signal enrichment — gatherTenantSignals() now pulls lat/lng from branchSettings or settings.coordinates and infers country from the free-text address using a small lookup of ISO codes + common country names. - Friendly reason chips flowing through the existing leads UI: - "Local — 8 km away" / "Same city" / "Ships within Sweden" / "Ships from neighbouring country (DK)" / "EU-wide delivery" / "Ships internationally" - "Pickup only — long trip" (amber friction signal) / "Pickup available" / "Ships to your address" - "Local supplier" / "Set up for export" (coverage-derived bonuses) - Supplier coverage editor at /supplier/coverage — three sections: 1. Coverage scope — radio-group of 5 emoji-prefixed cards with descriptions. Inline amber callouts when scope contradicts declared fulfillment methods ("International coverage usually requires Pick · Pack · Ship"). 2. Cities served — chip input for serving-region overrides; restaurants in these cities get an extra match bonus. 3. Distance precision (optional) — lat/lng + max self-delivery cap. Links to Google Maps when the supplier doesn't have coords handy. Live "What restaurants will see" preview banner at the top. Sticky Save button with toast confirmation. - API GET/PATCH /api/supplier/[supplierId]/coverage — requireSupplier gated, owns the coverage scope + serving regions + coords + shipping cap. Strict ownership check. - Public + tenant directory surfaces now show coverage badges (Local 🏘️ / Regional 🗺️ / Nationwide 🇸🇪 / EU-wide 🇪🇺 / International 🌍) on every supplier card. Detail page also surfaces serving regions as small chips ("Also delivers to: Stockholm · Göteborg · Malmö"). - Supplier marketplace public API (/api/public/suppliers + /api/public/suppliers/[slug]) now returns coverage so cards + detail pages can render the badge. - Reverse matching (`matchRestaurants`) uses the same geo engine in inverse — supplier-side leads no longer surface restaurants the supplier structurally can't serve. Skip rate drops dramatically for narrow-scope suppliers. - New "Coverage" nav link in the supplier left rail (Globe icon) between Orders and the logout. - Future-ready: engine is structured so freight integrations / dynamic shipping quotes / route optimisation can be additive — none of those require schema refactors. shippingMaxKm field is the future-hook for delivery-zone APIs. - 0 TypeScript errors, ESLint clean, next build green. 2 new routes registered.

    • Food Supplier Program — Phase H: Quote system + volume pricing (task 94)

    - `SupplierQuoteRequest` schema (new, in schema.ts) — full bidirectional record covering the request → respond → accept lifecycle: - Status machine: pending → responded → accepted | declined | expired | cancelled with side-aware transition table (only the supplier can respond; only the tenant can accept; system handles expired). - Per-line SupplierQuoteItem with optional productId (lines may reference a catalogue product OR be custom request items the supplier doesn't list publicly), requestedQuantity/requestedUnit, and supplier-filled quotedUnitPrice + quotedNotes. - Per-quote SupplierQuoteEvent history with { at, fromStatus, toStatus, by, reason }. Mirrors the order-event pattern so the audit UI is consistent across products / orders / quotes. - Snapshots restaurantName + supplierCompanyName so the response UI doesn't need master-DB joins. - Resolution fields: respondedAt, responseExpiresAt, responseNotes, responseTotalAmount + responseCurrency, acceptedAt, declinedAt, declinedReason, cancelledAt, cancelledReason, resultingOrderId (back-reference to spawned SupplierOrder). - Quote engine (src/lib/supplier/quote-engine.ts): - createQuoteRequest() — restaurant submits; per-supplier sequential numbering QU-YYYY-NNNNN. - respondToQuote() — supplier sets per-line unitPrice + optional notes + currency + expiry window (default 7d, max 30d). Auto-totals. - acceptQuote() — atomically spawns a SupplierOrder from quoted prices, currency, and contact. Bypasses placeOrder() because quoted prices intentionally diverge from catalogue prices; order flows through the existing payment/status pipeline. - resolveQuote() — unified decline / cancel with side validation (tenant can't decline a quote that belongs to another tenant; supplier can't cancel one outside their account). - expireStaleInPlace() — responded quotes past responseExpiresAt get rolled to expired lazily on every read (no cron). - APIs: - POST /api/tenant/[tenantId]/quotes — create - GET /api/tenant/[tenantId]/quotes — list this tenant's quotes - GET /api/tenant/[tenantId]/quotes/[quoteId] — detail - POST /api/tenant/[tenantId]/quotes/[quoteId] — { action: 'accept' | 'decline' | 'cancel' } (accept optionally takes fulfillmentMethod) - GET /api/supplier/[supplierId]/quotes — list incoming quotes - GET /api/supplier/[supplierId]/quotes/[quoteId] — detail - POST /api/supplier/[supplierId]/quotes/[quoteId] — { action: 'respond', itemPrices[], responseNotes?, responseExpiresInDays?, currency? } or { action: 'decline' | 'cancel', reason? } - All routes gated by requireTenantAccess / requireSupplier with strict ownership checks. - Restaurant UI: - "Request a quote" modal (new src/components/supplier/RequestQuoteModal.tsx) — multi-line item editor with a catalogue dropdown (pre-fills name + unit) or free-text custom items. Adds contact override, desired delivery date, free-text notes. Mounted on every supplier profile page in the marketplace. - `/dashboard/supplier-quotes` — operator inbox with 4-tile KPI row (Awaiting supplier / New offers / Accepted / Closed), open + closed sections, status-coloured chips. Detail view shows side-by-side requested vs. quoted, supplier reply notes, accept (creates a SupplierOrder + links to /dashboard/supplier-orders) / decline / withdraw actions, expiry callout. - Sidebar nav entry "Supplier Quotes" between Marketplace and Orders. - Supplier UI: - `/supplier/quotes` — quote inbox with the same KPI / tab pattern. Detail view is a response form: per-line unitPrice + per-line notes inputs, response notes textarea, quote-valid-for dropdown (1/3/5/7/14/21/30 days), currency picker. Decline button on pending quotes; resolved banners for sent / accepted / declined / expired / cancelled states. Left-rail "Quotes" link with FileText icon. - Volume pricing wired all the way through: - API schemas (CreateBody + PatchBody) accept priceTiers: { minQuantity, unitPrice }[] (max 6). - Product drawer editor — new "Volume pricing" section with add/remove rows, in-form validation. Sorted ascending on save; empty rows dropped. - Public marketplace card — tier badges, live unit price recomputed from the selected quantity, strikethrough base price when a tier is active. The active tier highlights in emerald so the operator can see exactly which bracket they're hitting. - Cart submission — effectivePrice(p, qty) helper mirrors the engine's priceAt(), so the cart shows the right unit price before checkout. Server still recomputes authoritatively in placeOrder() (price-tier maths already shipped in Phase C — no double truth). - Public supplier API now returns priceTiers on each product. - Phase H follow-ups deferred (per scoping): email notifications on quote events (uses the Phase D supplier-notifications hook — add when SMTP is wired), supplier-initiated "outbound introduction" flow (different direction; tracked as Phase I), tier-aware analytics in supplier dashboard. - 0 TypeScript errors, ESLint clean, next build green. 7 new routes registered.

    • Supplier Leads UX overhaul — match visualisation + multi-view layout (task 94 polish)

    - Match % bar with tiered colour ramp (amber → sky → teal → emerald). Score is normalised against TARGET_SCORE = 40 (engine's realistic ceiling: cuisine ≈16 + ingredients 14 + city 5 + reputation 5), so a 100% bar means all signals fired strongly — not just "the best of this result set". Keeps tier labels honest across pages. - Tier system: Weak / Moderate / Strong / Excellent thresholds at 26/51/76%. Tier label + colour drives the match bar, the inline TierBadge, and a new "Excellent fit" KPI tile. - Four view modes with a top-right radio-group switcher: Grid (current card layout, default), List (horizontal rows with mini-bar), Detailed (full metadata + matched-ingredient chips + action rail + larger compatibility bar), Compact (high-density table-style row for power users — ~3× more leads per screen). - View preference persistence in localStorage, scoped per supplierId (supplier_leads_view_v1:<id>). Restores on refresh, falls back to Grid if localStorage is blocked. - API + engine enrichment to feed the richer views: - RestaurantLead gains optional matchedIngredients[] (top 8 shared tokens, sorted), menuItemCount (menu-size proxy), isHeadquarters (HQ vs branch). Already computed by the matcher — just unexposed before. - /api/supplier/[supplierId]/leads response passes these through. - TenantSignals extends with menuItemCount. - Accessibility: match bar exposes role="progressbar" + aria-valuenow/min/max + descriptive aria-valuetext (e.g. "82% match — Strong Match"). View switcher is a role="radiogroup" with aria-checked; filter chips use aria-pressed. Compact view uses proper role="table"/row/columnheader/cell. Loading + error states marked with role="status" / role="alert". - Search box now also matches matchedIngredients, so a supplier can type "mozzarella" and find every lead whose tenant menu touches mozzarella. - KPI row expanded to 4 tiles: Total / Excellent fit / New prospects / Cross-sell. - Future-ready architecture: view registry pattern (VIEW_MODES) makes adding Kanban / Map / AI-ranked / Saved-Collections views a matter of appending an entry + writing one renderer. No fork of fetch / filter / state. - 0 TypeScript errors, ESLint clean, next build green.

    • Food Supplier Program — Phase G: Enhanced matching + supplier-lead discovery (task 94)

    - Ingredient extractor (src/lib/supplier/ingredients.ts) — tokenises restaurant menu names, product-level ingredient lists, IngredientItem inventory rows, and category names into a normalised "ingredient bag". Hand-tuned stop-word + unit list (~50 entries) drops noise; cheap singularisation handles tomatoes/cheeses/loaves without needing a tokenizer dependency. Set-based intersection helpers (intersect, intersectionSize) used by both sides of the matcher. - Enhanced matching engine (src/lib/supplier/matching.ts) — extends the Phase D cuisine + keyword scorer with three new signals: - Ingredient overlap — 2pt per token shared between the tenant's bag and the supplier's product-name/description/tag bag (cap 14). Surfaces matches the hardcoded CATEGORY_SIGNALS table misses, e.g. "tenant menu mentions pesto + ricotta → supplier listing pesto + ricotta scores even without a cuisine tag". - Location proximity — same-city match (+5pt) or city in supplier's free-text servingRegions list (+4pt). No lat/lng on suppliers so we keep this coarse. - Order-history affinity — restaurant has already placed a non-cancelled order with this supplier → +15pt + "Your usual supplier" prepended to reasons. Strong "stick with what works" signal that pins existing relationships to the top. - Reverse matching (matchRestaurants(supplierId)) — flips the engine: supplier → ranked restaurant leads. Combines ingredient overlap with supplier's catalogue, cuisine alignment with carried categories, city/serving-region proximity, and a small +3pt cross-sell bump for existing customers. Skips demo + inactive tenants. - Public API GET /api/supplier/[supplierId]/leads?limit=24 — requireSupplier gated, scopes by owning supplierId. Returns rank-ordered leads with { tenantId, name, subdomain, city, cuisine, score, reasons[], isExistingCustomer }. Default limit 12, max 40. - Supplier Leads UI at /supplier/leads — summary tiles (total / new / cross-sell), search box (matches name / city / cuisine / reasons), New/Existing filter chips, ranked lead cards. Top 3 in default view get a "Top match" trophy badge. Existing customers get a "Customer" badge. Reasons rendered as chips. Includes a collapsible "How we rank these leads" explainer that links back to product/CSV import / API to help suppliers improve their match quality. New "Leads" nav link in the supplier left rail with a Sparkles icon. - Phase G follow-ups deferred (per original scoping): per-product image AI tagging for richer bag tokens, AI-assisted recipe parsing, and lat/lng on suppliers for haversine distance ranking. Phase H (quote system + MOQ tiers + custom-quote request flow) is the planned next step. - 0 TypeScript errors. next build succeeds with the new /supplier/leads route + leads API.

    • Documentation Center rebuild — Phase 4 (task 89)

    - Analytics persistence engine (src/lib/docs-center/analytics.ts) — JSON flat-file at databases/docs-analytics.json (gitignored). Atomic writes via tmp + rename. Caps: 2000 recent search events, 120-char query truncation, 1000-char comment truncation, 50 comments retained per article. Exports recordFeedback, recordSearchEvent, recordView, getAnalyticsSnapshot. Aggregations cover totals, top-viewed articles, top-positive (≥5 votes), weakest articles (<60% helpful with ≥5 votes), top searches, zero-result searches (content-gap signal), and recent comments. - `/api/docs/feedback` upgraded from log-only stub to actual persistence — still returns 202 on persistence failure so the UX never blocks on a write hiccup. - `/api/docs/search-events` — new endpoint accepting { query, resultCount, clickedArticleId? }. Best-effort persistence; navigation-safe via keepalive: true on the client. - Client-side search instrumentation (DocsSearch.tsx) — debounced 800ms search-event ping captures query intent without flooding the endpoint, plus a click-event ping in go() that joins "what query → which article" for the admin dashboard. - Admin Docs Analytics page (/admin/dashboard/docs-analytics) — requireSuperAdmin-gated, noindex, dark-themed. KPI row (helpful votes / not-helpful / searches / views). Two-column grid: top searches, zero-result searches (with a "content gaps — write articles for these" hint), most-viewed articles, weakest articles. Recent reader comments below. AdminSidebar gets a "Docs Analytics" entry under Platform & System. - i18n schema field (additive, optional) on DocArticle — i18n?: Record<LocaleCode, { title?, metaDescription?, summary?, sections?, faq? }>. Slugs stay locale-neutral; missing fields fall back to the default-locale (English) field-by-field, not article-by-article. - New article /docs/i18n/overview — documents the convention: principles, article shape with worked TS example, storefront translation flow, RTL support, fallback chain (fr-CA → fr → tenant default → en), and how to add a new docs language until the CMS lands. - Phase 4 follow-ups deferred (intentionally out of scope): CMS adapter (Sanity/Contentful/Strapi reader behind the same renderer contract — the new i18n field is already shaped to be the JSON contract); per-language search indexing (currently the active locale builds the index at render time); analytics retention longer than 2000 events (P0 SQL migration). - 0 TypeScript errors. Total docs corpus now 34 articles across 30 categories.

    • Documentation Center rebuild — Phase 3 (task 89)

    - 11 developer / API articles taking the corpus from 22 to 33: - API: authentication (4 cookie types + role helpers), errors (envelope + status-code table + machine-readable codes), rate-limits (per-family limits + Retry-After semantics), idempotency (Idempotency-Key header + commission-ledger keying + webhook replay), orders (full lifecycle + transition rules + cancellation), payments (payment intents + escrow states + manual refunds), menu (public read + tenant CRUD + reorder). - API/Webhooks: overview (HMAC-SHA256 signature verification + exponential-backoff retry + event-type catalogue), order-events (payload reference for order.created / status_changed / canceled), payment-events (payment.succeeded / failed / refunded). - Developers: local-setup (clone → seed → run + tenant subdomain access), environment-variables (full env-var matrix organized by subsystem: Core / Stripe / Email / Cron / AI, with DEV-mode behavior when unset). - Style: every article uses real cURL + JSON examples through the existing fenced-code-block renderer (auto copy-to-clipboard from Phase 1). HMAC verification has a complete TS snippet developers can paste. - Coverage: the API category landing now shows 10 articles instead of 1; Developers category landing shows 2 instead of 0. Sidebar reflects this immediately. - Phase 3 follow-ups deferred: Postman collection download, downloadable OpenAPI spec, additional webhook events (reservation.* / gift_card.* / commission.*), per-language SDK examples (Node / Python / PHP). - 0 TypeScript errors. next build succeeds — 33 article pages now pre-rendered (SSG) + 30 category landings + dynamic /docs/changelog.

    • Documentation Center rebuild — Phase 2 (task 89)

    - 16 new polished articles taking the corpus from 6 to 22 across the most-trafficked surfaces: - Getting Started: checklist, platform-overview - Restaurant: onboarding - Payments: refunds-and-cancellations - Marketplace Commission: overview - Billing: pricing, manage-subscription - Ordering: overview - Management: menu-management, qr-menus, website-builder - Franchise: multi-location-overview - White-label: overview - Analytics: overview - Security: overview - GDPR: overview - Troubleshooting: payments - API: getting-started - FAQ: overview — single article with 30+ Q&As grouped by topic (onboarding, payments, subscription, refunds, sales agents, technical, account, security, API, enterprise) for FAQPage JSON-LD rich snippets. - Changelog renderer (/docs/changelog): server-rendered page that parses CHANGELOG.md via the new lib/docs-center/changelog-parser.ts (Keep-a-Changelog format: ## [Version] opens a release, ### Type opens a group). Renders each release with type-coloured pills (Added/Changed/Fixed/Removed/Security/Deprecated) + the MarkdownRenderer for full markdown support inside groups. BreadcrumbList JSON-LD + per-release in-page anchors. - Sidebar wiring: surfaces the dynamic /docs/changelog as a synthetic "Release notes" entry under the "Release Notes" category (special-cased — no DocArticle exists for it). - Icon registry extended with Utensils, Globe, RefreshCw (consumed by the new articles). - 10 new 301 redirects auto-emitted from each article's redirectFrom: /help/getting-started-checklist, /help/features-overview, /help/online-ordering-explained, /help/qr-menus-explained, /help/website-builder-overview, /help/pricing-explained, /help/security-and-privacy, /help/faq. - Phase 2b deferred (~24 more articles): Delivery, RBAC, AI features depth, SEO depth, Admin dashboard, System Architecture, Developer setup, i18n, individual Troubleshooting drill-downs. - 0 TypeScript errors. next build succeeds — 22 article pages + 30 category landings + dynamic /docs/changelog all generated.

    • Documentation Center rebuild — Phase 1 (task 89)

    - Plan (docs/decisions/0001-docs-center-rebuild-plan.md): full audit + 4-phase rebuild plan covering the 30 documentation sections from the master spec. Current → target URL mapping for every existing article (12 public + 33 dashboard). - URL space: new /docs tree — home (/docs), category landings (/docs/[category]), articles (/docs/[category]/[slug]). 30 categories registered ("Coming soon" landing for empty ones so the IA is complete from day one). - Schema (src/lib/docs-center/schema.ts): DocArticle / DocCategory / DocSection / DocFAQ / DocAudience / DocCodeBlock. Articles carry redirectFrom for automatic 301 maps and updatedAt for dateModified JSON-LD. - Markdown renderer (src/components/docs-center/MarkdownRenderer.tsx): dependency-free, ~150 LoC. Supports H2/H3/H4 (auto-id'd for TOC), unordered + ordered lists, bold / italic / inline code, fenced code blocks with copy-to-clipboard, blockquote callouts with emoji-coded tones (💡 tip / ⚠️ warning / 🚨 danger / default info), [text](href) inline links. - Search: client-side fuzzy with token-scoring (title × 10 + summary × 4 + tags × 3 + body × 1). Cmd/Ctrl+K opens a global modal anywhere on /docs; the home page renders the input inline with a live-results dropdown. Up to 12 results. ↑/↓/Enter keyboard nav. - Components: DocsHeader (sticky + search trigger + cross-platform nav), DocsSidebar (collapsible 30-category tree, active-article highlight), DocsTOC (right-rail, IntersectionObserver-driven active section), DocsSearch, MarkdownRenderer, CodeBlock, FAQAccordion (reuses partners-page ARIA pattern), RelatedArticles (3-card row), ArticleFeedback (👍 / 👎), ReadingProgress (top progress bar). - JSON-LD: WebSite + SearchAction on the docs home; Article + BreadcrumbList per article; FAQPage when the article has FAQ entries. - Starter articles (6): getting-started/what-is-restora, getting-started/sign-up, payments/stripe-onboarding, sales-agent/program-overview, management/table-qr-pay, promotions/gift-cards. Each has a full FAQ, related-articles set, CTA, and tags. - Plumbing: - /api/docs/feedback POST endpoint — logs feedback today, persistence in Phase 4. - next.config.ts redirects() block sourced from each article's redirectFrom so adding aliases never edits config. - Sitemap extended with /docs + 30 category URLs + 6 article URLs. - Side fix (caught by next build): split lib/feature-flags/engine.ts into pure evaluate.ts (browser-safe FNV-1a hash, no DB import) and DB-touching engine.ts. Admin feature-flags page (task 88) now imports evaluate from the pure file, so client components no longer drag server-only db.ts into the bundle. - Phases 2–4 deferred: writing the remaining ~24 sections (Phase 2), developer/API reference (Phase 3), feedback persistence + analytics + i18n + CMS adapter (Phase 4). Total remaining effort ≈ 55 hours. - 0 TypeScript errors. next build succeeds — /docs/[category] and /docs/[category]/[slug] are statically pre-rendered (SSG).

    • Feature flags system for gradual rollouts (task 88)

    - Schema (src/lib/db/schema.ts): additive — new FeatureFlag on MasterDB.featureFlags? with key (stable, unique, ^[a-z0-9][a-z0-9-]{1,49}$), name, description, mode: 'off' | 'on' | 'percentage' | 'tenants', optional rolloutPercentage (0–100), optional allowedTenantIds[]. - Engine (src/lib/feature-flags/engine.ts): - isFeatureFlagEnabled(key, tenantId?) — async helper. Returns false when the flag doesn't exist (opt-in semantics). - evaluate(flag, tenantId?) — pure synchronous evaluator. Used by the admin UI to live-preview without hitting the API. - bucketFor(key, subject) — SHA-256-derived deterministic bucket [0, 99]. Same (key, subject) always produces the same bucket, so percentage rollouts never flicker between requests. - Admin APIs under /api/admin/feature-flags: - GET — list all flags (newest first). - POST — create. Validates key shape, uniqueness, mode, clamps rolloutPercentage to 0–100 + ignores it unless mode = percentage. Audit-logged. - PATCH /[id] — partial update. Clears rolloutPercentage if mode becomes anything other than percentage, clears allowedTenantIds if mode becomes anything other than tenants. Audit-logged. - DELETE /[id] — removes. Audit-logged. - Admin UI (/admin/dashboard/feature-flags): - 4 stat cards (total / on / off / gradual+scoped). - "Evaluate as tenant" input — re-runs evaluate() against every card in real time and shows an ENABLED / DISABLED for sample pill per flag. - Per-row: click-to-copy mono key, mode pill, name + description, percentage bar for percentage-mode flags, allow-listed tenant count for tenants-mode flags, inline mode dropdown to flip state, Edit + Delete actions. - Create / edit modal with key (create only), name, description, mode dropdown, percentage slider, tenant-IDs textarea (newline or comma separated). - Admin sidebar: "Feature Flags" entry added under Tenants & Billing alongside Feature Grants. - 0 TypeScript errors.

    • White-label for restaurant groups (task 87)

    - Schema (src/lib/db/schema.ts): additive — Organization.whiteLabel: OrganizationWhiteLabel (master enabled flag + brandColors{primary,secondary,background,text} + favicon + hidePoweredBy + emailFromName + emailFooterText + updatedAt). - Resolver (src/lib/group/white-label.ts): getWhiteLabelForTenant(tenantId) resolves via tenant.organizationId then the master toggle, returns null for non-chain tenants. buildBrandCssOverride(wl) emits a :root{…} style string with --primary / --secondary / --background / --text overrides (skips any colour that's not set). - Tenant layout (/app/tenant/[tenant_id]/layout.tsx): - generateMetadata now sets icons.icon to the white-label favicon when configured — overrides the platform default on every branch site. - The layout injects the brand-CSS <style> element on every render path (demo gate, demo authorised, normal storefront) so chain colours apply across menu, checkout, confirmation, etc. - DemoGate now accepts a hidePoweredBy prop and renders "Demo environment" instead of "Powered by Restora · Demo environment" when true. - APIs: - GET /api/group/[orgId]/settings now includes org.whiteLabel in the response. - PATCH /api/group/[orgId]/settings accepts a whiteLabel: Partial<OrganizationWhiteLabel> block that merges into existing config (per-key updates; bumps whiteLabel.updatedAt). - UI (/dashboard/group/settings): new "White-label storefront" section at the bottom of the page: - Master enable/disable toggle in the section header (state pill). - 4-colour palette grid (primary / secondary / background / text) with paired colour-picker + hex text input per slot. - Favicon URL field. - "Hide 'Powered by Restora'" checkbox. - Email from-name + footer-text inputs (persisted; mailer integration is v2 follow-up). - All fields auto-save on blur via the new saveWhiteLabel helper. - Scope note: email from-name + footer-text are persisted but not yet rewired into sendMail — flagged as v2. - 0 TypeScript errors.

    • Staff scheduling — Phase 1: weekly grid + shifts (task 86)

    - Schema (src/lib/db/schema.ts): additive — StaffMember gains jobRole?, hourlyRate?, color?, isActive?. New StaffShift (id, staffId, date, startTime, endTime, role?, notes?, locked?) surfaced on TenantDB.staffShifts?. - APIs: - GET /api/tenant/[tenantId]/shifts?from=YYYY-MM-DD&to=YYYY-MM-DD — returns shifts in the window, enriched per row with staffName, staffColor, defaultRole, hours, hourlyRate, estimatedCost. - POST /api/tenant/[tenantId]/shifts — strict validation: YYYY-MM-DD date, HH:MM times, end > start, staff exists + active. - PATCH/DELETE /api/tenant/[tenantId]/shifts/[shiftId] — partial update with same validators; idempotent delete. - PATCH /api/tenant/[tenantId]/staff/[staffId] extended to accept jobRole / hourlyRate / color / isActive (all optional). - UI (/dashboard/scheduling): - Week navigator (prev / "This week" / next) anchored on ISO Monday; per-day hour + cost totals shown in column headers. - 7-day × staff matrix table with sticky left "Staff" column. Each cell shows shift cards (color-tinted background + 3px brand-coloured left border, time range, role + hours), plus a subtle "+ add" affordance below. - Click-to-add on empty cell + click-to-edit on shift card share one modal: staff dropdown (active-only), date, start/end (<input type="time">), role override, free-text note. Modal shows Delete on existing shifts. - Weekly per-staff hours + estimated payroll cost in the rightmost summary column. - Top right shows weekly totals: total hours, total shifts, estimated payroll cost (when hourly rates are set). - Staff-settings modal: inline editor per row for job role + hourly rate + grid color + active toggle, with per-row save. - Sidebar: "Scheduling" entry added under Operations next to Staff & Permissions. - Phase 2 deferred: time-off requests, conflict / overlap detection, "copy week" duplication, swap requests, mobile-friendly day view. - 0 TypeScript errors.

    • Advanced reporting — profit margins & food cost (task 85)

    - Engine (src/lib/reports/profit-margin.ts): no new schema — joins existing orders × RecipeMapping × PurchaseEntry to compute per-product COGS, then per-product / per-category margins. - buildIngredientCostMap — weighted-average unit cost per ingredient from purchase history (source: 'purchases'); falls back to IngredientItem.purchasePrice ('manual'); marks 'unknown' when neither exists. - buildProductCogsMap — sums recipe.ingredient.quantity × ingredient.unitCost per product. Products without a recipe mapping are flagged hasRecipe: false. - buildMarginReport — date-filtered + payment-status-gated; aggregates revenue / COGS / gross profit / margin% per product and per category, computes org-wide totals, surfaces top 5 by gross profit and bottom 5 by margin% (among products with revenue + a recipe), counts products with missing or partial COGS coverage. - API: GET /api/tenant/[tenantId]/reports/margins?from=YYYY-MM-DD&to=YYYY-MM-DD. Defaults to the last 30 days (inclusive) when params are absent; swaps from/to when out of order. Only paid orders count toward revenue. - UI (/dashboard/reports): - 5-stat KPI row: Revenue, COGS, Gross profit (brand-gradient highlight), Margin %, Units sold. - Date-range picker with 7d / 30d / 90d / YTD presets + custom from/to inputs. - "COGS coverage is incomplete" amber banner appears when sold products lack a recipe mapping or have ingredients without purchase history — deep-links to /dashboard/inventory. - Top earners + Weakest margins podiums (each top 5). - Category breakdown table sorted by revenue. - Per-product table sortable by revenue / gross profit / margin% / units / name. Margin pills color-graded (≥70% green, ≥50% blue, ≥30% amber, else red). Per-row warning chips for "no recipe" + "partial cost". - CSV export of the current window (product, category, units, revenue, COGS, profit, margin %, has-recipe, has-unknown). - Sidebar: "Profit Margins" entry under Analytics & AI alongside Analytics + AI Assistant. - 0 TypeScript errors.

    • Gift cards — purchase / email / redeem (task 84)

    - Schema: additive GiftCard + GiftCardRedemption on TenantDB. Statuses pending-payment → active → fully-redeemed | void. Order gains giftCardCode + giftCardAmount snapshot. CreateOrderSchema gains giftCardCode field. - Engine (src/lib/gift-cards/engine.ts): generateCode() returns GIFT-XXXX-XXXX codes from a 32-char alphabet that skips look-alikes (I/O/0/1). createPurchase (pending), createManual (active immediately, staff-issued), markPurchasePaid (idempotent transition to active + sends recipient email at the API layer), previewApply (non-mutating max-applicable calculation with full reason text), applyRedemption (decrements balance + appends redemption record + flips to fully-redeemed at zero balance), voidCard (refuses if redemptions exist), getCardByCode (case-insensitive lookup), publicProjection (safe to return over public API). - Email (giftCardEmail template): branded orange-gradient card with large balance, monospace code, optional personal-message callout, redeem-link CTA, plain-text fallback. - Public APIs: - POST /api/public/[tenantId]/gift-cards/purchase — creates pending card + Stripe PaymentIntent (DEV: simulated pi_dev_*; PROD: real Stripe SDK). - POST /api/public/[tenantId]/gift-cards/purchase/confirm — soft-confirms after confirmCardPayment reports succeeded (PROD re-checks the intent against Stripe), flips card to active, fires recipient email. - GET /api/public/[tenantId]/gift-cards/lookup?code=…[&subtotal=…] — public balance lookup; if subtotal provided, also returns the live max-applicable preview used by the checkout. - Public pages: - /gift-cards/[tenantId]/buy — branded hero, denomination presets ($25/$50/$100/$200) + custom + currency selector, from/to fields, personal-message textarea, Stripe Elements card form in PROD / DEV auto-accept. Success view shows the new code + copy button + check-balance link. - /gift-cards/[tenantId]/redeem?code=… — balance lookup with auto-fill from URL, full status pill (active / pending / redeemed / void), expiry display, redemption count, big balance card in brand colours when redeemable. - Checkout integration (/tenant/[id]/_components/CheckoutForm.tsx): new "Gift card" input alongside the coupon row. On apply, calls /lookup?subtotal= to get the max-applicable amount, displays it as an amber row in the totals with the remaining balance, deducts from grandTotal. giftCardCode is now part of the order payload; the server route re-applies the engine's previewApply + applyRedemption so the discount is never trusted from the client. - Order routes (/orders POST and /payment-intent POST): both now apply gift-card logic AFTER coupon (gift card pays toward the remaining total). applyRedemption is called after the order is persisted, with the orderId linked into the redemption record for full audit. - Tenant APIs: - GET /api/tenant/[tenantId]/gift-cards — list all cards (newest first). - POST /api/tenant/[tenantId]/gift-cards — staff manual issue. Optional sendEmail flag fires the recipient email. - DELETE /api/tenant/[tenantId]/gift-cards/[cardId]?reason=… — voids an unredeemed card. - Dashboard (/dashboard/gift-cards): 4 stat cards (active count, outstanding liability, lifetime issued, lifetime redeemed), table with click-to-copy mono code, status pills, source badge (purchased/manual), recipient column, void button (only when no redemptions). Issue modal with denomination + currency + recipient + personal message + expiry + email toggle. - Routing: /gift-cards added to BYPASS_PREFIXES so the buy + redeem pages work on tenant subdomains. - Sidebar: "Gift Cards" entry under Marketing alongside Loyalty. - 0 TypeScript errors.

    • Kitchen Display System (task 83)

    - API (/api/tenant/[tenantId]/kds): lightweight feed of active tickets only (status in pending / accepted / preparing / ready), sorted by createdAt ascending. Returns just the fields the kitchen needs — order number, type, customer name, items with quantity + modifier selections + removed ingredients + per-item notes, order-level note, scheduled time, all relevant transition timestamps. Bundles lane counts and polledAt for client display. - Page (/dashboard/kds): dark fullscreen-friendly view that escapes the dashboard chrome (-m-6 min-h-[calc(100vh-4rem)]). 4 lanes (New / Accepted / Preparing / Ready) with header pills and ticket counts. Per-ticket age timer (mm:ss) ticking every 1 s with color escalation — calm under 3 min, amber under 8 min, red 8 min+. Each ticket card: type chip (delivery / pickup), bold #orderNumber, customer name + scheduled-time hint, structured item list (quantity bold and tabular, modifier line indented, removed ingredients in red, item notes in italic amber), order-level note callout, single advance button with state-appropriate label ("Accept" → "Start cooking" → "Mark ready" → "Bump (complete)" — the last one removes the ticket from the board by transitioning to delivered). - Wiring: advance button uses the existing PATCH /orders/[orderId] endpoint so all escrow side-effects (applyOrderLock on ready, schedulePayout on delivered, fraud-detection prep-speed check, customer email notifications) fire correctly without re-implementation. Optimistic UI followed by re-sync. - Polling: every 5 s, paused on tab hidden. New pending tickets detected on a subsequent poll trigger a two-tone Web Audio chime (opt-in via header toggle; preference stored in localStorage). - Controls: header has sound toggle, fullscreen toggle (Browser Fullscreen API), manual refresh, plus live lane-count pills. - Sidebar: "Kitchen Display" entry added under Primary nav alongside Reservations. - No new schema. Reuses Order + OrderTimelineEvent end-to-end. 0 TypeScript errors.

    • Table-side QR pay (task 82)

    - Schema (src/lib/db/schema.ts): additive TableBillLineItem + TablePayment (statuses open / paying / paid / void) + tablePayments? on TenantDB. One open bill per table at a time. - Engine (src/lib/tables/table-payment-engine.ts): createBill (refuses if table already has an open/paying bill), getOpenBillForTable, markBillPaying (transitions on PaymentIntent creation), markBillPaid (idempotent), voidBill (refuses paid bills). All amounts rounded to 2dp. - Public APIs: - GET /api/public/[tenantId]/tables/[tableSlug]/tab — resolves the table by qrSlug, returns restaurant brand + table info + open bill (never reveals paid/void bills). - POST /api/public/[tenantId]/tables/[tableSlug]/pay — creates a Stripe PaymentIntent (DEV mode fabricates a pi_dev_* id; PROD uses real Stripe SDK). Idempotent — re-issues clientSecret for an already-paying bill. - POST /api/public/[tenantId]/tables/[tableSlug]/pay/confirm — soft confirmation called by the client after confirmCardPayment reports succeeded. In PROD it re-checks the intent against Stripe before marking paid; in DEV it accepts immediately. Idempotent. - Tenant APIs: - GET /api/tenant/[tenantId]/table-bills — list every bill, newest first. - POST /api/tenant/[tenantId]/table-bills — create new open bill (validates table + items + computes totals from line items). - DELETE /api/tenant/[tenantId]/table-bills/[billId]?reason=… — void an unpaid bill. - Public page (/pay/[tenantId]/[tableSlug]): brand-coloured gradient hero with table number/area, server-loads the open bill, hands off to a client island. Client island handles three states (no-bill yet with 6s polling + "Check now" button; open/paying bill with full summary, customer-note callout, and big "Pay <total>" CTA; paid with green confirmation card and final receipt). In DEV mode the PaymentIntent is fake — the confirm call marks the bill paid immediately. In PROD, mounts @stripe/stripe-js and runs confirmCardPayment before confirming. Per-table pages set robots: noindex. - Dashboard (/dashboard/table-bills): open-bills grid with status pills (open / paying), per-card itemised view, customer-pay-link opener (/pay/[tenantId]/[qrSlug]), inline void button. Recent (paid + void) list with timestamps and last4. New-bill modal with table dropdown (flags tables without a QR slug), line-item editor (add/remove rows, qty + unit price), tax + tip inputs, customer note, live currency-aware total preview. Polls every 6 s while there are open/paying bills. - QR generator (/api/tenant/[tenantId]/qr): new pay-table URL type emitting ${base}/pay/${tenantId}/${qrSlug} so a single QR sticker takes the customer straight to the pay page. - Routing: /pay added to BYPASS_PREFIXES so the route works on tenant subdomains. - Sidebar: "Table Bills" entry under Operations alongside Tables & QR Codes. - 0 TypeScript errors.

    • Customer-facing live order tracker (task 81)

    - Public pages: - /track — lookup form (subdomain + order id + email). Resolves the subdomain to a tenantId via the existing /api/domains resolver, then redirects to the tracker URL. - /track/[tenantId]/[orderId]?email=… — full tracker. Server-renders for SEO/initial paint (with email-verification step when the param is missing or wrong), then hands off to a client island that polls /api/public/[tenantId]/orders/[orderId] every 4 s while live and every 60 s once the order is terminal. Pauses polling when the tab is hidden; re-fetches immediately on tab focus. - UX: - 5-stage progress tracker (Placed → Accepted → Preparing → Ready → Done) with current-step ring + checkmarks for completed steps. - Status pulse badge ("Awaiting confirmation" / "Accepted" / "Being prepared" / "Ready" / "Delivered" / "Cancelled"). - ETA card (latest event + 25 min) shown while the order is in flight. - Itemised order summary with line totals, payment method, grand total. - Activity timeline pulled from OrderTimelineEvent[] (newest first) with actor pills (customer / restaurant / system / admin). - Share + copy-link buttons (uses navigator.share where available, falls back to clipboard). - Self-service cancel button gated by the existing escrow grace period; surfaces refund amount + refund status once cancelled. - Routing: - Added /track and /chains to the tenant proxy BYPASS_PREFIXES so both routes work on tenant subdomains without being rewritten into tenant subroutes (/chains was a Phase-4 follow-up — same proxy concern). - Confirmation flow: - CheckoutForm now also passes email to /order-confirmation. - /order-confirmation shows a primary "Track this order live" CTA pointing to /track/[tenantId]/[orderId]?email=… when both orderId and email are present. "Back to Menu" / "Go to Homepage" demoted to secondary actions. - Sitemap: /track entry added. - Security: per-order pages set robots: { index: false, follow: false }. Email comparison is case-insensitive but strict. - 0 TypeScript errors.

    • Multi-Location Phase 4 — Customer-facing chain site (task 80)

    - Schema (src/lib/db/schema.ts): additive — Organization gains slug, tagline, publicDescription, publicLogo, publicHeroImage, brandColor, publishedAt; BranchSettings gains city, latitude, longitude, isPubliclyListed. - Public API (/api/public/chains/[orgSlug] GET): no auth, returns 404 unless the org is published AND has a slug. Filters out inactive branches and branches with isPubliclyListed=false. Returns brand-safe fields only (no operator email, staff counts, etc.). - HQ settings APIs: - /api/group/[orgId]/settings GET (edit-side view of branding + every branch's public fields) + PATCH (slug + branding + published toggle). Slug is normalised via slugify() and uniqueness-checked across organisations. Publishing requires a non-empty slug; unpublishing clears publishedAt. - /api/group/[orgId]/settings/branches/[branchId] PATCH — edits the new public branch fields only (city, lat/lng, listed). Operational settings stay on the existing per-branch dashboards. - Public page (/chains/[orgSlug]): server-rendered with dynamic metadata (canonical URL, OG/Twitter cards using publicHeroImage), JSON-LD Organization with location[] of Restaurant entries (incl. GeoCoordinates when available) and a BreadcrumbList. Gradient hero coloured by brandColor with optional image overlay, opt-in tagline + description, branch count chip, footer CTA pushing partner programs. Client island ChainBranchList: - search by city / branch name / address, - city-chip quick-filter row built from distinct branch cities, - "Use my location" button (navigator.geolocation) → Haversine sort via existing lib/bazar/geo, - HQ-first fallback ordering when no location set, - distance pill on each card, - hand-off link respects customDomain when present, otherwise routes to the branch subdomain. - HQ editor (/dashboard/group/settings): publish/unpublish toggle with state badge + outbound "View page" link, auto-save-on-blur branding form with native colour picker, branch table with inline city/lat/lng inputs and one-click Listed/Hidden toggle. - Sidebar: "Chain Page" entry added under Operations. - Sitemap: app/sitemap.ts now appends every published org's /chains/[slug] route (lastModified = updatedAt). - Seeder (scripts/seed-multi-location.js): the Bella Cucina demo chain is now published as bella-cucina with real coordinates for Stockholm / Gothenburg / Malmö / Uppsala. Uppsala is hidden from the public page (inactive + isPubliclyListed=false) so the page lists 3 branches. - 0 TypeScript errors.

    • Multi-Location Phase 3 — Group-scoped staff & RBAC (task 80)

    - Schema (src/lib/db/schema.ts): additive UserRecord.organizationId? + UserRecord.groupRole?: 'owner' | 'manager'. Pure additions — every existing user record stays valid. - Access helpers: - requireGroupAccess(orgId) now grants the group surfaces to either the HQ tenant owner or any user whose UserRecord.organizationId === orgId AND groupRole is set. Verified against master DB on every call so demoting a manager loses access immediately. - requireTenantAccess(tenantId) extended with a single new branch: if session.tenantId !== tenantId and the caller is a tenant-owner with organizationId + groupRole matching the target tenant's org, access is granted. Every existing call site picks this up for free. - resolveActiveOrgForUser(userId) resolves via both HQ ownership and group-role membership. - Dashboard plumbing: - (dashboard)/dashboard/layout.tsx restaurants[] now includes every tenant in the user's organization, not just owned ones. RestaurantSwitcher shows the full chain. - /api/auth/login route gains a fallback: if a tenant-owner has no owned tenant but has organizationId + groupRole, session.tenantId falls back to the HQ (or any active branch) so they land in a working dashboard. - APIs: - /api/group/[orgId]/staff GET — lists group-role users + the HQ tenant owner (implicit owner) + per-branch staff counts. POST — invites a new group-staff user (creates a tenant-owner UserRecord with the matching org + role; PBKDF2 password hash, 8-char minimum). - /api/group/[orgId]/staff/[userId] PATCH — change role between 'owner' / 'manager'. DELETE — strips organizationId + groupRole from the user (account remains, org access removed). Both refuse to touch the HQ tenant owner. All audit-logged. - UI (/dashboard/group/staff): group users list with crown/shield role icons, HQ-owner + "you" pills, inline role select, remove buttons (disabled for HQ owner + self), separate per-branch staff counts panel with deep-link to each branch's /dashboard/staff. Invite modal with email + name + temp password + role selector. - Sidebar: "Group Staff" entry added under Operations alongside the other Group HQ entries. - Seeder: scripts/seed-multi-location.js now also creates manager@chain-demo.test / password123 with groupRole='manager' and no owned tenant — exercises the Phase 3 RBAC path end-to-end. - 0 TypeScript errors.

    • Multi-Location Phase 2 — Centralised menu engine + brand-wide promotions (task 80)

    - Schema (src/lib/db/schema.ts): added MenuPropagationMode union (all / categories-only / items-only / prices-only / availability-only), MenuPropagationEvent (append-only audit row with counts, results[] per target, triggeredByUserId), BrandPromotion (org-scoped offer with optedOutBranchIds[], same shape as Offer plus org metadata). Surfaced as menuPropagationEvents? + brandPromotions? on MasterDB. - Engine (src/lib/group/menu-engine.ts): propagateMenu({ org, source, targets, mode, user }) — five modes. prices-only patches basePrice + discountPrice on id-matched products; availability-only syncs stockTracking + stockQuantity. Per-target try/catch so a single bad branch doesn't poison the push. Returns an audit event the caller persists. - Resolver (src/lib/group/brand-promotions.ts): getActiveBrandPromotionsForBranch(tenantId) — filters by active, opt-out membership, start/end date window. - APIs: - /api/group/[orgId]/menu/propagate — GET history (last 50 enriched with tenant names) + POST push (validates source + targets belong to org, audit-logs). - /api/group/[orgId]/promotions — GET list + POST create. - /api/group/[orgId]/promotions/[promoId] — GET / PATCH (whitelisted editable fields) / DELETE, all audit-logged. - /api/group/[orgId]/promotions/[promoId]/opt-out — POST toggles a branch into/out of the promo's exclusion list. - /api/tenant/[tenantId]/brand-promotions — GET tenant-scoped read for branch dashboards. - UI (/dashboard/group/menu): 3-step wizard (mode → source branch → multi-select targets), success card with eventId, propagation history list with per-target ok/fail breakdown and per-mode counts. - UI (/dashboard/group/promotions): grid of promo cards (type badge, active/featured pills, branch participation count, redemption count + max cap, coupon code, date window), inline pause/resume toggle, delete, edit. Create/edit modal with all Offer fields plus a per-branch participation matrix (Included / Excluded toggle) reconciled against the opt-out API after save. - Sidebar: added "Group Menu" + "Group Promotions" entries under Operations alongside Branches + Group HQ. - Branch integration: /dashboard/offers page now fetches /brand-promotions in parallel and renders an "Inherited from Group HQ" panel above the local-offer list with a deep-link to /dashboard/group/promotions. - 0 TypeScript errors.

    • Multi-Location Phase 1 — Group HQ dashboard (task 80)

    - Access helper (src/lib/group/access.ts): requireGroupAccess(orgId) — gates on authenticated session, ownership of the HQ tenant in the target organization, AND the multi-location add-on being enabled. Returns { session, org, hqTenant, branches } on success. resolveActiveOrgForUser(userId) for server pages. - APIs under /api/group/[orgId]/: - overview (GET) — org-wide totals (orders today, ordersMTD, revenueMTD, revenueTotal, ordersTotal, staffCount), per-branch stats with HQ flag + 30-day per-day trend, top 10 items rolled up across all branches, 30-day org-wide trend (orders + revenue summed per day), active/total branch counts. - orders (GET) — unified order feed across every branch, branch-tagged (branchId, branchName, branchColor), sorted by createdAt desc, supports ?limit, ?status, ?branchId filters. - alerts (GET) — derived alerts with severity (critical/warning/info) and category (inventory/orders/reviews/branch). Surfaces: inactive branches, out-of-stock and low-stock ingredients (ingredientItems.currentStock vs lowStockThreshold), stale pending orders (>30 min), low review rating (<4.0 with ≥3 reviews), each with a deep-link href. - UI (/dashboard/group): server shell resolves org from session, renders empty-state for solo restaurants and an add-on upgrade card if the gate is closed. Client view (GroupHqClient) provides 4 KPI cards, alert centre with severity grouping and counts, sortable branch ranking (revenue / orders / name) with HQ + inactive pills and per-branch progress bars, 30-day revenue trend bar chart, top 8 items list, live order feed table with relative timestamps and status pills, refresh button. Sidebar entry "Group HQ" added under Operations. - 0 TypeScript errors.

    • Sales Agent / Affiliate Program landing page (`/agents`) — conversion-focused recruitment page for the SA program.

    - Sections: hero with live dashboard preview + 4 CTAs (Become a sales agent / Register / Log in / View commission structure), 6-stat animated trust strip, "What is a Sales Agent" 4-card explainer, 5-step "How it works" timeline, commission structure cards + interactive income calculator (sliders for restaurants × ARPR × months → live monthly / annual / lifetime projection with tier badge), 9-benefit grid, 8-card audience grid ("Who should join"), full-width dashboard preview, AI/GEO content block (4 Q/As targeted at LLM answer-engines), testimonials with earnings badges + aggregate rating, accessible FAQ accordion (10 entries), dark final CTA with 3 buttons. - SEO: dynamic metadata, OG/Twitter cards, JSON-LD for Organization + Service (program with AggregateRating) + BreadcrumbList + FAQPage. Canonical URL set. Targets keywords: restaurant affiliate program, restaurant sales agent, recurring affiliate commissions, restaurant SaaS affiliate system, etc. - AI/GEO: dedicated content block answering "How does the Sales Agent program work", "How can I earn commission from referring restaurants", "Best affiliate program for restaurant SaaS", "How to become a restaurant sales partner" so ChatGPT / Claude / Gemini / Perplexity / Copilot can extract verbatim answers. - Components: new client islands src/components/agents-public/IncomeCalculator.tsx (interactive sliders → live earnings projection) and src/components/agents-public/DashboardPreview.tsx (visual mockup of /agent/dashboard with KPIs, recent referrals, leaderboard). Reuses existing AnimatedCounter, PartnerFAQ, MobileStickyCTA from /partners work. - Cross-links wired: footer "Platform services" strip now points to /agents (was /agents/register); footer "Become a Partner" column SA entry → /agents; /partners SA card primary action → /agents (secondary still → /agents/register); /agents/register now has a "Back to program overview" header link. Sitemap entry added. - 0 TypeScript errors.

    • Become a Partner landing page (`/partners`) — world-class enterprise partner hub.

    - Sections: hero with four CTAs (Become a partner / Create account / Sign in / View docs), animated trust-bar stats (6 counters, intersection-observed with prefers-reduced-motion fallback), 7-card partner-type grid (Restaurant, Web Designer, Sales Agent — all LIVE — plus Delivery, Franchise & Enterprise, Supplier, Technology — coming soon), 5-step "How it works" timeline, 6-feature benefits grid, AI/GEO-optimised "What is Restora" content block (4 plain-spoken Q/As targeted at LLM answer-engines), documentation hub (4 links), testimonials with aggregate-rating row, accessible accordion FAQ (8 entries with full ARIA wiring), and a dark final-CTA block. - SEO: dynamic metadata, OG/Twitter cards, JSON-LD for Organization + SoftwareApplication (with AggregateRating) + BreadcrumbList + FAQPage. Canonical URL set. - AI/GEO: dedicated semantic content block answering "Who is Restora for", "Why restaurants choose Restora", "How partners earn", "What makes Restora different" so ChatGPT / Claude / Gemini / Perplexity / Copilot can extract structured answers verbatim. - Mobile: full responsive layout from 320px up, plus a scroll-triggered mobile-only sticky CTA bar (Become a partner + Start free) that slides in after the hero. - Accessibility: section-scoped aria-labelledby, FAQ accordion uses aria-expanded / aria-controls, animated counters honour prefers-reduced-motion, decorative SVGs marked aria-hidden, focus rings on all interactive elements. - Components: new client islands src/components/partners/AnimatedCounter.tsx, PartnerFAQ.tsx, MobileStickyCTA.tsx (single-purpose, reusable). - Partner registry refactor (src/lib/partners.ts): removed the sales-agent coming-soon entry (now LIVE at /agents/register); added delivery, enterprise, technology coming-soon entries. /partners/[program] continues to render the interest-capture flow for any program in the map. - Sitemap: added /partners, /register, /agents/register and all /partners/[program] slugs (auto-derived from the registry). - 0 TypeScript errors. Copy and routing verified against existing live routes (/register, /login, /designers/register, /agents/register, /help, /contact).

    • Sales Agent — Phase 5: Fraud hardening, attribution reassignment, code expiry & maintenance cron (task 77)

    - Referral-code lifecycle: ReferralCode gains expiresAt, revokedAt, revokedBy. validateReferralCode now rejects codes that are inactive, manually revoked, or past their expiry. Suspended agents' codes are auto-deactivated by the maintenance cron and re-enabled when reinstated; admin-revoked codes are permanent (never reactivated automatically). - Lead-create fraud guards (POST /api/agent/[id]/referrals): soft daily rate limit (20 manual leads / agent / day → 429 DAILY_LIMIT_REACHED) and per-agent duplicate detection by case-insensitive email (409 DUPLICATE_LEAD, returns the existing referral id so the UI can deep-link). - Admin attribution audit + reassignment (/api/admin/sales-agents/attribution GET + POST): list every tenant currently attributed to an SA with agent + lock + tenant active state; reassign or clear with a required ≥5-char reason. Historical commissions remain with the original agent for audit integrity; the old Referral is archived (with reason), a fresh Referral is created for the new agent at status='active' + lockedAt=now. Audit-logged. New admin page /admin/dashboard/sales-agents/attribution with search + reassign modal; sidebar entry "Attribution". - Maintenance cron (/api/cron/sales-agent-maintenance — CRON_SECRET or super-admin): one sweep that (a) auto-archives new_lead referrals with no activity for 60 days, (b) syncs every code's isActive to the owning agent's status (respecting permanent revocations), (c) runs the scheduled SA payout batch. Returns a structured report. - Seeder: added a revoked legacy code (SA-KARLSSON-LEGACY) and a 75-day-old stale new_lead ("Forgotten Lead") so the cron's archive + code-revoke paths fire on first run. - Verified end-to-end on a live server: revoked code → 404, active code → 200, duplicate lead → 409 with existingReferralId, reassignment moved tenant.salesAgentId + archived old Referral + created locked new one, cron archived 1 stale lead, deactivated Sofia's code, paid 2 eligible agents (Tom's large payout correctly held), total paid 299.7 SEK. 0 TypeScript errors; reseeded clean.

    • Sales Agent — Phase 4: Real KPIs, follow-up reminders & support escalations (task 77)

    - Dashboard KPIs API (/api/agent/[id]/dashboard): total referrals, active restaurants (locked + tenant.isActive), conversion rate, commission this month, pending payout, lifetime paid, overdue follow-ups, open tickets. /agent/dashboard now renders real values with loading skeletons and three colour-coded alert cards (overdue follow-ups → referrals, open tickets → support, lifetime paid → payouts). - Support escalations: new SalesAgentSupportEscalation + supportEscalations array in SalesAgentsDB. Agent API /api/agent/[id]/support (list/create) + /api/agent/[id]/support/[ticketId] (single + agent reply — an agent reply reopens a resolved ticket). Admin API /api/admin/sales-agents/escalations with reply / resolve / close / reopen + audit logging. /agent/support page (status counts, status+priority badges, right-drawer thread) + /admin/dashboard/sales-agents/escalations (dark admin queue, open-first sort, reply/resolve/close/reopen actions). Sidebars updated. - Follow-up reminders: /agent/referrals shows a "N follow-ups due" chip when any open-pipeline lead is past its followUpAt; toggling it filters the list/kanban to those rows. The dashboard alert card surfaces the same count and deep-links here. - Seeder (scripts/seed-sales-agents.js): 6 support tickets covering every status × priority + multi-message threads; one Anna referral set to an overdue follow-up so the chip + dashboard alert light up out of the box. - Verified end-to-end on a live server: KPIs return real numbers (6 referrals, 33.3% conversion, 1 overdue follow-up, 2 open tickets, 399.6 SEK paid), every ticket status surfaces in the queues, agent→admin reply round-trip transitions open → in-progress correctly, cross-agent isolation 403. 0 TypeScript errors.

    • Sales Agent — Phase 3: Shared commission engine, Stripe Connect & payouts (task 77)

    - Shared participant primitives (src/lib/commission/shared.ts): single source of truth for resolveRate (per-participant → global → default), assessRiskBaseline (large payout / rapid activity / new-account-high-value), transferToConnect (Stripe Connect with DEV simulation + idempotency + structured errors), and thresholds. Designer engines refactored to use these helpers while keeping their public APIs intact. - Composite invoice trigger (src/lib/commission/triggers.ts): one fan-out call records commissions for every attached participant (designer and sales agent) on a paid invoice; the matching reverse trigger handles refunds/chargebacks across both. Wired into the tenant billing DEV path and the Stripe webhook (invoice.payment_succeeded, charge.refunded, charge.dispute.created) so a single paid invoice stacks both ledgers. - Sales-agent commission engine (src/lib/sales-agent/commission-engine.ts): idempotent ledger writes keyed by tenant.salesAgentId; on first pending entry for a tenant the matching Referral is attribution-locked (lockedAt + status → active) and the agent's denormalised stats update; refund/chargeback reversals + paid-entry clawbacks. - Sales-agent payout engine (src/lib/sales-agent/payout-engine.ts): same lifecycle as designer (≥50 SEK batch, Stripe transfer, retry on failure) but with no membership gate — only requires an active SA account + a connected Stripe Connect. Anti-fraud holds via shared baseline; admin override supported. - Stripe Connect for SAs (/api/agent/[id]/connect): onboarding endpoint (DEV simulation or production Express account+link). Connect webhook (account.updated) extended to keep both designer and SA stripeAccountStatus in sync. - Agent pages: /agent/billing (Connect onboarding + earnings snapshot + referral link), /agent/commissions (ledger with totals), /agent/payouts (batch history + failures + annual CSV via /api/agent/[id]/tax-summary). Sidebar gains Commissions / Payouts / Payout Account. - Admin SA payouts (/api/admin/sales-agents/payouts + /admin/dashboard/sales-agents/payouts): per-agent pending + eligibility + held-for-review + risk reasons, run-all / pay-one / retry; sidebar gains "Sales Agents → Payouts". - Cron (/api/cron/sales-agent-payouts): scheduled SA payout sweep (CRON_SECRET or super-admin), respects anti-fraud holds. - Verified end-to-end on a live server: one paid invoice produces both a designer entry (199.8 @ 20%) and a sales-agent entry (99.9 @ 10%); first SA entry locks the referral (lockedAt set, status active); admin SA payout → paid; designer payouts still work after the shared-engine refactor (3 designers paid, 1 correctly skipped); cron 401 without secret. 0 TypeScript errors.

    • Sales Agent — Phase 2: Referral attribution & CRM lead pipeline (task 77)

    - First-touch referral attribution: - RefCapture client component mounted in the root layout reads ?ref=CODE and POSTs to /api/public/referrals/track, which validates the code and sets an httpOnly ref_code cookie (90-day TTL) — first-touch wins, never overwrites an existing valid attribution. - GET /api/public/referrals/[code] validates a code; GET /api/public/referrals/current reads the cookie + returns the agent's display name. The /register page shows a "Referred by …" banner when the cookie is set. - Tenant signup (/api/auth/register) now reads the cookie, blocks self-referrals (audit-logged), sets tenant.salesAgentId, creates a Referral row at signed_up, logs a forensic ReferralEvent with IP/UA/signup-user/tenant, and clears the cookie. - Agent CRM API (/api/agent/[id]/referrals + /[refId]): list with per-status counts; create a manual lead (source manual); PATCH lead fields + status transitions (agent-only subset: newlead → contacted → qualified → demobooked) + append a note; DELETE only allowed for pre-signup leads. Locked / signed-up referrals are read-only — only the platform mutates them. Strict per-agent isolation (cross-agent 403). - Referrals page (/agent/referrals): list + kanban view (reusable ViewToggle, persisted as agent-referrals-view), per-status KPIs, add-lead modal, right-drawer detail with notes timeline + status select + lead fields + contact shortcuts (mailto / tel) + delete. Sidebar updated with the Referrals link. - Verified end-to-end on a live server: code validate (valid + invalid), track sets cookie, first-touch second call returns alreadyAttributed, tenant signup applies attribution + creates the Referral + forensic event + clears cookie, manual lead create, PATCH status + note, DELETE 409 on locked referral, cross-agent 403, unauth 401. 0 TypeScript errors; test data cleaned up.

    • Sales Agent role — Phase 1: Foundation (task 77)

    - Schema (src/lib/db/schema.ts): new sales-agent role on UserRecord; UserRecord.salesAgentId and TenantRecord.salesAgentId (attribution pointer — does not grant access). New interfaces: SalesAgent, ReferralCode, Referral, ReferralNote, ReferralEvent, SalesAgentCommissionEntry, SalesAgentPayoutBatch, SalesAgentPayoutFailure, SalesAgentCommissionRule, and the SalesAgentsDB top-level shape. - Database (databases/sales-agents.json, src/lib/db/db.ts): new sales-agents.json with a default 10% global commission rule; getSalesAgentsDB/saveSalesAgentsDB accessors mirroring the designer pattern. - Auth (src/lib/auth/session.ts): SessionPayload extended with sales-agent role + salesAgentId claim. src/lib/auth/api-auth.ts: new requireSalesAgent() (agent-only gate, rejects every other role). Tenant dashboard layout now redirects sales-agents to their own portal. - Registration (POST /api/auth/agent/register): free public self-registration — creates a UserRecord (role sales-agent), a SalesAgent profile (active immediately, no membership), and a unique ReferralCode (e.g. SA-LINDBERG). Issues a session immediately. - Login (/api/auth/login, /api/auth/login/verify-2fa): resolves salesAgentId and blocks suspended/deleted agents; session cookie carries salesAgentId. Login page routes sales-agents to /agent/dashboard. - Portal (src/app/agent/): auth-gated layout (agent-only, redirects others to /login), AgentProvider context, sidebar + topbar with account-status badge, dashboard placeholder showing the referral code + copyable invite link + KPI placeholders + onboarding hints. Public registration page at /agents/register; /agent added to proxy bypass. - Footer "Sales Agent" link now points at the live signup (/agents/register). - Verified end-to-end on a live server: registration creates SA + code, portal gated with/without session, sales-agent redirected from /dashboard to /agent/dashboard, admin API rejects 401, login flow returns role: sales-agent + salesAgentId. 0 TypeScript errors; test data cleaned up.

    Changed

    • Super Admin sidebar — collapsible, scalable navigation refactor

    - AdminSidebar rebuilt as collapsible accordion groups under a cleaner 7-group taxonomy (Dashboard · Restaurants · Web Designers · Billing & Revenue · Monetization · Content · Platform & System) using only existing routes (routing preserved). The group containing the active route auto-expands; per-group open/closed state is remembered. - Added a compact icon-rail toggle (hover tooltips via title/aria-label), a mobile drawer (hamburger + backdrop) so admin nav works on mobile, single active-item highlighting (longest-prefix match), aria-expanded group buttons, aria-current items, and smooth grid-rows height animations. Existing .admin-* theme classes preserved (dark/medium/light intact). - New reusable SSR-safe usePersistentState hook (src/lib/hooks/usePersistentState.ts) backs sidebar state (admin-sidebar-compact, admin-sidebar-groups) and the designers view toggle.

    • Designers directory (`/designers`) — UI/UX redesign

    - Replaced the heavy orange-gradient hero + gray body with a clean, light Scandinavian/SaaS layout; redesigned search into a sticky minimal toolbar (softer neutral inputs, inline filters on desktop, mobile filter drawer). - Added a List/Table ↔ Grid view toggle (reusable ViewToggle, default List, persisted in designers-view). List columns: Designer, Location, Restaurants, Rating, Services, Status, Contact. Grid: refined cards. Added proper empty states. - Added the marketplace footer (reuses the landing Footer) and made PublicNav current-page aware — "Find a Designer" is a non-clickable aria-current label on the directory (no redundant CTA loop). - Footer anchors now use /#section (work cross-page); added a language selector (i18n-ready) + Contact link.

    Security

    • Removed the public super-admin hint from `/login` — the "Sign in as administrator" link is gone; the admin entry (/admin/login) remains internally accessible only. No demo credentials or debug text are exposed on the login page.

    Added

    • Web Designer — Phase 6: Automation, fraud prevention & scaling (task 76)

    - Anti-fraud payout holds (src/lib/designer/fraud.ts): assessPayoutRisk flags large payouts (≥50k), rapid onboarding (≥10 restaurants/7d), and high value from new accounts (<14d). Automated payout runs HOLD flagged designers for manual review; the payout engine accepts an override so a super-admin can pay anyway. Admin payouts page surfaces "Held for review" + reasons and a "Pay anyway" action. - Maintenance sweep (src/lib/designer/maintenance.ts): expireDemos() deactivates demo restaurants past demoExpiresAt (archives the designer link), purgeExpiredDemos() deletes demos beyond a 30-day grace (removes records + tenant file). - Cron endpoint (/api/cron/designer-maintenance): runs demo expiry + purge + the scheduled payout run; auth via CRON_SECRET (x-cron-secret/?secret=) or a logged-in super-admin. Admin payouts page has a "Run maintenance" button. - Annual earnings/tax summary (/api/designer/[id]/tax-summary?year=&format=json|csv): paid commissions grouped by month + totals; CSV download button on the designer payouts page. - Stripe Connect webhook (/api/stripe/connect-webhook): account.updated syncs each designer's payout-account status (active/restricted/rejected); restricted accounts are skipped by the payout eligibility gate. (DEV_MODE no-ops until Stripe keys are set.) - Verified on a live server: cron auth (401 without secret), fraud hold (60k payout → "Held for review", not auto-paid; override pays), demo auto-expiry (tenant inactive + link archived), maintenance report (1 expired, 2 paid, 1 held), and tax-summary CSV. 0 TypeScript errors.

    • Web Designer — Phase 5: Public directory, profiles, reviews & SEO (task 76)

    - Public directory (/designers + GET /api/public/designers): "Find a Web Designer" — searchable/filterable grid (country, specialization, service, featured) with computed facets; only active + publicly-listed designers appear; featured-first sorting. - Public profile (/designers/[slug] + GET /api/public/designers/[slug]): bio, stats, specialties/services/languages, portfolio gallery, published reviews, contact form. SEO generateMetadata (title/description/OpenGraph/Twitter) + JSON-LD ProfessionalService with aggregateRating. - Reviews: public submission (POST .../[slug]/reviews) stored unverified + unpublished; admin moderation (/api/admin/web-designers/reviews + a Reviews section on the designer detail page) with publish/unpublish/verify/delete and automatic avgRating/reviewCount recalculation from published reviews. - Contact (POST .../[slug]/contact): forwards an enquiry to the designer's account email via the mailer. - Profile editor (/designer/profile + GET/PATCH /api/designer/[id]/profile): edit bio, avatar/logo, location, specializations/services/languages, website, social links, portfolio, billing details, response time, and the public-listing toggle (only effective while the membership is active). - Sitemap (src/app/sitemap.ts): includes the directory, registration, and every public designer profile. - Verified on a live server: directory (4 listed + facets, specialization filter), profile page (JSON-LD + 3 reviews), review submit → pending → admin publish → appears with recalced rating, contact send, profile editor GET/PATCH (bio/portfolio/website normalization), sitemap lists all profiles. 0 TypeScript errors.

    Fixed

    • Admin/designer session collision across browser tabs — super-admin now uses a separate admin_session cookie instead of sharing platform_session with tenant-owner/web-designer. Previously, logging into a second role in another tab overwrote the shared cookie, so refreshing the first tab bounced to login. Now an admin session and a designer/tenant session coexist in the same browser. Changes: setAdminSessionCookie/deleteAdminSessionCookie + getAdminSession; requireSuperAdmin reads admin_session; requireTenantAccess/requireDesignerTenantAccess honor an admin cookie for the super-admin bypass; login + 2FA issue the admin cookie for super-admins; logout is scope-aware (?scope=admin); admin dashboard layout reads admin_session. Note: existing admins must log in once more after deploy (their old token lived in the platform cookie).

    Added

    • Web Designer — Phase 4: Client invoicing, dashboard KPIs & support escalations (task 76)

    - Invoices (/api/designer/[id]/invoices + /[invoiceId]): create draft (line items, per-item service type, VAT, sequential WD-<year>-<NNNN> numbering, server-computed subtotal/VAT/total), list with outstanding/paid totals + overdue flag, status transitions (draft → sent → paid, void/cancel), reminder resend, draft delete. Sending or reminding emails the client via the existing mailer (DEV logs to console). - Printable invoice (/designer-invoice/[invoiceId]): public, print-optimized invoice page (browser "Save as PDF") used by both the client email link and the designer's "view/print" — no PDF dependency added. Added to proxy bypass. - Invoices UI (/designer/invoices): builder modal with live totals, list with send / remind / mark-paid / view / delete actions. - Dashboard KPIs (/api/designer/[id]/dashboard + /designer/dashboard): real data — managed restaurants (live/demo), monthly recurring revenue, commission this month, pending payout, plus unpaid-invoice and open-ticket alert cards. - Support escalations (/api/designer/[id]/support + /[ticketId]; admin /api/admin/web-designers/escalations): designers open tickets to the platform and reply in a thread; admins triage from a global queue with reply / resolve / close. /designer/support (designer) + /admin/dashboard/web-designers/escalations (admin, with "Designer Support" sidebar entry). - Seeder (scripts/seed-web-designers.js): now also seeds 3 client invoices (paid / outstanding / overdue) and an in-progress support escalation; reset now clears seed invoices + escalations too. - Verified end-to-end on a live server: dashboard KPIs (2 restaurants, 166.5 MRR, 399.6 commission), invoice create (VAT 16500→20625) → sent (+email) → public print page → paid; support round-trip (designer open → admin reply → designer sees → admin resolve). 0 TypeScript errors.

    • Web Designer — Phase 3: Billing, Stripe Connect, commissions & payouts (task 76)

    - Commission engine (src/lib/designer/commission-engine.ts): recordCommissionForInvoice (idempotent per invoice+designer) records a ledger entry whenever a designer-managed restaurant pays a platform invoice — pending when all gates are open (designer active + membership active + link commission-eligible), else withheld for the audit trail. resolveCommissionRate applies per-designer rule → global rule → designer default. reverseCommissionForInvoice handles refund/chargeback reversals + paid-entry clawbacks. - Payout engine (src/lib/designer/payout-engine.ts): createAndProcessBatch sums a designer's pending commissions (≥50 min), marks them batched, transfers via Stripe Connect (simulated in DEVMODE), then settles to `paid`/`completed` or records a `PayoutFailure` and reverts to `pending`. `runPayoutRun` (cron-callable) pays all eligible designers; `retryPayoutFailure` re-attempts (max 3). Amounts derive only from the immutable ledger. - **Membership** (`/api/designer/[id]/membership`): activate (500 SEK/yr) via Stripe Checkout subscription or DEVMODE simulation (sets account active, unlocking live onboarding + commissions); cancel; status + earnings snapshot. - Stripe Connect (/api/designer/[id]/connect): Express onboarding link in production, simulated connected account in DEVMODE — gates payouts on a connected account. - **Commission triggers**: wired into the tenant billing DEVMODE checkout and the production Stripe webhook (invoice.payment_succeeded → record; charge.refunded/dispute.created → reverse). - Admin payouts (/api/admin/web-designers/payouts + /admin/dashboard/web-designers/payouts): per-designer pending + eligibility, run-all / pay-one / retry actions, batch history, failures. Sidebar "Designer Payouts" entry. - Designer pages: /designer/billing (membership + payout-account onboarding + earnings), /designer/commissions (ledger + totals), /designer/payouts (batch history + failures). - Verified end-to-end against a live server (DEVMODE): activate membership → connect payout → create live restaurant (gate passes) → subscription payment records 199.8 commission @ 20% → admin payout run pays it (`trsim_…) → ledger paid → idempotent re-run pays nothing → canceled membership records the next commission as withheld`. 0 TypeScript errors; test data cleaned up.

    • Web Designer — Phase 2: Restaurant assignment, delegated access & demo restaurants (task 76)

    - Delegated access (src/lib/auth/api-auth.ts): requireTenantAccess now grants owner-level access to a web-designer whose session is scoped to a tenant they actively manage (verified against designerRestaurants on every call — a revoked link loses access immediately). This lets designers reuse the entire existing tenant dashboard + API surface without duplicating logic. - Restaurant onboarding (/api/designer/[designerId]/restaurants): GET lists managed restaurants (with demo/expiry flags); POST creates a restaurant. Demo creation is free but capped at 1 active demo/designer; live onboarding requires an active membership (returns 402 MEMBERSHIP_REQUIRED until Phase 3) and creates/links the client owner account. Reuses createTenantDatabase. - Enter/exit delegated mode (/api/designer/[designerId]/access): POST {tenantId} verifies the active link, records a DelegatedAccessSession, re-issues the session scoped to the tenant, and audit-logs the entry; DELETE exits and revokes the session. The tenant dashboard shows a persistent "Managing X as a web designer" banner with an Exit button; the dashboard layout allows delegated designers and re-checks the assignment server-side. - Demo enforcement: demo restaurants are excluded from the marketplace (/api/bazar/menu + /sponsored), rejected from real orders (403 DEMO_MODE_ACTIVE), password-gated on their public site (/api/public/[tenantId]/demo-access sets an HMAC cookie; DemoGate form), show a persistent DEMO PREVIEW watermark, and display an expiry notice past demoExpiresAt (90-day default). - Designer pages: /designer/restaurants (managed list + onboarding modal with membership gate) and /designer/demo (create/manage the single demo, view-site link, password reminder, expiry). - Verified end-to-end against a live server: demo create + 1-max limit (409) + membership gate (402), order block (403), marketplace exclusion (0 items), password gate (401/200 + watermark), delegated enter → own-tenant API 200 / other-tenant 403 → dashboard 200 → exit → dashboard 307. Audit + DelegatedAccessSession records confirmed. 0 TypeScript errors; test data cleaned up.

    • Web Designer (Reseller/Agency) Role — Phase 1: Foundation (task 76)

    - Schema (src/lib/db/schema.ts): New web-designer role on UserRecord; added optional designerId to UserRecord. Extended TenantRecord with designerId, isDemo, demoPassword, demoExpiresAt, demoOwnerId (ownership stays with ownerId — designer link is separate). New interfaces: WebDesigner, DesignerMembership, DesignerRestaurant, DelegatedAccessSession, CommissionLedgerEntry, PayoutBatch, PayoutFailure, CommissionRule, DesignerReview, DesignerInvoice/DesignerInvoiceItem, SupportEscalation, and the DesignersDB top-level structure. - Database (databases/designers.json, src/lib/db/db.ts): New designers.json with a default 20% global commission rule; getDesignersDB()/saveDesignersDB() with atomic write and empty-default merge for forward-compatible array additions. - Auth (src/lib/auth/session.ts): SessionPayload extended with web-designer role + designerId claim. (src/lib/auth/api-auth.ts): requireWebDesigner() (designer-only gate, rejects superadmin/owner) and requireDesignerTenantAccess() (delegated-access gate — verifies an active DesignerRestaurant link on every call; authorization never derived from request input). - Registration (POST /api/auth/designer/register): Free public self-registration — creates a UserRecord (role web-designer), a WebDesigner profile with a unique URL slug, and an inactive DesignerMembership (500 SEK/yr). Issues a session immediately. - Login (/api/auth/login, /api/auth/login/verify-2fa): Resolve designerId and block suspended/deleted designer accounts; session cookie now carries designerId. Login page routes designers to /designer/dashboard. - Portal (src/app/designer/): Auth-gated layout (designer-only, redirects others to /login), DesignerProvider context, sidebar + topbar with membership-status badge, and a dashboard overview placeholder with KPI cards and an "activate membership" prompt. Public registration page at /designers/register. - Routing/Isolation: Tenant dashboard now redirects web-designer sessions to their portal; /designer added to proxy bypass prefixes. Verified end-to-end: designer login carries designerId; portal gated (307→/login without session); admin APIs reject designers (403); /dashboard redirects designers to their portal.

    • Web Designer — Super Admin management (pulled forward from Phase 3)

    - APIs (requireSuperAdmin-gated): GET /api/admin/web-designers (list enriched with email, membership status, restaurant count, commission totals; search/status-filter/sort + status counts); GET/PATCH/DELETE /api/admin/web-designers/[designerId]. - PATCH: suspend (records reason/by/at, unlists publicly) & reinstate (clears suspend fields), commission-rate override (0–100%), feature toggle, public-listing toggle, notes. - DELETE — full ownership-transfer lifecycle: managed restaurants transfer to platform (TenantRecord.designerId = null, restaurants keep operating), DesignerRestaurant links archived, pending/batched commissions set to withheld (not paid), membership canceled, designer profile + user account PII anonymized, paid commissions/payouts/invoices preserved. Audit-logged. - UI: /admin/dashboard/web-designers list page (KPI counts, search/filter/sort, dark admin theme) + /admin/dashboard/web-designers/[designerId] detail page (profile, totals, managed restaurants, commission ledger, payouts, memberships, suspend/reinstate/feature/rate-edit actions, delete-with-consequences modal). Added "Web Designers" sidebar entry under Marketplace + AdminTopBar title. - Verified end-to-end against a live server with a superadmin session: list/auth (401 without cookie), suspend→reinstate, and the full delete lifecycle (1 restaurant transferred to platform, 1 commission withheld, tenant still active, user/profile anonymized). Test data cleaned up.

    Fixed

    • Mobile bazar marketplace — stuck loading skeleton (root-cause fix)

    - Replaced AbortController pattern in init() with a let live boolean guard. The previous if (!signal.aborted) setLoading(false) check prevented setLoading(false) from ever being called on slow mobile WiFi where cleanup fired mid-response-body, leaving loading permanently stuck at true. - Added 10-second hard timeout as a failsafe — surfaces an error state instead of endless skeletons. - Added retryToken state + "Try Again" button on the error screen so users can recover without a full page reload. - init() now resets loading=true and offline=false at the start of each run (including retries).

    Added / Redesigned

    • Payment Escrow, Cancellation, Refund & Fraud System (task 75)

    - Schema (src/lib/db/schema.ts): Extended Order with escrow fields (paymentHoldId, escrowStatus, lockStatus, cancelledBy, cancellationReason, cancelledAt, refundId, refundAmount, timestamp fields, timeline). Added OrderTimelineEvent, PaymentHold, RefundRequest, PayoutRelease, CustomerRiskScore, RestaurantRiskScore, TenantCustomerBan, EscrowPolicy, EscrowDB interfaces plus all associated type aliases. - Database (databases/escrow.json, src/lib/db/db.ts): New escrow.json file with seed data; getEscrowDB()/saveEscrowDB() with atomic write; policy merged with defaults on read. - Escrow engine (src/lib/escrow/escrow-engine.ts): createPaymentHold() (idempotent), customerCanCancel() (grace period + lock state machine), applyOrderLock(), appendTimeline(), getEscrowPolicy(). - Refund engine (src/lib/escrow/refund-engine.ts): createRefundRequest() (idempotent, deduped), executeRefund() with Stripe/PayPal/cash stubs. - Payout engine (src/lib/escrow/payout-engine.ts): schedulePayout() (idempotent), releasePayout() (Stripe Connect stub), holdPayout(), processOverduePayouts() (cron-callable). - Risk scoring (src/lib/risk/scoring.ts): recordCustomerCancellation() (auto-ban at threshold ≥85), recordCustomerOrderComplete(), recordRestaurantOrderAccepted() (suspicious prep speed flag), recordRestaurantCancellation(), isCustomerBanned(). - Order creation (/api/public/[tenantId]/orders): Creates PaymentHold and appends order_created timeline event on every new order. - Public APIs: GET /api/public/[tenantId]/orders/[orderId] (order status + timeline + cancel eligibility); POST /api/public/[tenantId]/orders/[orderId]/cancel (customer cancellation with refund logic + risk scoring). - Tenant APIs: POST /api/tenant/[tenantId]/orders/[orderId]/cancel (restaurant cancel — always 100% refund); PATCH /api/tenant/[tenantId]/orders/[orderId] (escrow-aware: sets timestamps, applies lock at ready, schedules payout at delivered, fraud check); GET /api/tenant/[tenantId]/escrow; GET /api/tenant/[tenantId]/risk; POST|DELETE /api/tenant/[tenantId]/risk/ban. - Admin APIs: GET /api/admin/escrow; GET|PATCH /api/admin/escrow/policy; GET|POST /api/admin/payouts; GET /api/admin/risk; POST|DELETE /api/admin/risk/ban. - Restaurant dashboard (/dashboard/escrow): Summary cards (held/pending/released/refunded), pending payouts list with scheduled release times, holds and refunds history, customer risk indicators table, tenant ban list, restaurant own risk score. - Admin dashboard (/admin/dashboard/escrow): Platform-wide escrow summary, overdue payout queue with manual release, customer risk table with ban management, restaurant risk table, inline policy configuration editor. - Navigation: "Payouts & Escrow" added to restaurant sidebar; "Escrow & Risk" added to admin Marketplace sidebar group. - 0 TypeScript errors

    • Freeze Online Ordering When Restaurant Is Closed (task 74)

    - Centralized availability logic in src/lib/bazar/ordering-status.ts — getOrderingStatus() and canAcceptOrders() wrap getOpenStatus; closingsoon counts as orderable; empty hours default to open - **Backend validation**: `POST /api/public/[tenantId]/orders` checks opening hours before accepting any order — returns HTTP 409 with `{ error: 'RESTAURANTCLOSED', message, nextOpenLabel } if closed, preventing orders regardless of frontend state - **Restaurant menu page** (/bazar/restaurant/[slug]): - canOrder state computed from getOpenStatus after restaurant loads, refreshed every 60 seconds - Red "currently closed" banner with next-open time shown above menu when restaurant is closed - MenuItemCard: shows compact "Closed" badge instead of + button; the qty + control is also disabled when closed (removal still allowed) - PopularSection, offer items, and full menu all respect canOrder - **Item detail page** (/bazar/restaurant/[slug]/item/[productId]): - canOrder state computed and refreshed every 60 seconds - Red "Restaurant is currently closed" notice with next-open time appears inside the add-to-cart block - Qty + control disabled when closed; "Ordering Unavailable" disabled button shown when item not yet in cart - **Bazar landing page** (/bazar): - isRestaurantOpen field added to FoodItem type and /api/bazar/menu API response - FoodCard quick-add button replaced with compact "Closed" badge when isRestaurantOpen === false - Restaurant open status computed server-side at API request time via canAcceptOrders()` - 0 TypeScript errors

    • Compliance Center UX redesign — non-technical owner photo workflow (task 73)

    - Compliance Center becomes the primary, simplest way to fix missing product images - New ComplianceImageUploader component (src/components/compliance/ComplianceImageUploader.tsx): - Drag & drop zone — works on desktop and mobile - "Upload Photo" button triggers native file picker with capture="environment" for mobile camera - "Choose Image" button opens a full library panel (My Library tab + Free Food Photos tab) - Before/after comparison view in preview stage so owner sees old vs. new photo - Client-side file validation: type check, 10 MB size limit, friendly error messages - Submit CTA: "Update Product & Submit for Review" - Success stage shows new product photo, "Done" + "Menu" buttons - Escape key dismisses, z-index layering: uploader modal at z-200, library panel at z-300 - Compliance upload route rewritten (POST /api/tenant/[tenantId]/compliance/[issueId]/upload): - Accepts mediaUrl (also backward-compatible with imageUrl) - STEP 1: Updates product.imageUrl in tenant DB immediately (image goes live NOW before review) - STEP 2: Sets compliance issue to pending_review with productSyncedAt timestamp - Product sync failure logged but does not block compliance creation; repair tool can re-sync - Compliance GET route updated (GET /api/tenant/[tenantId]/compliance): - Now reads tenant DB in parallel with compliance DB via Promise.all - Returns currentProductImageUrl for each issue so the owner sees the live product state - Owner compliance page fully redesigned (src/app/(dashboard)/dashboard/compliance/page.tsx): - Renamed to "Product Photos" — plain English, no jargon - Issue cards show a product thumbnail (80×80 / 96×96) with "No photo" placeholder when missing - Orange "Add Photo Now" / "Upload Better Photo" CTA on each actionable card - Before/after image comparison in uploader preview - Alert banner with context-aware messaging (grace period vs active penalty) - Optimistic UI update — card shows new image immediately after upload success - Filter tabs: All / Need Action / Done (no URL pasting ever shown to owners) - Simplified "How penalties work" 4-step guide - getBazarUrl() subdomain-stripping helper retained for correct "View on Bazar" links - URL fields removed from owner-facing UI entirely — owners never see or paste raw image URLs

    Fixed

    • Compliance approval never wrote image to product (critical bug fix)

    - PATCH /api/admin/compliance/issues/[id] approve action now calls syncImageToProduct() before marking approved; returns HTTP 500 and blocks approval if the product is not found or the write fails - POST /api/admin/compliance/bulk bulk approve now batches product syncs per tenant (one read+write per tenant) instead of only updating complianceDB - Added productSyncedAt?: string and productSyncError?: string to ComplianceIssue schema so sync state is traceable - New POST /api/admin/compliance/repair endpoint — idempotent; re-applies approved image URLs to all products where the sync previously failed or never ran; batched by tenant - Owner compliance page "View" link now correctly strips the tenant subdomain from window.location.host so luigi.localhost:3001/bazar/... resolves to localhost:3001/bazar/... instead of 404ing - Admin review cards in the Review tab now show a sync debug panel: product_id, tenant_id, synced_at (green) or "not yet synced" (amber), and sync_error (red) if present - Admin Settings tab now includes a Sync Repair Tool card with "Run Repair" button and result summary

    Added

    • Marketplace Image Compliance System (task 72)

    - ComplianceIssueStatus type, ComplianceIssue, ComplianceSettings, ComplianceDB interfaces added to src/lib/db/schema.ts - getComplianceDB() / saveComplianceDB() DB accessors added to src/lib/db/db.ts; databases/compliance.json is the dedicated store - src/lib/compliance/image-checker.ts — hasValidImage() detection, scanAllTenants() cross-tenant audit, runPenaltyCycle() monthly accrual engine, markReminderSent(), buildAnalytics() metrics builder - Admin API routes (all under /api/admin/compliance/): GET /issues (filter/search/paginate), PATCH /issues/[id] (approve/reject/send-reminder/activate-penalty/waive/request-better-image), POST /bulk (bulk approve/reject/remind/activate), POST /scan (full audit + penalty cycle), GET+PATCH /settings, GET /analytics - Tenant API routes: GET /api/tenant/[tenantId]/compliance (owner issue view with summary), POST /api/tenant/[tenantId]/compliance/[issueId]/upload (submit image for review) - Super Admin Compliance Dashboard (/admin/dashboard/compliance): 4-tab page — Overview (KPI cards, monthly trend bar chart, top-offender list, status distribution), Issues (filterable/sortable table, bulk actions, CSV export, pagination), Review Queue (image cards with approve/reject/request-better inline), Settings (penalty amount, grace period, warning schedule, auto-activate toggle) - Restaurant Owner Compliance Center (/dashboard/compliance): summary cards, issue cards with upload modal, image guidelines, step-by-step resolution guide, filter tabs (all/active/resolved) - Admin sidebar: "Compliance" added under Marketplace group - Tenant sidebar: "Compliance Center" added under Food Bazar group - Workflow: Missing → Warning Sent → Penalty Active → Image Uploaded → Pending Review → Approved/Rejected. Penalty stops only after admin approval (never auto-cleared on upload) - Penalty: 300 SEK/month default, configurable; grace period 7 days default; auto-activates after grace period; monthly accrual via runPenaltyCycle()

    • Food Bazar — Premium restaurant page redesign (task 71)

    - Removed large hero/banner image entirely; page now opens directly to content - RestaurantHeader — compact card with text-avatar fallback (name-hash gradient → 6 presets), live open/closed/closing-soon status pill, favorite toggle (wired to /api/bazar/user/favorites), Web Share API + clipboard fallback with "Copied!" feedback, cuisine tags, stats row - CategoryNav — sticky top-14 horizontal-scrolling pills; IntersectionObserver (rootMargin: '-100px 0px -60% 0px') tracks active section; active pill auto-scrolls into view - PopularSection — top featured items; flex lg:grid lg:grid-cols-3 xl:grid-cols-4; shrink-0 w-[185px] lg:w-auto for mobile carousel / desktop grid duality - MenuItemCard — aspect-[4/3] image, in-card −/qty/+ controls replace add button when item is in cart, animated quick-add with Check feedback; each card is a Link to item detail page - RestaurantSidebar — desktop sticky (sticky top-28), hidden on mobile; SidebarHoursWidget with live status, today's hours, expandable weekly schedule; About card; Quick Actions grid (Call / Directions / Share / Save) - MobileInfoSection — description/address/phone + hours accordion; shown only below menu on mobile (lg:hidden) - MobileCartBar — fixed bottom-0 lg:hidden; shows total items + price; dispatches bazar-open-cart to open header cart drawer - bg-gray-50 page background for Uber Eats-style card contrast - nameGradient(name) + initials(name) helpers — text avatar with consistent per-restaurant colour derived from name hash - handleUpdateQty(productId, delta) reads live inCart() inside callback to avoid stale-closure on fast taps - Favorites state from single /api/bazar/auth/me call on mount; optimistic toggle reverts on API error - PageSkeleton updated to match new 2-column layout

    • Food Bazar Phase 4 — Opening hours, offer timers, quick-add, cart UX (task 70)

    - SpecialDay interface in schema; specialDays?: SpecialDay[] added to TenantSettings; endDate?: string added to BazarItemSettings - src/lib/bazar/opening-hours.ts — getOpenStatus() returns live open/closed/closing-soon status with countdown; getWeekSchedule() for weekly schedule rendering; formatOfferCountdown() for smart countdown labels (days/hours/minutes); handles midnight closing (00:00 = 1440 min end-of-day), special day overrides, recurring (MM-DD) and one-time (YYYY-MM-DD) holiday entries - Luigi's Pizzeria opening hours updated per spec (Mon–Wed 10–22, Thu 10–23, Fri 10–00, Sat 11–00, Sun 12–21) plus specialDays: Midsummer Eve (closed), Christmas Eve (closed), Christmas Day (14–20 special hours), New Year's Day (14–22 special hours) - Restaurant API (/api/bazar/restaurant/[slug]) now returns specialDays[] and per-item endDate - Item detail page layout reordered: image → Add-to-Cart → product name/details (previously Add-to-Cart was below all details) - OpeningHoursWidget on item detail page (before "You might also like"): live open/closed/closing-soon status dot + label, today's hours, services row (delivery/pickup/dine-in), full weekly schedule accordion, upcoming holiday notices within 14-day window - RestaurantWidget (right sidebar) upgraded to use getOpenStatus() — now shows amber "closing soon" state - Quick add "+" button on menu page item cards: ItemCard is now a Link to the item detail page; + button overlaid on image (hover-visible on desktop, always-visible on mobile), animated Check feedback on tap; onQuickAdd prop pattern avoids per-card hook calls - useBazarCart.addItem dispatches bazar-open-cart custom event after every add - BazarHeader listens for bazar-open-cart event and opens cart drawer automatically - CartDrawer undo system: "Clear cart" takes a snapshot, calls clearCart(), shows dark undo toast for 10 s; clicking "Undo" restores all items with exact quantities; timer is cleaned up on unmount - CountdownBadge reused on item cards and item detail Add-to-Cart panel for live offer expiry display

    Fixed

    • Infinite spinner on `/dashboard/food-bazar` (tenant offer management page): Page loaded but spun forever with no error shown. Root cause was a fetch('/api/tenant/${tenantId}/menu') call to a non-existent endpoint (only /menu/products and /menu/categories exist). The 404 returned HTML, causing menuRes.json() to throw a SyntaxError, which set the error state — but the spinner guard (if (!settings) return <Spinner>) ran before the error UI, producing a permanent spinner. Fix: changed to parallel fetch of /menu/products + /menu/categories, mapped products with basePrice ?? price, and added an if (error && !settings) return <ErrorScreen> guard before the spinner check. Also added RefreshCw import to fix the missing icon.
    • Branch creation system — 4 bugs fixed (task 65):

    - Feature gate inconsistency (primary blocker): GET /api/tenant/[tenantId]/branches had no feature gate check while POST did. This caused the branch management UI to always render (page loaded fine), but branch creation failed with an unhelpful 403 "Multi-location add-on not enabled" error inside the form modal. Fixed by adding the same tenantHasFeature('multi-location') check to GET — the page now correctly shows the upgrade wall when the add-on isn't active. - Partial branch creation / dangling master record: The POST handler saved the new branch to master.tenants before calling createTenantDatabase. If createTenantDatabase failed (template.json missing, disk full, etc.), the master DB had a dangling branch record pointing to a non-existent tenant DB file. Fixed by swapping the order: createTenantDatabase now runs first; if master DB save subsequently fails, the just-created tenant DB file is deleted as rollback. - HQ branch missing from list after first creation: handleBranchCreated did an optimistic append of just the new branch. On first-ever branch creation the current tenant gets branchRole:'headquarters' and organizationId written in the backend — the frontend optimistic state never reflected this, so the HQ card never appeared. Fixed by calling load() after branch creation to fetch the full, authoritative branch list from the server. - Missing feature gate on analytics and copy-menu routes: GET /api/tenant/[tenantId]/branches/analytics and POST .../[branchId]/copy-menu both lacked the tenantHasFeature check, allowing data access by tenants without an active multi-location subscription. Both routes now return 403 when the add-on is not enabled. - Modal error UX — billing link on feature gate error: When branch creation returns the "not enabled" error, the modal now shows a "Enable Multi-Location from Billing →" link pointing to /dashboard/billing rather than a plain red error text. - Orphan cleanup added to branch POST: Added same cleanup logic as restaurants/create — if a ${branchId}.json file exists without a master DB record, it's deleted before attempting creation. Prevents spurious "already exists" errors on retry.

    • Notification dropdown goes behind page content (z-index fix): The <header> element has sticky top-0 z-10, which creates a CSS stacking context. Any child — even at z-50 — can only compete globally at the header's own z-10. Page elements (modals, sticky table heads, editors, sidebars) with z-index ≥ 10 could therefore overlap the dropdown. Fix: the notification panel is now rendered via createPortal directly into document.body with position: fixed and z-[9999], completely outside the header's stacking context. Positioning is computed from the bell button's getBoundingClientRect() so the panel always aligns correctly. On mobile (< 640 px) it renders as a full-width strip; on desktop it right-aligns under the bell.
    • Notification badge count bouncing after mark-as-read: The 30-second poll called setLocalReadIds(new Set()) unconditionally, wiping optimistic read state before the server PATCH was confirmed. If the poll and the PATCH raced, the badge briefly reverted to the old (stale) unread count. Fix: fetchNotifs now selectively prunes the optimistic set — only removing IDs the server has confirmed as read — so in-flight PATCHes are never overwritten by a concurrent poll.
    • Read notifications not visually disappearing: The notification panel showed all 20 notifications (read + unread mixed) whenever any unread existed, so marking one read had no visible effect on the list. Fix: the panel now shows only unread items in the active list. A subtle footer shows "N earlier notifications already read" so users know older items exist. When the last unread is cleared the "All caught up!" state appears. This makes each click feel immediately responsive.
    • Notification panel: ESC key, scroll-close, ARIA roles: ESC now closes both the notification and help panels (previously ESC only closed the help panel). The notification panel closes automatically when the user scrolls or resizes the window. aria-haspopup="dialog", aria-expanded, and aria-label added to the bell button and panel for accessibility.
    • Back button 404 on subdomain tenant pages: account/page.tsx hardcoded basePath = /tenant/${tenant_id}, which caused subdomain navigation links to generate double-prefixed paths (/tenant/id/tenant/id/menu) → 404. Fixed by detecting subdomain access via usePathname(): if pathname starts with /tenant/, use full base path; otherwise use root-relative ''.
    • Contact nav link does nothing on non-home pages: Default nav href: '#contact' is a same-page anchor that only works on the homepage. Changed to href: '/#contact' so it always navigates to the home page and scrolls to the contact section, from any page.
    • Language switcher not persisting across navigation: Two issues fixed: (1) clicking a nav item dropped the ?lang= param from the URL; (2) the language preference was not saved across sessions. Fixes: SiteHeader now appends ?lang=currentLang to all nav item hrefs (desktop + mobile + Book CTA + logo) via a withLang() helper that correctly handles hash fragments. SiteHeader also reads a preferred-lang cookie as fallback (priority: URL param > cookie > tenant default). LanguageSwitcher writes preferred-lang cookie on every language click (1-year TTL, SameSite=Lax).

    Added

    • Food Bazar marketplace — Phase 3 Enterprise Enhancement (tasks 67–68 + geo/auth/analytics):

    - Analytics tab in tenant Food Bazar dashboard (src/app/(dashboard)/dashboard/food-bazar/page.tsx): New AnalyticsTab component behind 'analytics' tab. Time range selector (Today / Yesterday / 7 Days / 30 Days / Custom with date pickers). Two rows of KPI cards (Impressions, Clicks, Avg CTR, Conversions, Total Spend, Revenue, Overall ROI). SVG trend chart with metric toggle (impressions / clicks / conversions), area-fill gradient, gridlines, x-axis date labels. Traffic source breakdown with horizontal progress bars (Marketplace / Direct / Search / Social / Campaign). Per-campaign breakdown cards with 6-stat grid + spend progress bar. Fetches from new /api/tenant/[tenantId]/food-bazar/analytics endpoint. - PromoteWizard upgraded (same page): 7-step flow replaces 6-step flow. Step 3 (Duration) now shows Standard (≤ 2 days, included) vs Weekly Premium (7 days, 999 SEK) tier selector. Step 4 (Geo Radius) new — range slider 5–100 km with 6 preset buttons and smart targeting info box. Step 7 (Launch) now shows tier, radius, and weekly upgrade fee in the summary. targetRadius and campaignTier passed to API. - BazarUser auth system (src/lib/bazar/bazar-auth.ts new): Marketplace-level accounts (separate from per-tenant customers). HS256 JWT in bazar_user_session httpOnly cookie (30-day TTL). PBKDF2-SHA512 password hashing. getBazarSession(req) for session reads. - Bazar auth APIs (all new): POST /api/bazar/auth/register (email+password+name+GDPR), POST /api/bazar/auth/login, POST /api/bazar/auth/logout, GET /api/bazar/auth/me. - Bazar user APIs (all new): GET+PATCH /api/bazar/user/preferences, GET+POST /api/bazar/user/favorites (toggle), PATCH /api/bazar/user/location (geocoded or direct lat/lng). - BazarHeader account + notifications (src/components/bazar/BazarHeader.tsx): Added LoginModal (Sign In / Register tabs, GDPR checkbox, error handling). Added AccountMenu (user initial avatar, dropdown with My Account / Favourites / Sign Out). When logged out shows user icon that opens LoginModal. When logged in shows orange avatar initial + notification bell linking to /bazar/account. Session loaded from /api/bazar/auth/me on mount. - `/bazar/account` page (src/app/(bazar)/bazar/account/page.tsx new): 4-tab account portal — Profile (details, saved location, GDPR consent date), Favourites (restaurant cards with Visit/Remove), Preferences (offer subscriptions, notification radius slider, preferred cuisines chips, save), Notifications (email/push/campaign notification toggles, SMS coming-soon, GDPR rights text, save). - Admin Geo Analytics tab (src/app/admin/dashboard/food-bazar/page.tsx): New GeoTab component behind 'geo' tab. KPI cards (Total Approved, Geocoded, Missing, Coverage%). Warning banner when GOOGLE_MAPS_API_KEY is missing. Coverage progress bar. "Run Geocoder" button (POST /api/admin/food-bazar/geocode). Result panel showing new/cached/failed/total with API-savings note. Per-restaurant table with tenant, coordinates, GeoHash, and geocoded/missing status badge. - Geocoding API (GET+POST /api/admin/food-bazar/geocode new): GET returns coverage stats. POST geocodes all approved restaurants (or one by tenantId); uses addressHash for cache invalidation; skips gracefully without GOOGLE_MAPS_API_KEY. - Geo library (src/lib/bazar/geo.ts new): Haversine distance, GeoHash encoder/decoder/neighbours, radiusGeoHashSet, Google Maps geocoding with cache, hashAddress for change-detection, filterByRadius with sort. - Smart scoring engine (src/lib/bazar/scoring.ts new): 0–100 composite score: distance (0–30), cuisine match (0–25), bid (0–20), favourite restaurant bonus (0–15), urgency/offer countdown (0–10), engagement (0–10). - Offer countdown badges (src/components/bazar/CountdownBadge.tsx new): Client component updating every 30 s. Red (<6 h), orange (6 h–48 h), green (2+ days). Pulses when orange or red. Integrated in FoodCard (homepage), restaurant listing, and item detail pages. - Sponsored ranking API with geo (src/app/api/bazar/sponsored/route.ts rewrite): GET accepts lat/lng/radius/userCuisines params. GeoHash pre-filter (9-cell bucket, precision-5) → Haversine precise check. Scores via scoreItemForUser, returns endDate + targetRadius + distanceKm. Fire-and-forget daily stats increment. POST click tracking with sourceBreakdown. - Tenant analytics API (GET /api/tenant/[tenantId]/food-bazar/analytics new): Time ranges (today/yesterday/7d/30d/custom). Per-campaign stats with daily trend lines. Aggregate summary (CTR, CVR, ROI). Source breakdown. Aggregate trend. - Weekly upgrade API (POST /api/tenant/[tenantId]/food-bazar/sponsored/upgrade new): Extends campaign to startDate+7d, sets campaignTier: 'weekly', records 999 SEK billing event in tenant DB.

    • Food Bazar marketplace — Phase 2 Platform Redesign:

    - `src/hooks/useBazarCart.ts` (new): Lightweight localStorage-based cart hook with addItem, removeItem, updateQty, clearCart, inCart, totalItems, totalPrice. Uses CustomEvent('bazar-cart-update') for cross-component sync without a context provider. - `BazarHeader` enhanced (src/components/bazar/BazarHeader.tsx): Added right-side actions — shopping cart icon with live count badge (reads from useBazarCart), user account icon, language selector dropdown (EN/SV/AR with flag + local label). Added slide-in CartDrawer component with item list, quantity +/− controls, subtotal, and clear cart. Search autocomplete now links items to the new product detail URL (/bazar/restaurant/[slug]/item/[productId]). - Product detail page (src/app/(bazar)/bazar/restaurant/[slug]/item/[productId]/page.tsx) (new): Two-column desktop layout — left column has full product image hero with overlay badges (discount %, halal, featured), dietary tags section, allergen grid with emoji icons, spice level visual indicator, meat type, custom badges, price/offer price with discount %, quantity selector, Add to Cart button (updates to quantity controls when item already in cart); right column has sticky restaurant widget (banner, logo, open/closed status, delivery/pickup/dine-in, min order, address, phone, description, "See Full Menu" CTA). Mobile renders stacked with restaurant widget at bottom. "You might also like" section shows up to 4 related items. Full breadcrumb navigation. - Bazar homepage FoodCard (src/app/(bazar)/bazar/page.tsx): Quick-add (+) button now wired to useBazarCart.addItem(). Shows item count when already in cart (orange), shows ✓ flash on first add (green). Imported useBazarCart hook. - Admin sponsored management tab (src/app/admin/dashboard/food-bazar/page.tsx): New SponsoredTab component and sponsored tab added. KPI strip shows active placements, total revenue, impressions, clicks. Full management table with columns: restaurant/item, bid/day, budget, spent, date range, days remaining, views, clicks, CTR%, CVR%, status badge, and action buttons (pause/activate, delete). Inline create-placement form with restaurant dropdown (approved only), menu item dropdown (loaded from public bazar API), bid amount, total budget, start/end date with duration/estimate preview. Auto-filters to approved restaurants only. Uses the existing /api/admin/food-bazar/sponsored CRUD API. - `SponsoredItem` schema + `FoodBazarDB.sponsoredItems` (added in prior session): Full bidding system with bidAmountPerDay, totalBudget, spentAmount, impressions, clicks, conversions, auto-expire on read. - `/api/admin/food-bazar/sponsored` (added in prior session): GET/POST/PATCH/DELETE endpoints for sponsored placement management, all gated by requireSuperAdmin. - `/api/bazar/sponsored` (added in prior session): Public GET endpoint returning active sponsored items sorted by bid desc, with impression counting. - `/api/tenant/[tenantId]/food-bazar/sponsored` (new): Tenant-scoped sponsored campaign CRUD — GET returns own campaigns enriched with product name/image/price, computed analytics (CTR, CVR, ROI, daysRemaining), and live global rank position. POST validates min-5-SEK bid, bazar-enabled, restaurant-approved, item belongs to tenant, syncs offerPrice to BazarItemSettings for public menu display, and uses compositeId = tenantId::menuItemId to replace any active campaign for the same item. PATCH updates status (pause/activate) or bid on own campaigns only. DELETE removes own campaign only. All verbs perform tenantId ownership checks. - `/api/bazar/sponsored/ranking` (new): Public competitive ranking preview. Takes bid, tenantId, menuItemId query params. Excludes the proposing restaurant's own existing campaign (so they can update without competing against themselves). Returns position, totalActive, aboveYou, belowYou, topBid, lowestBid, avgBid, visible (position ≤ 20), isFull, slotsFilled, maxSlots: 20, willDisplace, and smart warnings[] (e.g. "Bid 3 SEK more to reach top 5", "This category is highly competitive"). No auth required — returns only aggregate competitive data, no PII. - Tenant Offer Management System (src/app/(dashboard)/dashboard/food-bazar/page.tsx) (complete rewrite): Transformed from a simple settings page into a premium 4-tab offer management center. Overview tab: KPI cards (active campaigns, total spend, total impressions, avg CTR), marketplace status banner, top-performing campaign card, quick-action shortcuts. Campaigns tab: searchable/filterable campaign cards with analytics rows (impressions, clicks, CTR%, CVR%, ROI%), budget progress bars, days-remaining badges, pause/resume/delete actions. Menu tab: full product grid with per-item Promote button; active campaign indicator badge on items that already have a running campaign. Settings tab: preserved existing bazar enrollment settings form. `PromoteWizard` 6-step modal: (1) Item confirmation with warnings if restaurant not approved or item already has active campaign; (2) Offer price input with live discount-% calculator; (3) Duration presets (1/3/7/14/30 days) + custom dates with start/end preview; (4) Bid input with preset chips (5/10/15/20/30 SEK) + total cost calculator (bid × days); (5) Live ranking preview — calls /api/bazar/sponsored/ranking, renders 20-slot visualizer (boxes, your position highlighted orange), competitor stats, inline bid adjuster; (6) Full campaign summary + Launch Campaign button.

    • Built-in food media library with stock photo gallery (task 66):

    - Schema extension (`MediaItem`): Added title, alt, tags, category, isFavorite, source (MediaSource union: 'upload' | 'pexels' | 'unsplash' | 'demo'), sourceId, credit, creditUrl, width, height. All new fields are optional or have defaults for full backward compatibility. - `src/lib/media/stock-search.ts` (new): Shared stock photo utility. FOOD_GALLERY_CATEGORIES — 19 food categories (pizza, burgers, sushi, pasta, desserts, drinks, bakery, bbq, seafood, vegan, breakfast, indian, chinese, thai, sandwiches, raw ingredients, restaurant, salads, all). searchPexels / searchUnsplash API callers. getDemoPhotos — 40 scored demo entries for dev/no-key fallback. searchStockPhotos unified with Pexels → Unsplash → demo fallback chain. - `GET /api/tenant/[tenantId]/media/gallery` (new): Returns stock photos with category+search+pagination. Reads imageApiSource/imageApiKey from tenant settings; falls back to PEXELS_API_KEY / UNSPLASH_ACCESS_KEY env vars. Returns photo list + categories array + source label. - `POST /api/tenant/[tenantId]/media/import` (new): Imports a stock photo by saving its CDN URL directly into the tenant media library (no download). Validates source ∈ ['pexels','unsplash','demo']. Deduplicates by sourceId — returns { media, duplicate: true } on re-import. - `PATCH /api/tenant/[tenantId]/media/[mediaId]` (new): Metadata editor — accepts title, alt, tags, category, isFavorite. - `GET /api/tenant/[tenantId]/media` updated: Backfills default values for all new fields on legacy records so old uploaded images appear correctly in the new UI. ...m spread now precedes defaults (fixed duplicate-key TS error). - `POST /api/tenant/[tenantId]/media` updated: Saves title (derived from filename), alt: '', tags: [], isFavorite: false, source: 'upload'. Max file size raised 5 MB → 10 MB. - `DELETE /api/tenant/[tenantId]/media/[mediaId]` updated: Only unlinks disk file when source === 'upload'; CDN URLs (pexels/unsplash/demo) are skipped. - `/dashboard/media` full rewrite: Two-tab page — My Library (search by title/filename/tags/alt, source filter dropdown, favorites toggle, skeleton loading, grid with hover overlay, star/unstar, detail panel with title+alt+tags editor, copy-URL, delete for uploads only, attribution display for stock) and Food Gallery (19 category pills, debounced search, photo grid, "Add to Library" button per photo, imported-in-session badge, load-more pagination, license notice). - `MediaPicker` enhanced: "Image Bank" modal now has two tabs — My Library (existing behavior preserved) and Food Gallery (category pills, debounced search, photo grid; clicking imports the photo via POST /media/import and immediately calls onChange(url) to select it). Category pills load from the gallery API. In-session importedIds Set shows a green checkmark on already-added photos. - `MediaUploader` updated: Now imports MediaItem type from schema (fixes TS incompatibility with media/page.tsx). Max size validation updated to 10 MB.

    • Add New Restaurant — Billing Confirmation + Trial + Onboarding Wizard (task 64):

    - `GET /api/restaurants/plan-info`: New endpoint returning the active PlanDefinition (monthly/annual prices, trial days, currency, badge label) and BillingConfig (trial requires payment method, grace period, auto-renewal) for use in the billing confirmation step. Falls back to sensible defaults (Basic 299 SEK/month, 30-day trial) when no plans are configured in master DB. - `POST /api/restaurants/create` extended: Now requires confirmedBilling: true in the request body (400 if absent). Accepts useTrial: boolean — if true and the active plan has trialDays > 0, the subscription is created with status: 'trialing' and endsAt = now + trialDays; otherwise status: 'active' with a 1-year endsAt. Subscription amountPaid is 0 for trials, annualPrice for active. Response includes status and trialEndsAt fields. - Multi-step wizard UI (src/app/(dashboard)/dashboard/restaurants/page.tsx): Replaced the flat single-step modal with a 4-step guided wizard: - Step 1 — Billing & Trial: Amber transparency notice ("each restaurant billed separately"), plan pricing card showing monthly + annual prices with savings%, auto-renew note. If trialDays > 0: radio-style cards to choose "Start with free trial" vs "Skip trial — start paid"; if no trials: plain "subscription starts today" notice. "I understand — Continue" CTA. - Step 2 — Restaurant Info: Name (required), business type dropdown, subdomain (auto-generated from name, inline format feedback showing subdomain.localhost). - Step 3 — Contact: Phone number + address textarea, marked optional with "update anytime" hint. - Step 4 — Review & Create: Summary table of all entered details + subscription summary card (green for trial, blue for paid), Create Restaurant button. - Step progress indicator with filled/ring/grey states and divider lines. Error banner renders inside the wizard at the top of the current step. Success toast on the main page after creation, including "Your free trial has started." when applicable.

    • Super Admin Monetization Control Center (task 63):

    - Schema (src/lib/db/schema.ts): Added ModuleStatus union type (active | beta | coming-soon | invite-only | deprecated | hidden). Added ModuleOverride interface — per-feature admin overrides for pricing, visibility, featured/recommended flags, help text, docs URL, setup wizard toggle. Added Promotion interface with type (percentage | fixed-amount | free-months), value, duration, feature scope, start/end dates, seasonal flag, campaign tags, redemption tracking. Added BillingConfig interface — global billing rules: grace period, auto-renewal, proration, failed-payment retry schedule, trial payment method requirement, invoice due days. Extended MasterDB with moduleOverrides?, promotions?, billingConfig?. - Module resolver (src/lib/features/module-overrides.ts): resolveModule() — merges FEATUREREGISTRY base with stored `ModuleOverride` (override wins on pricing/status/flags). `resolveAllModules()` — resolves all registry features. `computeModuleStats()` / `computeAllModuleStats()` — computes live subscriber counts (active/trialing/pending/canceled/expired), MRR, and trial-to-active conversion rate from subscription data. `resolveTrialDaysForModule()` — merges admin trial config with `DEFAULTTRIAL_DAYS fallback. buildCategoryMeta() — category list with counts for filter UI. - **Modules API** (GET/PATCH /api/admin/monetization/modules): GET returns all 50+ registry features enriched with resolved pricing, override metadata, live subscription stats, and trial config. PATCH accepts { updates: [...] } array for bulk pricing/status/visibility changes. - **Single Module API** (GET/PATCH /api/admin/monetization/modules/[featureId]): GET returns one resolved module with full stats. PATCH updates or creates a ModuleOverride for that feature. - **Promotions API** (GET/POST /api/admin/monetization/promotions + PATCH/DELETE .../[promotionId]): Full CRUD for platform promotions. GET auto-deactivates expired promotions on read. Promotions track redemption count, support selective feature scoping, and carry campaign tags. - **Billing Rules API** (GET/PATCH /api/admin/monetization/billing-rules): Reads/writes global BillingConfig. GET returns config merged with sensible defaults (gracePeriodDays: 3, autoRenew: true, prorationEnabled: true, retry days [1,3,7], invoiceDueDays: 14). - **Revenue Insights API** (GET /api/admin/monetization/insights): Aggregates across all tenant subscriptions: total MRR, active/trialing counts, overall trial conversion rate, churn count (last 30 days). Returns per-feature revenue table sorted by MRR, 12-month MRR trend (estimated from billing dates), top-10 modules, and 30 most-recent subscription events with tenant names. - **Monetization page** (/dashboard/admin/monetization, 6 tabs): - **Modules tab** — filterable grid (search + category + status + plan filter) of all 50+ modules as cards. Each card: category badge, status badge, featured/recommended chips, name, description, monthly/annual/trial prices, live subscriber count, MRR, conversion rate. Quick-action menu: Edit, Show/Hide, Add Promotion. Edit slide-over panel with 4 sub-tabs (General/Pricing/Visibility/Onboarding) for changing status, prices with annual savings calculator, visibility toggles (public/featured/recommended/setup wizard), help text, and docs URL. - **Pricing tab** — full-width editable table of all paid features with inline price inputs for monthly/annual, annual savings % column, unsaved changes highlighted, bulk save with count badge, discard button. - **Trials tab** — card grid of paid modules with enable/disable toggle and day-range slider (1–90 days). Live preview text shows trial vs no-trial message. Unsaved changes tracked per module, bulk save. - **Promotions tab** — card grid of promotions showing discount amount, type, applies-to, redemption count, start/end dates, status badges (Active/Scheduled/Expired/Inactive). Create/Edit modal with type selector (percentage/fixed/free months), value, duration, multi-feature checkbox list, start/end dates, seasonal flag. Delete with confirmation. - **Billing Rules tab** — single-column settings page: grace period slider (0–14 days), invoice due days slider (7–30), auto-renewal/proration/trial-requires-payment-method toggles, failed-payment retry schedule text input (comma-separated days). - **Revenue Insights tab** — 5 KPI tiles (MRR, Active, Trialing, Conversion%, Churn 30d), 12-month MRR bar chart, top modules by revenue table with inline bar charts and conversion rate badges, recent subscription activity feed with colored status dots. - **Sidebar**: Added "Monetization" (DollarSign icon) to Platform Admin group → /dashboard/admin/monetization`.

    • Complete Add-on Purchase, Trial, Billing & Feature Activation System (task 62):

    - Schema (src/lib/db/schema.ts): Added AddonSubscriptionStatus union type (trialing | active | pending-cancellation | canceled | expired | suspended | failed). Added AddonSubscription interface with full lifecycle fields: trial dates, billing period dates, cancellation dates, activatedBy, notes, createdAt/updatedAt. Added AddonTrialConfig interface (per-feature trial config: days, enabled, promotionalUntil, notes, updatedBy). Extended MasterDB with addonSubscriptions? and addonTrialConfigs?. Extended TenantDB with newFeatureHighlights?: Record<string, string> (featureId → activatedAt ISO) powering sidebar NEW badges. - Subscription state machine (src/lib/addons/subscription.ts): resolveTrialDays() — merges admin-configured trial days with per-feature DEFAULT_TRIAL_DAYS fallbacks. activateAddon() — creates AddonSubscription (trialing if trialDays > 0, else active immediately), writes/updates FeatureGrant, marks newFeatureHighlights in TenantDB. cancelAddon() — trial → immediate cancel + grant revoked; active → pending-cancellation with cancelEffectiveAt = end of current period. sweepAddonSubscriptions() — transitions expired trials and pending cancellations, revokes FeatureGrant.enabled on expiry/effective-date. buildInvoicePreview() — always shows full price with trial discount as a negative line item (never hides real pricing). buildTrialEndingSoonMessage() — human-readable trial warning. getAllTrialConfigs() — merges saved configs with defaults for all features. - Billing API rewrite (GET/POST/DELETE /api/tenant/[tenantId]/billing/addons): GET returns enriched add-on list with isActive, live subscription object, and trialDays per feature. POST calls activateAddon(), returns subscription + trial info. DELETE calls cancelAddon(), returns updated subscription. - Add-on detail API (GET /api/tenant/[tenantId]/billing/addons/[featureId]): Returns feature info, trialDays, trialEndsAt, billingStartsAt, firstInvoiceAt, full confirmationInvoice (with line items), and existingSubscription — used by confirmation modal before activation. - Highlights API (GET/DELETE /api/tenant/[tenantId]/billing/highlights): GET returns { highlights: db.newFeatureHighlights }. DELETE removes specified featureIds from newFeatureHighlights. - Admin analytics API (GET /api/admin/addons): Aggregates all subscriptions across all tenants; returns per-feature MRR, active/trialing/pending/canceled/expired counts, trial-to-active conversion rate, and 20 most-recent subscriptions. - Admin trial config API (GET/PATCH /api/admin/addons/trials): GET returns all trial configs merged with defaults. PATCH accepts { updates: [...] } array to bulk-update per-feature trial days, enabled state, promotional end date, and notes. - Cron sweep route (GET /api/cron/addon-billing): Secured via x-cron-secret header or ?secret= param. Calls sweepAddonSubscriptions(). Scans trialing subscriptions for daysLeft === 3 and 1; queues trial-ending-soon notifications with dedup key to prevent duplicate alerts. - Billing page full rewrite (/dashboard/billing): ConfirmModal — fetches live pricing from detail API, shows full invoice with trial discount line, trial period callout, ShieldCheck transparency guarantee, "Start Free Trial" vs "Confirm & Activate" button. CancelConfirmModal — different wording for trial (immediate cancellation) vs active (end of period). ActiveAddonCard — trial countdown with progress bar (red when ≤3 days), pending-cancellation amber banner, expandable details accordion, cancel button. Marketplace — "Try Free" badge with trial days, or "Add" button for no-trial features. - Sidebar NEW badge system: HighlightsProvider React context (src/lib/addons/highlights-context.tsx) — loads highlights from API on mount, clearForRoute() removes badge from state + calls DELETE API. DashboardProviders client wrapper (_providers.tsx) bridges server layout to client context. SidebarNavigation — NavLink shows pulsing animated indigo dot + "New" text when isNew=true; group header shows dot when collapsed and has new items inside. FEATURE_TO_ROUTE / ROUTE_TO_FEATURE maps wire featureId → sidebar href. - Admin add-on management page (/dashboard/admin/addons, 3 tabs): Analytics — per-feature revenue bar chart, active/trialing/pending/canceled/expired counts, conversion rate. Trial Config — TrialConfigEditor with per-feature enable checkbox + days input + preview badge, bulk save/reset. Recent Subs — table of last 20 subscriptions across all tenants with status badges.

    • Multi-Location / Branch Management (+39 SEK/month add-on — task 61):

    - Schema (src/lib/db/schema.ts): Added Organization interface (id, name, ownerId, createdAt, updatedAt). Added BranchSettings interface (displayName, address, phone, email, color, isActive, openedAt, notes). Extended TenantRecord with organizationId?, branchRole?: 'headquarters' | 'branch', branchSettings?: BranchSettings. Added organizations?: Organization[] to MasterDB. - Feature registry (src/lib/features/registry.ts): multi-location at 39 SEK/month / 390 SEK/year, category operations, planInclusion: 'addon', perTenantToggle: true, hasUsageLimits: true. - Branches API (GET/POST /api/tenant/[tenantId]/branches): GET returns { organization, branches } where each branch includes live stats (ordersToday, revenueThisMonth, staffCount) fetched in parallel from each tenant DB. POST creates a new organization if none exists (marks the requesting tenant as HQ), generates a slug-based subdomain, bootstraps a new TenantDB via createTenantDatabase, returns the new TenantRecord. - Single Branch API (GET/PATCH/DELETE /api/tenant/[tenantId]/branches/[branchId]): GET returns one branch. PATCH updates branchSettings fields + top-level name. DELETE soft-deactivates (sets isActive=false; HQ and self cannot be deactivated). - Copy Menu API (POST /api/tenant/[tenantId]/branches/[branchId]/copy-menu): Copies categories and/or products from HQ to a branch. Modes: all (default) / categories-only / items-only. Items-only auto-copies categories if target has none. - Analytics API (GET /api/tenant/[tenantId]/branches/analytics): Reads all org branch DBs in parallel; returns per-branch stats (ordersToday, revenueThisMonth, revenueTotal, ordersTotal, avgOrderValue, top 5 items) plus org-wide totals. - `/dashboard/branches` page (4 tabs): Branches — grid of branch cards with color dot, HQ/Current badges, contact info, quick stats (orders today / revenue / staff), "Copy Menu" and "Visit" actions, add/edit/deactivate controls. Analytics — org-wide KPI tiles + branch comparison table with revenue bars, per-branch top items chips. Shared Menu — branch selector + copy mode radio (all/categories/items), confirm button. Settings — organization details panel. Feature gate wall shown when add-on is not enabled (upgrade CTA with feature list + pricing). - Add Branch modal: Name (required), address, phone, email, color picker (10 swatches). Slugifies name into subdomain; appends numeric suffix if collision detected. Creates organization on first branch. - Edit Branch modal: Same fields as add + internal notes. Sends PATCH. - Sidebar: Added "Branches" nav item (GitBranch icon) to the Operations group in SidebarNavigation.tsx.

    • Sidebar navigation redesign — grouped accordion with mobile drawer (task 60):

    - Information architecture: Flat 28-item list reorganised into 6 primary items + 6 collapsible groups. Primary items (Overview, Orders, Reservations, Menu, Customers, Inventory) always visible at top. Groups: Marketing, Website & Content, Operations, Analytics & AI, Account, Platform Admin (superadmin only). - Accordion groups: Each group collapses/expands with smooth CSS grid-rows height animation (no janky max-height hacks). Group header shows its icon, label, and a rotating chevron. Active group is auto-expanded on load and on navigation. Open/closed state persisted to localStorage (restora_sidebar_groups). - Search: Inline search input at top of scrollable area. Pressing / focuses it from anywhere in the page; Escape clears and blurs. While typing, groups are replaced by a flat filtered list showing matching items with their group label underneath. No results message shown when nothing matches. - Active state: Active item has blue-50 background + blue-700 text + small blue dot indicator. Active group header shows tinted state. isItemActive() handles both exact matches (/dashboard) and prefix matches (/dashboard/orders/...). - Mobile drawer: Sidebar renders as a fixed slide-in drawer on screens < md. Backdrop overlay (black/40) closes on tap. X close button in drawer corner. Body scroll locked while open. Drawer auto-closes on route change. - Mobile trigger: MobileMenuButton component exported from SidebarNavigation, rendered inside TopHeader (mobile-only, fires restora:toggle-sidebar CustomEvent). TopHeader listens for the event. - Visual design: Width reduced from w-64 (256px) to w-60 (240px) on desktop. Logo row with "PRO" badge. User footer with avatar initials, name, email, logout button. Superadmin purple badge instead of amber. Group item icons in text-gray-400 default → text-gray-600 on hover → text-blue-600 when active. - No features removed: All 28 routes preserved. Role-based adminOnly groups still gated. tenantId disabled state still respected. RestaurantSwitcher unchanged.

    • Inventory UX Enhancement — Zero-navigation ingredient setup (task 59):

    - Inline category creation: Category dropdown in "Add Ingredient" now includes "➕ Add New Category" option. Opens InlineCategoryModal (z-60, over the ingredient form) with 15-emoji quick-picker + name field + live preview. Category is created via POST /inventory/categories, instantly selected, and available in all dropdowns — no page reload or form data lost. - Quick supplier creation: Supplier dropdown in "Add Ingredient" shows "+ Create new supplier" link when no supplier is selected. Opens InlineSupplierModal (z-60) with name/contact/phone/email fields. On save, supplier is created via existing API, optimistically added to the supplier list, and auto-attached to the ingredient — all without leaving the modal. - Custom Categories API (/api/tenant/[tenantId]/inventory/categories): GET/POST/DELETE for tenant-scoped custom categories stored in db.inventoryCustomCategories. Duplicate detection (case-insensitive). Loaded at page mount alongside ingredients. - Schema: Added InventoryCustomCategory interface (id, name, emoji, createdAt). Added inventoryCustomCategories?: InventoryCustomCategory[] to TenantDB. Changed IngredientItem.category from IngredientCategory to string to support custom values. Added isDemoData?: boolean to IngredientItem and InventorySupplier. Added address?: string to InventorySupplier. - Smart UX: IngredientModal pre-fills unit and category from localStorage (inv_last_unit, inv_last_category) on next open. Enter key submits the form. Inline validation with red border + error message under Name field. Unit label shown as suffix inside stock/threshold inputs. Sticky header and footer in modal. Selected supplier shows contact info inline with "× remove" link. - Demo / Sample Data system: POST /api/tenant/[tenantId]/inventory/demo seeds 5 demo suppliers + 10 ingredients + 6 stock movements, all flagged isDemoData: true. Idempotent (409 if already seeded). DELETE removes all demo-flagged items (ingredients, suppliers, movements, cleans up recipe mappings and supplier refs). Overview tab shows "Load Sample Data" CTA when inventory is empty (with description). Amber warning banner when demo data is present with "Remove Demo" button. "Sample" badge on demo ingredients and suppliers throughout. "Sample Data" button in page header when inventory is empty. - Demo data content: Chicken Breast (25 kg), Mozzarella Cheese (1.5 kg — already low), Tomato Sauce (15 L), Burger Bun (48 pcs), Coca Cola 33cl Can (48 cans), Olive Oil Extra Virgin (0.8 L — already low), Basmati Rice (50 kg), Frozen French Fries (20 kg), Iceberg Lettuce (8 kg), Yellow Onion (12 kg). Suppliers: Fresh Meat Supplier AB, Nordic Dairy Co., Stockholm Food Supply, Beverage Partner Sweden, Green Farm Vegetables. All with realistic SEK prices and storage notes. - Optimistic state updates: When a supplier is created inline, it is immediately pushed to the local suppliers state without waiting for a full loadAll(). Same for custom categories — no visible loading delay. - Category display: Category emoji shown in ingredient table rows. Custom categories show their chosen emoji. Built-in categories all have emojis (🥩🐟🥛🥦🌾🍺🌶️🫒📦🗂️). Category filter dropdown and all selects show emoji + label.

    • Full Inventory Management System (+99 SEK/month add-on):

    - Schema (src/lib/db/schema.ts): Added IngredientItem, StockMovement (types: purchase/order/manual-add/manual-remove/waste/correction), InventorySupplier, RecipeMapping, RecipeIngredient, PurchaseEntry. Added InventoryUnit (12 units: kg/g/l/ml/piece/bottle/box/pack/can/bag/slice/portion) and IngredientCategory (10 categories). Extended TenantDB with ingredientItems, stockMovements, inventorySuppliers, recipeMappings, purchaseEntries. - Feature registry (src/lib/features/registry.ts): inventory-management at 99 SEK/month, 990 SEK/year. - Billing add-on API (/api/tenant/[tenantId]/billing/addons): GET active add-ons; POST activates in dev mode by writing FeatureGrant directly to master DB; DELETE disables. Fixed billing page "Add" button which previously did nothing (called setBanner stub). - Ingredients API (/api/tenant/[tenantId]/inventory/ingredients): GET list; POST create. With [ingredientId] route: PATCH update any field; DELETE removes ingredient and cleans it from all recipe mappings. - Stock Movements API (/api/tenant/[tenantId]/inventory/movements): GET (filterable by ingredientId + limit); POST creates movement and updates currentStock — correction type sets stock directly to quantity value. - Suppliers API (/api/tenant/[tenantId]/inventory/suppliers): Full CRUD. DELETE clears supplierId from linked ingredients. - Recipe Mappings API (/api/tenant/[tenantId]/inventory/recipes): GET enriched with productName/ingredientName + unmappedProducts list; PUT upserts (empty ingredients array = delete); DELETE removes. - Purchase Entries API (/api/tenant/[tenantId]/inventory/purchases): GET newest-first; POST validates all ingredients, increases currentStock, logs StockMovement records with type 'purchase', saves PurchaseEntry. - Auto-deduction on orders (/api/public/[tenantId]/orders): When an order is confirmed, ingredient stock is deducted via recipe mappings. Each deduction logged as StockMovement with type 'order' and orderId reference. - Admin dashboard (/dashboard/inventory): Complete rewrite — 6-tab layout: - Overview: 4 KPI cards (total items, low stock, out of stock, est. value in SEK); low-stock alert list with +Stock quick action; recent 8 movements feed. Red badge on tab when alerts exist. - Ingredients: Search + category + status filters; table with stock level, threshold, status badge; ± Adjust / Edit / Delete per row. IngredientModal (add/edit full form). AdjustModal with 4 action types (Add Stock / Remove / Waste / Correct — correction sets new stock level directly). - Recipes: All products listed (mapped + unmapped); recipe editor modal with dynamic ingredient rows (dropdown + quantity + unit per row). - Movements: Full movement log filterable by ingredient and type; direction +/− indicators, type labels, before→after stock columns. - Purchases: Expandable purchase cards with items; PurchaseModal for logging supplier deliveries with auto-calculated total cost. - Suppliers: Simple CRUD list with SupplierModal. - Upgrade prompt shown to tenants without feature — 99 SEK/month, links to Billing & Plans. - Schema: Preserved backward-compatible Product.stockTracking/stockQuantity product-level tracking alongside new ingredient-level system.

    • Advanced Dietary, Allergen & Religious Menu Filters (task 57):

    - Schema: Added MenuFilterSettings and CustomMenuTag types. Extended Product with mayContainAllergens, dietaryTags, crossContaminationWarning, certificationNote. Added filterSettings to TenantDB. - Defaults library (src/lib/menu/dietary-defaults.ts): 14 standard EU allergens with icons, 21 dietary/lifestyle/religious tags (vegan, vegetarian, pescatarian, gluten-free, dairy-free, keto, low-carb, organic, healthy-choice, spicy, kids-friendly, halal, kosher, hindu-vegetarian, jain, buddhist-vegetarian, no-beef, no-pork, alcohol-free, seafood-free, fasting-friendly), DEFAULT_FILTER_SETTINGS, Tailwind badge class map, lookup helpers. - API — GET/PATCH /api/tenant/[tenantId]/menu/filter-settings: Admin reads and updates filter config (enabled allergens, dietary tags, custom tags, 4 disclaimer fields, show/hide toggles). - API — GET /api/public/[tenantId]/menu/filters: Public config endpoint — returns enabled allergens, dietary tags, custom tags, disclaimers. Returns { enabled: false } if disabled. - Admin ProductEditorModal: Added "Dietary & Allergens" third tab. Contains allergens (amber chips), may-contain allergens (orange chips), cross-contamination warning toggle, dietary/lifestyle tag chips, religious & cultural tag chips, certification note field. Badge count on tab when items are tagged. - Admin menu page: Added "Dietary & Allergen Filters" top-level tab with FilterSettingsPanel. Enable/disable entire filter system, show/hide filter bar, enable/disable individual allergens (All/None shortcut), enable/disable individual dietary tags per category (dietary/lifestyle/religious), custom tag builder (icon + label), 4 configurable disclaimer text areas. Save button with success feedback. - Public MenuClient.tsx: Full filter bar replacing old basic allergen filter. Search box + Allergens exclude button (expandable panel) + Dietary include button (expandable panel with 3 categories). Active filter chips summary row. Combined filter logic: text search AND allergen exclusion AND all required tags must match. Instant client-side filtering. Item badges: dietary tag chips with color coding, allergen "contains" badges, "may contain" badges (distinct style), cross-contamination warning badge. Disclaimers section at page bottom. Filter config fetched once from public API on mount.

    • Advanced Guest Requirements, Special Occasions & Add-on Services (task 56):

    - Schema: Added ReservationExtraOption, SelectedExtra, SpecialOccasion, ReservationAddOn, SelectedAddOn, DecorationPackage types. Extended ReservationSettings with extraOptions, occasions, addOns, decorationPackages, showExtrasToCustomers, showOccasionToCustomers, showAddOnsToCustomers. Extended Reservation with occasion, occasionLabel, selectedExtras, selectedAddOns, addOnsTotal, isVip, decorationPackageId. - Defaults library (src/lib/reservations/defaults.ts): 22 predefined extra options across 5 categories (children, accessibility, seating, dining, other), 10 special occasions with prep checklists, 10 add-on services, 5 decoration packages, and mergeWithDefaults<T>() merge utility. - API — GET /api/public/[tenantId]/reservation/config: New public endpoint returning booking settings, filtered+enabled extras/occasions/add-ons for customer wizard. Separate from availability endpoint (static config, loaded once). - Settings PATCH: Automatically seeds all defaults on first save (when arrays are empty/absent), then mergeWithDefaults() on subsequent saves to preserve restaurant overrides while picking up new defaults. - Customer booking wizard redesigned to 6-step flow: Guests → Date → Time → Special Touches (new) → Contact → Confirm. Extras step auto-skips if restaurant has nothing configured. Occasion chips with color coding, category expandable sections for requirement options, add-on cards with quantity controls. Submission includes full extras/occasions/add-ons payload. - Admin Prep View (new tab in reservations dashboard): Filter by Today/Tomorrow/Next 7 days. Per-reservation cards show occasion badge + prep checklist (checkboxes), requirements chips, add-on cards with pricing, staff notes. "Mark prep done" button turns card green. Prep tab shows today's count badge. - Admin Settings: 4 sub-tabs — Booking Rules | Occasions | Requirements | Add-ons. Each with show/hide toggle (customer visibility) and per-item enable/disable. Add-ons tab includes price input, staff instructions, showToCustomers flag. - List view enhanced: inline occasion badge + extras/add-ons chips per reservation row. - Activity log enriched: includes occasion label, extras list, add-on names in log line.

    • Reservation System Redesign (task 55):

    - Schema: Added BlockedSlot, ReservationSettings, and SlotMode types to schema.ts. Extended Reservation with noShow, tableId, source, and staffNotes fields. Added reservationSettings and blockedSlots to TenantDB. - API — GET/PATCH /api/tenant/[tenantId]/reservations/settings: Read and update reservation configuration (slot mode, capacity, booking rules, auto-confirm, messages). - API — GET/POST/DELETE /api/tenant/[tenantId]/reservations/blocked: Manage time blocks (full day, date range, specific slot, recurring weekday with optional time window). - API — GET /api/public/[tenantId]/reservation/availability: Returns available time slots for a given date, respecting blocked slots, advance booking rules, and per-slot capacity. Returns public booking settings (min/max party size, instructions). - Public booking route (/api/public/[tenantId]/reservation): Validates against booking rules (party size limits, advance hours, slot availability/capacity). Supports auto-confirm mode. Returns autoConfirmed + confirmationMessage in response. - Availability library (src/lib/reservations/availability.ts): Pure function getAvailableSlots(db, date) — handles interval/fixed/capacity slot modes, blocked slot filtering, advance time validation, and per-slot capacity counting. - Customer booking flow: New ReservationWizard 5-step component replaces ReservationForm. Steps: Guest count picker → Calendar date picker → Live time slot grid (with spotsLeft indicators) → Contact details → Review + submit. Shows "Booking Confirmed!" vs "Request Received!" based on auto-confirm. - Admin reservations page: Full redesign with 4 tabs — List (search/filter table with confirm/reject), Calendar (monthly grid with per-day reservation counts), Settings (booking rules form, slot config, custom messages), Blocked Slots (add/remove blocks with form for all 4 block types). KPI row: Pending / Confirmed / Today / Upcoming. - Extended PATCH /api/tenant/[tenantId]/reservations/[reservationId] to accept staffNotes and noShow fields alongside status.

    • Feature Registry (`src/lib/features/registry.ts`): Centralized catalog of 50+ platform features grouped into 13 categories. Each feature carries: id, name, description, category, plan inclusion tier (basic/premium/enterprise/addon/none), monthly and annual add-on pricing, dependencies, per-tenant toggle flag, usage limit flag, and badge label. Exports getFeatureById, getFeaturesByCategory, getPlanFeatures(tier), getAvailableAddons(tier).
    • Subscription Plan Builder (super admin):

    - Schema: PlanDefinition and FeatureGrant types added to MasterDB and schema.ts. Plans have base/annual pricing, trial days, per-limit caps (menu items / staff / tables / orders / storage), included feature IDs, and available add-on feature IDs. - API — GET/POST /api/admin/plans and GET/PATCH/DELETE /api/admin/plans/[planId]: full CRUD for custom plan definitions. - API — GET/POST /api/admin/tenants/[tenantId]/features and DELETE /api/admin/tenants/[tenantId]/features/[featureId]: per-tenant feature grants (override enable/disable, add-on pricing, expiry, notes). - UI — /dashboard/plans page (super admin only): create, edit, clone, delete plans; feature picker with search by category; pricing fields; usage limit controls; public/active toggles; card grid view. - Sidebar: "Plan Builder" entry appears only for superadmin role.

    • Feature Add-on Marketplace (billing page): Restaurant owners now see a structured add-on section below the plan comparison cards. Features not included in their current plan tier are listed by category with monthly/annual pricing and an "Add" CTA (triggers support contact flow until Stripe add-on billing is live).
    • Help centre — 8 new articles:

    - email-marketing — Campaign builder, AI content, scheduling, GDPR consent - two-factor-auth — TOTP setup, backup codes, recovery, managing 2FA - customer-accounts — Registration, saved addresses, order history, GDPR deletion - payment-methods — Stripe, PayPal, Swish setup and dev mode - notifications-system — In-dashboard alerts, SMS via Twilio/Vonage - multi-restaurant — Multiple locations, restaurant switcher, franchise notes - seo-features — Meta tags, AI generation, local SEO, schema.org, sitemap - ai-content — All 7 AI touchpoints across the platform, prompt tips - security category added to DOC_CATEGORIES - Popular articles updated to include new 2FA, payments, AI content, SEO entries - Quick-search chips updated

    • Memory update: feature_inventory.md and subscription_architecture.md added to project memory.
    • Two-Factor Authentication for restaurant owners (P2):

    - Login flow: POST /api/auth/login now issues a 2FA challenge for tenant-owners (not just superadmins) when twoFactorEnabled is set. Returns { requires2FA: true, tempToken } — caller must redirect to TOTP verify step before a full session is issued. - Verify route upgraded: POST /api/auth/login/verify-2fa now accepts backup codes in addition to TOTP codes (one-time use, consumed on match). Sets tenantId in the session cookie for tenant-owner accounts (was missing). Returns usedBackupCode + backupCodesRemaining so the UI can warn about low codes. - Backup codes: generateBackupCodes() produces 8 random XXXX-XXXX hex codes. Stored as SHA-256 hashes in user.twoFactorBackupCodes. findBackupCode() timing-safe matches and returns the hash for removal. Added to src/lib/auth/totp.ts. - Schema: UserRecord.twoFactorBackupCodes?: string[] added (SHA-256 hashed codes). - Owner 2FA API routes (/api/tenant/[tenantId]/security/2fa): - GET — returns { enabled, backupCodesRemaining } (no secrets) - POST — initiates setup: generates secret + QR code PNG (not saved yet) - PATCH — confirms setup with TOTP code, enables 2FA, generates + returns 8 plaintext backup codes (stored as hashes) - DELETE — disables 2FA after TOTP verification; clears secret + backup codes - POST /backup-codes — regenerates backup codes after TOTP verification; old codes invalidated - Admin reset route: DELETE /api/admin/users/[userId]/2fa — superadmin can force-disable 2FA for any user; full audit log entry. - Admin status route: GET /api/admin/users/[userId]/2fa — superadmin can check 2FA status + remaining backup codes for any user. - SecurityTab component (src/components/dashboard/settings/SecurityTab.tsx): - Status card shows enabled/disabled state + backup codes remaining with low-code warning - Setup flow: QR code scan → manual secret entry (blurred with show/hide toggle) → TOTP confirm → backup codes display (one-time, copy-all button) - Disable flow: requires current TOTP code - Regenerate backup codes: requires current TOTP code; shows new codes immediately - All errors inline with dismissal - Settings page: Security tab added (Shield icon) — wired to SecurityTab component. - Audit logging: All 2FA events (enabled, disabled, backup-code used, admin reset) written to audit log.

    Fixed

    • Blog/News editor formatting pipeline + royalty-free image integration (task 52 follow-up 4):

    - PART 1 — Editor insertion fixed: insertAIOutput was calling setHTML(existingHTML + newHTML) which mapped to TipTap's setContent() on a concatenated HTML string. TipTap re-parses the full string as a new document, destroying heading/list node structure. Fixed by adding insertHTML(html) to EmailEditorRef that calls editor.chain().focus('end').insertContent(html) — TipTap properly appends parsed nodes without touching the existing document. - PART 1 — Content styling fixed (editor + public): @tailwindcss/typography is not installed so prose prose-sm and prose prose-neutral classes were dead CSS — headings, lists, blockquotes were completely unstyled in both the editor and published articles. Fixed by removing all prose classes and replacing with explicit shared rules in globals.css under .tiptap-editor-content .ProseMirror, .ai-output-preview, and .blog-article selectors. All three surfaces now share the same h1/h2/h3, ul/ol/li, blockquote, strong, em, img, and hr styles. - PART 1 — EmailEditor refactored: Replaced prose prose-sm max-w-none [&_.ProseMirror_...] long className with clean tiptap-editor-content class; placeholder CSS moved to global stylesheet. - PART 1 — Public blog article: blog/[slug]/page.tsx article element uses blog-article instead of unsupported prose prose-neutral ... classes. - PART 2 — Royalty-free image search: New GET /api/tenant/[tenantId]/images/search?q= route. Dev mode returns curated food photography using a 21-entry keyword→Unsplash-photo-ID map (scored against the query). Production uses Pexels API (PEXELS_API_KEY) or Unsplash API (UNSPLASH_ACCESS_KEY); per-tenant key override via settings.imageApiSource + settings.imageApiKey. - PART 2 — Auto-suggest after generation: After any AI action that targets the editor, searchImages(topicHint) fires automatically and populates a 3-column image grid in the AI panel sidebar. - PART 2 — Image panel UI: Image suggestions section at the bottom of the AI tab — search box, refresh button, 3×3 thumbnail grid with hover overlay. "Set Cover" → sets form.imageUrl (featured image field). "Insert" → calls insertHTML to embed <figure><img><figcaption> into the article with lazy loading and attribution. - Schema: TenantSettings.imageApiSource and TenantSettings.imageApiKey added (optional, no migration needed). - `.env.local`: PEXELS_API_KEY and UNSPLASH_ACCESS_KEY documented.

    • Blog/News AI output formatting pipeline (task 52 follow-up 3):

    - Prompt leakage fixed: Dev mock's extractTopicFromPrompt() previously failed to strip trailing instruction text (e.g. "Tone: Professional. Use HTML: <p>, <h2>... Length: 500-800 words") from the structured prompts sent by action buttons — the leftover blob was used as the article title/topic, leaking instructions into the HTML. Fixed by adding a topicHint field to the AI request body: the blog editor now passes the raw clean topic alongside the full instruction prompt; the dev mock uses topicHint directly, bypassing all regex extraction. - Dev debug notes removed: *(Dev mode active — connect ANTHROPIC_API_KEY...)* notes stripped from all mock article HTML templates. - HTML rendering fixed: AI output panel replaced the <textarea> (which showed <h2>Title</h2> as literal characters) with a <div dangerouslySetInnerHTML> when the output contains HTML. A basic scrub (<script>, <style>, on* attributes, javascript:) is applied before render. .ai-output-preview CSS added to globals.css for headings, paragraphs, lists, and blockquotes. - Prompt wording cleaned: "Generate Full Article" and "Continue Writing" user messages no longer carry Use HTML: <p>, <h2>... / Length: 500-800 words instructions — the buildContentSystemPrompt system prompt already covers these; putting them in the user turn was causing the model to echo them back. - `runCustomPrompt` wording fixed: "Write a complete, well-structured blog article for a restaurant about: ..." → "Write a professional restaurant blog article about: ..." — no instruction detail in the user message.

    • Blog/News AI content generation routing (task 52 follow-up 2):

    - Root cause: AI route used a single system prompt scoped as a "restaurant navigation/support assistant". For inputs like "onion rings recipe" or "best pizza sauce", the dev-mode mock had no matching pattern and fell back to a navigation-style help list. In production, the prompt gave Claude no instruction to generate articles from bare keyword inputs. - Fix — AI route `mode` parameter: /api/tenant/[tenantId]/ai now accepts mode: "content" | "general" in the request body. "content" mode uses a purpose-built buildContentSystemPrompt() that explicitly directs the model to generate blog articles/recipes/marketing copy — never navigation responses — and bumps max_tokens from 1024 to 2048. - Fix — dev mock content mode: getContentMockResponse() added with patterns for recipes, guides, reviews, and a universal article-generation fallback. Any input in content mode now produces real HTML article output (with a dev-mode note), never a help list. - Fix — BlogPostEditor `runAI`: All requests from the Blog/News editor now include mode: "content" in the payload. - Fix — custom prompt wrapping: runCustomPrompt() helper detects bare keyword/topic inputs (no instruction verb, < 80 chars, no question mark) and automatically wraps them with explicit article-generation instructions before sending, so "onion rings recipe" becomes "Write a complete, well-structured blog article about: 'onion rings recipe'…". - The general assistant mode (chat widget, admin AI page) is completely unchanged.

    • Blog/News AI assistant quick actions (task 52 follow-up):

    - Root cause 1: runAI was sending { prompt: promptText } but the AI route requires { messages: [{ role: "user", content: promptText }] } — caused {"error":"Invalid messages"} on every request. - Root cause 2: Streaming parser tried to parse SSE data: lines from the plain-text stream the AI route returns; a logic error also caused the entire chunk buffer (not the decoded line) to be appended, then the loop broke immediately — output was always garbled or empty. Fixed to plain accumulation: out += decoder.decode(value, { stream: true }). - Enhanced AI actions: Expanded from 9 flat buttons to 27 categorized actions across Content / SEO / Distribution / Insights tabs, each with an InsertTarget that routes output to the correct field (editor, SEO title, meta description, slug, social caption, email subject, or display-only). - Smart insert routing: insertAIOutput() maps InsertTarget to the correct state setter; Insert button label changes dynamically ("Insert into Editor", "Apply as SEO Title", etc.). - Rich context building: buildAIContext() passes title, excerpt, category, tags, and current word count to every prompt for more relevant completions. - AbortController: Each new action cancels any in-flight request. - User-friendly errors: Network failures and non-OK responses show a plain human message — never raw JSON.

    Added

    • Item modifiers / extras (task 52b):

    - `ProductEditorModal` already had full option group editing UI (built in a prior task) — no UI changes needed. - `ProductCustomizeModal` (public menu) already fully implemented — radio/checkbox groups, removable ingredients, price calculation, qty selector, notes, add to cart. - Fixed: `optionGroups` not persisted on product create — CreateProductSchema (Zod) extended with optionGroups: z.array(z.any()).optional(); POST route now saves and returns optionGroups. - Fixed: `optionGroups` lost after product edit — PATCH route response now includes optionGroups: p.optionGroups ?? []; dashboard local state updates correctly. - Complete end-to-end: admin creates groups → customer sees customization modal → selections tracked in cart → prices verified server-side on order submit.

    • Enhanced Blog & News editor (task 52):

    - `BlogPost` schema extended with optional fields: updatedAt, status, scheduledAt, postType, category, tags, featured, excerpt, seo — all backward-compatible; existing JSON files require no migration. - `BlogPostEditor` component (src/components/dashboard/blog/BlogPostEditor.tsx) — full-page WordPress-style editor with sticky header (back, breadcrumb, auto-save indicator, Save Draft, Publish), 2-column layout (cover image + title + excerpt + TipTap rich text on left; sidebar tabs on right). - Sidebar tabs: - Publish — status selector (draft/published/scheduled), scheduled datetime, post type (blog/news), featured toggle, category, tags input, URL slug editor. - SEO — SEO title, meta description (with char counters), keywords, OG image URL, canonical URL, live Google-style search preview card. - Distribute — social media post to connected accounts only (fetched from social/accounts API), custom caption with AI generate, email campaign creator with subject, schedule toggle, single "Distribute Post" button. - AI — tone selector (Professional/Friendly/Promotional/Luxury/Casual), 9 quick-action buttons (Generate Article, Write Intro, Improve Content, Grammar Check, SEO Title, Meta Description, Excerpt, Tags, Change Tone), custom prompt input, streaming output textarea with Insert into Editor and Clear actions. - Auto-save — debounced 30 s after last change; only fires after post has an ID; shows Saving… / Saved / Save failed indicator in header. - New post flow — first explicit Save Draft POSTs to create; editor navigates to /dashboard/blog/[newId]/edit and auto-saves thereafter via PATCH. - `/dashboard/blog/new/page.tsx` — new post page. - `/dashboard/blog/[postId]/edit/page.tsx` — server component that loads post from DB and passes initialData to editor. - Blog API routes updated — POST/PATCH accept all new fields; GET single post added; status and published fields kept in sync; updatedAt set on every PATCH. - Blog list page — removed modal editor, now routes to /dashboard/blog/new and /dashboard/blog/[id]/edit; status badges (Published/Draft/Scheduled), featured star, news badge, category, tags, scheduled date shown per post.

    • Branding system + social sharing (task 51):

    - `BrandConfig` schema added to TenantSettings — logoMode: 'image' | 'text', logoUrl, logoHeight, textFontSize, textFontWeight, textColor, textCustomColor, tagline, taglineFontSize. Fully optional; existing tenants with settings.logoUrl auto-upgrade to image mode. - `RestaurantBrand` component (src/components/tenant/RestaurantBrand.tsx) — single centralized component for all logo rendering. Used by SiteHeader, SiteFooter, and MobileNav slide panel. Supports image or text mode, taglines, custom colors. Logo homeHref prop always links to homepage consistently across all surfaces and devices. - `SiteHeader` updated to use RestaurantBrand — removed inline logo logic. - `SiteFooter` updated to use RestaurantBrand — removed inline logo logic. - `MobileNav` updated — accepts brand, legacyLogoUrl, homeHref props; panel header uses PanelBrand (client-compatible brand renderer). - `ShareButtons` component (src/components/tenant/ShareButtons.tsx) — client component with Facebook, X/Twitter, LinkedIn, WhatsApp (mobile-optimized wa.me deep link), Email (mailto: with auto-filled subject/body/excerpt), and Copy Link (clipboard API with fallback + visual confirmation). No external SDKs. - Blog post page OG metadata — generateMetadata() exported from blog post page. Generates og:title, og:description, og:image (post image or settings fallback), og:url, twitter:card (summary_large_image when image present), canonical URL. Host/proto resolved from request headers. - Share buttons appear in blog posts — above and below article body for maximum visibility. - Branding tab in dashboard Settings (/dashboard/settings) — toggle image/text logo mode, logo URL input + height slider, text size/weight/color pickers (with live preview), custom hex color picker, optional tagline + tagline size. Saves to /api/tenant/[id]/settings with brandConfig. - Settings API updated — brandConfig and logoUrl added to allowed PATCH fields; UpdateSettingsSchema extended with BrandConfigSchema validation.

    • Navigation management system redesign (task 50): Complete overhaul of tenant site navigation — centralized, database-backed, and fully manageable per restaurant.

    - `NavConfig` / `NavItem` schema added to TenantDB — per-tenant JSON with items array (id, label, href, badge, visibility, order, enabled), showBookButton, showCartButton, updatedAt. - `src/lib/nav/nav-service.ts` — shared utility library: getDefaultNavItems(), getDefaultNavConfig(), resolveHref(), getItemsFor(), AVAILABLE_PAGES constant. - `SiteHeader` new async server component (src/components/tenant/SiteHeader.tsx) — reads db.navConfig (falls back to defaults), computes basePath from proxy header, renders unified desktop nav + MobileNav with pre-resolved hrefs + LanguageSwitcher. Replaces ~15 lines of duplicated inline <header> on every tenant page. - `SiteFooter` new async server component (src/components/tenant/SiteFooter.tsx) — reads footer-visible nav items, brand info, and social links. Replaces inline footers across tenant pages. - `MobileNav` rewritten to accept dynamic items: MobileNavItem[] array — no more hardcoded nav items; supports badges, external links, ARIA keyboard nav. - All tenant pages updated to use <SiteHeader> + <SiteFooter>: home, menu, reservations pages. - Blog listing page created at /tenant/[id]/blog — published posts grid with image, excerpt (HTML-stripped, 140 chars), date, and Read More link. - Blog post page created at /tenant/[id]/blog/[slug] — full article with rendered HTML content, Back to Blog, hero image. - Nav CRUD API at GET/PATCH /api/tenant/[tenantId]/nav — returns current config (or defaults), validates with Zod, saves and logs activity. - Navigation Builder dashboard page at /dashboard/nav — full drag-to-reorder UI, add/edit/delete items, device visibility toggles (D/M/F), badge picker (New/Hot/Promo), link-type picker (Page/Anchor/External URL) with AVAILABLE_PAGES picker, global book/cart button toggles, save to API. - "Navigation Builder" entry added to dashboard sidebar.

    • SMS notifications for new orders and reservations (task 49): Restaurant owners receive instant SMS alerts when customers place orders or make reservations. Supports Twilio and Vonage as providers. Settings are in a new "Notifications" tab inside Restaurant Settings: configure credentials (Account SID / Auth Token), the sender number, and one or more recipient numbers in E.164 format. Toggle order notifications and reservation notifications independently. Test-send button fires a real (or dev-mode logged) SMS on demand. In dev mode without credentials, messages are logged to the server console. API: GET/PATCH /api/tenant/[tenantId]/sms for settings, POST for test-send. SMS sending is non-blocking (sendSmsToAll fires and forgets) so it never delays the order/reservation response. SmsConfig added to TenantDB schema.
    • Full customer registration and accounts system: Customers can now create real accounts, sign in, and manage profiles on any tenant restaurant site. Replaces the email-lookup-only account page (task 44) with a proper authenticated portal.

    - 8 new auth API routes under /api/public/[tenantId]/auth/: register, login, logout, me, forgot-password, reset-password, verify-email, profile (GET/PATCH/DELETE). - `CustomerRecord` schema added to TenantDB — scoped per tenant, with CustomerProfile, SavedAddress[], CustomerConsents, CustomerAnalytics, CustomerAuth (pending tokens, lockout), favoriteItemIds, and loyaltyMemberId linkage. - Security: PBKDF2-SHA512 password hashing (10,000 iterations, random salt), HS256 JWT session in a 30-day HttpOnly customer_session cookie (separate namespace from admin platform_session), 5-attempt account lockout (15-minute window), in-memory rate limiting on registration (5/10min), login (10/min), and forgot-password (3/15min) endpoints, email enumeration prevention on forgot-password. - Pending token system: email_verify (24h TTL), password_reset (1h TTL), phone_otp (10min TTL) — stored in CustomerRecord.auth.pendingTokens, single-use (marked with usedAt). - GDPR (Article 17) account deletion: DELETE /auth/profile soft-deletes the record and erases all PII (email, name, phone, password, profile, addresses) via eraseCustomerPII(). Order history is retained anonymously. - Auth-aware account page at /tenant/[id]/account: guests see Login / Register / Forgot-Password / Reset-Password forms; authenticated users see a 4-tab dashboard — Profile (edit name, phone, birthday, dietary preferences, language), Orders (full expandable history), Loyalty (points card, tier progress, transaction history), Settings (saved addresses CRUD, notification consent toggles, change password, delete account). - New auth helpers: requireCustomerAuth() and optionalCustomerAuth() added to src/lib/auth/api-auth.ts; full customer-auth service at src/lib/auth/customer-auth.ts. - Dev mode exposes _devVerifyToken and _devResetToken in API responses to allow testing without email delivery.

    • Customer account page (task 44): Public-facing account page at /tenant/[id]/account. Customers enter their order email to look up their full order history (expandable per-order detail: items, totals, delivery fee, discount, coupon, payment status) and loyalty program status (points balance, reward progress bar, current tier badge, last 20 transactions). Data served by a new GET /api/public/[tenantId]/account?email= endpoint. "My Account" link added to desktop nav and mobile drawer on the menu page.
    • Branded QR codes with logo overlay (task 43 QR): Premium plan feature. Tenants on Premium or Enterprise can paste a logo URL and control its size (10–40% of QR area) in a new "Logo Overlay" panel on the QR Generator page. SVG output has the logo embedded via <image> with a white rounded-rect pad for readability. PNG downloads use client-side canvas compositing (no extra server deps). Non-premium tenants see an upgrade prompt with a link to Billing. Plan status is fetched from the billing API on page load.
    • Three new Website Builder section types (task 43): Instagram Feed (placeholder gradient grid, configurable columns/count, handle + follow button), Team/Staff (up to 6 members with name, role, bio, photo — coloured avatar initials fallback), Newsletter Signup (email capture with Name toggle, success state, 3 background styles: Branded/Dark/Plain). All three registered in SectionRegistry and wired into ContentEditor with full field definitions.
    • Custom font selection in Website Builder (task 42): Tenants can now pick from 10 curated Google Font pairs (Inter, Playfair Display, Merriweather, Montserrat, Raleway, Cormorant, Poppins, Josefin Sans, Abril Fatface, DM Serif) via a new typography picker in the Theme & Colors panel. Selected pair is persisted to db.settings.fontPair, loaded on builder init, auto-saved with the other builder settings, and applied site-wide (heading and body fonts injected via Google Fonts <link> + CSS custom properties) on both the builder canvas and all live tenant pages.

    Fixed

    • `translate()` on plain strings shows only first character (task 43): The builder stores block content as plain strings (e.g. "Welcome to Our Restaurant"), but translate() expects {en,sv} objects. When called with a string, it fell through to text[Object.keys(text)[0]] = text["0"] = "W" (first character). Added a tContent() helper that short-circuits for strings and delegates to translate() only for objects. All translate(block.content.*) calls in the tenant live page replaced with tContent().
    • Menu Preview live page now matches builder (task 43): The live site's menu-preview case was hardcoded to list/category layout and ignored all builder settings (layout, showNumbering, numberingStyle, maxItems, showImages, showDescriptions). Rewrote to support all 4 layout variants (list/grid/cards/minimal) with real tenant products, numbered items, descriptions, and images — matching the builder's visual output.
    • Hero block field name mismatch (task 43): The builder API normalizes hero content field names on read (title→headline, subtitle→subheadline, ctaOrder→ctaText, ctaBook→ctaLink) and saves the normalized names. The live tenant page was still reading the old field names, causing the hero to show fallback values (settings.name, default CTA text) instead of the edited content. Fixed by reading both new and legacy field names with fallback. Also fixed hero background image to use block.content.backgroundImage (set via builder) instead of only settings.bannerUrl. Hero overlay opacity and min-height are now also applied on the live page.
    • Announcement block colors (task 43): Live page announcement was hardcoded to bg-primary text-secondary. Now reads block.content.bgColor / block.content.textColor (set via the builder's color pickers), with theme defaults as fallback. Alignment, font size, and padding settings also applied.
    • "Live Site" / "View Live Site" dashboard links (task 43): On subdomain access (e.g., luigi.localhost:3001/dashboard), these buttons linked to /tenant/luigi-pizza which the proxy double-rewrote to /tenant/luigi-pizza/tenant/luigi-pizza (404). Both dashboard/page.tsx (server component) and builder/page.tsx (client component) now detect whether the current host is a platform host or a tenant subdomain and route to / (root of the subdomain) vs /tenant/${tenantId} accordingly.
    • Broken tenant site navigation links (task 43): All internal links on the public restaurant website (/menu, /reservations, /checkout, /order-confirmation) were broken when accessed via localhost:3000/tenant/<id> because they used root-relative paths that relied on the proxy rewriting (which only activates on custom domains).

    - src/proxy.ts — sets x-tenant-base: root response header when performing a custom-domain rewrite, signaling to server components that root-relative links are safe. - src/app/tenant/[tenant_id]/page.tsx — computes basePath from the header; prefixes all <Link href> values (/menu, /reservations) and passes basePath to MobileNav. - src/app/tenant/[tenant_id]/menu/page.tsx — same basePath pattern; fixes Home/About/Contact/Book links and passes basePath to MenuClient. - src/app/tenant/[tenant_id]/reservations/page.tsx — fixes Home/Menu links. - src/app/tenant/[tenant_id]/checkout/page.tsx — fixes Home/Back-to-Menu links; passes basePath to CheckoutForm. - src/app/tenant/[tenant_id]/order-confirmation/page.tsx — fixes Back-to-Menu and Go-to-Homepage links. - src/app/tenant/[tenant_id]/_components/MobileNav.tsx — accepts basePath prop; all nav links updated. - src/app/tenant/[tenant_id]/_components/CartDrawer.tsx — accepts basePath prop; router.push('/checkout') updated. - src/app/tenant/[tenant_id]/_components/MenuClient.tsx — accepts and threads basePath to CartDrawer. - src/app/tenant/[tenant_id]/_components/CheckoutForm.tsx — accepts basePath prop; "Browse Menu" link updated.

    Added

    • Per-section background image upload (task 41):

    - ContentEditor — added Background Image (MediaPicker image-upload) and Overlay Darkness (select) fields to cta-banner and stats sections. - CtaBannerSection — when backgroundImage is set, renders the image as background-image CSS with an RGBA overlay at the chosen opacity; text and button colors switch to white automatically. - StatsSection — same background/overlay pattern; stat values turn white on dark backgrounds, labels use 70% white opacity for contrast.

    • Menu item drag-to-reorder within a category (task 51):

    - PATCH /api/tenant/[tenantId]/menu/products — new bulk-reorder endpoint; accepts { order: [{id, order}] }, updates all matched products in one save. - Menu page — product grid now wrapped in DndContext + SortableContext (dnd-kit, same library as categories). Each card has a GripVertical drag handle on the left. Drag-end calls handleReorderProducts which updates state optimistically and persists to the API.

    • Self-service reservation cancellation (task 45):

    - Reservation schema — added cancelToken?: string (opaque UUID stored server-side). - Reservation creation (POST /api/public/[tenantId]/reservation) — generates a cancelToken per reservation, returns it in the response. - PATCH /api/public/[tenantId]/reservation/[id]/cancel — new public endpoint. Validates token matches, rejects already-canceled/rejected reservations, sets status to canceled, writes activity log. - ReservationForm — stores cancel link from API response, shows it in the success state as a "Cancel this reservation" link. - /tenant/[tenant_id]/reservations/cancel — new public page. Reads ?id=&token= from URL, shows confirmation UI, calls the cancel API. Handles invalid links, already-canceled, and error states.

    • Product image lightbox (task 46):

    - MenuClient — product images are now clickable buttons. Hovering shows a zoom icon overlay. Clicking opens a full-screen lightbox (dark backdrop + blur) without triggering the customize modal. - Lightbox component — Escape key and click-outside both dismiss it. Image is not affected by card's onClick propagation (stopPropagation).

    • Coupons / promo code redemption tracking (task 30):

    - Offer schema — added couponCode (the code customers type, falls back to offer title), maxUses (redemption cap), minOrderAmount (minimum subtotal required). - resolveCoupon() — now matches on couponCode ?? title, enforces maxUses limit (returns null when cap is reached), enforces minOrderAmount (returns null when subtotal is too low). - Offers API POST/PATCH — new fields saved and updated. redemptionCount initialised to 0 on create. - OfferEditorModal — coupon-specific settings panel (Coupon Code input auto-uppercased, Max Uses, Min. Order Amount) shown only when type = "Coupon". - Offers page — coupon cards now show: code badge in monospace, redemption count, progress bar when maxUses is set, min. order line.

    • Language settings page (task 35): LanguageSettingsTab — enable/disable per-language toggles, set default language, save via /api/tenant/[tenantId]/languages. Cannot disable the last enabled language. Already implemented.
    • Domain connection UI (task 34): DomainSettingsTab — custom domain input, CNAME record instructions table, verify button that calls /api/tenant/[tenantId]/domain/verify and shows Verified / Pending / Mismatch status badge. Already implemented.
    • SEO settings page (task 33): 5-tab SEO page — Overview (score ring + checklist), Meta & Tags (title, description, keywords, OG image, Google Preview), AI Generator (one-click full SEO package), Local SEO (Google Business description + local keywords), Technical (robots policy, canonical URL, JSON-LD schema). Already implemented.
    • Notifications system (task 32): Server-persisted read state in TenantDB.readNotifIds, PATCH endpoint for mark-read/mark-all, optimistic local set for instant badge update, empty states, Mark All as Read button. Already implemented.
    • Customer management page (task 31): /dashboard/customers — aggregates customers from order history (keyed by email), KPI row (total customers/revenue/orders), search by name/email/phone, sortable table, expandable per-customer order history. API: GET /api/tenant/[tenantId]/customers. Already implemented.
    • Cart persistence across page navigation (task 29): CartContext.tsx stores cart state in localStorage under key cart_${tenantId}. Hydration runs once on mount; write-back runs after every state change (guarded by hydration flag to prevent overwrites). Wrapped in try-catch for corrupted storage. Cart is isolated per tenant and persists across page navigations and browser refreshes.
    • Revenue by category chart (task 23): Analytics page shows a horizontal bar chart with a 10-color palette for all product categories. Analytics API computes revenueByCategory from paid orders in the selected date range, cross-referenced with the tenant product/category maps. Already implemented.
    • Date range picker on analytics page (task 22): Preset buttons (Today / 7 days / 30 days / 90 days) plus a Custom mode with From/To date inputs and an Apply button. Active preset highlighted in blue. Current range shown as from → to label. All analytics data and CSV exports use the selected range. Already implemented.
    • CSV export working end-to-end (task 21): Analytics CSV export now correctly filters by the selected date range.

    - GET /api/tenant/[tenantId]/analytics/export — added inRange(isoDate) helper; orders filtered by createdAt, reservations by date, top-products filtered by order createdAt. Date params from/to (YYYY-MM-DD) are applied to all three export types. Added discountAmount column to orders export. Filename now includes date range (${type}-${from}_${to}.csv). Full try-catch added. - Analytics page — handleExport passes active date range to the export URL, sets exportError state on failure, shows a dismissable error banner.

    • Notification read-state system — full fix (task 45): Read state previously lived only in localStorage, so notifications reappeared as unread after logout, browser restart, or a different device.

    - TenantDB.readNotifIds (Record<string, string>) — lightweight server-side map of notifId → readAt ISO timestamp. No separate table; stored in the tenant JSON. Entries older than 60 days are pruned automatically on each PATCH. - GET /api/tenant/[tenantId]/notifications — now returns isRead: boolean per item (derived from readNotifIds) and unreadCount. Full try-catch added. - PATCH /api/tenant/[tenantId]/notifications — new endpoint. Body { ids: string[] } marks specific notifications read; { markAll: true } marks all current notifications read. Persists to DB, returns updated unreadCount. - TopHeader — removed localStorage read-tracking (was the source of bugs). Read state now comes from server. Optimistic local set (localReadIds) gives instant UI feedback between PATCH and next poll. Badge counter and highlight styling update immediately on click. - Added "Mark all as read" button (with CheckCheck icon) in the notification dropdown header, visible only when unreadCount > 0. - Fixed empty state: shows "All caught up!" when there are items but all are read, vs "No notifications yet" when the list is empty. - Dropdown now shows only unread items highlighted; read items display in muted style and the "all caught up" state replaces the list body when unreadCount === 0.

    • Multi-Restaurant Accounts — Batch H (task 44): One user can now own and manage multiple restaurant locations from a single account.

    - GET /api/restaurants — returns all restaurants owned by the current user with their active status. - POST /api/restaurants/switch — issues a new session cookie with the selected restaurant as the active tenantId. - POST /api/restaurants/create — creates a new restaurant (tenant DB + master record + subscription) for the logged-in user and auto-switches the session to the new restaurant. - RestaurantSwitcher component in sidebar — dropdown showing all owned restaurants with one-click switching and "Add Restaurant" shortcut. - /dashboard/restaurants management page — full list of locations with active indicator, site link, switch button, and "Create Restaurant" modal with auto-generated subdomain. - Dashboard layout now fetches all tenant records owned by the user and passes them to TenantProvider via new restaurants: RestaurantSummary[] prop. - Login route updated to prefer the first active restaurant when a user has multiple locations.

    • Website Builder polish & media system (task 42): UX, image uploads, and section improvements.

    - MediaPicker component (src/components/builder/MediaPicker.tsx) — unified image selection used across all image fields. Features: drag-and-drop upload, click-to-upload, Image Bank modal (browses existing tenant media library), image preview with hover Replace/Remove, URL paste fallback. Architecture is future-ready for CDN and shared asset library. - Menu Preview — item numbering: New Show Item Numbers toggle + numbering style select (Arabic 1,2,3 / Padded 01,02,03 / Roman I,II,III / Alphabetical A,B,C). Numbers appear dynamically in all 4 layout variants. Quick-link to Menu Settings in the editor panel. - Hero Banner improvements: New Content Alignment (centered/left/right), Banner Height (300px–fullscreen), Overlay Darkness (0–85%) content controls. Background image now uses MediaPicker for upload + image bank. Empty-state placeholder shown in builder when no image is set. - Delivery & Takeaway — flexible image system: New Image Layout select (1/2/4 images). Each image slot uses MediaPicker (upload + image bank). Empty slots disappear cleanly; grid adapts to chosen count. - Announcement Bar bug fix: Background color and text color now use content-level fields (bgColor, textColor) that override theme defaults correctly. Added alignment (left/center/right), font size (sm/base/lg), and padding (compact/normal/spacious) controls. - ContentEditor new field types: image-upload (renders MediaPicker), color (color picker + hex input with clear), nav-link (navigation shortcut button), divider (visual section separator for long field lists). All blocks with many fields now have labeled sections. - ContentEditor UX: Image fields show size/format hints inline. tenantId prop threaded from builder page so uploads work in context.

    • Website Builder Elementor-style enhancements (task 41): Major upgrade making the builder feel like Elementor/WordPress.

    - StyleEditor rebuilt: Full gradient backgrounds (from/to/direction), border (color/width/radius), box shadow (5 levels), opacity slider, entrance animations (fade-in/slide-up/slide-left/zoom-in), per-device responsive visibility (hide on mobile/tablet/desktop), custom CSS class, copy/paste style between blocks. - Canvas hover toolbar: Each block shows a floating toolbar on hover with move-up, move-down, duplicate, and delete buttons. No click required to reveal block controls. - Keyboard shortcuts: Delete/Backspace to remove, Ctrl+D to duplicate, Ctrl+Z/Ctrl+Y to undo/redo, Escape to deselect. - ContactInfoSection rebuilt: Phone, email, address, and WhatsApp all editable per-block. Toggle show/hide for: Call button, SMS button, WhatsApp button, Map link, Contact form, Social links. Instagram/Facebook URLs configurable per-block. Two-column / single-column layout choice. - MenuPreviewSection rebuilt: 4 layout variants (List / Cards / Grid / Minimal). Toggle show/hide for images, prices, descriptions. Max items (2/3/4/6) and button text configurable. - ContentEditor toggle field type: New toggle field type renders a pill switch. Toggle state stored as boolean. Contact and menu preview blocks now have full field coverage in the editor panel. - Builder canvas: blockStyleToCSS() updated for gradients, borders, shadow, opacity, margins, animations, responsive classes. Block wrappers apply entrance animation CSS class and responsive Tailwind classes. - Tenant live page: withBlockStyle() updated for all new style properties. Contact section respects block.content overrides with settings fallback, respects all show/hide toggles. Animation CSS keyframes added to globals.css. - CSS animations: @keyframes for fade-in, slide-up, slide-left, zoom-in added to globals.css; .builder-animate-* utility classes applied on the live site.

    • Website Builder redesign (task 40): Upgraded the basic 2-panel builder into a modern 3-panel drag-and-drop page builder.

    - 3-panel layout: Left panel (Library / Layers / Theme / Templates tabs) | Center canvas (device preview) | Right contextual edit panel (Content / Style tabs). - BlockLibrary component (src/components/builder/BlockLibrary.tsx) — categorised, searchable block picker with emoji icons; 4 categories: Layout, Content, Restaurant, Contact. - ContentEditor component (src/components/builder/ContentEditor.tsx) — block-specific field definitions for every block type; renders text, textarea, URL, image-URL, and select inputs. - StyleEditor component (src/components/builder/StyleEditor.tsx) — per-block visual style controls: background colour, background image + dark overlay (0–80%), vertical padding (XS–XL), content width (full/wide/normal/narrow), text colour, text alignment, hide-on-mobile toggle, reset button. - LayersPanel component (src/components/builder/LayersPanel.tsx) — scrollable layer list with drag-to-reorder (dnd-kit), visibility toggle (eye icon), duplicate button, delete button; shows active block highlighted. - 8 new block types: heading, paragraph, image-full, cta-banner, stats, video, faq, announcement — each with its own section component and ContentEditor field definitions. - BlockStyle system: BlockStyle interface added to schema.ts and BuilderState.tsx; new UPDATE_STYLE, DUPLICATE_BLOCK, TOGGLE_VISIBLE reducer actions; style is persisted with each block in the DB. - SectionRegistry enhanced with category and icon fields; new SECTION_CATEGORIES export for the block library. - Tenant page (src/app/tenant/[tenant_id]/page.tsx): withBlockStyle() wrapper applies custom background/padding/text styles to any block; 8 new block types fully rendered for customers. - Backward compatible: All existing blocks continue to render exactly as before; BlockStyle is optional.

    • Email Marketing upgrade (task 38): Full professional campaign builder replacing the basic HTML textarea editor.

    - EmailEditor component (src/components/email/EmailEditor.tsx) — TipTap 3 rich text editor with full toolbar: headings (H1–H3), bold/italic/underline/strikethrough/highlight, text colour picker, alignment, bullet/ordered lists, blockquote, link + image insert (via inline dialogs), undo/redo, live character count. - Campaign builder overlay (CampaignBuilder) — full-screen panel with left editor pane and right live preview pane; template picker step (6 built-in templates + blank), subject/preview text fields, AI Generator section (type selector + generate button), TipTap editor, scheduling section (Send Now / Schedule / Recurring with frequency/interval/time/end-date inputs), analytics mini-strip on sent campaigns. - Desktop/mobile preview toggle — <iframe srcDoc> renders wrapForSend() HTML in an isolated sandboxed frame. - Test email send — /api/tenant/[tenantId]/email/campaigns/[campaignId]/test POST — sends to the owner's account email (or a custom address); shows "[TEST]" prefix and note in the email. - AI email generation — /api/tenant/[tenantId]/email/ai-generate POST — generates subject + bodyHtml for the selected campaign type; uses Claude in production, template-based mock in dev. - Schema: CampaignStatus extended with recurring; new types RecurringPattern, CampaignAnalytics, EmailTemplate, AutomationType; emailTemplates added to TenantDB. - src/lib/email/campaign-templates.ts — 6 responsive inline-styled HTML email templates with applyVars() and wrapForSend() utilities. - KPI strip improved: Avg Open Rate and Drafts/Scheduled cards added; campaign list shows analytics mini-strip (delivered, open rate, click rate) for sent campaigns. - STATUS_META extended with recurring status badge.

    • Payment settings page (task 16): New "Payments" tab in dashboard Settings. Restaurant owners can enable/disable Stripe, PayPal, Swish; enter Stripe publishable key, PayPal client ID, and Swish merchant number. Stored in paymentSettings on tenant settings. Schema extended with stripePublishableKey and paypalClientId.
    • PayPal checkout (task 17): Full PayPal order flow — /api/public/[tenantId]/paypal-order creates a PayPal order and pre-saves the DB order; /api/public/[tenantId]/paypal-capture captures payment and marks order paid. PayPalCheckout.tsx component loads PayPal JS SDK dynamically and renders PayPal Buttons. Dev mode shows mock PayPal approval UI (no real payment). Required env vars: PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_ENV (sandbox/production), PAYPAL_WEBHOOK_ID.
    • Swish checkout (task 18): Order is created immediately; customer is shown the restaurant's Swish merchant number, order total, and step-by-step instructions to pay via their Swish app. After paying, customer taps "I've completed the Swish payment" to proceed to the confirmation page.
    • Payment failure handling (task 19): Stripe payment failures now show a "Try a different card" button that resets the card form and lets the customer retry without losing their order details. Error messages from declined cards, network failures, and PayPal capture failures are surfaced inline. PayPal and Swish errors propagate to the same inline error display.
    • PayPal webhook (task 20): /api/webhooks/paypal — Verifies PayPal webhook signature via PAYPAL_WEBHOOK_ID env var. Handles PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, and CHECKOUT.ORDER.APPROVED events. Dev mode returns 200 immediately. Acts as belt-and-suspenders behind the synchronous capture route.
    • Checkout payment filtering: Checkout page reads paymentSettings from tenant DB and passes enabled methods to CheckoutForm. Only enabled providers are shown as payment options (cash always available). "Coming soon" badges removed from PayPal and Swish.
    • Error boundaries: src/app/(dashboard)/dashboard/error.tsx — catches dashboard page crashes with retry + home button; identifies tenant DB errors with a specific message. src/app/global-error.tsx — catches root-level application crashes.
    • Zod validation: src/lib/validation/schemas.ts — typed Zod schemas for all public and tenant API inputs (login, register, orders, reservations, contact, products, settings, staff, blog, offers). parseBody() helper for consistent 400 error responses.
    • API hardening: Zod schemas wired into auth/login, auth/register, public/orders, public/reservation, tenant/menu/products, tenant/settings routes. Removed all manual field checks in favour of schema validation.
    • Mobile navigation: MobileNav.tsx — slide-in panel with hamburger toggle, outside-click to close, body scroll lock, backdrop overlay. Replaces the hidden "Book" button on mobile. Wired into public tenant homepage header.
    • Website Builder section renderers: 9 new preview components registered in SectionRegistry — FeaturedSection, MenuPreviewSection, TestimonialsSection, ReservationCtaSection, DeliveryCtaSection, GallerySection, OpeningHoursSection, CampaignsSection, GoogleMapsSection. Builder now previews all 11 section types correctly.
    • Stripe checkout: Full Stripe payment flow for restaurant orders — StripeCardForm.tsx (Stripe Elements card input), /api/public/[tenantId]/payment-intent (creates PaymentIntent + pre-saves order), webhook handles payment_intent.succeeded and payment_intent.payment_failed to update order payment status. Dev mode shows mock card form with 3 test card numbers. Order schema extended with stripePaymentIntentId. Required env vars: STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET.
    • Logging & monitoring (tasks 9–15):

    - src/lib/logger.ts — Structured logger: JSON lines in production, coloured pretty output in dev. All DB errors and auth events now route through this. - src/lib/monitoring/sentry.ts — captureException / captureMessage wrappers ready for @sentry/nextjs — fall back to logger until SENTRY_DSN is set. - instrumentation.ts — Next.js instrumentation hook with Sentry init stub. - src/lib/audit/log.ts — writeAuditLog() helper appends to master DB audit log (capped at 10,000). Wired into login success, login failure, login blocked events. - src/lib/audit/activity.ts — writeActivityLog() helper appends per-tenant activity log (capped at 500). Type-safe: order | reservation | menu | settings | staff | auth. - src/app/api/log/client-error/route.ts — POST endpoint for client-side error reporting from error boundaries. - Error boundaries updated to POST errors to /api/log/client-error on mount. - DB layer (db.ts) updated to use structured logger instead of console.error. - Login route logs all auth events (success, failure, suspension) with IP and user context. - ActivityLog type added to schema; activityLogs?: ActivityLog[] added to TenantDB. - src/app/api/tenant/[tenantId]/activity/route.ts — GET activity log with type filter. - src/app/(dashboard)/dashboard/activity/page.tsx — Activity Log dashboard page with filter chips, colour-coded event types, refresh. - Activity Log added to sidebar navigation. - Activity logging wired into: order placement, reservation creation, settings PATCH.

    • Delivery integrations (tasks 24–28):

    - DeliveryZone CRUD API — GET/POST/PATCH/DELETE /api/tenant/[tenantId]/delivery/zones with Zod validation and full auth guard. - src/lib/delivery/normalize.ts — findTenantByPlatformAccountId() helper + order normalizers for Uber Eats, Wolt, Foodora, Deliveroo (each converts platform-specific payload → internal Order schema). - Webhook receivers — /api/webhooks/ubereats, /api/webhooks/wolt, /api/webhooks/foodora, /api/webhooks/deliveroo — HMAC-SHA256 signature verification, tenant lookup by platform account ID, duplicate-order protection. - Menu sync API — POST /api/tenant/[tenantId]/delivery/sync — pushes menu to all enabled platforms with syncMenu: true. - Delivery dashboard page overhauled: two-tab layout (Delivery Platforms / Delivery Zones), webhook URL display with one-click copy per platform, Sync Menu Now button, full delivery zone add/edit/delete UI.

    ---

  2. 0.1.0

    2026-05-25

    Added

    Platform Core

    • Multi-tenant subdomain routing via src/proxy.ts (Next.js 16 middleware convention)
    • Platform login (/login) and registration (/register) for restaurant owners
    • Super-admin panel at /admin with separate login + 2FA support
    • Role-based access: owner, manager, kitchen, waiter
    • JWT session tokens (HS256) stored in httpOnly platform_session cookie
    • PBKDF2-SHA512 password hashing with per-user random 16-byte salt

    Admin Dashboard (21 pages)

    • Overview, Menu, Orders, Reservations, Website Builder, QR Codes, Analytics
    • Staff & Permissions, Offers & Campaigns, Loyalty, Reviews, Media, AI Assistant
    • Blog, Social Media, Google Business, Import Data, Delivery Integrations
    • Billing & Plans, Support, Settings

    Public Tenant Website

    • Dynamic restaurant website with 11 configurable sections
    • Online ordering (delivery, pickup, dine-in) via /api/public/[tenantId]/orders
    • Table reservation booking via /api/public/[tenantId]/reservations
    • Full menu with category navigation
    • Checkout: Stripe, PayPal, Swish
    • Order status tracking page
    • Multi-language support (English, Swedish)

    Help & Documentation System

    • In-app Help Centre (/dashboard/help) — 22 articles, 9 categories
    • Article detail pages with FAQ accordion, bookmarks, related articles, "was this helpful?"
    • Setup Wizard (/dashboard/onboarding) — 8 steps, localStorage progress, completion celebration
    • Floating help button (FloatingHelpButton) with inline search on all dashboard pages
    • ContextualHelp tooltip component for inline contextual guidance
    • Public Help Centre (/help) — 12 articles, 6 categories, no login required
    • Help Centre integrated into landing page header and footer

    Engineering Workflow System

    • AGENTS.md extended with full stability and workflow rules
    • CHANGELOG.md, TODO.md, ROADMAP.md, BUGS.md, KNOWN_ISSUES.md created
    • docs/ folder: architecture, API reference, ADRs, release notes
    • Git push rule: never auto-push, always ask for confirmation first

    Fixed

    • Security: PBKDF2 hardcoded salt replaced with per-user random salt — all new passwords now stored as salt_hex:hash_hex
    • UI: QR code SVG width/height attributes replaced with 100%/auto to fix preview overflow
    • Hydration: dnd-kit ID counter mismatch in Website Builder — fixed with ClientOnly wrapper + stable DndContext id
    • Hydration: ThemeInjector moved from inline <style> render to useEffect imperative injection
    • Hydration: suppressHydrationWarning added to <body> for browser extension attribute injection
    • Routing: /login and /register added to proxy BYPASS_PREFIXES so subdomain visitors can reach platform auth
    • UI: TopHeader hardcoded restaurant name replaced with dynamic useTenant() context value
    • Data: databases/tenants/template.json websiteBlocks migrated from {en, sv} objects to plain strings with correct field names (headline/subheadline for hero)
    • API: Builder GET route (/api/tenant/[tenantId]/builder) now normalizes legacy {en, sv} content on read — backward compatible with existing tenant DBs
    • React: Subscriptions page bare <> fragment wrapper replaced with <Fragment key={s.id}> to fix missing key warnings
    • UX: Public help page quick chips now set category filter (not text search) — "Getting started" previously returned 0 results

    Changed

    • AGENTS.md updated with Next.js 16 rules + full engineering workflow
    • Landing page footer Resources links wired to real /help articles instead of #
    • Landing page header includes "Help" navigation link

    ---

    Version History

    | Version | Date | Description | |---|---|---| | 0.1.0 | 2026-05-25 | Initial build — full platform foundation |