🌀

Hermes — Living Breakdown

Customer retention command center · hermes.appolis.app
← Appolis · the city of apps

FLIP CMS — LIVING APP BREAKDOWN

App: Flip CMS ("Flip My Life Wellness — Retention" on the branded front-end) · Owner: Tyler (Spartan Studios) Doc rule: update this file and APP_BREAKDOWN.html on every change to the app. This is the master reference for everything the app is, does, and still needs.

Live breakdown: open /breakdown in the running app. It renders from the live scoring engine + database (can't drift), and the risk engine section is two-way — signal weights, the high-LTV multiplier, and tier thresholds save to the per-tenant app_config table and the at-risk queue rescores immediately. In-code defaults in server/lib/risk.js apply for anything not overridden.

Last update: 2026-07-06 — LIVE + DATA-COMPLETE 🚀 https://flip-cms.flipmylife.workers.dev (Cloudflare Workers + D1, access-key gated — key in Flip CMS/ACCESS_KEY.txt; append ?key=<value> once per device). All five services connected and backfilled: ~274k Shopify orders (to Aug 2023), full Recharge history incl. 18,496 skips, ~34k Gorgias tickets, Rivo points live. 30-min auto-sync cron + precomputed queue cache (dashboard loads in ~200ms). Deploy: green tests → cd chardizy; npm run deploy. 70 tests.


1. WHAT IT IS

A retention command center for Shopify wellness brands. It watches every customer for churn risk (supply running out, stalled shipments, skipped renewals, unresolved negative tickets, first-order stalls, email disengagement), ranks them in an at-risk queue with a plain-English "why flagged" explanation, and makes the fix a one-click action: guided cancel-save scripts, call/text scheduling with topic-based routing, portal phone calls with auto-transcribed notes, and zero-margin loyalty-points save offers.

Business context: runs on Flip My Life's real store (flipmylifenow.myshopify.com, selling since Aug 2023 — tenant id t_verdant is a historical artifact from the demo era; the data in it is all real). Architected multi-tenant from day one so it's a sellable SaaS at company exit, not a rewrite-at-exit liability. The aggregated voice-of-customer data (pain points / win factors per churn reason) is itself a pitch asset — "the data an acquirer can't buy elsewhere."

Positioning: Gorgias is where you talk to customers, Recharge is where subscriptions live, Rivo is where loyalty points live. Flip CMS sits across them: see the churn risk, act on it in one click. The wellness-specific differentiators nobody else computes:

  1. Replenishment ring — days-of-supply-left per customer, with real-dose inference.
  2. Shipping as a churn signal — a stalled shipment breaks the habit loop; treated as churn-in-waiting, and structurally NOT counted as incoming supply.
  3. Wellbeing-first medical guardrail — medical/side-effect cancel reasons can never receive a retention offer (enforced in code, not UI).

Naming

The app is Flip CMS. "Chardizy" was the original builder's codename — it survives only in technical identifiers that are expensive to rename (the chardizy/ code folder, the D1 database name, data/chardizy.db locally). Flip My Life Wellness is the brand skin on the front-end. t_verdant is the tenant id (a leftover label; the data in it is the real store). The loyalty platform is Rivo; subscriptions are Recharge; support is Gorgias; logistics is Flexport.


2. HOW TO RUN IT

Production (the real app): https://flip-cms.flipmylife.workers.dev — Cloudflare Workers + D1, no local server involved. First visit per device: append ?key=<value> (key in ACCESS_KEY.txt) → sets a 30-day cookie. Dashboard at /, Connections at /connections, dose facts at /products, live config at /breakdown. A cron syncs all connected services every 30 minutes and rebuilds the precomputed queue cache.

Deploy: tests must be green first — cd "F:\Claude Code\Flip CMS\chardizy"; npm test then npm run deploy (never chain them blindly; gate on the test exit code).

Local dev (requires Node ≥ 22.5, zero npm dependencies by design; Node 24.18.0 LTS installed 2026-07-03):


cd "F:\Claude Code\Flip CMS\chardizy"
npm start          # ONE server on http://localhost:8787 — same code path as the Worker
npm test           # 70 tests (node --test, no runner deps)

Persistence: production data lives in D1 (database chardizy); local dev uses chardizy/data/chardizy.db (CHARDIZY_DB=:memory: for a throwaway run).

Every API request is tenant-scoped via the x-tenant-id header (defaults to t_verdant). In production this should come from an authenticated session — the interim access-key gate covers the whole app meanwhile (Cloudflare Access on a custom domain is the planned upgrade).


3. ARCHITECTURE


Flip CMS/
  APP_BREAKDOWN.md / .html      ← this living doc + styled mirror
  GO_LIVE_CHECKLIST.md          ← vendor credential/webhook setup steps
  ACCESS_KEY.txt                ← dashboard access key (never in chat/commits)
  chardizy/                     ← the code folder (historical name — the app is Flip CMS)
    package.json                ← zero runtime deps; scripts: start / test / deploy
    worker.js                   ← Cloudflare Workers entry: access-key gate, API+webhooks on D1,
                                   static assets, 30-min cron (sync all services → rebuild queue cache)
    wrangler.jsonc              ← Workers config: D1 binding (db `chardizy`), assets, cron, APP_KEY
    server/
      db.js                     ← ONE async facade, two engines: node:sqlite (local/tests) + D1 (prod)
      schema.js                 ← DDL, 15 tables + MIGRATION_COLUMNS upgrade list
      seed.js                   ← idempotent demo seed (local dev only; demo customers purged from prod)
      service.js                ← orchestration: scale-tier queue (aggregates + 150-day window),
                                   precomputed queue/VIP cache, vips, products, risk config
      api.js                    ← fetch-native handleFetch(Request)→Response — same code on
                                   Workers and local node; JSON API + webhook receivers + static
      lib/                      ← storage-agnostic, fully tested domain core
        ring.js                 ← supply-days + per-customer dose inference (+3-day delivery buffer)
        risk.js                 ← weighted explainable signal catalog + scoring/tiers (weights
                                   injectable per tenant via /breakdown)
        saveflow.js             ← 6 cancel-save plays, offer validation, points lever, auto-notes
        routing.js              ← topic → role → team member resolution
        upserts.js              ← idempotent tenant-scoped writes (external-id matching, identity
                                   unification, LTV recompute; never mints identity-less customers)
      integrations/
        registry.js             ← THE connection hub: per-service fields/test/sync/webhook defs,
                                   chunked resumable syncs (cursors in sync_state), event log
        shopify.js              ← Dev Dashboard client-credentials tokens (24h, auto-minted),
                                   HMAC verify, normalizers, since_id crawls, read_all_orders
        gorgias.js              ← Basic-auth REST, tickets + full thread fetch, heuristic sentiment
        recharge.js             ← cursor pagination, subs/orders/skipped-charges, HMAC verify,
                                   self-registering webhooks
        rivo.js                 ← per-id loyalty lookups (member id == Shopify customer id),
                                   bulk member pages, award/redeem, points lever
        flexport.js             ← Logistics API 2024-07; webhooks only (no order-list endpoint)
        adapters.js / voice.js  ← Twilio SMS/Voice + Whisper seams (endpoints not yet exposed)
    test/                       ← 70 tests total: core / api E2E / integrations / connect
    web/
      flip-my-life-retention.html ← the LIVE dashboard (queue, profiles, VIPs, threads; responsive)
      connections.html          ← integrations screen (creds, sync, webhook registration)
      products.html             ← dose-facts screen (powers the ring)
      dashboard.jsx             ← LEGACY prototype (Cohorts/Playbooks ideas worth porting live here)
    data/chardizy.db            ← local-dev SQLite only (production data lives in D1)
    deploy/export-data.js       ← one-time local→D1 data export (used at initial deploy)

Layering rule: lib/ and service.js never touch vendor specifics; integrations normalize external payloads into internal row shapes. The same handleFetch + db facade run identically on Cloudflare Workers (D1) and local node (SQLite).


4. DATA MODEL (15 tables — D1/SQLite in production, tenant scoping enforced in the query layer)

TableKey fieldsNotes
tenantsid, name, shopify_domain, planplan defaults 'internal'
team_memberstenant_id, name, email, role, phoneroles: wellness_coach, cx_lead, retention, admin
customerstenant_id, name/email/phone, sms_opt_in, first_order_at, ltv_cents, email_opens_last3, rivo_customer_id/points/points_value_cents/tierloyalty denormalized onto customer
productstenant_id, title, servings_per_unit, default_daily_dosedrives the ring
subscriptionstenant_id, customer_id, product_id, status, cadence_days, next_charge_at, paused_reason, status_changed_atstatuses seen: active, skipped, paused, cancelled
orderstenant_id, customer_id, product_id, quantity, total_cents, placed_at, delivered_atdelivered_at anchors the ring
shipmentstenant_id, order_id, carrier, tracking_number, status, last_scan_at, etastatuses: label_created, in_transit, customs, delivered, lost, stalled
ticketstenant_id, customer_id, subject, status, sentiment, csat, assignee_email, tags / updated_at / awaiting_reply / channel (v0.13.0)sentiment feeds negative_open_ticket; assignee seeds books; tags/channel/awaiting-reply power the Flippers messages tiles (open tickets swept from the Gorgias "All" view every cron cycle)
routing_rulestenant_id, topic, route_to_role, fallback_member_id5 seeded topics
touchpointstenant_id, customer_id, channel, topic, owner_id, scheduled_at, booked_by, booking_link_token, statusself-serve booking mints lnk_ token
conversation_notestenant_id, customer_id, touchpoint_id, author_id, body, tag, sourcetags: pain_point / win_factor / feedback / note; source: manual / save_flow
save_flowstenant_id, customer_id, run_by, reason, offer_made, outcome, ltv_at_risk_centsoutcomes: saved / follow_up / cancelled
integration_settings(tenant_id, service) PK, credentials JSON, status, last_test_at, last_sync_at, last_error, sync_statesync_state holds resumable crawl cursors per resource; secrets never returned unmasked
webhook_eventstenant_id, service, topic, status, detail, received_ataudit trail: ok / rejected / error — feeds the Connections screen
subscription_events (v0.5.0)tenant_id, customer_id, subscription_id, from_status, to_status, atstatus transitions + skipped charges (deterministic se_rcch_<chargeId> ids); powers repeat_pauser + renewal_skipped
app_config (v0.5.0)tenant_id PK, config JSONper-tenant risk tunables edited on /breakdown
queue_cache (v0.7.1)(tenant_id, category) PK, payload JSON, computed_atprecomputed at-risk queues per category + VIP list + per-member Flipper books (500-item blocks); cron-refreshed — main queues every run, member books rotated 2 per run (all books alone exceed the Worker 128MB cap; each also rebuilds solo via /api/my-flippers?fresh=1); the reason the dashboard loads in ~200ms at 150k+ customers

External-ID columns (v0.2.0, for idempotent ingestion): customers.shopify_customer_id + .recharge_customer_id, products.shopify_product_id + .sku, orders.shopify_order_id + .recharge_order_id, subscriptions.recharge_subscription_id, shipments.flexport_shipment_id, tickets.gorgias_ticket_id. Applied to older persisted DBs via MIGRATION_COLUMNS in openDb().

Indexes: (tenant_id, customer_id) on orders, notes, touchpoints; (tenant_id, service, received_at) on webhook_events.


5. THE ENGINES (all numbers)

5.1 Replenishment ring (lib/ring.js)

5.2 Risk engine (lib/risk.js) — rule-based v1, every point explainable

Queue scoring at scale (v0.7.1): atRiskQueue is set-based and aggregate-first — per-customer order aggregates (COUNT, last delivery anchor) come from one GROUP BY; full order rows are loaded only for the trailing 150 days (ring math needs recency, not 2023). Bulk-loading all ~274k orders per request blew the Worker memory limit — that was the 503 incident. Only customers with ≥1 order, subscription, or ticket are scored (marketing contacts can't churn). The dashboard never runs this live: results are precomputed into queue_cache by the 30-min cron (top-200 per category + VIPs) and served in ~200ms; ?fresh=1 forces a rescore (~26s).

SignalWeightFires when
supply_depleted30ring ≤ 0 days AND nothing dependable in flight
shipment_stalled25in-flight shipment, no carrier scan ≥ 48h
first_order_stall22exactly 1 order, day 30–45 since it, no repurchase
repeat_pauser22≥2 skips/pauses in 90 days (from subscription_events — the "essentially pausing" pattern)
negative_open_ticket20any open ticket with negative sentiment
renewal_skipped20a skipped charge ≤60 days on a non-cancelled sub (skips live on charges — the sub stays "active")
subscription_paused18sub status paused (detail carries the reason)
supply_low_no_order15ring ≤ 25% AND >0 days AND no reliable inbound AND next charge won't beat depletion
lapsed_buyer12bought before, no live sub, quiet 60–365 days — the win-back pool
email_disengaged8opened 0 of last 3 emails

5.3 Cancel-save plays (lib/saveflow.js) — reason → say-this script → matched offers

Reason keyLabelOffers
resultsNot seeing results yetFree wellness-coach session · Dosing & timing adjustment plan · 2-week pause with auto-resume
priceToo expensive right now[dynamic: Redeem $X in points now — lead offer] · Switch to smaller size · Stretch cadence to 6–8 weeks · 15% loyalty credit (last resort)
overstockToo much product on handSkip next shipment · Move to 8-week cadence · Swap to smaller count
shippingShipping keeps going wrongFree reship + carrier upgrade · SMS tracking · Review address & carrier routing
medicalSide effects / medical concernnoPush — Pause immediately (no save attempt) · Full refund on current bottle
otherSomething elseListen & log · Route to teammate · Offer a pause

5.4 Routing (lib/routing.js)

topic → routing_rules.route_to_role → first member with that role → fallback_member → members[0] → null. Unknown topic → admin. Seeded: product_results→wellness_coach (Dana), shipping/billing→cx_lead (Miguel), sub_changes/cancel_save→retention (Sasha).

5.5 Insights (service.insights)

GROUP BY exact note body per tag → pain points (with mentions + last_heard), win factors, feedback, and save-rates by reason+offer with ltv_saved_cents. Works because auto-notes are canonical strings — free-text manual notes will fragment the rollup (known limitation, see TODO).


6. API (server/api.js — one fetch-native handler: UI + API + webhooks, identical on Workers and local)

MethodPathPurpose / notes
GET/api/queueAt-risk queue served from the precomputed cache (?limit= cap, ?category= drill-down taking one of subs, shipping, supply, winback, unhappy, ?fresh=1 forces a rescore, computed_at in the payload)
GET/api/vipsBest customers: tenure gate + (live sub OR ≥2 orders), LTV-ranked, last_touch_at guard; default-tenure requests ride the cache (~200ms), ?tenure= runs live
GET/api/tickets/:id/threadFull Gorgias conversation fetched live (chat-style thread modal)
GET/PUT/api/configPer-tenant risk tunables for /breakdown (weights, multiplier, tiers — validated)
GET/api/customers/:idScore + ring + signals + loyalty + pointsLever + orders/sub/tickets/notes/touchpoints/shipments (powers the full profile at #customer=<id>)
POST/api/touchpoints{customerId, channel, topic, bookedBy, scheduledAt} → routes owner; bookedBy:'customer_self_serve' mints lnk_ token
POST/api/notes{customerId, body, tag} tagged conversation note
GET/api/saveflow/playsThe script library (PLAYS)
POST/api/saveflow{customerId, reason, offer, outcome} → validates offer against play + loyalty, records, auto-writes notes. Invalid offer → 400
GET/api/insightspainPoints / winFactors / feedback / saveRates

Integration routes:

MethodPathPurpose
GET/api/integrationsAll services: status, masked creds, field specs, webhook URL + topics
PUT/api/integrations/:serviceSave credentials (masked round-trips keep stored secrets) + auto test-connection
POST/api/integrations/:service/syncRun a sync chunk (?pages= override); returns counts + remaining; resumable via cursors
POST/api/integrations/recharge/webhooksOne-click webhook registration — the server registers its own receivers with the stored token (Recharge has no webhook admin UI); idempotent
GET/api/integrations/rivo/sampleDiagnostic: raw vendor payload passthrough (?qs=/?email=) — found both Rivo API bugs
GET/api/webhook-eventsRecent webhook audit log (?limit=)
GET/api/productsProducts usage-sorted with orders/customers counts + ring_ready flag
PUT/api/products/:idSet dose facts (servings_per_unit, default_daily_dose) — validated positive; enables the ring
POST/webhooks/:service/:tenantIdSignature-verified receiver → normalize → upsert → logged. 401 on bad signature. Exempt from the access-key gate

Static: GET / → dashboard, /connections, /products, /breakdown (live config doc); other paths served from web/ (traversal-guarded locally, ASSETS binding on Workers).

Validation (v0.2.0): POSTs against unknown customers → 404; saveflow outcome must be saved|follow_up|cancelled; touchpoints require channel+topic. Remaining infra notes: CORS *, tenant header client-controlled (auth is P0), unexpected errors in core routes still fall through as 400 (webhook routes return proper 500s).

Routes that still do NOT exist: /api/voice/token, TwiML endpoint, recording status callback (voice loop), booking-link consumer page.


7. INTEGRATIONS (ALL FIVE LIVE + fully backfilled as of 2026-07-06)

All services are connected on /connections and maintained by the 30-min cron. Backfills are DONE: Shopify ~274k orders (complete to Aug 2023) · Recharge full history incl. 18,496 skipped charges · Gorgias ~34k tickets · Rivo points for the top ~10k LTV customers + all points-holders in the oldest 25k members.

ServiceCredentials (Connections screen)Webhook securitySyncWebhook ingests
Shopify (shopify.js)store domain + Dev Dashboard app Client ID/secret (client-credentials grant → 24h tokens, auto-minted/cached; app+store must share an org). read_all_orders scope required — without it Shopify silently serves only the trailing 60 daysHMAC-SHA256 base64, timing-safe — Notifications-page secret or app client secretcustomers + orders, since_id cursor crawls, resumableorders/ → customer+product+order; fulfillments/ → shipment (delivered stamps the ring anchor); customers/*
Recharge (recharge.js)API token, webhook client secretHMAC-SHA256 hex, timing-safecursor-paginated (page numbers 422 past ~p98): customers-first identity phase → active subs → history → skipped charges (newest-first; INSERT OR REPLACE self-heals)order/created → confirmed order; subscription/* → status + transition events; charge/updated → skip events (subs stay "active" when a charge is skipped — charges are the truth). Webhooks self-register via the button
Rivo (rivo.js)Developer Toolkit token, optional shared webhook tokenshared-token gatetwo-phase: bulk member pages (API caps at ~250 pages) + per-id refresh of top-10k LTV — member id is the Shopify customer id; list filters are silent no-ops, never trust thempoints/updated → customer rivo_* columns
Gorgias (gorgias.js)account domain, email, REST API key, optional shared webhook tokenshared-token gatetickets, cursor-paged (⚠ still re-walks the archive each pass — incremental marker is on the board)ticket create/update → tickets row; heuristic sentiment (LLM classifier is the drop-in upgrade) feeding negative_open_ticket. Full threads fetched live on click
Flexport (flexport.js)API token (expires yearly)shared-token gatenone — the Logistics API 2024-07 has no order-list endpoint; tracking flows via Shopify fulfillmentsshipment events → exception→stalled (churn-in-waiting)
Twilio Voice + Whisper (voice.js)not yet on Connections screenX-Twilio-Signature (timing-safe)still needs /api/voice/* routes + browser SDK wiring (P1)

Sync cursors: integration_settings.sync_state holds per-resource crawl cursors, persisted after every page so an interrupted sync never rewinds. Chunked runs return remaining: true when there's more; caught-up passes are cheap incremental pulls (updated_at_min / since_id). Proven Workers envelope: ~2 pages (500 all-new rows) per run.

Ingestion design (lib/upserts.js + integrations/registry.js): every row matches on its external id first — replayed webhooks and repeated syncs never duplicate. Customers unify across platforms (Shopify id ⇄ Recharge id ⇄ email), external ids backfill onto existing rows, order writes recompute LTV + first_order_at, delivered shipments stamp orders.delivered_at (the ring anchor). Products auto-created from live orders arrive without servings_per_unit — the ring stays null until dose facts are filled in (needs a small product-settings UI, see TODO).


8. FRONT-END

8.1 The dashboard — web/flip-my-life-retention.html (LIVE on real data, self-contained)

Design language "The Flip": the name is a verb — the signature moment is a customer's card physically flipping (3D CSS rotateY) from at-risk to saved. Palette: midnight ink #12121A ground; flip gradient coral #FF5A5F → gold #FFB347 reserved for the moment that matters; mint #4FD1A5 for saved/healthy; slate #8A8FA3 muted. Type: Space Grotesk display, Inter body, IBM Plex Mono data. Apple cues: negative space, layered shadows, backdrop blur, spring-ish easing, prefers-reduced-motion respected, focus-visible outlines.

Tabs:

  1. At-risk (core screen) — hero stat strip (at-risk count + LTV exposed, saved today + LTV kept, 30-day retention [hardcoded 96.4%], session save rate [fake denominator +2]); queue of flip-cards (ring, tier pill, VIP chip, top signal, score, LTV); sticky detail panel: "Why flagged" explanation, signal chips, Run cancel-save → (shimmer gradient CTA), Schedule call or text, Call now — auto-transcribed, 4 quick actions (win-back email / reship / task / 15% credit — toast-only), Rivo loyalty panel (points, tier color-coding, zero-margin redeem button ≥$5), order-processing timeline (Recharge charge → Flexport pick/pack → transit → delivered, with stalled blink + skipped state), last-3 conversation notes with tag chips.
  2. Fulfillment — radar of stuck orders across all customers: skipped charge (high → "Recover the renewal"), stalled carrier (high → "Reship + upgrade"), customs hold (watch → "Send heads-up"); 3 summary counters; Open → jumps to that customer in the queue.
  3. Insights — three-column rollup of tagged notes (pain points / what wins subscriptions / product feedback) with attribution; populated live as you run saves/calls.
  4. Team — routing display: 3 teammates with their owned topics (static).

Modals: SaveModal (3 steps: diagnose reason → say-this script + offers, with gold points-lever block leading on price + wellbeing-first banner on medical → outcome buttons); ScheduleModal (Call/Text → topic with live routing preview → slot picker or "let them pick" self-booking link); CallPanel (simulated portal call: dialing pulse → connected timer + streaming scripted transcript → "Transcribing with Whisper…" → 3 auto-tagged notes drop into the timeline).

Fully wired to the live API (since v0.4.0): queue with category chips, lazy per-customer detail, VIPs tab (60s auto-refresh, real points/tiers), Gorgias thread click-through, save flows/notes/touchpoints POSTing live, URL-addressable full customer profiles (#customer=<id>, reachable from queue/VIPs/radar — browser back works), and a full responsive/mobile build (900/640 breakpoints, touch targets, full-screen detail sheets, iOS input-zoom guard). Tech debt: React 18 UMD + Babel standalone from CDN (in-browser JSX transform) — a real Vite build is on the board.

8.2 Legacy prototype — web/dashboard.jsx (852 lines, ESM React component)

The first clickable prototype (light sage/fern "Chardizy" theme). Has features the branded UI dropped:

8.3 Connections screen — web/connections.html (v0.2.0, LIVE)

The first real UI→API wiring. Vanilla JS + fetch, same brand tokens. Per-service cards: credential fields (password inputs for secrets; masked values round-trip safely), Save & test (auto connection test with honest error surfacing), Sync now (enabled once connected, reports counts), the webhook URL to register + which topics, last test/sync timestamps, last error. Below: auto-refreshing webhook activity table (ok / rejected / error with detail). Served at /connections.

8.4 Duplicate file

Flip CMS/flip-my-life-retention.html (zip root) is byte-identical to chardizy/web/flip-my-life-retention.html. Keep web/ as the source of truth; delete or ignore the root copy (drift risk).


9. TESTS — 70/70 passing (verified 2026-07-06, Node 24.18.0)

FileCovers
core.test.jssupply math, ring pct/clamp/delivery buffer, dose inference (+fallback), all signal firings incl. repeat_pauser + lapsed_buyer, 48h stall threshold, charge-beats-ring suppression, stalled-isn't-supply, LTV multiplier, play completeness, medical structural block, points lever, loyalty-never-unlocks-medical, outcome note tags, routing + fallbacks
api.test.jsE2E over real HTTP: ranked queue, detail bundle, loyalty + lever thresholds, touchpoint routing, self-serve lnk_ token, save→notes→insights loop, medical 400, fabricated-offer reject, tenant isolation
integrations.test.jsFlexport exception→stalled, Recharge HMAC + cadence + order mapping, Rivo normalize (both payload shapes) + lever threshold, TwiML, Twilio signature algorithm, voice grant shape
connect.test.jsShopify client-credentials mint/cache + signed webhooks + credential clearing, settings save/test/mask round-trips, webhook verify→ingest→replay-idempotent/forged-401, queue cache vs ?fresh=1 semantics, skipped-charge ingestion (nested schema, dedupe, webhook + sync), recharge webhook self-registration idempotency, rivo two-phase sync (bulk + per-id targeted), identity unification, VIPs gating, Gorgias sync + sentiment, chunked sync cursors + incremental flip, sync route refusals

Run: npm test. No test runner deps — Node's built-in node --test. Deploys are gated on green (a red suite once nearly shipped chained behind ;).


10. VERIFIED STATE (2026-07-06, evening)


11. KNOWN ISSUES & FINDINGS (from the 2026-07-03 code audit)

Bugs / correctness

#WhereIssueStatus
B1seed.js c1 (Marisol)Order delivered before placed + in-transit shipment on a delivered orderFIXED v0.2.0 — split into a delivered order (−31d/−28d, ring anchor) + a separate in-flight resupply order carrying the stalled shipment; engine output unchanged, coherence covered by a test
B2risk.js isStalledA shipment with no scan ever (last_scan_at null — label created, never moved) computes h=0 and is never stalled. Fall back to shipment creation/order placed date.open
B3voice.js transcriptToNotesStale model idFIXED v0.2.0claude-sonnet-5
B4voice.js verifyTwilioSignatureNon-constant-time compareFIXED v0.2.0crypto.timingSafeEqual
B5voice.js outboundCallTwimlUnescaped XML interpolationFIXED v0.2.0escXml() on all params
B6api.jsEvery thrown error → 400PARTIAL v0.2.0 — explicit 404s (unknown customer/service/route), webhook + sync errors → proper 500; core-route catch-all still 400
B7api.js POST routesNo input validationFIXED v0.2.0 — unknown customers → 404 (tested), outcome enum enforced, channel/topic/body required
B8README.mdStale test count; references chardizy-data-model-spec.mdfile not in the zip◐ README refreshed for v0.2.0; the missing Postgres spec still needs regenerating
B9UI stats30-day retention hardcoded 96.4%; save-rate denominator hardcodedFIXED v0.4.0 — removed with the live wiring
B10duplicate HTMLRoot copy vs web/ copy will driftopen (delete the root copy)
B11db.js id()Not collision-safe at scaleFIXED v0.2.0 → prefixed crypto.randomUUID()
B12insightsGROUP BY exact body — free-text manual notes fragment rollups; needs canonical topic tagging (LLM classify) for real dataopen

Front-end ↔ back-end contract mismatches

ALL RESOLVED v0.4.0 — the dashboard rewrite made API slugs the single source of truth end-to-end (M1–M5 can't recur by construction).

Security / compliance


12. TODO BOARD (prioritized)

DONE (the demo→product jump, all shipped)

Wired live dashboard (v0.4.0) · persistence (v0.2.0) · Cloudflare Workers + D1 deploy (v0.3.x) · all five services connected (v0.3.2) · full backfills complete (v0.7.0–v0.8.x) · dose facts UI + rings (v0.2.3) · Retention 2.0 signals incl. skips (v0.5.0/v0.8.0) · queue cache at scale (v0.7.1) · mobile buildout (v0.6.0) · full customer profiles (v0.7.0) · webhook self-registration (v0.8.1) · Rivo points live (v0.8.1) · ghost purge + minting guard (v0.8.2)

P0 — trust & access (what stands between here and handing this to the team)

P1 — close the loops that are 80% built

P2 — features designed but not started

P3 — SaaS wrapper (the exit story)


12b. DEPARTMENT REPORT ROADMAP (batch 20 plan — the Insights tile dashboard)

The Insights tab is now a dashboard of nine department tiles. Fulfillment is live (the Delivery Performance report); each other tile becomes its own cinematic report "comprehensive enough to be the only thing you bring to that meeting," plus a department login whose account can actually do that department's work, not just read about it.

DeptThe report (slides)The login can DOData source
Fulfillment ✅ liveDelivery performance, carriers + pie, claims scoreboard, money left on the table, loss/gainFile claims, reship, export Excel/PPTX/PDFFlexport + Shopify + mined CS disputes
SalesRevenue by channel (store · wholesale · Amazon · distributors), AOV, cohort repeat rate, sub vs one-time mix, top moversSee every channel side-by-side; discount/price experiments; wholesale account bookShopify + Recharge today; Amazon/distributor feeds when connected
MarketingCAC by channel, ROAS, attribution wired into the profitability engine's break-even ("an ad dollar becomes a profitable customer on order N / day D"), creative performanceManage/pause ads and budgets from the app; VOC pipeline — pain points & wins mined from CS become the messaging briefAd platforms (to connect) + cac_cents scaffold + the dispute/notes miner
FinanceStore P&L, margin by product, refund exposure, per-customer ROI + store-average break-even, claim recoveries, LTV:CACSet per-variant COGS, review refund outcomes, export books-ready sheetsThe profitability engine (costBasis/orderProfit) — already computes all of it
InventoryConsumption & seasonality (real days-per-bag vs label, cohort split — already collected), stock runway, reorder forecast by monthDemand forecast → PO planning; low-stock alertsconsumptionStats + Shopify inventory levels
Customer serviceTicket volume/sentiment trend, save rates by offer (already computed), win-backs, response times, team performance, heat casesEverything the Flippers dashboard does today — this IS the home teamGorgias-mirrored tickets + save_flows + touchpoints
Social mediaChannel growth, engagement, post/creator performance, social sessions → ordersPost scheduling hooks; creator shortlist from the VIP/affiliate overlapSocial platform APIs (to connect)
AffiliatesReferral revenue, commissions owed, sample-shipment log (the ~300 unattributed fx_direct sends become visible here), top-partner leaderboardRecruit/pay partners, mint referral codes, track sample→revenue conversionRivo referrals + fx_direct samples + discount-code attribution
IT 🧙Wizard-behind-the-curtain master control: sync freshness per connector, webhook failure feed, cron/D1 health, error log, deploy watchRe-run syncs, rotate creds, purge caches, feature flagsintegration_settings + queue_cache + worker logs

Marketing deliberately leans on customer service — who better to know what customers want than the team already talking to them; the systems are one, so tagged VOC notes flow straight into campaign briefs. Department logins ride the existing role system (roles already gate tabs; a department account gets its tile's report + that action surface).


13. CHANGELOG

① THE RECORD AND THE PROFILE ARE ONE PLACE. server/lib/sends.js ensureCustomerForCreator() (POST /api/creators/:id/customer, behind the sends gate): the conversation and the affiliate profile hang off the customer row, so a creator with none gets one the moment Scott reaches for either — on his press, never behind him. A customer already carrying the record's email is linked (that press is the human confirmation creators.customer_id asks for); otherwise a customer row is made from the record (name, email, phone, address, no orders, no store account) and the day they buy or sign in the Shopify upsert matches by email and fills it in. The link is written through upsertCreator, the same door the record's PATCH uses — creatorProfile still only offers a candidate. The creator record (CreatorSheet) now always shows 🪪 Affiliate profile and 💬 Message them (plus the stage select once linked); the link-first dead end ("cannot join the funnel yet") is gone. The affiliate profile gains 🎁 Add a creator record when there is none (POST /api/creators with their name, email, phone and the customer link), beside the existing 🎁 Creator profile → when there is. The merged creators list offers 🪪 View profile and 💬 Message on every row — a creator-only row gets its customer on the press — and 📞 Log a call on rows with a customer; the Dashboard's roster refreshes when a record gains its customer. AffiliatesView registers window.__openAffiliateProfile beside __openAffiliateThread so the record, which lives at app level, can open either through the desk.

② A NOTE IS WHAT YOU TYPE; A CALL IS A CALL. Both desks' Log a note used to write a canned line with no input ("VIP check-in — reached out…", "Affiliate outreach — spoke with them…"). Now the button opens the styled prompt — Log a note for Dawn — what happened? — and logs what he typed, nothing otherwise; the VIPs tab's button reads ✎ Log a note. The affiliates VIP row (and the creators list) gains 📞 Log a call, which opens the app's own call sheet (CallLogSheet) for that person — for now, until the phone service is wired in.

THE SHAPE. sends.js +1 function (exported); api.js +1 route; hermes.html: the App's two note handlers and one call handler, AffiliatesView, AffiliateVipList, AffiliateCreatorsList, CreatorSheet (openAff replaces linkAndRecruit), AffiliateProfileSheet (addRecord), FulfillmentView (roster refresh). Tests: the ensure door on the seeded database (creates once, links by email without duplicating, 404 on an unknown record, and the new customer can be profiled and written to email-only), the old never-links pin kept (creatorProfile writes nothing), and the UI pins for every button above. Suite 1,224 (+2).

① ONE SEND, TWO DELIVERIES. server/affiliate-inbox.js send() is now the only door a message leaves through — the conversation composer and POST /api/affiliates/invite both call it. It writes the conversation row (the creator's FLIP HQ inbox reads that row) and sends a copy through the suite mail door with the buttonReply in your FLIP HQ inbox →, deep-linked to their own thread (#/inbox?m=aff_<member>) — under the do-not-reply line ("Please don't reply to this email — replies here don't reach us…"). Each row is stamped with the channel it really went out on: both, app (no email on file, or the copy was refused), or email (no store account yet — the button then says Sign in to your FLIP HQ inbox, and the row is keyed on the customer so the conversation is already waiting the day they sign in: the FLIP HQ doors now resolve the member to the customer instead of reading only by member id). A refused email copy is reported, never hidden, and never blocks the inbox delivery; with no store account the email is the delivery, so a refused mail is a refused send and a recorded one. The old recordInvite mirror is gone — the send writes its own row. The button, its text and the line above it are settings (portal_url, portal_button, portal_note), editable on the Dashboard; the default opt-out line now points at the inbox too. The invite path keeps its record (affiliate_outreach), its funnel move (first contact → invited), its cap and its refusals.

② THE DESK. Three sections — 📊 Dashboard (the Creator Sends view, renamed; key stays sends so old links land), ★ VIP list, 📥 New messages. The intro paragraph is gone. My creators merged into the Dashboard's creators view as AffiliateCreatorsList: one list from two sources — the Creator Sends records and the customers in the funnel — a record linked to a customer is one row wearing both (mark, tier, handle, sends, spend), a funnel customer with no record is a row of its own; the stage chips are a scrolling strip; a row opens the conversation, or the creator record when there is no customer behind it, with 🪪 View profile and 🎁 Creator record beside it. New messages shows only conversations with something unread from the creator (GET /api/affiliates/inbox?unread=1), the chip carries the count, and everything else is reached through the conversation view. ✉ Email them is gone from the profile and the thread; ✉ Invite on a VIP card now opens the conversation with the first template ready. The conversation is the composer: a template strip (From scratch or any template, filled through the preview door with their facts in place), a subject for the email copy (or the default A note from Scott), the message, one Send. Bubbles say ✉ + inbox / ✉ email only and carry the honest line for each. ✉ Message templates on the Dashboard opens the panel: add a template (up to eight), remove one (never the last), edit any, plus the sender and the button that rides the email copy. Every chip strip (.secnav) gets a thin bar and 8px of room under it on a phone.

③ WHAT THIS DEPENDS ON. The email button lands on the FLIP HQ inbox thread — which draws and answers an affiliate conversation only on the preview theme (#182110716205). Until that theme is pushed live, a creator who taps the button on the live site sees the message but no reply box. Pushing it is Tyler's call at the runbook's approval gate; the form asking for it is on the board.

THE SHAPE. affiliate-inbox.js rewritten around send() (threads gains unreadOnly; the member doors read by member id OR customer); recruiting.js gains the portal settings, wrap() takes the portal block, sendInvite is a wrapper; two routes touched; hermes.html loses InviteSheet and RecruitingView, gains AffiliateCreatorsList, a rewritten RecruitingSettings, AffiliateThreadSheet and AffiliatesView. Tests: the unified send on a real seeded database (inbox + email with the deep-linked button; a refused copy reported; no-account → email-only, then the conversation appears the day the account exists, and a reply lands), the two refusals re-pinned, the recruiting pins moved with the desk, the UI pins (three sections, no intro, no email button, templates on the Dashboard, the strip CSS). Suite 1,222 (+3).

① THE AFFILIATE PROFILE — new AffiliateProfileSheet, fed by one new door, GET /api/affiliates/profile/:id, behind the same denyAffiliates gate as the inbox. What it holds is the whole of what the affiliates desk knows about a person: name, email, phone and the one-line address; the funnel mark and tier; lifetime value, orders and the average per order, tenure, last order; whether they are a subscriber (one word — active / paused / cancelled / not — a fact for the recruiter, never a control); loyalty tier and points; what they buy (their three most-ordered products); invites sent and the last one; the conversation (messages, unread, last); and whether they are already one of his creators — the linked Creator Sends record with handle, platform and real sends counted (cancelled and failed are not sends), with a 🎁 Creator profile → button to it. From the sheet he can move them in the funnel, ✉ Email them, or 💬 open the conversation. What it does NOT hold, and a test forbids: tickets, dispositions, call outcomes, touchpoints, notes, the owner, subscription controls, order lines. Every lookup rides an index that already exists.

② WHERE IT OPENS FROM, AND WHAT NO LONGER OPENS. ★ VIP list: the name and a new 🪪 View profile button both open the affiliate profile; the "open →" text is gone; nothing on a VIP card reaches the inbox any more. 🤝 My creators: the row still opens the conversation (the earlier ask), now labelled "💬 conversation →", and gains the same View profile button. The thread sheet: "Full customer profile →" is gone for everyone — admins included — replaced by 🪪 View profile; AffiliatesView no longer receives ProfileView from the App at all (onOpen left the mount), so there is no path from this desk into customer service left to find. A test pins View profile on exactly those three surfaces and nowhere else.

③ THE FLIP HQ FOLDER — A FINDING, NOT A FIX. Tyler: "I opened up the inbox on Flip HQ, saw the affiliate message, but there is no additional affiliate folder at the top." That is precisely what the live theme shows: the message comes from the live server (v2.137.0) and lands in the inbox, but the live theme's INBOX_FILTERS has no affiliate entry — measured in the .drift/live mirror, zero occurrences of the word in flip-hq-account.js — so the row wears the generic 🔔 kind and no folder chip is drawn. The preview theme (#182110716205, pushed in v2.137.0) draws ['all','affiliate'] for that same inbox, re-proven this shift by running its chip code in node against an affiliate item. So the folder exists only on the preview link until the theme is pushed live, which stays Tyler's call at the runbook's approval gate. No theme change this shift.

THE SHAPE. server/affiliate-inbox.js gains profile(); server/api.js one route; web/hermes.html the sheet, the two row changes, the thread change and the mount change. Tests: 3 on the profile on a real seeded database (the shape, the creator link with sends counted, the 404), 1 on the door's gate through real HTTP (Scott and an admin 200, customer service and fulfillment 403, unknown 404), and the UI pins. Suite 1,219 (+5).

① THE HONEST PROBLEM THIS DESIGN EXISTS TO SOLVE. Hermes has no inbound-email door. When Scott emails a creator through the suite mail door, the reply goes to scott@flipmylifewellness.com's own mailbox and nothing brings it back — the only inbound path in the whole repo is the Gorgias mirror, and that is the customer-service address, not his. So a thread built on email would have shown one side of a conversation while quietly implying it was the whole of it. Instead the live channel is the app: Scott writes in Hermes, it lands in that creator's FLIP HQ inbox under their own 🤝 Affiliate folder, they reply there, and the reply lands back in his inbox. Both ends are real and both are stored. The invite emails are still mirrored into the thread (channel='email') so the conversation starts where it really started — and every one of those bubbles says on its face "Sent by email — a reply to this one arrives in your own mailbox, not here." A creator with no store account has no FLIP HQ, so there is no other end: that composer is closed and says why, rather than accepting a message that would go nowhere.

② WHAT SCOTT GETS. A 📥 Inbox section on the affiliates desk — one row per person, not per message, newest conversation first, with an unread count and a preview. And clicking a creator anywhere on that desk now opens the affiliate sheet instead of ProfileView: the funnel mark, lifetime value, tenure, tier, ✉ Email them, and the conversation. No tickets, no dispositions, no call outcomes, no touchpoints, no subscription controls. This is a real boundary and not a hidden button — GET /api/affiliates/inbox/:id returns recruiting facts and messages and nothing from the customer-service side, and a test asserts that payload can never grow one. The full customer profile stays exactly one line away for anyone who genuinely holds Support Hub access, so an admin loses nothing while somebody with only the affiliates area can no longer land in a desk that was not built for them.

③ THE DROP-DOWN ON A CREATOR PROFILE"in the View Creators section, from those profiles that have been added already, he should be able to add them on his creators list with the affiliate drop down on each one of their profiles." Creator Sends profiles (the gifting records) now carry the affiliate stage picker, and choosing a stage puts that person on My creators. The funnel mark lives on the customer row, so a gifting creator can only carry one once the two records are linked — the server therefore looks for a customer with the same email and offers the match, and the link is a button Scott presses. It is never written automatically: creators.customer_id is documented as "set ONLY when a human confirms they are also an FML customer", and silently matching on a shared address is exactly the confirmation that comment forbids. A test pins that creatorProfile never writes the link itself.

④ THE DEMO FIXTURE IS OUT OF THE PRODUCTION DATABASE (todo_3258 closed — "Delete the demo team members and routing rules"). Backed up verbatim to deploy/restore-demo-fixture-2026-09-09.sql first, then deleted: 4 team_members rows (Dana Reyes, Miguel Santos, Sasha Kim, Ops Admin) and 5 routing_rules. Read back after: 0, 0, and 0 across every tenant. One two-month-old touchpoint still pointed at m_admin, so its owner_id was set to NULL in the same pass — it now reads as unassigned, which is the truth, rather than pointing at a row that no longer exists. scheduleTouchpoint writes NULL from here on, so a booking is honestly unassigned instead of dishonestly assigned. It cannot come back: seed() is called only from server/api.js's require.main === module local-dev entry point, never by the Worker, and it early-returns on a tenant that already exists.

THE SHAPE. New table affiliate_messages (one thread per creator, keyed on the customer, member_id copied at write time so FLIP HQ reads it with one indexed lookup) — deliberately not the messages table, which is the Gorgias-mirrored support thread and would have put Scott's recruiting words in front of every agent, and not member_messages, which is a one-way broadcast letter with no author, no direction and no reply. New server/affiliate-inbox.js; three doors behind the existing affiliates gate; FLIP HQ's inbox gains a third source, its own aff_ id prefix on the read / thread / reply doors, and a reply path that returns before any of the support-intake code runs — a test asserts not one ticket is created. ⚠️ Every ORDER BY tiebreaks on rowid, not id: ids here are random UUIDs, so two messages written in the same millisecond would otherwise order arbitrarily and the thread could show the answer above the question. Theme side (flip-my-life-theme, assets/flip-hq-account.js): the 🤝 Affiliate folder chip, the thread render, and a reply box with no paperclip — that lane stores no attachments, and offering one that silently dropped the file would be a lie. Tests: 14 new, on a real seeded database — including one for each of the two defects an adversarial pass caught before this shipped (the archive that would have buried the conversation, and the client report counting a recruiting reply as customer self-service). Suite 1,214.

WHAT THIS SHIP ADDS. The splitter moves out of the route into schema.js as schemaStatements(), where a test feeds it both comment forms; declaredObjects() turns the schema into the list of what must exist — tables, indexes (including the ones added later) and the columns ALTERed on afterwards. server/lib/schema-verify.js asks the live database what it actually holds and returns every missing object by name. The admin migrate route now runs that check after applying and answers 500 with the names when anything is absent — counting statements RUN was never proof, because a statement can run and build nothing. A new machine door GET /internal/schema-check (shared-secret gated, 404 otherwise) asks the same question and applies nothing, and npm run deploy ends by calling it, so a release that needs a column the database does not have fails loudly instead of going green over the gap (the /api/admin/migrate route is admin-session-gated, so nothing else could ever run unattended — that is [todo_2318], the fourth time this bit).

📊 MEASURED ON PRODUCTION BEFORE THE SHIP (read-only): the code declares 68 tables · 95 index statements · 140 migration columns, and the live database holds 69 tables · 97 indexes and every one of the 140 columns — nothing is missing today. Two small things the count itself surfaced: the 95 statements are only 94 distinct names, because idx_orders_customer is declared twice inside SCHEMA (lines 845 and 888 — harmless, every CREATE is IF NOT EXISTS, and now visible instead of invisible), and the live extras over what we declare are Cloudflare's own objects. Two constraints found while proving the checker against the real database, both now designed around: D1 refuses a compound SELECT with many UNION ALL terms (too many terms in compound SELECT at twelve), so the column probe runs one statement per table; and wrangler d1 execute --file returns only a summary and no result rows while --command returns them — a verification built on --file would have seen nothing and called it clean, which is the directive's own failure shape wearing a CLI's clothes.

🔓 THE GATE, PROVEN BY ONE REAL PASS (criterion 3). The public signed attachment door was exercised against production on 2026-09-06 02:41Z: a freshly minted link returned the real file (image/png, 54,331 bytes); a tampered signature, an expiry moved into the past, no signature at all, and a valid signature reused for a different message were all refused with 404 — deliberately the same answer as a file that does not exist, so the door never confirms what it holds. The emailed login-code gate is Tyler's own press to record (it cannot be exercised without acting as him).

🧪 +6 tests → 1,162. VERIFIED: the suite; the served version; the production counts above; the gate pass. NOT VERIFIED: a deploy that actually fails on a real production gap — there is none to catch. The failure path is proven twice instead: by unit tests against a database missing a table, an index and a column, and by running the verifier over production's own inventory with three objects added to the declared list that do not exist, where it named all three and refused to report ok.

THE FIX IS IN THE READER, NOT THE TABLE. Deleting the five rows is a production write for the owner to approve (the request was refused by this session's safety guard, and the rows are dumped to .blueprints/echo-cleanup-20260906/ so it stays a one-liner). Showing them was a bug regardless of whether they exist, so message-mirror.js now exports withoutEchoes(rows) — the same correlation the mirror's own skip uses, as a pure function over one conversation's rows: a Hermes row whose rfc_message_id is gorgias:<id> OWNS that message, so the vendor's copy is not a second message. conversations.threadFor runs every read through it (that is the agent's case card AND FLIP HQ's inbox thread, which reads the same function) and drops the echo's attachment rows with it; hq.js inboxSupportItems runs each conversation through it before cutting threads, so an echo can no longer date a row, write its preview, or end a thread with the customer's words repeated back at them.

🧪 +3 tests → 1,156 (echo-read.test.js: the helper drops only a claimed Gorgias row and returns the same array when there is nothing to drop · threadFor hides the echo, keeps the genuine agent reply and the Hermes row, and stops carrying the echo's attachment · the member's inbox list is not dated or previewed by an echo, and a thread ends on the customer's real message). VERIFIED: the suite; the served version; the audit counts above, each read back from production; Tyler's own conversation in the browser after the deploy. NOT VERIFIED: the five rows are still in the table by design — the display is fixed, the data is the owner's call.

THE CAUSE: hq.js inboxSupportItems grouped one row per ticket, and the message row could not tell a form submission from an inbox reply — both were provider hermes / via self_service / channel contact_form (41 customer rows on production, every one identical in shape; the only trace of the difference was conversation_events hermes.intake payload.source: 39 contact_form, 2 hq_inbox).

(1) THE ROW SAYS HOW IT WAS CREATED (server/lib/intake.js viaFor): the website form now stamps via 'contact_form' — Gorgias's own word for it; the rows its embedded form mirrors already carry via contact_form / source_type contact-form — and a reply written inside FLIP HQ's inbox (source hq_inbox) or a chat handoff stays 'self_service'. Nothing else reads the value (via 'rule' is the only other consumer, agent-side). Backfill on production after the deploy (00:04Z): the 40 form rows → contact_form (39 at plan time; a 40th arrived through the old code while the plan was written); the 2 inbox replies (t_3bdf1bde… 10:06:40Z, t_91d24aea… 21:08:51Z, matched on ticket + the event's second) stay self_service.

(2) THREADS (server/hq.js segmentConversation): a conversation's public messages, oldest first, are cut at every form submission (isFormSubmission: a customer row with via contact_form or source_type contact-form); the first message always starts a thread. Each thread is an inbox row: id = sup_<ticket> for the first (so every read/archive mark from before this ship still holds) and sup_<ticket>_<message> after; subject = the form's subject (the conversation's for the first); preview = the newest words in the thread ("You: …" when the member's); state = waiting when the member's words are the last, else replied; agent_at / from_label = the newest agent reply IN the thread (unread follows it, as before); can_reply only on the newest thread of the conversation — a reply always continues the open conversation, so older threads carry latest_id and the theme sends the member there; thread = the start message id, count = its size. POST /proxy/hq/inbox/thread { ticket_id, id } answers that one thread (plus thread: { id, subject, state, can_reply, latest_id }) and marks THAT thread read; without an id — an older theme — or with an id that no longer matches, the whole conversation, as before, never a 404 on the member's own conversation. /inbox/reply marks the thread the member wrote from.

THEME, both skins (flip-my-life-theme classic-account + retro-pop, marker fhq-ui-v20-a): the row carries a "Sent · waiting for a reply" chip while state is waiting; the thread cache is keyed by the row's id and the thread door is asked with { ticket_id, id }; the composer shows only on the newest thread — an older one says "This conversation continued in a newer message" with an Open the latest button (and keeps Archive); the reply carries the row's id.

🧪 +2 tests → 1,153 (hq.test.js: a form submission starts a new row — waiting, the form's subject, can_reply/latest_id, the old mark does not cover it, an inbox reply joins the latest thread, Gorgias's own form row starts one, archiving hides one thread · the thread door: one thread by id, the first thread ends where the submission begins, no id / stale id = the conversation, a stranger's 404, the reply door's mark) + the via pins (intake.test.js, site-e2e.test.js, intake-merge.test.js: both form messages on one conversation are contact_form; hq.test.js keeps pinning the inbox reply as self_service). BLUEPRINT ship. VERIFIED: the suite; the served version; the backfill counts read back (40 / 2, the two survivors named); the served theme assets read back (FHQ_BUILD = fhq-ui-v20-a on the live Classic theme and the Retro preview); Tyler's signed-in inbox in the browser pane. NOT VERIFIED at ship time: a brand-new form submission after the ship producing a new row on a real account beyond Tyler's — the code path is the same one his account exercised.

(1) THE FILE STORE READS (server/site.js blobsOf): the R2 branch returned { put } only — enough to store an upload, nothing for uploadTwinFiles (v2.117.0) to read back, so no bytes ever reached Gorgias's upload door and the twin quietly fell back to the signed link. CORRECTION to the v2.117.0 entry: with the door open since v2.117.0, Gorgias rendered that link — Tyler saw the photo on the Gorgias ticket for this very submission — so the claim that Gorgias never renders an external attachment URL was wrong; it renders one it can fetch, and the 05:07Z photo was invisible only because our door was shut. The upload path is still the right default (a Gorgias-hosted copy outlives our 400-day link and does not depend on our door), and this ship is what makes it actually run. The handle now carries get in the makeBlobs shape ({ body, contentType }, null for a missing key); the upload path needs nothing else.

(2) THE ECHO SKIP FOLLOWS THE ROWS, NOT THE TICKET'S PROVIDER (server/lib/message-mirror.js): the mirror skipped a customer's own echo only when existing.provider === 'hermes' — right when every Hermes-originated conversation was Hermes-born, wrong since v2.108.0 appends a customer's next form message to their OPEN conversation whichever side it was born on and posts the echo onto that Gorgias ticket. The correlation lives on the Hermes ROW (rfc_message_id = 'gorgias:<id>'), so the skip now looks at the rows on any existing conversation. A Gorgias-born open conversation no longer shows each appended message twice — in Hermes and in the FLIP HQ inbox thread that reads the same rows.

NOT IN THIS SHIP, routed to the lane that owns it (todo_2899): Tyler's rule that a form submission should appear in the customer's own account inbox as its own "sent, waiting for a reply" item rather than inside the existing conversation — that is server/hq.js's inbox grouping, the FLIP HQ lane's file; the Hermes/Gorgias side keeps one open conversation per customer (D22, and tickets.gorgias_ticket_id is unique per tenant).

🧪 +2 tests → 1,150 (twin-echo.test.js: the raw R2 handle reads body + content type and answers null for a missing key, no store → null · a Gorgias-born open conversation absorbs the next form message (no new twin), the echo is correlated on the Hermes row, and the mirror — with the echo in Gorgias's list — stores it ONCE, twice over). BLUEPRINT ship. VERIFIED: the suite; the served version after deploy. NOT VERIFIED at ship time: the upload door itself against live Gorgias — Tyler's next form submission with a photo is the proof (expect hermes.twin_uploaded on the events and the message once in Hermes; the photo already shows on the Gorgias ticket via the link, and should now be Gorgias-hosted).

THE DOOR (server/inbox.js, server/api.js): POST /api/inbox { customerId, kind, subject, body | body_html, cta_label, cta_link, expires_days, discount: { amount_cents } } — admins only (the role ladder: master admin first). Plain text becomes paragraphs; HTML is sanitised. An offer with an amount mints a Shopify discount code HQ-XXXXXX through the referral machinery's creator — fixed amount, locked to this customer's store account, one use — writes the code into the letter and sets the CTA to the auto-apply link (/discount/<code>?redirect=…Shop All); its own HQ- prefix so the reward engine never mistakes it for a REW- reward. The row lands in member_messages unread (the badge shows on the member's next HQ load); the activity log names the sender. Nothing is emailed by this. GET /api/inbox?customerId= lists what is already in their inbox. Refused with the reason when the customer has no store account (the inbox is reached through FLIP HQ), when Shopify will not mint the code (nothing written), or when there is nothing to say.

THE SCREEN (web/hermes.html): the customer panel's action row gains 📥 inbox → a composer (kind · subject · message · for offers $ off + days shown · optional button label/link) that says "Mint code + send" when money is involved, shows the minted code, and lists what is already in their inbox with read / archived marks.

🧪 +5 tests → 1,140 (a letter lands unread on the STORE account id with the sender in the activity log · an offer mints a one-use code locked to the customer and writes it into the letter + CTA · refusals: no store account, nothing to say, Shopify refusing → nothing written, unknown customer · the sent list · sanitiser + paragraphs). Also in this build: support conversations in the inbox are limited to the last year, and with no read mark yet only a reply from the last 30 days counts as unread — measured on the master account 2026-09-05: 60 unread on day one otherwise (+1 test → 1,141).

SYSTEM OF RECORD: new table member_messages (kind founder · offer · system · announcement, subject, body_html, from_label, source_ref, cta, read_at / archived_at / expires_at) for what Hermes — or the theme, for an editor pop-up the member has just seen — writes to ONE member. Support replies are not copied: they are read live off the member's own tickets and messages (the agent's PUBLIC words on the Gorgias twin's mirror — internal notes never show), newest reply per conversation, so nothing new is plumbed and nothing can drift.

SIX DOORS (server/hq.js, server/storefront-proxy.js), all behind the member's own customer row: GET /proxy/hq/inbox → items + unread · POST /proxy/hq/inbox/read and /archive (support items mark in hq_member_data, letters on their own row; a reply newer than the read mark is new again) · /inbox/thread { ticket_id } → the public words of one conversation, oldest first (opening marks it read) · /inbox/reply { ticket_id, body } → the member's words onto their own conversation through the SAME intake path the contact form uses (appendToOpenConversation: the conversation reopens for the help desk, the Gorgias twin gets the message when it has one, email keeps going out) · /inbox/deliver { block_id, … } → an editor pop-up becomes a letter once per member (idempotent on block id; scripts, handlers and javascript: links stripped; marked seen). /hq/me carries inbox_unread for the badge, bounded by a 1.5 s timeout so a slow read costs the count, never the page.

🧪 +5 tests → 1,135 (the list is member-gated and newest-first with the internal note absent; read/archive marks land where they belong; the thread is the member's own or a 404; a reply writes the Hermes self-service message and reopens the conversation, saying plainly when there is no twin; deliver sanitises and never doubles). Production schema: the new table lands through the admin migrate door or wrangler d1 execute — this ship's P-step proves it by counting.

THE FIX (worker.js, server/integrations/gorgias.js, server/lib/intake.js, server/site.js, server/integrations/registry.js): /a/ joins the Worker's prefix list, and a source pin now asserts the two lists name the SAME prefixes so the next door cannot pass the tests and miss production. uploadGorgiasAttachmentsPOST /api/upload?type=attachment, multipart, one file part per file, Gorgias's [{ url, name, size, content_type }] back, loud on anything but 2xx, routed through the one wrapper (which now drops the JSON content type for a multipart body so fetch writes the boundary). uploadTwinFiles reads each R2-backed public file from whichever blob store rides along (the site route's blobsOf(deps), the comms lane's raw R2 binding on the twin retry), uploads them, and hands the twin Gorgias's own attachment objects; buildTwinPayload and the appended message carry those instead of the signed links. A failed upload never blocks the twin: it is recorded on the ticket (hermes.twin_upload_failed with the reason and counts), that file falls back to the signed public link, and the footer still names every file; a clean upload records hermes.twin_uploaded. With no blob store at all (a caller that has none) the signed links ride as before and nothing is logged as a failure.

🧪 +5 tests → 1,129 (twin-attachments.test.js +4: a new twin uploads first — every file, type and byte count as parts, no JSON content type, the upload strictly before the ticket create — and posts Gorgias's objects while the footer still names the files · a 500 from the upload is recorded with its reason and the signed link stands in, the twin still created; no blob store → nothing uploaded, nothing logged · the append path uploads and carries Gorgias's object · both prefix lists admit /a/ and agree exactly; gorgias-client.test.js +1: the upload door's shape, a 413 is loud, an empty list sends nothing, and it joins the one-wrapper pin). BLUEPRINT ship (planned on the strong model, executed on the cheap one; the plan was re-baselined once when the HQ lane shipped v2.115.0 and v2.116.0 during planning). VERIFIED: the suite; the served version after deploy; a positive fetch of the public door on production with a link minted from the deploy's own signing value (the parent step). NOT VERIFIED at ship time: Gorgias rendering the uploaded file — Tyler's next form submission with a photo is the proof on the Gorgias side.

THE DOOR (server/hq.js, server/storefront-proxy.js): POST /proxy/hq/onetime { variant_id, quantity } → a Recharge onetime on the member's own subscription address, riding the next queued charge (add_to_next_charge, next_charge_scheduled_at = the subscription's next charge date) — same box, same date, charged once. The item's price and title are read from Shopify by variant id (productVariant GraphQL) — the browser never names a price; an unavailable variant is a 404 and nothing is created. Refused with the reason when there is no subscription on file, no active line, or no upcoming charge; a Recharge refusal is logged verbatim and answered "nothing was added" (createRechargeOnetime in server/integrations/recharge.js).

WHAT STAYS OUT OF HERMES: the non-subscriber paths (buy once / subscribe → checkout) and the discount-code price preview run on the storefront's own Ajax cart — measured 2026-09-05: this store's /cart/update.js accepts a discount and /cart.js answers discount_codes[{code, applicable}], total_discount and per-line final prices, so FLIP HQ shows Shopify's own numbers and never an invented one.

🧪 +3 tests → 1,124 (hq.test.js: the onetime rides the subscription's address + next charge with Shopify's price/title and the product id · refused without a subscription, an upcoming charge or an available item, with no onetime created; bad input is a 400 · a Recharge 422 is a 502 that says nothing was added).

THE RULE IS THE ONE SUPPORT ALREADY RUNS: cancelShopifyOrderSafe reads the order's fulfillment orders — untouched → a plain cancel; submitted/accepted to Flexport → a cancellation request rides through Shopify and the cancel completes when Flexport releases it; packed/shipped → blocked, with the reason. There is no separate clock: the window is "until the warehouse starts on it", exactly what the help desk gets.

THREE MEMBER-GATED DOORS (server/hq.js, server/storefront-proxy.js): POST /proxy/hq/order-options { order_id } → what the member may still do (cancel, address, a state of open · with_warehouse · shipped · cancelled, the reason when not, the current shipping address) · POST /proxy/hq/order-cancel → cancel + a FULL refund in the same motion (Shopify calculates the money, as support's route does), Hermes' order row gets cancelled_at / financial_status / refunded_cents from a re-read of the live order, the customer's rollups recompute, and the activity log names the member as the actor · POST /proxy/hq/order-address { order_id, …address } → the new shipping address while the fulfillment order is not yet with the warehouse (updateShopifyShipping), refused with the reason once it is. Ownership: the theme hands over the order NUMBER, Hermes looks the order up by name and the customer id on it must be the signed-in member — an id the browser sends is never trusted.

🧪 +5 tests → 1,121 (hq.test.js: another member's order and a missing number are refused on all three doors with nothing touched · the window reads open / with the warehouse / shipped / cancelled from the fulfillment orders · an open order cancels, refunds through calculate + create, marks the row and writes the activity line naming the member · with the warehouse the cancellation request rides to Flexport; shipped and already-cancelled are 409s with the reason · the address door PUTs the normalised address (FL → province_code, country kept) and refuses once the warehouse has the order; a missing ZIP is a 400).

TWO DOORS (server/lib/attachment-links.js, server/api.js): the private door GET /api/attachments/<message>/<idx> — the same session as the thread; an R2-backed row streams (its stored content type, inline; filename, private, max-age=3600, nosniff), a vendor-hosted row 302s to its source. The public door GET /a/<message>/<idx>?e=<exp>&s=<sig> — a link a vendor can fetch: an HMAC-SHA256 over tenant|message|idx|exp with the deploy's ID_SECRET, 400-day expiry, constant-time compare, and it serves only rows that have an R2 object and public=1 (the customer's own uploads) — a bad or expired link, a private row, a vendor-only row or a missing object is a plain 404. The secret reaches every isolate through configureAttachmentLinks (worker fetch + scheduled, the API deps, the site route); with no secret the public door is closed and the twin falls back to naming the file.

THE THREAD (server/api.js, server/conversations.js, web/hermes.html): an R2-backed image reaches the page through the private door beside the vendor-hosted ones; non-image files ride as a new files list (url, name, size, content_type) and the message shows them as 📎 chips that open in a new tab. THE TWIN (server/lib/intake.js): buildTwinPayload and the appended message both carry attachments: [{ url, name, content_type, size }] built by twinAttachments from the R2-backed public rows — so an agent working in Gorgias sees the customer's photo, not a filename. The footer still names the files. The twin retry on the comms lane signs links too (the scheduled entry point configures the secret).

🧪 +4 tests → 1,116 (attachments.test.js: the signed link round-trips and refuses a different index, a different tenant, a tampered signature, expiry and a closed door; twinAttachments travels only R2-backed public rows · the public door streams the bytes with type, name and cache headers and answers 404 to a bad signature, expiry, a private row, a vendor-only row and a vanished object · the private door is 401 signed out, streams an R2 row, 302s a vendor row, 404s an unknown index; the thread route and the shaper both carry the three images (two through the app door, one vendor) and the PDF as a file · the twin message carries two real attachments with signed links when the door is open and no attachments key at all when it is closed, the footer naming them either way). VERIFIED: the suite; the served version after deploy. NOT VERIFIED at ship time: Gorgias actually rendering a fetched attachment on a twin — Tyler's next form submission with a photo is the proof, in Hermes (the chip / the image) and in Gorgias (the attachment on the ticket).

THE CHANGES (server/service.js, worker.js): a small pool(items, width, fn, shouldStop); healRefundedOrders now fetches four orders at a time and stops the fetch phase at two-thirds of the budget, then runs the rollups four at a time inside the last third — batch 300, budget 75 s, so a tick heals roughly four times the rows in about the same wall time (≈21 ticks to drain 6,283 instead of ≈68); budget_hit is set when the pool stopped short. healStaleSubscriptions orders its candidates next_charge_at DESC — the newest stale rows are the likely-wrong end, and Tyler's would have been corrected on the first pass rather than the second. Counts, state and the remaining figure are unchanged in shape.

🧪 +1 test → 1,112 (heal-sweeps.test.js: with limit 1 the RECENT stale subscription is fetched first and the old one waits · twelve refunded orders across two customers through the pool — every one healed exactly once, two rollups, fetches observed overlapping and never wider than the pool · a clock that jumps 20 s per look trips the budget honestly and leaves the unreached rows queued). VERIFIED: the suite; the served version after deploy. NOT VERIFIED at ship time: the 00:40Z pass under the new numbers — read heal_refunds in queue_cache (expect ~300 healed, budget_hit false or a near miss) and the 10,40 lane's total time.

THE THREE CAUSES, ALL SYSTEMIC (production, 2026-09-04 23:0xZ): (1) 554 of 21,208 active/paused subscription rows carried a next charge more than three days in the PAST — the Recharge lane walks updated_at_min forward, and nothing ever re-asked about a row that stopped changing (Tyler's: Recharge 833762930, "active", next charge 2026-07-16, untouched since 07-07). (2) 6,469 of 7,325 refunded orders had NO refund amountsweepRefunds reaches 60 days back, two pages a tick, and the per-order heal fires only when someone opens the order sheet — so recomputeCustomerRollups, which subtracts refunds correctly (Tyler's 2026-08-05 rule), had nothing to subtract. (3) 4,901 of them had no cancelled_at either, and the profile row showed the GROSS beside a "refunded" chip with no amount, and a never-shipped, never-cancelled order older than ten days as late.

THE HEALS (server/service.js, on the 10,40 lane after the refund sweep — worker.js): healRefundedOrders(limit 150, 60 s) — every refunded/partially-refunded order with refunded_cents IS NULL, newest first: fetch it from Shopify, write refunded_cents + cancelled_at (+ the live financial_status), then recompute each touched customer's rollups once; an order Shopify no longer has (404) is settled as fully refunded when it was 'refunded' (net zero is the economic truth) and as 0 when only partial. healStaleSubscriptions(limit 50, 45 s) — every active/paused row with next_charge_at more than three days past: re-read from Recharge by id and true it up through the one write door (upsertSubscription, which dates the change and writes the subscription_events ledger row); a row Recharge no longer has is cancelled; a row Recharge still calls active with a stale charge (a charge stuck retrying) is remembered in the heal's state for seven days so the pass never re-fetches the same fifty forever. Both are bounded per tick with a wall budget, count every outcome — healed · gone · unchanged · errors + the first error's text — never a swallowed failure, write their state to queue_cache (heal_refunds, heal_subs) and report remaining, which reads 0 when they are done: ~43 ticks for the refunds, ~11 for the subscriptions, self-completing.

THE ROW (web/hermes.html, the profile's orders): the money column shows net — gross struck through, net beside it, the split in the tooltip — whenever money went back (a full refund with no recorded amount counts as the whole order, so the row is right before the heal reaches it); and the delivery chip reads cancelled for a refunded order that never shipped, whatever the mirror knows about cancelled_at. Live refunds made from the app still overlay instantly through window.__moneyPatch.

🧪 +4 tests → 1,111 (heal-sweeps.test.js: the refund heal end to end — two healed, one vanished order settled, the paid order never fetched, the customer recomputed once and LTV down by exactly what went back, remaining 0, state written, a second pass finds nothing · a vendor 429 is counted and named and the row stays queued with nothing invented · the subscription heal — a cancelled one trued up with its date and ledger row, a vanished one cancelled with a ledger row, a still-active one remembered and not re-fetched, a future-charge one never fetched, remaining honest · the profile row's source pins). VERIFIED: the suite; the served version after deploy; the first tick's counts are the day-after read. NOT VERIFIED at ship time: the heals against real vendors — the 23:40Z tick is the first pass; Tyler's profile should read one active subscription and $0.00 within two ticks.

THE FIX (server/hq.js): withTimeout(promise, ms, label) — rejects <label> timed out after <ms>ms, clearTimeout on settle either way so a fast leg never leaves a dangling timer; ME_TIMEOUTS = loyalty page data 12 s · Recharge charges 12 s · Recharge subscriptions 12 s · member data 5 s · order streak 5 s. A timeout on the page-data or charges leg → the existing 502 live account data is unavailable right now — try again in a moment; on the subscriptions leg → the existing 502 live subscription data is unavailable right now — try again in a moment; member data and the streak fall to their defaults as before. The Recharge subscriptions read is now the FIFTH leg of the same Promise.all (its filter/map unchanged), so the two Recharge calls overlap instead of queueing. me() takes an optional { timeouts } so tests run at 50 ms. Everything else — the tier block, earn status, milestones, the return shape — is untouched; the theme's own client-side timeout is a separate lane.

THE TIMING LINE: once per call, console.log('hq/me', JSON.stringify({ member (last four digits of the id), status, total_ms, pageData_ms, subs_ms, items_ms, member_ms, streak_ms, failed: [labels] }))npx wrangler tail flip-cms --format json shows where the time goes; never an email, a token or a vendor's error text.

TESTS (+5 → 1107, test/hq.test.js): withTimeout passes a fast promise with no timer left behind (process.getActiveResourcesInfo) and rejects a slow one with its label; a page-data read that never settles → 502 inside the timeout; a stalled Recharge subscriptions listing → 502 inside the timeout; the happy path still returns loyalty + subscription.items, proven parallel by a charges read that answers only once the subscriptions read has been asked (serial code deadlocks there); the hq/me line is emitted once per call with the leg millis, the failed labels and no @.

ONE DISCOUNT PER FRIEND CODE (server/integrations/shopify.js, server/referrals.js): landing() no longer touches the pool. It mints the friend code through the new shopifyCreateFriendCode(fetchImpl, creds, { code, valueCents, title }) — the owned-reward input with customerSelection.all (the friend has no account yet), usageLimit 1, appliesOncePerCustomer true, combinesWith { order false, product false, shipping true }, plus customerGets.appliesOnOneTimePurchase true and customerGets.appliesOnSubscription true — titled HERMES referral — $15 off (friend) — <code>. ⚠️ The two flags live INSIDE customerGets (DiscountCustomerGetsInput), not on the discount input: introspected on the store while planning — DiscountCodeBasicInput has no such fields, so the top-level placement the brief described would have failed every mint with a GraphQL validation error, and the live pool measures customerGets.appliesOnSubscription false / appliesOnOneTimePurchase true (the diagnosis, measured). A refusal is the existing 502 "unavailable right now" with nothing written. shopifyCreateOwnedRewardCode (the $25 reward codes) gains the same two flags, so a reward code works at a subscription checkout too. The old pool discount and its two codes are left exactly as they were. The sweep's shopifyDiscountStatus(code) was verified to resolve BY CODE — the REST discount_codes/lookup.json?code= door, then the orders(query: discount_code:"…") probe (the coupon-truth path) — so a per-code discount resolves exactly as a pool code did; a test now pins both lookups to the row's own code.

AUTO-APPLIED FROM THE INVITATION (landingHtml): once the code arrives, the Shop Flip My Life button points at <store>/discount/<CODE>?redirect=%2F — Shopify applies the code to the session and lands the friend on the home page; the code stays visible with a Copy button (navigator.clipboard, select + execCommand as the fallback, the label flips to Copied); the line under it reads Tap Shop and it's applied for you — or copy it for later. Works on one-time and subscription orders.

THE PIPELINE (page-data → referral.pipeline, server/schema.js): up to 50 rows, newest claim first, for the advocate's invited + rewarded referrals (blocked rows are never shown): { id, name, status, landed_at, rewarded_at, nudged_at, nudges, can_nudge }name = the friend's name on file or the email masked (j@gmail.com), status claimed (invited) | ordered (rewarded), can_nudge = claimed AND the claim older than 24 h AND (never nudged OR the last nudge older than 72 h) AND fewer than 3 nudges. The counts keep clicks, rewarded, points_earned and add claimed (invited + rewarded). Two new MIGRATION columns* — referrals.nudged_at TEXT, referrals.nudges INTEGER DEFAULT 0 — hand-applied on prod BEFORE the Worker (deploy/migrate-2.110.0.sql); no index (trap 16).

THE NUDGE DOOR (POST /proxy/hq/referral-nudge { referral_id }, member-gated; storefront-proxy.js dispatches to referrals.nudge): 404 unless the row is the member's own (a blocked row reads as not found); 409 Already ordered; 429 with a plain sentence when the window is shut (Give it a couple of days — you can remind them again on September 7 / You've reminded them three times — leave it there); 503 without a mail door; 502 when the mail door says no — and nothing is stamped. Otherwise ONE email to the friend through deps.appolis.sendMail (tag referral-nudge, the brand as the sender surface): subject Your $15 FLIP code is still waiting, <first name>; the shell of the code emails wearing the shop's gold logo, You asked for a friend code from <advocate> on <date> — here it is again: <CODE>, a Shop with it applied button to the same /discount/ link, and the store link — no system name anywhere in it (the v2.109.0 rule; the From line itself is the suite mail door's Hermes · <brand>, an Appolis matter). Then nudged_at = now, nudges + 1 and { ok: true, nudged_at, nudges_left }.

TESTS: +7 (1102) — the friend-code input (both applies flags, usageLimit 1, all customers, shipping-only stacking, the title) with landing() minting through the new function, NO pool call, and a 502 on a userError; the owned code's flags and its owner binding; the pipeline's shape, masking, ordering, the three windows, claimed and the 50-row cap; landingHtml's /discount/ link, Copy button, new line and script safety; the sweep's status lookup BY CODE; the nudge email's subject / body / escaping / no system name; the door's 400/404/409/429/503/502/200, the single email, the stamp, the 72 h refusal after it, and page-data carrying claimed + pipeline. ⚠️ NOT verified from here: that Shopify's app proxy passes the /discount/ markup through unchanged (the parent curls the store-served landing after deploy) and that a subscription checkout now accepts a friend code (Tyler tests with a fresh alias). The theme side — the pipeline list, the Nudge button, the sign-in pop-up — ships in BP-C5 / BP-R5.

SENDER NAME (server/integrations/registry.js, server/integrations/gorgias.js): a new optional sender_name on the Gorgias connection card ("Sender name customers see (e.g. Flip My Life Support)"); resolveEmailSender now returns { address, name } when one is set (trimmed, 80 chars) and { address } alone when not — so every outbound email path (a reply, an outbound thread, a return label) carries it on source.from. assertPinnedSender compares the address only; the pin is untouched. ⚠️ Whether Gorgias honours source.from.name on the email it composes is NOT verified from here — the name Tyler saw is the mailbox's display name as Gorgias holds it, and the certain fix is in Gorgias (Settings → Channels → Email → the support@ integration → its name) and in Microsoft 365 (the support@ user's display name, which the future M365 mail card will send from). Setting all three the same is the intent.

THE FOOTER (server/lib/intake.js buildTwinPayload, appendCustomerMessageToTwin): now Attachments (1): Screenshot… (75 KB) and Ref H-A42EE3 · sent from the website contact form (the appended form: · added to your open conversation). No "stored in Hermes", no "Hermes ref" — the reference stays, because a customer quoting it is exactly what it is for.

🧪 +1 test → 1,095 (gorgias-client.test.js: the sender name rides beside the pinned address, trimmed; with none configured the address goes alone — exactly as before). The two footer pins in intake.test.js and intake-merge.test.js now also assert the customer-visible words carry no system name. VERIFIED: the suite; the served version after deploy. NOT VERIFIED: Gorgias's use of the name on the wire — Tyler's next reply shows it, after he sets the name on the connection card AND in Gorgias's channel settings.

WHAT WAS HAPPENING (production, 2026-09-04 afternoon): every website form message became a new Hermes ticket and a new Gorgias twin. Gorgias's auto-merge then folded the twin into the customer's older open ticket and deleted it — a second after creation — so the number Hermes held was dead. Two real customers hit it (orders 197932 and 300346) before this shipped: their words reached Gorgias on their older tickets and the mirror carried them back, so nobody was lost, but the Hermes-native rows were trashed with the order numbers and files riding along, and any reply from Hermes 404'd. Worse, the merge event was being read backwards: it rides on the SURVIVOR with data.source_ticket = the ticket merged away (a 30–230 KB object, which is why the stored payload shows only its key), and the code looked for a target key, found none, and stamped merged_into='unknown' on the survivor — twelve of them on prod, every one the wrong ticket.

THE RULE (server/lib/intake.js openConversationFor + appendToOpenConversation): a contact-form message from a customer who already has an open, untrashed, email-answerable conversation with a delivery path (status='open', channel in email/contact_form/help-center/api, provider='hermes' OR gorgias_ticket_id IS NOT NULL; the newest by last message wins) is appended to it: one new customer message on the existing ticket (awaiting_reply=1, messages_count+1, tail refreshed — it surfaces in the queues again), the file rows hang on that message, one hermes.intake event with appended_to_open:true, and the customer is told the reference of the conversation they already have (added_to_open:true on the form's 201). No new ticket, no new twin — so Gorgias has nothing to merge. Then appendCustomerMessageToTwin posts the same words onto that conversation's Gorgias ticket as the customer's own message (from_agent:false, from the customer, to the pinned sender, the attachment names and the Hermes reference in the footer, the inbound label following the ticket it lands on) and correlates the echo exactly as createTwin does — rfc_message_id='gorgias:<id>' on the Hermes row, any early mirrored copy dropped — so the thread never shows the words twice. A closed conversation does not absorb a fresh question (a new ticket, as in Gorgias); a Messenger/Instagram/chat thread never absorbs a form message (its replies cannot go out by email); a conversation with no delivery path never absorbs (it would swallow the words and refuse every reply — the seeded "Derek" found that one); chat sessions are untouched. Lockstep and connection flags gate the twin append the same way they gate the twin create; a refused append is twin_error + a hermes.twin_append_failed event, never a lost message.

THE MERGE EVENT, THE RIGHT WAY ROUND (server/integrations/registry.js applyGorgiasEvent): MERGE_SOURCE_KEYS (source_ticket, …) read off the survivor's event → the SOURCE row gets merged_into=<survivor>; the survivor is never written. The old target-key guess stays as the fallback; writing 'unknown' is gone. The source's own ticket-deleted (Gorgias sends one right after) still trashes it as before. Backfilled on prod: the twelve 'unknown' marks cleared; today's three dead twins pointed at their survivors (115827988→115469720, 115830716→115821386, 115834261→115784006).

A REPLY FOLLOWS A MERGE (server/lib/intake.js dispatchAgentReply): before sending, the reply walks merged_into from the ticket's Gorgias id — at most five hops, 'unknown' and self-loops stop it — and posts on the ticket that still exists; the event and the response carry gorgias_ticket_id (where it went) and merged_from (where it started). A merge Hermes has not yet heard about still 404s loudly — the events lane relinks it within its cadence.

🧪 +4 tests → 1,094 (intake-merge.test.js +4: the append end to end — one ticket, one twin POST, the customer's words on the twin from the customer to the pinned sender with the order number and the file names, awaiting_reply back on, two Hermes rows and the echo correlated, then the mirror run with both echoes in Gorgias's list and still exactly two rows · a closed conversation does not absorb · a Messenger thread and a trashed ticket do not absorb · a reply follows merged_into to the survivor and 'unknown' stops the walk; gorgias-events.test.js +1 inside the merge test: the live source_ticket shape marks the source and leaves the survivor NULL, and an empty payload writes nothing. intake.test.js's rate-limit pin now reads one conversation, six messages — six accepted posts from one customer were six tickets yesterday. Also caught by this run: case-engine.test.js's Stuck-tile test seeded its ages from a FIXED date while the engine reads the real clock — "acked until tomorrow" expired at exactly 24 h and the acked ticket surfaced as stuck at 15:00Z today; the tile test now seeds against the real clock through a switchable base, and the business-hours unit tests keep their fixed Thursday.) VERIFIED: the suite; the merge-event shape against four real events; the served version after deploy; the backfill read back. NOT VERIFIED at ship time: a live second submission — Tyler's next form message from an address with an open conversation is the proof (it should come back with the same reference and land on the open ticket in both systems). STILL OPEN: attachments are stored and shown nowhere (todo_2630 — a real customer's "Flip Review.jpg" from 14:21Z is invisible in both systems); the one-shot delivery probe (note_568).

THE BUG (server/integrations/gorgias.js sendGorgiasReply): Hermes stamps the Gorgias twin of a website message with its OWN word — buildTwinPayload (lib/intake.js) sets channel: 'contact_form' while sourceTypeFor() correctly sets source.type: 'email'. The reply path then mirrored that label onto the outbound: channel = inbound.channel || …. Gorgias 201-accepted the message, stored it, and displayed it as sent — and never put it on a mail transport, because contact_form describes where a ticket came FROM, not a way to send. No bounce, no failed_datetime, no error anywhere: the exact silent non-delivery this file already guards against on the SENDER (the v2.91.0 pin), reappearing one level up on the CHANNEL.

THE EVIDENCE (production, messages, from_agent=1, since 2026-08-01): email 2,680 → 2,658 sent · facebook-messenger 631 → 629 · chat 377 → 377 · instagram-direct-message 106 → 106 · contact_form 1 → 0. Tyler's reply was the first message this tenant had ever posted on that channel and the only agent reply of any channel to produce no delivery. Two controls rule out the alternatives: the SAME recipient was delivered successfully on 2026-08-15T20:12:51 from the same pinned sender on channel email, so neither the address nor the sending identity is at fault; and the two failures that day (the login-address bug) recorded failed_at, where this one recorded nothing at all.

THE FIX: channel = inbound.source.type === 'email' ? 'email' : (inbound.channel || ticket.channel || 'email'). The source type already knows what the conversation IS — trust it for email, mirror the label for everything else. ⚠️ Deliberately not widened to every EMAIL_LIKE channel and the else branch is load-bearing: chat, facebook-messenger and instagram-direct-message MUST keep their own channel, the only thing Gorgias can deliver between (17,986 of this tenant's 51,213 tickets are non-email). assertPinnedSender branches on source.type, never on the channel, so the sender pin is untouched by this.

BLAST RADIUS: one message, Tyler's own. provider='hermes' AND channel='contact_form' had exactly one row in the entire table, dated today — the store form went live this morning, so no customer was ever left unanswered. It was found because the ship was tested end to end within the hour.

🧪 +2 tests → 1,090 (gorgias-client.test.js: a contact_form twin's reply posts channel:'email', keeps source.type:'email', still answers the address that wrote in and keeps the pinned sender · a Messenger thread still posts facebook-messenger with the handle as recipient and the page as sender). Both pins were proven to FAIL on the unpatched tree before the fix was kept.

VERIFIED: the suite; the fix reverted and re-run to prove the pins bite; the served version after deploy. NOT VERIFIED at ship time: a real answered form message — Tyler re-replies to his own test ticket as the live proof. ⚠️ STILL OPEN (note_568): the delivery probe fires ONCE 1.5 s after posting and NOTHING re-reads a message afterwards, which is why a reply that never sends is never reported to anyone — that follow-up sweep is the next fix, and it is what would have caught this in minutes instead of by eye.

THE PAGE (referrals.js landingHtml(slug, opts)): one theme, the FML palette — cream ground #F5F2EC, ink #411F18, gold #C79A3B for the value and the button, green #95B267 for the success state; Google Fonts Outfit (display) + Assistant (body); a centred 440px card; the brand's gold logo above the eyebrow when the shop's landing_logo_url is set, else the brand name as a gold wordmark; no bobbing emoji. The copy: eyebrow A gift from {first name} (fallback A gift from a friend) · You've been invited to FLIP. · {name} is giving you $15 off your first Flip My Life order. Tell us who you are and your one-time code appears. · First name + Email · Get my $15 off code; on success the eyebrow turns green — Here's your code — the code sits in a gold dashed box, Use it at checkout on your first order — it's yours alone., and Shop Flip My Life goes to the store; fine print One per new customer. Your friend gets a thank-you when your first order ships. Errors in plain words (Please add your first name. / That email doesn't look right. / the server's own message). opts = { brandName, logoUrl, storeUrl, advocateFirst, friendValue } come from a new landingOpts(db, tenant, fetchImpl, link): the brand from the tenant (the same helper the code emails use), logoUrl from the shopify creds' landing_logo_url, storeUrl = https:// + the shop's primary domain (the myshopify host as the fallback; null when no shop is configured — the button then points at the store root), advocateFirst = the first word of the advocate's customers.name (by shopify OR rivo id), null when unknown. Both doors (/l/refer/<slug> on Hermes, /proxy/refer/<slug> on the store) build them and pass them; every field is optional so the page always draws.

NAME REQUIRED (referrals.js landing()): { slug, email, name } — the name is trimmed, 2–60 characters, letters (any alphabet) / spaces / ' / - / . only, else 400 Please add your first name. (checked before the email, so an empty form hears about its first field first); stored in the new referrals.friend_name column — a MIGRATION column (schema.js MIGRATION_COLUMNS, never the SCHEMA block — trap 16), hand-applied on production BEFORE the Worker because both INSERTs name it with no catch (deploy/migrate-2.106.0.sql). The already-invited path returns the stored code as before and keeps the first name that was given.

NO CODE ENTRY (storefront-proxy.js): the referral is the link. GET /proxy/refer (no slug) → 302 to the store's front door; POST /proxy/refer404; the v2.104.0 code field is gone from the page. page-data.referral.code / enter_url stay in the contract untouched (the theme stops rendering them — removing fields is not worth a contract change).

POST-DEPLOY (parent-run D1): landing_logo_url = https://flipmylifenow.com/cdn/shop/files/Gold_FML.webp set by json_set on t_verdant's shopify settings creds — the same file the site header renders. ⚠️ A later save of the Shopify settings from the admin UI replaces the creds JSON whole; the key lives only in D1, so re-set it if the page ever falls back to the wordmark.

🧪 +3 tests net → 1,088 (two r8 tests rewritten — the code-entry page no longer exists — and five written: referrals.test.js the redesigned page with and without a logo / an advocate / any opts, escaped · landing() name guard + friend_name + the already path + a blocked row keeps the name · landingOpts brand / logo / primary domain and its myshopify fallback / advocate first name or null / no shop at all; storefront-proxy.test.js no-slug GET 302 + POST 404 + PUT 404 · the link landing with the advocate's name + the name field + noindex, POST without a name 400, friend_name stored, the click still counted once, page-data keeps code + enter_url). Fourth blueprint ship.

THE ROLE (server/lib/auth.js, server/api.js, web/hermes.html): a new grantable area affiliates (enforced, on the Team tab's access picker) and a new affiliate_manager rank. ⚠️ The rank is INSERTED into RANKS between dept_manager and dept_user, never appended: that array is an ORDERED ladder read by index, and a rank below dept_user would have let any department user mint affiliate managers through "you can only add ranks below your own". affiliate_manager joins LEAD_RANKS, so Scott sees the lead surfaces (VIP retention, Unassigned) as a co-founder should. The client twin in TeamView carried a hard-coded ai>=3 for the bottom of the ladder — now RANKS.indexOf('dept_user'), because the insert invalidated the literal. DEPT_AFFILIATES (/affiliate|creator|partner|influencer|ambassador/i) is the belonging-side twin of DEPT_FULFILLMENT, pinned verbatim against DEPT_HOME's row in the client.

THE SECTION (web/hermes.html): the 📦 Fulfillment tab held nothing but Creator Sends, so this is a MOVE, not a copy: the tab key is now affiliates (🤝), #tab=fulfillment is aliased to it so every bookmark and pasted link still lands, and FulfillmentView is mounted unchanged as the Creator sends section beside a new VIP list section (A_SECTIONS, its own #asec= URL so browser back walks the two). **No /api/sends/ or /api/creators/ route moved — only their gate widened, to auth.canSends (either desk), so the fulfillment team keep Creator Sends** after it changed tabs; TABS draws the new tab for a fulfillment grant for the same reason.

THE COMPLETE VIP LIST (server/service.js, server/api.js): GET /api/affiliates/vips serves every VIP, claimed and unclaimed, richest first, searchable by name/email, filterable by affiliate status, paged — with name, email, LTV, tenure, last order, owner, last contact, affiliate status on the row. It is a second route rather than a flag on /api/vips because that one deliberately hides claimed VIPs (they belong on their owner's retention board); ownership says nothing about who makes a good affiliate. ⛔ It never scans the 159,991-row customer table. computeAndCacheQueues now ranks VIPs once with unassignedOnly:false, writes the full set to a new vips_all cache and filters in memory on c.vip_owner IS NULL for the existing vips blocks — one query where a naive build would have put a **second ~2 s LTV rank on the /30 lane*, which trap 14 says is exactly how the tail of a long cron lane starves. Search and the status chip filter the cached array; the only live SQL is the status overlay.

THE LOGGING TOOLS (Tyler's D21): not re-implemented. A row opens the existing customer profile — Log call (CallLogSheet / OutcomeStrip mode=call), outcomes, touchpoints, check-ins and notes all live there — and the row itself carries the existing CallOutcomeMenu, a check-in and a scheduled reach-out, wired to the very handlers the VIPs tab uses. One surface, no second copy to drift.

THE MARK (server/schema.js, deploy/migrate-2.105.0.sql): affiliate_status + affiliate_status_at on customersnone · invited · active · declined — as MIGRATION columns (trap 16: an index over a migration column inside the SCHEMA block aborts every table below it), hand-applied on production before the Worker because the list route reads them with no catch. 'none' clears the column rather than storing the word, which is what keeps idx_customers_affiliate PARTIAL — partial for both the alt_emails reasons: it indexes the handful of marked rows, and a partial index can never be chosen for a plain WHERE tenant_id=? customers query, so it cannot win the stats-less equal-cost tie that bit idx_shipments_status and idx_tickets_status_owner in v2.101.0. The overlay query restates affiliate_status IS NOT NULL verbatim — without that term SQLite silently declines the partial index and full-scans — and that line is pinned by a test. The overlay is per-request, so a mark Scott just made shows on his very next load instead of waiting up to 30 minutes for the cron.

🧪 +21 tests → 1,085 (affiliates-access.test.js +7: canAffiliates/canSends and the both-desks rule, the department fallback, the ladder order + the dead literal index, an affiliate manager on the desk / Creator Sends / /api/calls / /api/outreach, the admin-only back-office doors still refused, a support agent refused and a packer still served, the one-source-of-truth department pin · affiliates-vips.test.js +8: claimed AND unclaimed richest-first, paging + the recruiting columns, search on name and email, the mark written and overlaid past a warm cache and filtered, an unknown word refused / none clearing to NULL, one ranked pass feeding both caches as superset and subset, the partial-predicate source pin + the migrate file, Creator Sends still answering · affiliates-ui.test.js +6: the tab in all four places + the alias, FulfillmentView mounted exactly once, one list behind the chips and the mark, trap 19 — every helper module-level, no bare ago(, no bare confirm — the fulfillment grant still drawing the tab + the asec hash strip, and the logging tools reached rather than rebuilt). Third BLUEPRINT ship (planned on the strong model, executed step by step on the cheap one against a Kosmos checklist). VERIFIED: the suite; the two columns and the index read back on production D1 before the deploy; the served /api/version after it. NOT VERIFIED: a click in a browser — Scott's invite goes out once the section is live, and his first real recruit is the watch; the vips_all cache is first written by the next */30 tick, so the list answers from its bounded live fallback (source:'live') until then.

REFERRAL CODE ENTRY (referrals.js, storefront-proxy.js): landingHtml(slug) with NO slug is the same landing page plus a "Referral code" field above the email ("Have a friend's code?"); the form posts {slug, email} to its own path. New public door GET/POST /proxy/refer (no slug) — GET renders that page (noindex; no click counted — clicks are link visits), POST upper-cases the code and hands {slug, email} to landing(), so the same guards and the same one-code-per-friend rule apply. /proxy/refer/<slug> is unchanged. page-data.referral now carries code (the slug) and enter_url (<share base>/refer, i.e. https://flipmylifenow.com/apps/sub-points/refer) beside share_url.

REVIEW RECORD (hq.js, storefront-proxy.js): hq_member_data kind review (k = product id, v = {"at","order_id"}); memberData() returns reviews: { [product_id]: { at, order_id } } so the dashboard can say "Reviewed ✓" per item. New door POST /proxy/hq/review {product_id, order_id} — 400 without a 1–20-digit product id, else { ok, product_id, at }; wired beside /proxy/hq/rate. Junip keeps the review; this is HQ's own memory of it.

SPEND MILESTONES IN THE PROGRAM SHAPE (storefront-proxy.js, hq.js): the "VIP tier upgrade · 50" row was never a rule — it was two one-time bonuses. EARN_RULES now says so: Reach $300 lifetime spend · 2,500 and Reach $750 lifetime spend · 5,000; the public shape adds milestones (FLIP Platinum at 30000¢ / 2,500 · FLIP VIP at 75000¢ / 5,000) and milestones_for_subscribers (loyalty settings creds.milestones_for_subscribers, default false, read once in pageData). earnStatus gains a lifetime spend branch: done when a loyalty_milestones row exists for that threshold (read once in /me) or, for a NON-subscriber, when lifetime spend is past it (Shopify Flow paid those at tier upgrade); otherwise open. A subscriber with the flag OFF gets the two rows removed from earn_rules (a filtered copy — the shared catalog is never mutated). The old vip tier branch stays for any tenant that still names a rule that way. Payout logic is untouched.

SUBSCRIBER MILESTONES, FORWARD ONLY (loyalty.js, worker.js): Tyler, 2026-09-04, shown the numbers (9,632 active subscribers already past $300, 4,807 past $750 — a 48,115,000-point retro grant): no retroactive grant, going forward only. New subscriberMilestoneSweep on the 10,40 cron lane, gated on loyalty creds milestones_for_subscribers + milestones_since: an order placed on/after milestones_since by an ACTIVE subscriber whose lifetime spend crosses an UNCLAIMED $300 / $750 line pays the bonus — claim row first (loyalty_milestones, unique per member + threshold — the same row nativeEarnSweep claims once native earn flips, so the two can never pay twice), then the points through the SAME provider seam a Top-Flavor vote uses (provider.award — Rivo until the flip, the ledger after); an award that throws or refuses deletes the claim so the next run retries, never a silent 0. Members already past a line at the flip never cross it again — nothing retroactive by construction; non-subscribers are skipped and counted (Flow still pays them). The SELECT keeps only orders that cross an unclaimed line, so paid crossings leave the scan. The flag and milestones_since are set on prod by this ship's post-deploy step (D1 json_set on the tenant's loyalty row, read back).

CLAIM ON THE PROXY DOOR (storefront-proxy.js): POST /proxy/claim {rule} — the twin of /ext/claim (claimFlatRule: instagram_follow / tiktok_follow / facebook_like / review, once per member, "already claimed" said politely). It never existed on the app-proxy door, so HQ's follow rows could record nothing.

🧪 +15 tests → 1,064 (storefront-proxy.test.js +4: the code-entry landing + POST round-trip + no click counted; the review door + memberData shape + member gate; the milestone shape + /me from spend / paid row / flag; /proxy/claim once · hq.test.js +4: the earnStatus lifetime-spend branch; memberData reviews; review() guards + row; /me strips the rows for a subscriber under the flag and keeps them otherwise · referrals.test.js +1: both landings · flat-rules.test.js +6: gate, cross-once + rerun + second line, before-since, already-past, non-subscriber skipped, award-throws-claim-returned). Second BLUEPRINT ship (planned on the strong model, executed on the cheap one against a Kosmos checklist). VERIFIED: the suite; the served /api/version; the prod loyalty row read back with both keys. NOT VERIFIED: a browser click on either skin (the theme blueprints ship those); a real subscriber crossing on prod — the first live crossing is the watch (cron subscriber milestones t_verdant in the worker log).

WHAT CHANGED: the case strip at the top of an open thread lists its last three effects; an effect row that is an outcome now carries a small remove beside its date, shown only to someone the server would allow (the recorder, a lead, an admin — the same canRemoveCs gate as the ✓ line). It goes through the ONE removal door from v2.99.0 (removeCsRow: the styled confirm in its danger style, the DELETE behind a named wait, the toast whose Undo restores). Afterwards the strip re-reads its case from the server so effects, stage and resolution move together, and it tells the thread through a new onCsChange prop wired to the same state the ✓ line's handler uses — so the ✓ line drops to its pick phase and the 🎯 Outcome button returns without a reload, and an undo mounts the ✓ line from the live row again. No server change: the effect rows already carried the disposition id (ref.id) and the recorder (actor) since v2.99.0.

🧪 +3 tests (test/case-strip-remove.test.js): the strip takes onCsChange, calls removeCsRow('disposition', …), re-reads /api/tickets/:id/case, reads the answer with csRemoveAnswer, and tells the thread in the onDone shape; the control is gated on kind, ref.id and canRemoveCs with no bare confirm; the thread mount wires onCsChange to setDispo / setStripMode exactly as the ✓ line does. Shipped as the first BLUEPRINT ship (planned on the strong model, executed step by step on the cheap one against a Kosmos checklist). VERIFIED: the suite; the served version after deploy. NOT VERIFIED: a click in a browser — Tyler is the first who can; the telemetry day log stays the watch.

REFERRAL (storefront-proxy.js, api.js, referrals.js, integrations/shopify.js): share_url is now https://<store domain>/apps/sub-points/refer/<slug> — the store's PRIMARY domain from Shopify's shop.json (cached an hour per shop; creds.public_host overrides; the myshopify host and finally Hermes' own host are the fallbacks, so an outage degrades to a working link). A new public door /proxy/refer/<slug> above the member gate: GET counts the visit and renders the capture page (noindex), POST takes the friend's email — the page posts to its own path, so it works unchanged through the app proxy. The old /l/refer GET counts visits the same way, and the capture-time increment is gone: "Link clicks" now means clicks, invited means captures.

RETURN FOR POINTS (storefront.js): Tyler's $10 reward was never returned because the theme ran the door on a button the confirm dialog had already detached — the write took ~3 s and nothing showed (fixed on the theme, below). The door itself had three holes, all closed: a code Shopify no longer has now returns its points (it is unusable; the old exit told the member "its points are back" and credited nothing); an unanswered Recharge releases the claim and answers 502 instead of crediting points while the code may still sit on an upcoming order; a refused Shopify delete likewise releases and refuses — points never come back while the code stays spendable. couponDeath now knows revoked_at. The two storefront return fixtures answer Shopify's token mint and Recharge's charge list, because the door no longer swallows either.

EMAIL CHANGE BY CODE (hq.js): a NEW sign-in email no longer switches on Save. Hermes mails a 6-digit code to the new address (Appolis mail door, brand name from the tenant), stores it hashed in hq_member_data (kind email_change; 10-minute life, 5 tries, 5 codes an hour), saves the other fields at once, and answers pending_email + code_sent; POST /proxy/hq/profile/confirm-email {code} performs the switch (Shopify → Hermes row → Recharge mirror) and clears the pending record. No mail door → 503, said plainly. The dispatcher now threads deps into the profile door.

BOXES PER ADDRESS (hq.js): paymentMethods returns groups (one per Recharge address with active lines: label, card, items) and each card carries bills — the boxes it charges — so in_use is true for any card billing any box; addresses carries the items shipping from each address. ⚠️ Still one-box in this release: schedule/delay/cancel act on every active line, shipNow on the earliest charge, addLine anchors on the first line — filed as the next multi-box step.

THEME (classic-account 1354dde → 7ab6774 · retro-pop 0b0e276 → ffb2aba, all pushed): HQ's own My Account row is #/profile (a name Rivo's embed does not match) with an un-hook — it was still opening Rivo's panel; every subscription-area "Edit" reads "Change" (Chris); cancelled orders are Cancelled (the island now carries cancelled + financial_status) and the delivery card follows the latest order that is actually going somewhere; the swap picker shows subscription prices; streaks are hidden behind STREAKS_ON=false (shelved — subscribers already hold the best rate); on mobile the fixed top bar and drawer are gone and the menu is a swipeable strip at the top of the HQ area under the site's header; the live box card has a padded body, white contained art and a one-row stepper; a My rewards card lists every held code (Copy, state, Apply to next order, Remove from order, Return for N points, past rewards on demand); every reward write shows its own centred, named wait and a refreshed result modal with the new balance; the cancel flow opens on "Here's what you'd be giving up" — subscriber price vs one-time for the actual box, FLIP Fam's points rate vs a member's, the balance and distance to the next reward, any reward waiting on the next order, exclusive content and the monthly vote — with no streak anywhere; the profile form runs the email-code step; members with boxes at several addresses get one card per box and every payment card says which boxes it bills.

🧪 1,046/1,046. VERIFIED LIVE (Tyler's session, both skins): My rewards card and actions render from the live coupon book; the $10 reward's return completed through the door (1,000 points back, 8,050 → 9,050); the cancel step shows real numbers ($5.50 more per delivery one-time, 10 vs 5 points per $1); the mobile strip sits under the site header with no horizontal scroll; the served referral link, the store-domain landing and the per-box groups are verified after this deploy (see the brief). NOT PRESSED: the email-code round trip on a real address, a real cancel, Use for subscription, address saves.

WHAT WAS MEASURED (D1 insights, 24 h, and a prod-shaped local copy with no statistics — production's state): every FROM shipments s JOIN orders o … WHERE s.tenant_id=? AND o.customer_id … made SQLite walk every shipment of the tenant through the tracking index's tenant prefix and look each order up by key — two visits per shipment, 582,000 rows per run on production — no matter how few customers the query asked for. The scorer's books half ran it 626 times a day; the case card's shipment lookup ran it on every ticket thread open (468,000 rows a time). The delivery report's lost-parcel queries did the same walk. The ticket tiles cost the whole open-ticket seek range, 16,500 rows per tile, a dozen tiles per dashboard compute. Together: "D1 DB is overloaded" 23 times in the minute 20:07Z with Shopify and Recharge webhooks refused, storefront account requests abandoned at :07 and :37, and the half-hourly run at eight minutes.

THE FIX, three parts, results byte-identical: (1) the four customer-scoped shipments joins are now written FROM orders o CROSS JOIN shipments s, which in SQLite pins the loop order so the customer index drives and the shipment-by-order index follows — measured 608,886 → 241 rows for the scorer's books load, 319,717 → 18 for one ticket's case card, 608,886 → 36 for the activity log; the tenant-wide at-risk walk and the paged scorer load were left as they were (already right); explicit ORDER BY s.rowid makes tie order a fact of the data, not the planner. (2) Three indexes, hand-applied after the deploy: a partial index shipments(tenant_id, status, order_id) WHERE status='lost' for the delivery report (309,021 → 9,078 rows) — the refuters rejected the plain (tenant_id, status) shape because it would have stolen the six status-update statements' plans; and two expression indexes that match the tile text verbatim, tickets(tenant_id, status, COALESCE(awaiting_reply,1), channel) and tickets(tenant_id, status, LOWER(COALESCE(assignee_email,'')), resolved_at). (3) Two spelling changes in the tile builder so the indexes apply: the unowned test written as LOWER(COALESCE(t.assignee_email,''))='', and a unary-plus hint on the duplicate-check subquery's owner term — because, measured, SQLite with no statistics gives an equal-cost tie to the newest index, and without the hint the new owner index claims that subquery and the unread tile explodes to 4,126,081 rows a run. Per dashboard compute: 265,245 → 104,962 rows (undisposed 36,349 → 500; waiting 17,716 → 4,609; returns 16,557 → 3,450; unread 18,880 → 10,851).

DEPLOY ORDER, the reverse of every ship before it: the Worker first (new text on old indexes returns exactly the old rows), then the indexes — the hint has to be live before the owner index exists. KNOWN, HARMLESS CHANGE: the scorer's books blocks now see a customer's stalled parcels in row order rather than tracking-number order, so a customer with two stalled parcels may show the other one's carrier and tracking after the first rebuild. 🧪 1,041 tests, 0 failing (+6 files' worth: d1-shipments-plans, d1-tile-plans, d1-refuter-identity — every pinned statement is captured from the real code path, run against the verbatim v2.100.0 SQL as the oracle, and its plan asserted with and without ANALYZE). Reader, two implementers, integrator, two refuters (both refuted the first index shape and closed it at the root), a finishing pass. VERIFIED: the suite; the served version and bundle; the three indexes read back from production after the hand-apply (below). NOT VERIFIED: the real D1 planner's tie-breaking (measured only on node:sqlite of the same SQLite generation); the live magnitudes (the seek shape transfers, the 2–70× figures depend on the real mix) — the day-after wrangler d1 insights is the proof. FOLLOW-UPS FILED: shipments by flexport_shipment_id (no index; 311,943 rows per Flexport upsert), the case engine's t.id IN (20 ids) walking all 52,728 tickets, and the 150-day window being day-granular for ISO-T timestamps (pre-existing, same in old and new text).

📈 FIRST LIVE: Worker deployed 23:33:23Z in the quiet window, served v2.101.0 (bundle 4a553c7302) at 23:33:51Z, stable across three reads; the three indexes created on production at 23:35Z, about two seconds each round trip, and read back from sqlite_master by name — idx_shipments_status, idx_tickets_status_await, idx_tickets_status_owner. The real D1 planner was then asked directly with EXPLAIN QUERY PLAN on production (read-only): see the runbook's 🏎️ section for the lines; the day-after wrangler d1 insights is the proof of the magnitudes.

HERMES (server/hq.js, integrations/shopify.js): a Shopify call that throws (429, 5xx, a socket) now answers 502 "the store did not answer" instead of a 400 carrying Shopify's raw error line; addresses answers 409 when the store or Recharge is not connected instead of "none on file" / "you have no subscription"; sameAddress compares the person as well as the place, so a gift recipient at the member's own street is no longer merged into one card and renamed by one edit; the profile write keys Hermes' own row strictly by shopify_customer_id and reports recharge = updated / not_linked / unavailable / failed, with a warning on a 200 for anything that fails after the Shopify write (the store has changed — "that didn't save" would invite a retry); "email has already been taken" reads as a neutral That email can't be used on this account (no account-existence oracle); country_code is forced only when CREATING an address, an edit keeps the record's own (Canadian members were being re-stamped US → 422); a refused default after a successful create is saved with a warning instead of a failure that would create a duplicate on retry; the Recharge address door maps a 2-letter US state to the name Recharge keeps ("FL" → "Florida"). 🧪 +7 tests, 1,022/1,022.

THEME (classic-account 085f376 · retro-pop 0c1a0db, both pushed and live-verified): the account prefetch carries a generation so an answer that left before a write can never repopulate the cache; a failed cached prefetch is retried behind the loading modal, never shown as the answer; Your information refuses to open on the page's seed when its load failed; the mirror warning is a modal with a Refresh button, not a toast a timer wipes; Customer Support gates on sign-in, not on live data (a member whose live data was still loading or had failed was getting "Message sent — sample only" while nothing was sent), sends the anti-bot token under the name the door reads (cf-turnstile-response), drops the honeypot field for signed-in members, needs a ticket reference to say "sent", never opens with invented subjects when the door's GET failed, and says so if the anti-bot widget never renders; the loading guard is bound to its own modal so two overlapping opens can no longer let an earlier fetch open its answer on top of the wrong screen; Classic's router closes modals on navigation and the card switch waits in a centred modal instead of on a button the confirm had detached; Retro removes its loading modal outright (no 150 ms refocus), flags a dismissal through the fade, and keeps an open modal through the post-save data refresh; the card-switch confirm reads the same on both skins; account-address edits carry the record's own country_code.

LEFT FOR TYLER (C4): a member can change the sign-in email from HQ with no confirmation code to the new address and no notice to the old one — Shopify's Admin update simply accepts it. Keep immediate, require an emailed code to the new address first (Hermes already sends codes), or route email changes through support.

THE SHAPE: records stay forever, so this is a soft removal — deleted_at and deleted_by on cs_dispositions and cs_calls (four nullable columns, added through MIGRATION_COLUMNS and hand-applied on production before the deploy), the same shape as the loyalty ledger's revoked_at. DELETE /api/dispositions/:id and DELETE /api/calls/:id stamp the row; POST …/restore clears it and keeps the original created_at so the order is preserved. Permission is the recorder (by user id, or by e-mail for rows written before the dual identity), a lead, or an admin; anyone else is refused with the server's own sentence, a signed-out call is 401, a foreign tenant's id is 404, a second removal is 404. Removing an outcome never touches the ticket's status in Gorgias — the confirm says so.

THE HIDDEN READER the reader agent found: tickets.resolution is a mirror of the latest outcome, and four surfaces count that mirror rather than the outcome table — the undisposed tile, the case card's stage, the thread payload and the tile-row chip. Excluding deleted rows from every outcome query alone would have left a removed outcome still counting everywhere that matters. Removal and restore therefore re-mirror the ticket from the latest live outcome, or to null, and purge the per-person dashboard caches. Every direct reader now carries deleted_at IS NULL: the profile's outcome and call lists, the activity log, the thread's current chip, the case card's effects and stage, the stuck-tile predicate, the five-second duplicate guard on the record route, and the "How did it end?" patch (a removed row cannot be patched back to life). The one deliberate exception, said in a comment: the categories-in-use check still counts removed rows, because a category that ever had an outcome may retire but never vanish. Two swallowing catches on the activity queries were narrowed to "no such table" so a missing column can never make every outcome quietly disappear from the log.

THE SCREENS: the strip's ✓ line gains remove beside change and + note; the profile's "Outcomes & calls" rows and the activity log's outcome and call rows gain it too, shown only to someone the server would allow. Every removal goes through the app's styled confirm in its danger style, the wait is the centred card, the strip drops back to the pick phase and the 🎯 Outcome button returns, and the toast carries Undo, which is the only toast that is clickable. A refused removal reads inline where the row was, in the server's words.

OUT OF SCOPE, said plainly: a call that carried a category also wrote an outcome with the call's id — the two are removed on their own, no cascade; removing a reached call does not undo the completed touchpoint that rested the VIP (they stay rested until the cooldown); a cancel-save's outcome can be removed but its save-flow row remains counted; touchpoints themselves are not removable yet. Each is filed.

🧪 1,015 tests, 0 failing (+50: remove-outcomes 11, remove-outcomes-ui 13, and the refuters' remove-outcomes-refute 3, remove-outcomes-refute-perms 7, remove-outcomes-refute-perms-ui 10 — the last one compiles the app with Babel into a virtual machine with a hooks stand-in, a fake fetch, a recording confirm host and armed window.confirm/prompt/alert, and clicks through the removal). Reader, two implementers, integrator (no reconciliation needed), two refuters (both held, both left executing tests behind), a finishing pass. VERIFIED: the suite; production columns read back before and after the hand-apply; the served version and bundle after deploy (below). NOT VERIFIED: nothing was clicked in a browser — a real remove and undo of an outcome and of a call log on a served ticket and profile is owed, and Tyler is the first who can do it. KNOWN LOOSENESS, harmless: a legacy row with no recorder at all shows remove to any agent, who then reads the server's refusal inline.

THE SHAPE: a member is TWO records — the Shopify customer (who they are; the address one-time orders use; the store account) and the Recharge customer (the subscription's own copy: where the box ships, which card bills it). Every card in My Account now names which record it is, and every form says exactly what it changes.

HERMES (server/hq.js, commit 76dcad6): GET/POST /proxy/hq/profile — first/last/email/phone read from and written to the Shopify customer (the system of record) through new getShopifyCustomer / updateShopifyCustomer helpers; Hermes' own customers row follows immediately; the Recharge customer is mirrored by updateRechargeCustomer and, if Recharge refuses, the answer carries recharge:'failed' + a warning the dashboard shows — never a silent success. A Shopify 422 comes back as the field's own sentence (Phone is invalid), so a typo is a 400 the member can read, not a generic throw. GET /proxy/hq/addresses now returns the Shopify account address (default + all) beside the Recharge subscription addresses with subscriber and same flags (street/apt/city/ZIP-5 normalised), and answers 200 for a member with no subscription instead of 409. POST /proxy/hq/account-address updates one of the customer's own Shopify addresses (403 on anyone else's), creates one when there is none, makes it the default, and sends "FL" as province_code / "Florida" as province. 🧪 +7 tests (971/971 in the deploy tree).

THEME (classic-account 98a027f · retro-pop c4efa04, both pushed): 👤 Your information = first, last, email, phone, prefilled from the live profile (the island now carries customer.phone); saving reloads HQ so the Liquid-rendered name follows. 🏠 Addresses = two labelled cards — Subscription ships here (Recharge) and Store account · one-time orders (Shopify) — or ONE card with both badges when they match; each edit form leads with what it changes and offers "Also use this as my store account address" / "Also ship my subscription here", saving to both doors and reporting each by name. 💳 Payment methods opens from a prefetch (ACCT cache filled when live data lands and again when My Account opens; measured 34 ms to content on the served page) and every row says Bills your subscription or Saved on your store account · not billing your subscription, with Use for subscription on the others and a plain sentence that switching changes the subscription only. 💬 Customer Support is a form into the existing /proxy/contact door (subjects and prefill from its GET, the member's orders as an optional picker, honeypot kept, Turnstile rendered if the tenant configures one) and shows the ticket reference on success. 🚪 Log out uses each skin's secondary button and a non-danger confirm. Waits live in the centred modal itself ("One sec — loading your cards…"), never a corner toast.

DEPLOY NOTE: another lane's shift was open in the Hermes tree (five dirty files, APP_VERSION already at v2.97.0 uncommitted), so v2.98.0 was built, tested and deployed from a clean git worktree on branch hq-r6-deploy (7a2822a = master 76dcad6 + the bump + build outputs); master carries the code, and that lane was asked (flip-cms todo_2562) to bump past v2.98.0 before its own deploy.

VERIFIED on the served preview pages in Tyler's signed-in session: Classic log-out renders as the secondary button (white, ink border); payment modal from cache in 34 ms with the two labels on his two Shop Pay methods; support form with the store's five real subjects, 11 order options and his email prefilled. ASSUMED / NOT PRESSED: Save on the profile form (a live Shopify write), Save address on either record, "Also use…", Use for subscription, Send message (creates a real ticket + Gorgias twin), and Recharge's acceptance of a customer PUT with phone — the mirror is reported, not assumed.

WHAT HAPPENED: the strip's collapsed ✓ line (the one that appears the moment a chip saves) formatted the disposition's timestamp with ago(saved.created_at). There is no module-level ago in the app — it exists only as a const ago = … local inside two other components — so React threw ReferenceError: ago is not defined on the first render after every successful save, and the error boundary replaced the whole ticket view with "This screen hit an error". The chip itself was already recorded (two rows on ticket t_e9fc…, 19:00:14Z and 19:00:41Z), which is why it "did submit"; the secondary outcome picker lives in the same ✓ line and never got to paint. The line shipped in v2.90.0 (2026-09-02); those two rows are the only dispositions ever recorded, so nobody else met it — and the v2.90.0 close-out verified the strip by source pins, never by a save in a browser.

HOW IT WAS FOUND: the boundary's automatic report — window.HT.error to telemetry.appolis.app — carried the stack (at OutcomeStrip (app.1da2d3c544.js:617:1080)at ThreadModal) twice at 19:00Z on v2.96.0; the telemetry day log is readable with the admin key; no guessing was needed.

THE FIX: one call — agoShort(saved.created_at), the module-level helper the tiles already use ("just now / 12 min ago / 3 h ago / 2 d ago", tolerant of both the API's ISO stamp and D1's space-separated one). 🧪 New test/outcome-strip-pins.test.js: the strip's ✓ line must use agoShort and never a bare ago(; and — because a single-file JSX app has no compiler to catch a free identifier — every ago( call anywhere in hermes.html must sit inside a top-level function that defines its own ago (the two legitimate callers still do). Suite 961 (+2). VERIFIED: the guard test fails on the old line and passes on the new; the served bundle pin after deploy. NOT VERIFIED: a chip save in a browser (Hermes needs Tyler's sign-in; the proof is Tyler's next chip showing "How did it end?" and the telemetry log staying free of render crash for OutcomeStrip). LESSON, filed: UI features that end in a save get verified by a real save on a served page before close-out, not by source pins alone.

WHAT WAS ACTUALLY HAPPENING (measured in the Worker tail across 2026-09-03): the */30 lane ended exceededMemory at 06:00, 07:00, 08:00 and 09:00Z and cleanly at 06:30, 07:30, 08:30 and 09:30Z — the risk scorer's computeAndCacheQueues held all 120,716 scored customers in memory at once and crossed the 128 MB isolate on the heavier ticks; a memory death in that isolate had already killed a neighbouring COMMS tick once. Independently, the Rivo half-hourly refresh made up to 300 sequential per-customer lookups, each with a ten-second cap and no per-customer catch, and wrote its cursor only after a full success — so one slow answer failed the whole step, set last_error (the coral line on the Rivo card), and re-ran the same 300 next tick. It failed at every top-of-hour tick and at 07:30Z (the 09:00Z failure landed 35 s in, within the first few lookups) and passed at 06:30, 08:30 and 09:30Z, advancing the cursor 1,200 → 1,800: Rivo answers slowly at the top of the hour, and we had no tolerance for it.

THE RIVO STEP (server/integrations/registry.js, the targeted phase only; the bulk walk is byte-identical): every lookup is caught on its own — a timeout, a 500 or a socket failure counts as an error for that customer and the loop continues; a 404 stays "not a member"; a 90-second wall budget with an injectable clock stops the batch and advances the cursor only past the customers actually processed (the precedence guard keeps a budget-stopped short batch from flipping the cycle to bulk); the cursor advances even when errors occurred, so the same 300 are never re-run forever; the returned counts carry errors, first_error, budget_hit, elapsed_ms and the worker's existing tick line prints them; every partial is also stored as sync_state.rivo_last_partial {at, errors, scanned, first_error} and never cleared, so nothing is silent. The loudness rule stands: if every lookup in a step failed, the step throws after the cursor write with the count and the first error, so a dead Rivo or a bad token still goes red on the card.

THE SCORER (server/service.js): computeAndCacheQueues now scores in pages of 5,000 customers by a keyset walk on (tenant_id, id) with per-page child loads and bounded per-category top-N merges (score desc, walk position asc), so no page holds more than its own rows; the tick line reports N scored in P pages, peak page X customers / Y child rows. Identity was the whole point: the new path is pinned byte-for-byte against the verbatim v2.94.0 algorithm (the old function spliced into the test by md5-checked extract, and the old module loaded as a real oracle by the refuter) on 12,509 synthetic customers across every book, tie and cutoff case, page size invariance included. The refuter still broke it once — inside equal-score groups the old order was SQLite's implicit row order of unordered child queries — and the fix was to give every child load an explicit, data-defined tie order (ORDER BY placed_at, rowid and its siblings). Known change on the first paged rebuild, unprovable against prod: if production D1 planned those old unordered queries differently from the test's SQLite, a score-tied customer at a cutoff (position 5,000 of "all", 1,000 of a category) can swap for another equally-tied one, and a same-second tied ticket or a multi-line order can pick a different quote or product as its "latest" — nothing in the UI depends on which arbitrary tie wins. D1 statements per rebuild rise from about 12 to about 245 (measure on the line; raise the page size to 10,000 if the lane nears its cap). Synthetic memory at 40,000 members: +24 MB paged vs +89 MB before.

🧪 959 tests, 0 failing (+24: rivo-sync-resilience 18 incl. 8 adversarial cases the refuter kept — first/last/every lookup timing out, a 401 on every lookup, the budget expiring on the very first customer, a short last batch with errors; queue-cache-chunked 6). Two implementers, one integrator, two refuters (Rivo held on 24 executed cases; queues refuted once and fixed as above), a finishing pass. No schema change. VERIFIED: the suite; the build; the served version and bundle after deploy (below). MEASURED LIVE: see the tick line below — the first top-of-hour tick on the new scorer is the proof this ship exists for. NOT VERIFIED: the real ten-second abort against a real slow Rivo endpoint (the mock threw the same TimeoutError); the Rivo card in a browser after a partial (the paint rule was read, not clicked); D1's true memory ceiling (only the live tick can say). DESIGN CALLS LEFT OPEN, named: a partial batch leaves no trace on the Rivo card itself, only on the tick line and in sync_state; an all-failed batch still advances the cursor, so a bad token left for 17 hours burns one 10k targeted cycle with zero writes; the shipments select in the queue path does not project kind, so the in-place Flexport resend remedy can never fire there (pre-existing, identity-pinned, filed).

📈 FIRST LIVE TICKS (deployed 10:35:16Z in the quiet window; the 10:30Z tick, still on v2.94.0, failed Rivo on the timeout one last time): the 11:00:16Z half-hourly tick ended ok — the first top-of-hour tick today that did not die — with queue cache t_verdant: 120720 scored in 32 pages of 5000, peak page 5000 customers / 13263 child rows, 18422ms, and sync t_verdant/rivo: updated 298, scanned 300, errors 2, first_error "The operation was aborted due to timeout", budget_hit false, elapsed_ms 76300 — two slow answers skipped, 298 written, the step green, the cursor advanced. The same tail proved the events feed: the 10:37Z lane tick carried events=7 (hwm 09:57:58Z) and the 10:51Z tick events=13 enqueued=1 (hwm 10:44:01Z) — the first non-zero counts since the 400 was fixed; the 10:37Z tick also mirrored 1,006 conversations and 8,513 messages in 13 minutes at zero 429s. No exceededMemory, canceled or exception outcome anywhere in the 40-minute tail. Three consecutive clean :00 ticks remain the bar for calling the memory fix proven; one is in hand.

THE CONTACT FORM (server/lib/intake.js, server/site.js; POST /apps/sub-points/contact through the signed app proxy): field-for-field with the live Gorgias form (name, email, the tenant's five subjects, message, up to ten files of 10 MB, plus the order number the page shouts for), pre-filled from the store login, a honeypot, Cloudflare Turnstile when a key is configured and a five-per-hour visitor limit otherwise, files to R2. It creates a first-class Hermes ticket (provider hermes, served from Hermes's own copy, on the tiles, with the case card and the outcome strip like any other) and its Gorgias twinPOST /tickets with external_id hermes:<id>, the one Gorgias write this ship adds — so the team's queue stays whole in the tool they use today. The twin id is written back in the same batch; a failed twin keeps the Hermes ticket, marks it, and is retried by the COMMS lane; the refuter found the webhook could still mint a duplicate when the twin's own ticket-created delivery raced the write-back, and upsertTicket now adopts the Hermes ticket by external_id first. An agent's reply on a contact-form ticket reaches the customer by email through the twin via the existing pinned-sender path, and refuses loudly when no twin exists ("no delivery path"). Which form the customer sees is Tyler's theme placement, deliberately not code: the Gorgias form stays on /pages/say-hello until the help center is ready (D16 B); the copy-paste section and loader are in docs/theme-snippets/hermes-site.liquid.

THE KNOWLEDGE BASE AND HELP CENTER (server/lib/kb.js, kb-render.js, kb-seed-data.js): categories, articles in markdown rendered through a small allow-list (no raw HTML, no scripts, links nofollow), a version on every save with restore, publish / unpublish / archive, search over title and body, a Help center admin view in Hermes for leads and admins, and Seed FAQ — the store's 41 published FAQ entries, captured verbatim from the served page, one press. The public pages live on the store domain through the app proxy (/apps/sub-points/help, /help/<slug>), answered as Liquid so Shopify wraps them in the live theme, noindex, follow for v1, a "was this helpful" pair and a contact card on every page.

THE CHATBOT (server/lib/chatbot.js, chat-authority.js, the widget web/chat-widget.js served at /apps/sub-points/chat.js): owned end to end, on the company key through the same ledger and daily cap as the card. Identity is the store's own login and nothing else — a signed-in visitor gets their own orders, subscriptions and rewards through the SAME member-scoped doors the account page uses (foreign ids refused server-side); an anonymous visitor gets answers from published articles, order tracking by the existing parcel link, and "sign in to manage your account". Tier 1, semi-unattended after login: skip, delay, ship now, swap, add or remove a product, reactivate, rewards, a return request, order tracking, "report an issue" (which opens a ticket with the order attached) — every action is proposed by the model, shown to the customer with a confirm card, and executed only against a ten-minute signed confirm token. Tier 2 staged for a human: cancelling or re-addressing an unfulfilled order, cancelling a subscription, any return, changing email/phone/address — the bot prepares it as a ticket. Tier 3 never: refunds, credits, off-catalogue discounts, anything on a fulfilled order except tracking, other customers, anything at Gorgias. Refusals: health claims beyond published articles, delivery promises beyond shipment data. Limits: 20 model turns per session, 60 per visitor per day, 30 requests a minute; the cap or the kill switch or a missing key gives one honest state — "Our assistant is taking a break — leave a message and a person will reply". Every proposal, execution, refusal and handoff is an event. Handoff creates a chat ticket in the Hermes inbox (with a Gorgias twin) and the agent's reply reaches the customer's chat window; a record-only copy into the twin exists behind a flag that defaults off until a human tests that seam live. The refuter hardened the doors: prototype-key lookups answer 404, the client IP is the rightmost forwarded hop, and the widget treats an executed action as success (it had printed a failure).

ADMIN: the SITE card on /connections (form on/off, chat on/off, bot kill switch with the reason shown, Turnstile keys, lockstep flags in plain English, the theme snippet), a Chat view of recent sessions and transcripts, GET/PUT /api/site/settings, /api/kb/*, /api/chat/sessions.

THE LANE, from this morning's tails: the events catch-up's 400 is named and fixed — Gorgias's events endpoint treats object_type as a single-object filter ("object_id is required"), so the feed is now pulled unfiltered and ticket events kept on our side (first flowing tick expected 08:51Z); the shortened 21,51 tick ran 8 minutes and ended clean at 07:59Z; the first real card job scored nine conversations on Sonnet 5 and nine customer turns on Haiku 4.5 for $0.041, all successful, the sample problem line reading as a person would write it; the pace fix moved the mirror to about 55–65 conversations a minute, bounded now by the per-step clock rather than the vendor budget; the */30 sync lane ran out of memory again at 08:00Z (todo_2486).

🧱 Schema hand-applied on production and read back before deploy (five tables, four indexes, the default shelf) — through the query endpoint after the import endpoint answered a transient authentication error. 🧪 935 tests, 0 failing (+91: intake, intake-twin-mirror, kb, chatbot, site-e2e, site-ui, site-doors-keying). Built by six implementers, one integrator and two refuters. VERIFIED: the suite; production schema; served v2.94.0 bundle pins (Help center, Seed FAQ, "from the site"); the served Connections page (kill switch, no bare confirm); signed-out 401 on the admin routes; unsigned app-proxy calls to every public door answer 401. NOT VERIFIED: nothing was looked at in a browser — the Help center and Chat views, the SITE card, the widget on the preview theme and the Liquid form need a served-page check with an admin signed in, one real contact submission and one real chat turn before the theme placement; Gorgias's acceptance of the twin body for contact_form and chat channels has met only the mock; the store's chat hours (Mon–Fri 9–8 ET per the live widget) should be set on the CS settings before the launcher goes live. LEFT OPEN, named: the rate-limit rows are never swept (a cron sweep belongs on a lane); one-vote-per-visitor on "helpful" is the widget's job; the FAQ tags are hand-assigned and unmeasured for retrieval quality.

THE HELPER (server/lib/claude.js, raw fetch like the app's three earlier callers, no new dependency): one createMessage for the whole app — structured JSON output through output_config.format (the shapes copied from the Anthropic documentation fetched at build time and quoted in the file header), adaptive thinking left at its default with effort: low for extraction, the frozen system block cached (cache_control), retries on 429 and 5xx honouring retry-after and never on 4xx, refusals and truncations reported as failures with the reason, every call written to ai_usage with its tokens, cache reads and writes, and cost at the list prices for claude-sonnet-5 ($2 / $10 per million) and claude-haiku-4-5 ($1 / $5), batch results at half price. dailySpend, countTokens, and the Message Batches API (create, poll, fetch results keyed by custom id). The IT board's alt-text scanner now rides the same helper (retries where it used to fail at once); two legacy callers (ad copy, voice notes) stay on raw fetch until a test covers them — named, not hidden.

THE CARD JOB (server/lib/case-model.js): the input is the stored thread (≤40 turns, ≤1,500 chars each, oldest and newest kept), never the old 3,000-character excerpt; the system block is byte-stable so the cache hits; the card runs on Sonnet 5, sentiment on Haiku 4.5 as its own call per unscored customer turn (never re-scoring old turns); the result lands in case_cards (problem_line, entities, promises with kind and due-by, confidence, model, prompt version, input hash, tokens, cost, status ok | not_analysed | failed with the reason) and messages.sentiment; a hermes.card event is written. Honest states, exactly: no company key → not_analysed / no_key and the strip says "add a Claude key on /connections"; daily cap reached (default $10, editable in the CS settings' AI block) → not_analysed / cap, loudly; a refusal or truncation → failed with the text and a Retry; confidence below 0.5 → the text greyed, "unsure — read the thread". When it runs: on the COMMS lane as step 0b — at most 40 tickets or 60 seconds per tick, only open tile-channel tickets whose last customer turn is newer than their card and older than five minutes; on Analyse now; and the one-time backfill of the open book, only when an admin presses "Analyse the open book": a dry run first (ticket count, a 20-ticket token sample, the estimate), then the Batch API in chunks of 60 with the next chunk submitted by the poller and a cost guard that stops the continuation past 1.5× the estimate — the refuter measured a full chunk's poll at 511 statements, under the invocation cap; a mispriced batch result (the echoed model id not in the price table) was caught and fixed the same pass.

WHERE IT SHOWS: PROBLEM becomes the model's line with entity chips and ✏️ Edit (styled prompt; an edit is an override the model never overwrites); DONE gains ⏳ promise rows ("refund the Aug charge — by Sep 5 · no refund seen") that turn ✓ when a matching effect lands or a person presses Mark done with a note; FEELING shows the label, the trajectory ("frustrated → neutral") and the quote; Analyse now runs behind the centred wait card ("Reading the conversation… · Claude Sonnet 5 · usually 3–6 s"), never a spinner; stuckOf gains promise overdue. /connections → the Claude card gains the ledger (today, this week by day, seven days by purpose and model), the cap (editable), the backfill button with its estimate, and the pending-batch line.

ROUTES: POST /api/tickets/:id/analyse (409 when capped, 400 when no key) · PATCH /api/tickets/:id/case · POST /api/tickets/:id/promises/:idx/done · GET /api/ai/usage (admin) · PUT /api/cs/settings accepts ai: { cap_usd_per_day, card_model, sentiment_model } (models validated against the price table) · POST/GET /api/admin/case-backfill.

THE LANE, corrected by measurement (three live ticks read): (1) the pace never reached the remote steps — each SELF-bound step built its own budget at 1.0 inside the route, so three ticks ran at 52 conversations a minute with the bucket idling at 0–18 of 40; the step body now names the pace and the route honours it within bounds. (2) The 21,51 tick died exceededMemory at 06:51Z in the same minute the half-hourly sync lane did — that lane runs out of memory on about half its ticks scoring 120,716 customers in memory (06:00Z and 07:00Z dead, 06:30Z clean; filed as todo_2486 with the tail lines), and a tick sharing its isolate inherits the blast; the 21,51 tick is now capped at 8 minutes so it ends before :00/:30. (3) The events catch-up got a 400 from Gorgias on every tick and the error carried no body — the timestamp is now sent without milliseconds, the bracketed keys percent-encoded, and the response body rides in the error so the next tick names the cause.

🧱 Schema hand-applied on production and read back before deploy (case_cards with ticket_id NOT NULL — tightened while the table existed nowhere; ai_usage; three indexes). 🧪 844 tests, 0 failing (+61: claude-helper 13, case-model, card2-api, card2-ui, card2-e2e, step-0b and pace pins). Built by six parallel implementers, one integrator and two refuters, across a credit outage and a resume. VERIFIED: the suite; production schema; served v2.93.0 bundle pins (Analyse now, Reading the conversation, Mark done, not analysed); the served Connections page (Analyse the open book, no bare confirm); signed-out 401 on every new route. NOT VERIFIED — say it plainly: no real Anthropic call has been made yet; request shapes are proven against mocks and the fetched documentation only, and the first real card (Analyse now on a mirrored ticket, or the first lane tick with an open ticket due) is the proof — its ledger row and the card's status are the read-back. ASSUMED: the strip's new cells on a phone width. LEFT OPEN, named: the two legacy raw-fetch callers; alt-text calls unledgered (no db handle in that path); step 0b has a count and clock bound but no statement budget of its own; a ~$30 open-book backfill against a $10 cap will pause background scoring for the rest of that day (said in the UI).

THE CASE ENGINE (server/lib/case.js, one pure function family driving the thread strip, the tile rows and the queue so the three can never disagree): stageOf → New · Waiting on us · Waiting on customer · In progress · Resolved · Closed, no outcome · Reopened, with "stale 21 d" and "reopened ×2" as badges, never extra stages; stuckOf → the first true rule and its age: unanswered (24 business hours, or 24 calendar hours on cancel/refund/edit-address/lost/damaged tags), repeat inbound (two customer messages with no reply between), vendor aging (a recorded refund/reship/credit/return-label outcome with no matching effect after 7/5/21 days), reopen loop, orphan (unowned while the customer has another open ticket); stale silence is informational, never coral. Business hours ship as a labelled default — Mon–Fri 9–7 America/New_York — editable on the CS settings sheet (Tyler's call D17: "the number must be yours"), with a pure, unit-tested business-hours clock (weekends and a DST week covered). effectsFor lists what has actually been done from data Hermes already holds — refunds, $0 reships, orders, subscription events, dispositions, calls, replies, shipments — and problemLine names the problem from tags, then the tenant's taxonomy suggester, then the subject. Nothing here calls a model.

WHERE IT SHOWS: the CASE strip above the messages on every conversation (PROBLEM · DONE · STAGE · STUCK, FEELING honest); a "Not stuck until <date> — reason" control on the STUCK badge (styled prompt, never a browser prompt; a bare date means the end of that day; logged as an event); tile rows gain one line under the subject — stage pill, stuck age in coral when stuck, the problem in 70 characters — the subject itself never replaced; a team-wide Stuck tile on the hub, oldest first; stage pills on the profile's ticket rows; the profile's old "unhappy" chip relabelled "cancel/refund topic", which is what that column always measured. The refuter caught the tile pre-filter and the strip disagreeing on a refund-pending case; both now read one shared list of pending outcomes.

MIRROR II — real change events: conversation_events (idempotent on tenant + event id, written with ON CONFLICT DO NOTHING so a malformed event still fails loudly instead of vanishing); the COMMS tick now starts with step 0, the Gorgias Events catch-up from a high-water mark with a five-minute overlap (bounded: 20 s, 500 events, and — the second refuter's catch — 600 statements, so a backlog after an outage can never exhaust the invocation before the mirror steps run); every ticket-scoped event re-queues that conversation at top priority; merge/split/delete/trash/untrash and reopen are recorded (merged_into, split_from, trashed, reopen_count), and a 404 is never "resolved". The four per-trigger webhooksticket-created, ticket-updated, ticket-message-created, ticket-message-failed, each carrying {"ticket_id", "topic"} because Gorgias's body template is the only way to know which trigger fired — are registered only when an admin presses "Register event hooks" on the Connections page, dry run first (Tyler's "be careful" on D12); the old HERMES APP integration is never removed by code. The sync lane's 11-day newest-first re-walk became an incremental walk by updated_datetime stopping ten minutes before the mark. The webhook door now reads the topic when present, dedupes on ticket + topic + last message time, and still fetches back — the body is never trusted.

THROUGHPUT, from measurement: the first live tick showed the bucket at 18 of 40 with zero 429s at 1.0 request per second, so the lane now runs at 1.5 per second (10 of every 20 seconds left to humans, a 429 still halves it) on two non-overlapping offsets — 7,37 and 21,51 — 26 of every 30 minutes; the only other offsets either overlap the sibling tick or land on a multiple of five. Expected: the 49,935-conversation backlog in roughly 12 hours instead of 36.

ROUTES: GET /api/tickets/:id/case · POST/DELETE /api/tickets/:id/stuck-ack · thread and tile payloads carry case / stage / stuck / problem · GET/PUT /api/cs/settings gains business_hours + stuck thresholds (validated: IANA zone, days, HH:MM, positive integers) · POST /api/integrations/gorgias/register-events (admin, dryRun) · GET /api/integrations/gorgias/events (admin: mark, last run, 24-hour counts by type, registered topics, whether the legacy hook is still present — the integrator caught this route parsing the topic out of the wrong field and returning nothing).

🧱 Schema hand-applied on production and read back before deploy (conversation_events + two indexes + five ticket columns). 🧪 783 tests, 0 failing (+61: gorgias-events 13, case-engine, case-api, case-ui, ship2-e2e, the lane's step-0 test). Built by six parallel implementers, one integrator, two refuters. VERIFIED: the suite; production schema; served v2.92.0 bundle pins (strip, Stuck tile, relabel, settings section); the served Connections page (Register event hooks, Change events, no bare confirm); signed-out 401 on every new route. ASSUMED until seen: the strip and tile line on a phone width and with a long subject (an agent's five-minute look is owed before CARD II); that Gorgias accepts http.form as the JSON body-template slot (settled the moment Tyler presses the dry run); D1's acceptance of the window functions the tile pre-filter uses (node:sqlite passes; the first hub load tells). LEFT OPEN, named: ticket-message-failed does not yet stamp the matching reply_attempts row (needs a provider message id on it); the "≥3 customer turns after the first resolve" half of reopen-loop needs a first-resolved timestamp; the events retention window at Gorgias is unverified (the mark starts at first run by design); the */30 sync lane ended exceededMemory once at 06:00Z and cleanly at 06:30Z — intermittent, filed.

WHAT IT PROVES: a conversation opens from Hermes's own copy with zero vendor calls; the backfill of every message body across the whole history runs itself on its own cron trigger inside Gorgias's rate limit; and the mirror reports its own progress and agreement. Nothing existing changes meaning.

THE COPY (server/lib/message-mirror.js, server/conversations.js): five tables — messages (one row per Gorgias message, unique on tenant + provider + provider id; body text capped at 32 KB with a truncated flag; HTML bodies to R2; internal notes kept as public=0; the RFC Message-ID stored, which is the key the mailbox shadow will match on), attachments, participants, mirror_jobs (the work queue: open conversations first, resolved behind them, trashed marked done without fetching), channel_transport (six rows per tenant naming who transports each channel — all gorgias today; the flip is one row change per channel). tickets gained provider, external_ref, messages_count, mirrored_at, tail_text, reopen_count, transport_override. mirrorTicket writes a whole conversation in ONE database batch (db.batch now exists on both engines — the design pass found every earlier draft calling a method that did not exist) and re-derives the header fields the app already relies on — awaiting_reply, last_message_at, last_customer_at, body_text — from the stored messages with parity to the old computation by construction (the refuter reproduced a divergence on a bot-sent via='api' turn and the fix passes the raw payload through the same human-agent test the old code used).

THE LANE (server/lib/comms-lane.js, worker.js, the fifth cron trigger **7,37 ): every 30 minutes, seven minutes clear of every other lane, it reconciles (one 79 ms header query, 52,633 rows read, measured on production), then pulls conversations through the queue at 1.0 request per second on the house key — half of Gorgias's 40-per-20-seconds budget, leaving the other half to agents opening threads and replying — through server/lib/vendor-budget.js: every Gorgias call in the repo now goes through one wrapper (gorgiasFetch; a grep test pins that zero direct calls remain), the wrapper records X-Gorgias-Account-Api-Call-Limit on every response so the Connections card shows the live bucket, and a 429 sleeps Retry-After, halves the pace for the rest of the tick and is counted, never swallowed. Because a 13-minute lane would issue ~6,000 statements against a per-invocation cap the documentation states as 1,000, the lane runs the backfill in steps: a self service binding (SELFflip-cms, accepted by wrangler) calling POST /internal/comms/step behind the same x-id-internal gate the other internal routes use, then the public self-fetch, then in-process — the ladder is tried in order and any non-2xx or throw ends the loop, finishes the tick in-process and marks needs_more_triggers in the sync state, so a mid-loop refusal can never silently truncate the backfill. The first tick also runs a cap probe (1,500 statements individually, then batched) and writes a marker before measuring, so a poisoned invocation cannot repeat it. FIRST TICK ON PRODUCTION (05:37Z): it never ran. The invocation died 26 seconds after the cron fired, in 119 ms of wall time, on the scheduled handler's opening read of the tenant list — D1_ERROR: D1 DB is overloaded. Requests queued for too long. — before any lane code executed, so there was no marker, no counters and no log line; only the tail showed it. That is a D1 saturated by everything else, and the insights report named the load (see v2.91.1 below). THE SECOND TICK (06:07Z, on v2.91.1) is the measurement: cron comms t_verdant (788170ms) steps=28 tickets=691 messages=2600 statements=8549 batches=652 429s=0 bucket=18/40:house mechanism=self cap_probe=individual:562/batch:686 queued=49935 reconcile=408ms deadline=hit. Read: the self service binding works (28 remote steps, no fallback, so each step ran as its own invocation with its own budget); the cap probe issued 562 statements one at a time and then 686 in batches inside one invocation with no error, so the documented 1,000-per-invocation ceiling did not bind at 1,248; zero 429s with the bucket at 18 of 40, exactly the half the lane is allowed; 52.6 conversations a minute** against a queue of 49,935 — about 36 hours of wall clock at one 13-minute tick every 30 minutes, which the next release shortens with a second trigger offset and a higher pace now that the bucket headroom is measured.

THE THREAD ROUTE now serves from the mirror when the conversation is mirrored and fresh (served_from:'mirror', zero vendor calls); when it is stale or not yet mirrored it returns what it has with stale:true, refreshes from Gorgias in the background and puts the conversation at the front of the queue, so the next open is instant. A reply from Hermes re-mirrors the conversation immediately. The webhook fetch-back mirrors messages too, paced and accounted. The delivery probe that reported a Gorgias send as failed used to read a field that does not exist on the message object (failure.type); it now reads last_sending_error / is_retriable — every "failed" verdict before this was a guess.

THE UI: the conversation page never shows "Pulling the full conversation from Gorgias…" for a mirrored, fresh thread; for a stale one that same line appears once below the stored messages while it refreshes (one labelled state, no popup); internal notes render as notes ("Internal note · Chardo · 2 d ago"), never as agent turns; a footer says "Mirrored from Gorgias · N messages · synced <ago>". /connections → Gorgias card: a Conversation mirror gauge (mirrored of total, open %, queued, ETA), the reconcile lines with provenance, the live bucket and last 429, and a mint Seed / Resume button behind the styled confirm. The page's own "Run schema migrations" button lost its bare browser confirm( on the way (house rule).

ADMIN + INTERNAL ROUTES: GET /api/integrations/gorgias/mirror (admin) · POST /api/admin/mirror-seed (admin, idempotent) · POST /internal/comms/step?tenant= (internal secret; 404 otherwise) · the admin probe whitelist gained integrations, so note_1601's "how many email channels does this account have" is answerable from the UI.

🧱 Schema done the right way round: the five tables, seven columns and indexes were hand-applied on production D1 from deploy/migrate-2.91.0.sql and read back with pragma_table_info BEFORE this build deployed; channel_transport = 6 rows; the queue was seeded with the same three statements the code uses — 14,416 open + 36,210 resolved queued, 2,007 trashed done. 🧪 721 tests, 0 failing (+71 across db-batch, vendor-budget, gorgias-client, message-mirror, comms-lane, gorgias-mirror, mirror-ui; the connect.test.js:497 and api.test.js:1777 pins rewritten to the new contract — "served from the mirror, refreshed when stale"). Built by six parallel implementers on disjoint files, one integrator and two refuters (both refuted something real: the awaiting-reply parity gap above and a cap probe that could repeat after a crash). VERIFIED: the suite; production schema and seed counts; served v2.91.0 bundle pins; signed-out 401 on the admin routes and 404 on the internal route; the served /connections page carrying the gauge and no bare confirm; the reconcile query's cost. ASSUMED until a shift uses it: the thread page on a real 30-message email, a notes-only thread and a Messenger ticket in a browser (needs a signed-in agent); R2 put latency on long email threads; whether the bound self-invocation gets a fresh D1 budget (the tick log answers it). LEFT OPEN, named: attachments' bytes to R2 (metadata only in this ship), real Gorgias event topics + the four per-trigger integrations (v2.92.0), a small last-write-wins window on sync_state.gorgias shared by the 10,40 and 7,37 lanes, the 10,40 sync lane not yet accounted in the bucket, HTML bodies reusing the AI_BLOBS bucket.

THE STRIP (OutcomeStrip, web/hermes.html, one component, three modes, five hosts): on the conversation page Close ticket now asks one question inline — "What was this about?" — a row of category chips (Shipping / delivery · Order change / refund · Subscription change · Product / results · Billing / account · Return · General question · Spam), and one tap records the category AND closes the ticket in Gorgias through the same sequence the button always used. Your last pick is remembered (Enter repeats it, 19 jump, Esc backs out, the tag says last); a suggested chip (dashed, from Gorgias tags first, then the subject/body themes) takes the focus when nothing is remembered; Just close is always there for the ticket that needs no outcome. After the tap the strip collapses to ✓ Shipping · you · just now with optional How did it end? ▾ (that category's outcomes — Refunded, Reshipped, Tracking sent…), + note (one line, 400 chars) and change. A ticket that already has an outcome closes exactly as before. 🎯 Outcome records without closing; 📞 Log call on the same page logs a call (Out/In · Reached · Voicemail · No answer · Busy · Callback set · optional length, topic, note; admins can back-date one that already happened — Chardo's migration door). The Cancel-request chip is not a chip: it opens the existing 3-step cancel-save flow, and completing that flow writes the outcome row itself (POST /api/saveflow now takes ticketId) — no double entry, and the medical reason still cannot carry an offer. Tile rows got a 🎯 (or the recorded chip) — an outcome without opening the ticket, record-only by default with a remembered & close in Gorgias toggle. The catch-up tile — "Closed without an outcome" — is the only path to tickets closed inside Gorgias (every other tile starts from open tickets): yours, resolved in the last 7 days, no outcome yet, hides at zero, a disposed row drops off. The three VIP Log call ✓ buttons are unchanged (one tap still rests the 105-day clock) and gained a for the call that did not reach them — Voicemail / No answer / Busy / Callback set write a call row and do not rest the VIP: a voicemail is not a contact, and the row says so structurally. The profile got 📞 Log call on the contact row and an Outcomes & calls block; the timeline shows 🎯 Outcomes and 📞 Calls. The demo dialer (CallPanel, invented transcript lines, "Call now — auto-transcribed (demo)") is gone; the button is Log a call and it logs a real one. Admins get ✎ edit chips under the strip: labels, icons, outcomes, retire/restore, add a category, restore the shipped default — saved as one document with if_version, so two admins cannot silently overwrite each other, and no deploy to change a chip.

THE SERVER: server/lib/cs-taxonomy.js (the per-tenant taxonomy; the default's cancel reasons are the save-flow PLAYS cloned, medical locked to the care offers and never a credit/discount/points word; validate() refuses deleting a category with history — retire it), tables cs_settings (one JSON document per tenant, versioned), cs_dispositions (append-only; the newest row for a ticket is current; every row snapshots agent, assignee, channel, status and taxonomy version, because a Gorgias sync overwrites the ticket row) and cs_calls (shaped so a Twilio status/recording callback later fills the SAME row by provider id — Twilio itself deferred on Tyler's word), save_flows.ticket_id/call_id/agent_user_id, and tickets.resolution finally has a writer (mirror of the current outcome; NULL means none, and every report will say so). Routes: GET/PUT /api/cs/settings (read open, write admin-only, 409 on a stale version, settings:null restores the default), POST /api/tickets/:id/disposition (outcome first, close second — if Gorgias refuses, the outcome is kept and the caller hears 502 with disposition_saved:true, the strip prints "Outcome saved · Gorgias did not close the ticket: …" and the Close button stays live; 5-second dedupe; the cancel category is refused with flow:'save'), POST /api/calls (reached → a completed touchpoint; anything else → no touchpoint), the thread payload carries disposition + suggested_category, tile rows carry resolution/status, MESSAGE_TILES.undisposed with the resolved-window swap in tileWhere, and recordDisposition purges the per-person dashboard caches so the count is true the moment the outcome lands (found by the test: the tile stayed at 1 until the cache aged). api() in the app now attaches status and body to a thrown error — the strip could not otherwise see the 502's flag.

⚠️ Shipped shape vs the design pass (scratchpad/cs-final-design.md, 88k chars, three designs judged and grafted): the design named cs_config keyed per document, a superseded_by pointer, a medium (voice/sms) column and a picked_via field; what shipped is cs_settings as one document, newest-row-is-current, calls only (texts stay touchpoints) and suggested/defaulted flags — the same behaviour, fewer moving parts; the weekly report (ship 2), /report/service (ship 3) and cancellation reasons + VIP import (ship 4) build on these tables unchanged.

🧱 Schema step done the right way round: the three tables, three columns and nine indexes were hand-applied on production D1 and read back with pragma_table_info BEFORE this build deployed — a declared schema is not an applied one. 🧪 650 tests, 0 failing (+9 in test/cs-outcomes.test.js, on real node:sqlite: the route, the dedupe, the 502 shape, the cancel refusal, the save-flow link, the call/touchpoint rule, the catch-up tile including the cache purge, and source pins on the app — the strip on its hosts, the demo dialer gone, no bare browser prompt). VERIFIED: the suite, the production schema, the served version after deploy, a signed-out POST …/disposition answering 401. ASSUMED until a real shift uses it:** chip layout on a phone-width conversation page, and the Gorgias close-after-outcome on a live ticket (the sequence is the Close button's own, unchanged).

THE DRIVER (web/wait.js, header now rev 6): HW.work() gained after — the card is built at once but appended only if the work is still running when the timer fires, so a 200ms save never shows it and a 4s one does (criterion 1's restraint, inside the driver where a call site cannot forget it); a failure appends the card even if it had not shown, because a silent loss is the one thing worth interrupting for — and progress(i, n, name): a real "3 of 8 · name" with a filled track INSIDE the card (criterion 5; never a line at the top edge).

THE APP (web/hermes.html, 60 patches): one helper beside api()named(label, fn) — wraps a user-started action in the card with 600ms of restraint and replaces it with the reason on failure; fourteen handlers now go through it with specific words ("Saving Priya's access", "Granting Agora access for …", "Switching Studio billing to house", "Diffing the model catalog", "Adding … to the team", "Checking the health of every AI tool" …). The bulk award shows its card at once ({after:0}) — "Awarding 500 points to 1,240 customers" — and the button reads "Awarding…"; the bulk subscription loop counts on the card (w.progress(i,n)) and on the bar in place ("Skip next — 3 of 8…") with all four buttons disabled until the last write lands; the house/reference uploads and My Files upload count per file on the card and on the button ("Uploading 3 of 8…"); sign-in ("Emailing you a sign-in code", "Checking your code", "Creating your account"), chat reply ("Sending your reply through Gorgias") and add person are named waits; runHealth guards against a second click and disables; TeamView save guards, disables, relabels and restores in finally; the eleven bare "Loading…" states got their nouns (loyalty activity, files to attach, the sends list, this send, the customers on this tile, your tasks, this discount, your edits, your pipelines, the gallery, your files — none remain, a test greps for it); the sign-out wall and queue rebuild popups carry a live elapsed time (<Elapsed/>); the IT/SEO ⚡ ✓ ✕ buttons dim when disabled.

THE PROOF PAGE (/progress, rev 6): a new counted-card demo ("Skip next on 8 subscriptions (fake)") runs w.progress on a genuinely slow fake loop; the coverage table gained rows for every kind of wait above and says plainly that anything still running after 600ms is no longer quick.

📧 THE EMAIL MINORS FROM THE SAME AUDIT (cmp_34f01cf47c stays attested at rev 3): the app sign-in code's subject read "493028 — your sign-in sign-in code" (the app passes surface:'sign-in') — it now names the brand instead; the code mail — the one every user gets — wears the Hermes mark and a mint rule (criterion 10), like the invite already did; the invite's plain-text part now opens with the app's name (criterion 12: a fallback that never says which app sent it); the test-send confirmation returns the From the door actually STAMPED plus any apex fallback, and the panel shows it in amber (criterion 2); the report invitation's coral button gets ink text — 3.05:1 was below AA, 6.9:1 clears it, computed in the test.

🔍 THE PRE-DEPLOY REVIEW, AND WHAT IT CHANGED (five lenses — React, behaviour, driver, server, the rule itself — two skeptics per serious finding; 2 majors survived, 17 minors, 0 refuted). Major 1: named() surfaced EVERY failure on the card, including a sub-600ms one the caller already reports inline — a wrong sign-in code got a red inline error AND a coral Dismiss card dead-centre over the modal, the exact "pop-up piling on" rev 6 forbids. The driver gained drop() (end an unshown wait: no card, no stored estimate) and named() now replaces the card only if it was actually on screen. Major 2: the proof page claimed "anything still running after 600ms … the card says what it is" while seven user-started writes still drew nothing — per-subscription Recharge actions (a second press sent a second skip), add subscription (two presses, two subscriptions), merge, Rivo points, Flexport refresh, lander publish, remove teammate — and the batches (auto-assign, alt-text scan/apply, refunds/returns/reships/credits, generate concept) answered with a busy label alone. All wired: fifteen more named() sites, an actBusy flag so the row's buttons, the add button and the merge hits wait, the auto-assign button reads "Assigning…", and the claim reworded to what is true. Minors taken: bulk labels in present tense ("Skipping the next charge — 8 subscriptions", not "Cancelled — 2 of 8"); the bulk bar only dims — the card is the one indicator, so nothing double-tells; every counted loop ends with fail() when some writes did not go through instead of counting to N of N; saveAsPlay and TeamView save() got their own flags so the Award button never reads "Awarding…" while a play saves and the add form never relabels the edit row; the access chip waits per chip; the "clear" button waits during a bulk run; the estimate key ignores numbers, dates and quoted names so dynamic labels share one honest "last time"; a delayed card's first paint says 0.6s, not 0.0s; the invite text no longer stutters "Hermes · Hermes" on the brand-fallback path or leaks " · " on a blanked alt; the subject regex is word-bounded so "Design intake" cannot flip it. Not taken, on purpose: {after:0} on the award stays as a policy for money (reworded so it does not claim a duration). Found, out of lens, filed as todo_2409: the award's "N left — press again" never advances — the server re-walks the same first hundred; real money, pre-existing, untouched here.

🧪 615 tests, 0 failing (+17 in test/rev6-wiring.test.js, source-level on purpose: the audit's evidence was the shipped source and bundle, so the pin is at the level the finding was made; +10 in test/wait-card.test.js, which runs HW.work() itself against a fake document and a controllable clock — until now the card was only ever grepped — and measures every state path: a quick action never shows it, a slow one does with a live elapsed, a failure surfaces even before the card had shown, drop() leaves nothing behind, progress() is a real fraction, the estimate key is shared across dynamic labels, done twice is a no-op, two cards are independent). Built to bundle app.56a5554b73.js. ⚠️ Nothing in this entry was clicked in a signed-in session by the shipping lane — the proof page's demos were driven live; the in-app cards are proven from source, exactly as the audit found them.

FIX: the award now fires only when the customer's SECOND Recharge order was placed at or after flipped_at — the forward-only discipline nativeEarnSweep already applies to orders. Renewals from before the cutover belong to the vendor's era and stay there.

⚠️ NOTHING WAS PAIDflatRulesSweep is gated on flipped_at, which is unset, so the defect never reached a customer. It was caught by verifying the population against the vendor's own feed after shipping the rule, not by the tests, which were happily green on a rule that would have cost six figures.

ALSO CHECKED AND CLEAR: sms_signup has zero opted-in linked members and zero vendor awards ever, so no retroactive burst there — though that also means nothing populates sms_opt_in, which is now a known gap. Anniversary and birthday are naturally forward-only (they fire only on the day). Tests 384 (+3: the pre-flip renewal that must NOT pay, the post-flip one that must, and a third renewal proving it is the FIRST-renewal reward and not every renewal).

⚠️ FIRST, A CORRECTION TO THE AUDIT. Its "nine flat rules have no caller" was measured by grepping the FLAT_RULES constant, which undercounts. REFERRAL WAS ALREADY FULLY BUILT (server/referrals.js — fraud guards, claim-before-award, its own ADVOCATE_POINTS = 1500, and a post-flip ledger branch); it simply does not read FLAT_RULES. Measured what Rivo actually awards live, by source: order_placed 32,838 · shopify_flow 11,704 · customer_anniversary 10,057 · subscription_milestone 9,929 · points_purchase 1,787 · manual 514 · revoked 409 · vip_tier_upgrade 267 · facebook_like 219 · instagram_follow 196 · custom_action 188 · tiktok_follow 93 · customer_birthday 49 · referral_complete 14.

NEW flatRulesSweep (cron, gated on flipped_at exactly like nativeEarnSweep, so it is INERT until the cutover and cannot double-issue while Rivo still pays): loyalty anniversary 500/year on the first order's anniversary — trigger established from the data, not guessed: of 3,761 vendor anniversary awards with an order date, 3,405 (90.5%) landed on the first order's month-day; SMS signup 150 once; first subscription renewal 500 once (a renewal = a second Recharge-billed order); birthday 500/year.

NEW claimFlatRule + POST /ext/claim for what nobody can detect — Instagram/TikTok/Facebook follows and reviews. The vendor took the customer's word too, so this is a claim door; a second claim pays nothing and says so politely rather than erroring. Deliberately not gated on the flip: a customer action must never silently do nothing.

NEW POST /ext/birthday + a birthday column, stored MM-DD only — we need the day to award on, never the year, so this cannot become a date of birth nobody asked for.

🐛 A BUG THE TESTS CAUGHT, AND IT IS THE FAMILIAR ONE. The sweep counted its own loop iterations, so it reported paying an anniversary twice in one day when INSERT OR IGNORE had silently done nothing the second time. ledgerAward now returns whether it actually inserted, and all four counters only count real awards — otherwise the sweep's own numbers lie, which is precisely how the drift and coupon bugs on this project started.

subscription_milestone IS DELIBERATELY NOT BUILT. Rivo has awarded it 9,929 times at 500 points and I could not determine its trigger: the correlation query exceeded D1's CPU limit, and guessing is worth roughly $50k/yr of points. It needs the programme owner to say what it means. Tests 381 (+9).

STEP 3 — RETURN-FOR-POINTS. The Shopify code is deleted BEFORE the credit, deliberately (it would stay spendable otherwise). So if the credit then failed, the reward was already gone from the store — and the old code released the claim and asked the customer to "tap Return again". That is worse than it sounds: releasing re-opens a coupon Shopify no longer has, and if they never tapped again nothing anywhere looked for them — a silent, permanent loss. (The earlier audit claimed this path also lied to the customer; it does not — the message was honest. The defect is the silent loss, not the wording.) NOW: a refused credit falls back to our own ledger under a key stable on the code, and the claim is KEPT — the reward really is returned and the points really are back. ⚠️ Pre-flip this can leave our ledger a few points ahead of the vendor: deliberate, and strictly better, because reconcile SEES a drift and heals it whereas a shorted customer is invisible forever. Post-flip the provider IS the ledger, so the branch is barely reachable.

STEP 4 — WHY 9,824 MEMBERS' POINTS WERE INVISIBLE. backfillRivoLinks learned "which shopper is which member" by asking Rivo one customer at a time, 60 per cron pass (~2,880/day). Measured: 72,610 checked, 72,596 linked (99.98% hit rate) — with 67,213 still to visit, about three more weeks. Fine until Rivo is being switched off: every lookup needs Rivo to answer, so if the account closed first the walk could never finish and ~$106,800 of customer points would stay unreachable in the app permanently. And it was never necessary — a Rivo member id IS the Shopify customer id (84,790 of 84,790 identical in production, zero exceptions) and our own rivo_events already carries the member id. New linkRivoMembersLocally() derives it from data we hold, needs no credentials, runs first in the backfill, and is idempotent.

⚠️ rivo_points IS SET FROM THE LEDGER AT THE SAME TIME, ON PURPOSE — linking alone would leave these rows at the schema default of 0 against a real balance, and reconcile would report 9,842 fresh phantom drifters, the exact "chase customers who are fine" failure the drift filter exists to prevent.

RUN IN PRODUCTION: 9,824 linked. Verified after: still-orphaned fell from 9,829 to 2 (a pair who joined loyalty but never ordered — $51, deliberately left alone), and the whole-book drift check reads 398 drifting / 398 explained by lag / 0 genuine — the relink created no phantom drift.

Tests 372 (+5); reverting the return fallback or the local link turns their tests red.

nativeProvider(db, tenantId) implements the same seven members as rivoProvider (a missing one would be undefined() mid-flight — there is a test that diffs the two). summary reads our own ledger and returns tier and value, not just a balance (a points-only stub NULLs rivo_tier on every member it touches — a review caught that one cron pass from stripping VIP tiers at scale); the tier is DERIVED from our own ladder rather than mirrored. award writes a ledger event instead of an API call that can fail with the coupon already deleted. connected is always true — there is no key to expire.

THE FACTORY now returns native when flipped_at is set, Rivo otherwise. Deliberately keyed on flipped_at and not native_earn: the flip is the single moment the business decided Hermes owns the points, and reading any other flag would let the two halves disagree about who is paying.

⚠️ INERT IN PRODUCTION TODAYflipped_at is not set, so behaviour is unchanged; this is groundwork, deployed ahead of the decision rather than with it.

🐛 A BUG I WROTE AND THE TEST CAUGHT. My first award keyed idempotency on member + timestamp + Math.abs(delta) — so a −500 spend and the +500 return of that same reward, in the same millisecond, produced an IDENTICAL key and INSERT OR IGNORE silently dropped the return: coupon back, points gone. The key now carries the sign and a nonce, and the comment says why. Exactly the failure class this work exists to remove.

Tests 367 (+6); reverting the seam turns 5 of 6 red.

TWO CATCHES, both deliberately awkward. (a) You must NAME the liabilityacknowledge_points has to equal the live outstanding balance computed at the moment of the call (69,873,227 pts ≈ $698,732 today), so it cannot be fired from memory; being off by one point is refused. (b) force is DEPLOY-held, not role-heldrankOf() makes every admin an overall_admin (api.js:523) so no role check can narrow it; the override now needs FLIP_FORCE_KEY, and an unset key means force is unavailable, never "no check". Same staged-secret idiom as WEBHOOK_REQUIRE_TOKEN.

NEW GET /api/loyalty/flip-preflight (admin, read-only, no vendor calls) returns the numbers you must read first: the acknowledgement figure, ledger rows, how many the native writer has ever written (0), live unspent codes, the make-goods that would be waived, the live readiness gate, and the irreversibility warning as part of the payload rather than folklore.

⚠️ These exist because the gate being GREEN is not the same as the cutover being SAFE — note_1716 lists four measured reasons it is currently both. Do not simplify either catch away. Tests 361 (+6), driving the real route; reverting both catches turns 4 of 6 red.

⚠️ This deploy also carries 92b4962, an appolis-lane commit (forward the signed cookie on /api/my-connector, F1 phase B) that was committed but undeployed and had no version bump of its own. Not my change; carried deliberately rather than silently, and /api/my-connector re-verified after.

Move, not change — identical values, no behaviour difference. chardizy/wrangler.jsonc is gitignored here, so these two were on disk only and never entered git history (Kosmos's config is tracked, so its were — this repo was already doing the right thing).

⚠️ THE RUNBOOK'S ORDER IS IMPOSSIBLE. It says put the secret, then delete the vars line. Cloudflare refuses a secret whose name is already a plaintext binding — "Binding name already in use" [code: 10053]. The var must be stripped and deployed first, opening a brief window where the binding is undefined. It fails CLOSED (unavailability, never exposure), and the affected surfaces here are the standalone first-admin bootstrap gate and the Studio machine-trust hop — not the webhook doors, which are gated by webhook_token and were untouched. Done in one window, off-hours, values copied out-of-repo first.

ID_SECRET stays in vars — the weekly Appolis tripwires need to read it, and no session can read a secret. Verified after: hermes 200, whole suite 200.

Why it matters: that route returns a credential, not information. The amt_ connector token acts fully as the person across every app they are licensed for, needs no header of its own, and no key rotation invalidates it — Appolis rotated its session-signing key the same day and those tokens were untouched, exactly as the finding predicted. Until now, naming somebody in an unsigned ?email= under the shared machine key was enough for any holder to collect theirs. Appolis prefers the signed token when present, ignores the email, and echoes proven: true. The email is still sent on purpose: a local (non-SSO) Hermes session has no such cookie, and that edge case is why Appolis has not yet stopped answering the legacy form. Full detail: appolis/APP_BREAKDOWN.md v0.9.1 and Kosmos note_1689 (finding F1, phases A+B).

Checked and needed NO change here: Hermes never pre-verified the shared SSO cookie locally — its resolve() helper calls /id/resolve directly and lets Appolis judge the signature. Kosmos, Agora and Phantasia all did, and all three had to be changed before Appolis could separate its session-signing key from ID_SECRET. Hermes was already doing it the right way, which is worth recording so nobody "fixes" it later.

RIVO — the door was never used at all. Measured before touching anything: webhook_events holds zero rivo deliveries, ever (shopify 60,549 · recharge 15,359 · gorgias 11,523 · flexport 3,834 · rivo 0). Rivo is PULLED, not pushed — syncRivoEvents on the */5 cron. So the earlier instruction to paste a token into a Rivo webhook URL was wrong: there is no webhook, and the REST API is the only path. Closed with a generated token; nothing to coordinate, nothing to break. (Rivo is being retired anyway, which is the second reason not to build a webhook into it.)

FLEXPORT — the token is OURS, not theirs. Nothing to fetch from Flexport: we invent a secret, register it with authMethod: 'TOKEN' / tokenHeader: 'x-webhook-token', and Flexport returns it on every delivery. The real obstacle was that the existing hooks were created with authMethod: NONE and registerFlexportWebhooks SKIPS any type that already exists — so the register button looked like it worked and upgraded nothing. Proven live before changing anything: a plain register returned created: [], existing: [all six], tokenAuth: false. NEW: deleteFlexportWebhook + ?replace=1, which deletes only hooks matching OUR url prefix and recreates them carrying the token. Ran it: 6 removed, 6 created, tokenAuth: true — and the unrelated Shopify-owned hook (Inbound.ShipmentStatusChanged) was correctly left alone.

THEN THE GLOBAL FLIP. With all three token-gated services now holding a token, WEBHOOK_REQUIRE_TOKEN=1 was set, closing the fail-open fallback for any service added later. Verified live: rivo/gorgias/flexport 401 unauthenticated, shopify/recharge 401, unknown tenant 404 — while all four real feeds keep flowing (shopify 126 · recharge 42 · gorgias 18 · flexport 17 ok in a 12-minute window, 125 Flexport deliveries succeeding since the swap).

⚠️ A NEAR-MISS WORTH RECORDING. After the swap I ran a query that hit D1's known transient error, read the empty output as "zero deliveries", and concluded from ~2 hours of apparent silence that I had broken the Flexport feed — I was about to roll back a system that was working perfectly (hours 17 and 18 held 64 and 45 deliveries the whole time). An empty result and a failed query look identical when you only grep for the fields you want. The runbook already warns that D1's first query often 7403s; that warning now extends to every "nothing came back, so nothing happened" conclusion. Tests 355 (+3, covering replace, the skip-without-replace behaviour that caused this, and never deleting another app's hook).

BATCHED, NOT PER-HIT — this runs on a 260ms-debounced keystroke path over 157,536 customers. Two queries serve the whole page of hits (ROW_NUMBER() OVER (PARTITION BY customer_id …)), not two per hit, which would be ~24 extra round trips per keystroke; a test asserts the count is exactly 1 per kind so a future refactor cannot quietly make it per-hit. Both are served by existing indexes (idx_orders_customer, idx_tickets_customer) and scoped to the ids already selected, so cost does not scale with the book — measured on production D1: 139 rows / 3.96ms. ?recent=0 opts out for callers that only want the identity row.

⚠️ ORDER BY datetime(), NOT THE RAW COLUMNorders.placed_at stores MIXED offset formats (1,809 rows -04:00, 214 +00:00, one with none), so sorting as text interleaves them and "most recent" would be silently wrong for exactly the customers whose history spans a format change. Caught before shipping because the runbook already carried this warning for hour-of-day analysis.

⚠️ Message bodies stay in Gorgias by design — pulling them would put a vendor round trip inside every keystroke (note_1601). The headline is local and is what makes a hit recognisable. Also fixed while wiring the UI: the omnibar had no when() in scope (every other definition is local to a different component), which would have thrown at render the first time a hit carried a date.

TESTS 352 (+4), driving the real route and asserting on the data; reverting the enrichment turns 3 of 4 red.

THE THING THAT MADE THIS SAFE TO SHIP: Appolis has been sending a signed envelope on every connector call all along (appolis/worker.js:361-366) and Hermes silently ignored it. Reading a header that is already arriving cannot break a caller, so step one is purely additive. server/lib/envelope.js verifies it with Appolis's algorithm verbatim (base64url(JSON) + '.' + hex(HMAC-SHA256), audience-bound to hermes, 60s expiry). Signed identity now wins; the unsigned header remains the fallback.

WHY IT IS NOT MANDATORY YET — a full caller inventory across the workspace found 10 call sites in 5 apps, and exactly one (Appolis's connector hub) can mint an envelope at all: only the destination app's own key signs one, and Kosmos, Agora, Phantasia and rivo-sub-apply hold no such key. Making it mandatory today would break Kosmos's revenue sync + nightly cron, Agora's role resolution, Phantasia's company brains and the storefront reward pings — and six of the seven fail SILENTLY, two of them into a wrong answer rather than an absence (an Agora admin silently demoted; a Phantasia employee's company brains silently replaced with an empty personal workspace). So the flip is INTERNAL_REQUIRE_ENVELOPE=1, shipped off.

⚠️ THE PRECONDITION, stated because getting it wrong locks every door: APPOLIS_APP_KEY must be set on Hermes before the flip, or nothing can verify. A missing key deliberately degrades to the legacy path rather than 403-ing — and per the runbook, a broken cutover in this suite presents as a 404/403 that reads like a missing route. ⛔ Known and NOT fixed here: the envelope carries an expiry but no nonce, so a captured one is replayable for ≤60s; that is Appolis's format and cannot be fixed from the verify side alone. Also still open: api.js computes internalUid from x-hermes-user at the TOP of the shared handler, so an ID_SECRET holder can impersonate on any route, not just these two — a wider surface than this change covers.

TESTS 348 (+9), which MINT REAL ENVELOPES with Appolis's own algorithm and drive the real routes. Reverting the preference turns exactly the two override tests red while the legacy-path tests stay green — which is the point: the fix must not be able to break the callers that cannot sign yet.

(1) THE DEV SERVER (worst of the two, and nothing to do with production). server.listen(PORT, cb) with no host binds 0.0.0.0 — every interface, and the local deps object never set requireAuth, so the session wall and 59 role checks written as deps.requireAuth && … all short-circuited to "allowed". The file it serves is data/chardizy.db68,845,568 bytes of the real book (~157k customers with names, emails, phones, home addresses); seed.js returns early when t_verdant exists, so it never replaces it with demo data. Either defect alone is survivable; together they meant anyone on the same cafe/hotel/office network could read the customer book with no credential, using the exact curl the README publishes. FIX: loopback is now the default, and asking for a network bind (HERMES_HOST=0.0.0.0, still supported for phone testing) turns sign-in ON. The dangerous pair is now unrepresentable rather than merely discouraged.

(2) THE WEBHOOK DOOR. /webhooks/:service/:tenantId took the tenant from the URL and never checked it existed, so an invented id reached the ingest handlers and created durable rows under a name of the sender's choosing — an unbounded allocation lever needing no credential, and an oracle besides (an unconfigured tenant has no secret, so verify behaved differently for real vs invented ids; recharge is the sharpest case, hashing sha256(client_secret + body), which with an absent secret degrades to sha256(body) — computable by anyone). Now rejected with a 404 before any settings or body read.

STILL OPEN, DELIBERATELY — the token half. tokenGate FAILS OPEN when no webhook_token is configured, and production has none on rivo or flexport (verified live, presence only), so both accept unauthenticated writes to real customer and order rows today. It cannot just be flipped: the vendor registrations do not send a token yet, so failing closed would silently kill both live feeds. Shipped as a staged flip — WEBHOOK_REQUIRE_TOKEN=1, off — behind the ordered cutover documented on tokenGate in server/integrations/registry.js. The flip is one secret; the rollback is deleting it.

TESTS 339 (+16), and every one drives the real request path over HTTP — no test greps the source for its own fix. Each guard was reverted and the suite re-run to prove the tests actually fail: 3 of 5 red for the dev bind (including a booted server handing back the customer book instead of a 401), 4 of 11 red for the webhook door, one per token-gated service — because the audit's own warning was that a sibling app fixed one door and left its twin raw twenty lines away.

(1) THE GHOST-DRIFTER FILTER (todo_1621). Every loyalty "drifter" the report named on 2026-08-17 was a measurement artifact — twice in one day, two sessions sent after customers who were fine. rivo_points is a vendor reading taken at rivo_points_synced_at, and points keep moving after it. THE TEST IS ARITHMETIC, not a time heuristic: column + Σ(our events after the stamp) == our ledger proves the two sides agreed at that moment. Measured whole-book before shipping: 59,916 compared · 11 drifting · 11 explained · 0 genuine — the ledger is not 99.99% right with a small real problem, it is 100% right. It lags in BOTH directions (ledger>column = earned after; ledger<column = spent after), so the filter handles both. ⛔ CRITICALLY, THIS IS NOT THE FAKE-100% BUG RETURNING: that one accepted "some event is newer than the stamp" (nearly always true) and removed rows from the denominator. This demands the delta match to the point, counts the row in the NUMERATOR, and leaves it in the denominator — strictly more conservative than the vendor-confirmed pendings, which do leave. New lag_explained field; those rows also leave unverified, since they are adjudicated by proof. Verified after shipping: the filtered query names zero members on the live book.

(2) /api/queue NEVER RECOMPUTES IN-REQUEST (todo_1622, the unfixed half of todo_1541). A cache miss or ?fresh=1 used to call computeAndCacheQueues synchronously — whose FIRST query alone measures 718,562 rows read / 618 ms returning 119,487 customers, before a GROUP BY over 293,888 orders plus every shipment, sub and ticket; service.js documents that path blowing the 128MB Worker cap. The page agents hit hardest was the one that could take the worker down, exactly when D1 was already busy. Now the cron owns the rebuild: a miss serves the last good slice with an age_minutes, a genuinely empty cache returns 202 building rather than dressing an empty list up as "nobody at risk", and the rebuild rides waitUntil behind a 2-minute debounce (five agents opening the page must not start five passes; ?fresh=1 skips the debounce because it is a deliberate human action). Off-Worker there is no background to defer to, so it still rebuilds synchronously — deliberately kept, or a cold cache would never rebuild outside production. Tests 323 (+2: both halves of the ghost filter, and the Worker path proving it does not block).

(1) THE :25 BURST WAS NEVER HOURLY. The daily brief read a minute-of-hour histogram as "a wave every hour at :25". It is one wave a day: Recharge's subscription billing run at ~04:24 UTC = 00:24 America/New_York, ~250 charges that fan out into a Shopify webhook storm (orders/create 28.6× baseline, customers/update 7.8×). It only looked hourly because Shopify's retry backoff preserves a delivery's original minute offset, painting the same :25–:29 signature into later hours. Proven by stripping every redelivery: hour 04 still carries 4,167 first-ever deliveries against 204 in hour 03 — and the wave predates our Shopify webhook integration entirely (251 recharge orders in hour 04 on 08-12, when webhook_events held 5 rows). Not our cron: the peak failure minute is :26, which no lane touches, while :10/:40 — the heaviest lane, nine jobs — carries exactly ZERO failures.

(2) THE REAL COST: a full table scan on the ingest hot path. findOrCreateCustomer's duplicate-healing lookup ran (lower(email)=lower(?) OR alt_emails LIKE ?) as ONE query. The OR defeats the index on the email half, so every orders/, customers/ and Recharge subscription delivery read the entire tenant — measured on production D1: 157,468 rows / 132.7 ms — to serve the 19 customers (0.012%) who have an alternate email. FIX: two index-served probes instead of one scan, plus a partial index idx_customers_alt_emails (20 rows, not 157k). ⚠️ Callers MUST restate the partial index's own predicate or SQLite silently full-scans anyway — verified with EXPLAIN QUERY PLAN in both directions, including the counter-test. Measured after, on production: 157,468 rows → 21 rows, 132.7 ms → 1.4 ms per delivery.

HARDENING — where the invisible failures were. getSettings sat OUTSIDE the webhook try, so an overloaded settings read escaped the handler and became a bare Cloudflare 500 with no webhook_events row of any status — deliveries vanished from the health log (~1,257 rows recorded in a peak window Shopify's own numbers put near 7,900). Now inside. The session read at the top of handleFetch had the same shape and is the actual cause of todo_1541's "queue 500s" — the queue handler's own catch returns 400, so the 500 could never have come from inside it. Both now answer 503 + a retryable JSON body when D1 is busy, and worker.js has an outermost net so nothing returns a bodyless 500 again. recordWebhook('ok') rides waitUntil — a shorter request hold, honestly not less total D1 work. /api/admin/migrate now also runs MIGRATION_INDEXES, which it never did — an index over a migrated column had no automated path to production at all.

(3) OUTBOUND EMAIL IS PINNED TO THE BUSINESS ADDRESS (Tyler: "we should only be sending stuff from the support@flipmylifenow.com email that is linked to gorgias"). v2.52.1 mirrored the inbound thread's to back as our from — taken off the vendor payload unvalidated, so a customer writing to (or forwarded through) a personal inbox made that personal address the sender; the mirror overrode a correctly configured pin. Worse, return labels were still leaking: api.js passed fromEmail: creds.email unconditionally as the FIRST term of the fallback chain, making sender_address unreachable dead code, and rebuilt creds in a way that stripped the field entirely. Every return label was going out from a login address. FIX: one resolveEmailSender + an assertPinnedSender choke point before both POSTs; email sources use the pin, never the mirror. Non-email channels still mirror verbatim and must — on chat/Messenger/IG the address is a platform handle, and 17,986 of this tenant's 51,213 tickets are non-email. A missing pin now throws loudly: letting the address go undefined is not safe, because JSON.stringify turns {address: undefined} into "from":{} — accepted by Gorgias, silently never delivered, the exact bug class this ends. Tests 321 (318 + 3: the personal-inbound case, the refusal, and the first-ever return-label sender test).

Deliberately NOT forced — the 30-minute cache and the 30-minute cadence line up, so it rebuilds each cycle on its own and a user-triggered rebuild minutes earlier is respected rather than duplicated. Its own try/catch like every sibling (one failing sweep must never starve the next), and the log line carries the row count plus any partial or error, so a half-failure now surfaces in the cron log on the next cycle instead of on Tyler's screen. Tests 313.

THE VERDICTS, loaded straight into the rulings store: 24 in-house (the ten real store campaigns + all fourteen automatic discounts), 1 employee (FLIPMW), 24 affiliate → stored as excluded (both UGC codes, all twenty first-name codes, GOAFFPRO/CLIFFORD10, and the whole Checkmate family), 1 neither (the FB/IG capture family). ⚠️ The doc labels and the real Shopify titles had to BOTH be mapped: an automatic discount has no code at all, only a title, so a ruling filed under “AUTO: 0 Off 2” would never have matched the row titled “0 Off 2” — the same trap in reverse for CLIFFORD10, whose title is GOAFFPRO.

VERIFIED AGAINST THE REAL FEED, not mocks: the actual classifier was run over eighteen live codes with the rulings pulled from production D1 — all eighteen correct, including two codes that DO NOT EXIST YET (CHECKMATE-NEVERSEEN-CN, IG-EMAIL-BRANDNEW) landing as excluded, which proves the family rulings cover whatever those machines mint tomorrow. Classification cache busted so the change is live now. Tests 312.

🗳 AND THE BIGGER CHANGE: A HUMAN VERDICT NO LONGER NEEDS A DEPLOY. Classification was a pile of regexes inside classifyDiscount, so "ERIN10 is a staff perk" would have meant editing code — and the next sweep would happily re-litigate it. There is now a per-tenant rulings store (queue_cache.discount_rulings) consulted FIRST, ahead of every pattern. POST /api/discounts/rulings accepts either a {code: category} map or the plain text the review doc's "Copy my answers" button produces — headed sections parsed straight into verdicts, and it busts the classification cache so the change lands immediately. FAMILY rows become patterns, so one ruling on FAMILY: Checkmate Network covers every code that family mints tomorrow (a Proxy makes the pattern rulings answer a plain map lookup, keeping the classifier a simple overrides[CODE] read). The doc says "affiliate", the engine stores "excluded" — the vocabulary is translated at the boundary rather than leaking a UI word into storage, and a whitelist rejects any category that is not real. Tests 310 → 312, covering: a ruling beating the built-in pattern, family rulings catching never-before-seen codes, unruled codes still falling through to the normal rules, and the pasted-text parser (including that undecided rows are never given a verdict).

REPAIRED AT BOTH VENDORS: Flexport — re-registered all six events (Order.Packed/Shipped/Delivered/Cancelled, Return.Created/Updated) to hermes.appolis.app, deleted the six dead-URL hooks, left Shopify Fulfillment Network's own hook strictly alone. Recharge — repointed all five subscriptions in place via PUT (same token, so the signing secret still matches). VERIFIED FLOWING: first Flexport events since 2026-07-14 landed within the hour (order ×2, and the first fresh flexport_order_id stamp), shipments written minutes ago, and the Shopify feed registered in v2.48.0 is a firehose (888 orders/updated, 234 fulfillments/update in ~80 min). Recharge arrivals pending its next billing batch — repoint confirmed on their side.

🐛 And the bug the diagnosis exposed: Flexport's GET /webhooks returns a bare array, but registerFlexportWebhooks read .data — so existing was always empty and the idempotency check could never fire; every press of the register button would have re-created every hook. Measured live, fixed to accept both shapes. Tests 307. Remaining tail: the 07-17→08-13 carrier/shipment gap wants a backfill, and the ship-date-contaminated delivered_at rows still await re-derivation (todo_1550).

🔌 SHOPIFY HAD NEVER SENT HERMES A SINGLE WEBHOOK. The registry has always declared it wants orders/create · orders/updated · fulfillments/create · fulfillments/update · customers/update, and webhook_events held zero shopify rows, ever — confirmed a second time by asking Shopify with Hermes' own credentials: 0 subscriptions owned by this app. upsertShipment (which writes orders.carrier) is only reached from the order ingest and the fulfillments/* branch, so nothing had written carrier or shipment data since 2026-07-27 — 16,759 orders with no carrier, and the shipments table frozen for 17 days. New registerShopifyWebhooks + GET|POST /api/integrations/shopify/webhooks (admin-gated, idempotent, leaves other apps' subscriptions alone) — the twin of the Recharge and Flexport registrars that was never built. ⚠️ A subscription belongs to the app that creates it and is signed with THAT app's secret, so it must be registered on Hermes' stored credentials; one made from any other tool would deliver and then fail HMAC forever. Registered all five and verified live: orders/updated → order ingested, fulfillments/update → shipment 9261…, customers/update → customer upserted, all status: ok (so HMAC verified against client_secret), and the first carrier stamps since 07-28 — DHL eCommerce and Tele Post — landed within two minutes.

🔴 AND delivered_at HAD BEEN RECORDING THE SHIP DATE. deliveredAtFromOrder ended … || f.created_at || f.updated_at, and created_at is when the fulfilment was created — the moment the parcel left. Whenever Shopify's delivered-event timestamp was absent (the bulk path never spends a call on the events log) it wrote the ship time into delivered_at. The evidence is stark: the Flexport era, whose dates came from Flexport's own delivery webhooks, averages 5.88 days over 34,823 orders; the rows built by this fallback average 1.9 days with ZERO late deliveries. Re-scoping the report without catching this would have published a halving of transit time as fact. An unknown delivery time now stays NULL — the order stays undelivered and deliveredEventAt fills it properly. ⚠️ The old test asserted the fallback as intended ("no events → fall back to created_at"); it now asserts NULL, with the measurement written beside it.

📦 THE REPORT RE-SCOPED — Tyler's "combination of A and B". The Flexport-only filter (18 call sites) becomes a ShipBob-cutover date floor (placed_at >= 2025-10-31): the original anti-leak guarantee is preserved exactly, but every modern order counts whoever shipped it — 2,617 delivered orders in the last 30 days instead of 9. Where the parcels came from is now reported as provenance rather than silently filtered: the page states the Flexport/other mix, warns in amber when carrier data is missing (naming the last carrier stamp), and warns in red when non-Flexport transit is under half the Flexport average — which is exactly the ship-date contamination above, so the page declares its own worst figure untrustworthy until the historical rows are re-derived. Cache version 21 → 22 so no stale payload can serve without it. Tests unchanged at 307 (one rewritten from asserting the bug to asserting the fix).

THE CRASH: lifecycle returned D1_ERROR: too many SQL variables. Self-inflicted in v2.46.0 — raising the columns from 8 rows to 50 made customersByMemberIds ask for up to 200 ids in one IN(), past D1's bound-parameter cap. Now chunked at 40 (pinned by a test that fills a 50-row column and asserts every displayed row still resolves its owner across chunk boundaries).

THE HOLE — "I feel like we're missing a lot of our in-house ones", and he was right by an order of magnitude. Two causes. (a) The reward flood: the store holds ~1,600 REW- codes and a few dozen real in-house ones, so a newest-first walk returned pages of rewards and the in-house codes never surfaced. Fixed by filtering at Shopify (-title:REW* AND -title:'FLIP Reward') — one filtered page carries more signal than six unfiltered, and SUB20 / WELCOME10 / Flip20 / Flip25 / NEW25 / STIX25 / BMUGC / BRATT40 / Shaw10 / Neal20 appeared immediately. (b) AUTOMATIC discounts were never queried at all — and they are the heaviest machinery in the store: measured live, $10 Off 2 has 36,397 uses, $5 Off 2 18,266, Free shipping $75+ 10,369, FREE SHIPPING FOR SUBSCRIPTION 5,808 (active). A discount board that cannot see the volume tiers is not a discount board. Both feeds now merge, sorted active-first then by uses.

⚠️ AFFILIATE CODES ARE DiscountCodeBasic, NOT DiscountCodeApp — so the type-based exclusion excluded NOTHING. Checkmate Network (CHECKMATE-…-CN) and GoAffPro (title GOAFFPRO, code CLIFFORD10) are ordinary code discounts created through the API, which is how Checkmate reached Tyler's deck as "in-house". Now matched by name against a maintained pattern list, with the app-owned type rule kept as a second net.

🎖 ID.me GETS ITS OWN SECTION, per Tyler — it is a verified-group benefit, not a campaign. Measured: two programmes, one store-wide code each, 15% off — Military 1,031 uses, First Responder 247. The section leads with total verified redemptions and a card per programme; the strip's Discounts position now reports Live discounts / Total uses / Automatic live / ID.me uses. BOGOs lost their tile (folded into Discounts as a card badge — a BOGO is a kind of discount, not a category). Tests 305 → 307.

THE FIX: drive small, then indexed IN(). New customersByMemberIds helper resolves member ids through the two indexed lookups (idx_customers_shopify, idx_customers_rivo) and returns a Map keyed by either id; deck fetches its 12 coupon rows bare and resolves owners after (3.2ms + 1.1ms measured on production, vs a database reset); wallet splits into one name/email scan (146ms over 138k — a scan, not a nested scan) plus a code hunt over the 1,600-row book whose owners resolve via the helper; lifecycle groups bare and resolves names only for the ≤32 rows that display. Response shapes unchanged — all 10 deck tests pass untouched, suite 304. ⚠️ The lesson, now in a comment on the route: never OR-join into customers; the per-row scan is invisible locally (seed data is tiny) and lethal at production scale — timing the exact query remotely via d1 execute --json (timings ride every response) is the two-minute check that catches it.

WHAT COUNTS, BY DECREE: loyalty rewards (Rivo/Hermes), in-house admin-created codes, BOGOs, and ID.me. Codes minted by other apps — affiliate programs and the like — are EXCLUDED, shown only as a dashed "Not ours · N" card so the filtering is visible rather than silent. The classifier is a pure function (loyalty book first, then REW- shape, then DiscountCodeBxgy → BOGO, then ID.me by name, then DiscountCodeApp → excluded) and the in-house feed is a validated GraphQL pull cached 30 minutes in queue_cache, fail-soft: a Shopify outage serves the stale copy stamped with why, because the deck must open on a bad morning.

🔄 LIFECYCLE FLOW (tile): Active → Spent → Returned → Gone as money-totalled columns, actives idle >14 days flagged ⚠ nudge? — an unspent code is a customer who paid points for nothing yet. 🎁 BULK GIFT (tile): "everyone who spent $X in the last N days gets Y points" — and because this moves REAL points (in Rivo, the balance customers see), it is built adversarially: preview-first (the award button does not exist until the group has been looked at), drift refusal (apply re-counts and 409s if the group changed since preview — orders land constantly), receipts before money (one deterministic ledger id per day/filter/points/member, checked BEFORE Rivo is called, so a crash-retry pays nobody twice — and a FAILED award holds no receipt, so the retry pays exactly the missed ones), batches of 100 with an explicit remainder. 🎚 PAUSE/REACTIVATE ships too: Hermes-owned codes flip via discountCodeDeactivate/Activate (validated mutations; userErrors surfaced, never swallowed); a Rivo-minted code is refused with an explanation, not a crash.

⚠️ Route-order trap pinned by a test: deck, wallet and lifecycle are all valid matches for the /api/discounts/:code regex — the fixed routes must precede it, and the deck test asserts the payload is a deck, not a 404 drill-in for a code named DECK. Admin-only throughout (bulk + pause double-gated server-side). Built atop the concurrent v2.43–44 lander-feed releases from the other session's lane, no collisions. Tests 294 → 304.

LIST — tenant-wide totals header (Active / Used / Returned, count and dollar value, computed over the whole registry so it can never contradict a filtered page), debounced search across code / reward name / member id, state filter pills, and a row per code carrying owner, points paid, value and derived state. When the page is capped it says it is showing the first N rather than truncating silently.

DRILL-IN — reward, value, points paid, state, shape (pooled / owned / legacy — the same distinction the void router uses), origin, issued / used / returned timestamps, note; the owner as a button that closes the sheet and opens their Hermes profile, degrading to plain text when no profile is linked; the live Shopify block; and a "what the customer sees" panel read from the rivo_coupons mirror, so a support agent can see the two views side by side and spot a disagreement.

⚠️ A FAILED SHOPIFY CHECK RENDERS AS A FAILURE — amber, reading "Not checked … Treat this as unknown, not as 'fine'." Rendering an unreachable check as a clean bill is precisely how someone refunds points for a code that was already spent.

💡 AND IT NAMES THE ORDER. Because v2.41.0 (shipped hours earlier) taught shopifyDiscountStatus to find the order carrying a code, the drill-in prints "Spent on order #292217" — the "which order consumed it" line this design asked for and which was not knowable at all the day before, since the usage counter never moves for a Recharge-billed order. Where only the counter knows, it says so explicitly instead of implying an order.

📌 AUDIENCE: ADMIN-ONLY, added to the admin TABS set and to the hub menu under Look up with admin:true. It is a new cross-customer view of money, so it starts closed; widening it is a one-line change in both places. This also sidesteps the documented redirect-loop trap — a non-admin routed to a tab outside their allowed set gets bounced straight back to their department home. Read-only by design: no writes at all — a browse surface that cannot mutate cannot break a live store. Tests unchanged at 279 (the six discount-registry tests already pin the response shapes this screen consumes; the new code is view layer).

🚨 THIS WAS NOT A DASHBOARD BUG — FOUR MONEY GUARDS READ THAT COUNTER. shopifyDiscountStatus is the app's single liveness test, and everything downstream inherited its blindness: the storefront return path (storefront.js) would let a customer return a code they had already spent and take the points back too; the agent refund guard (api.js) would approve the same refund from the inside; the abandoned-checkout sweep (loyalty.js) is worse still — it actively voids and credits points back for anything the counter calls unused, so it would have paid out on genuinely spent codes; and the nightly coupon walk recorded the wrong answer for all of them. Each one hands the customer the discount and their points. This is the same double-refund shape the standing Shopify-is-coupon-truth rule exists to prevent, arriving through a door nobody had checked.

THE FIX: THE ORDER IS THE TRUTH. New shopifyCodeOnOrder asks orders(query: "discount_code:…") — if an order carries the code, it was spent, and that is true for both the web-checkout and Recharge paths. shopifyDiscountStatus now falls through to it whenever the counter says unused, so every caller is fixed at once rather than one guard at a time. It also runs on the 404/deleted branch, because a spent-then-deleted code must still refuse a refund — and the guards all read used before exists, so it lands correctly. Fail-safe by construction: any wobble on the order probe leaves the counter's answer untouched, because a network failure must never be read as evidence of a spend.

COST IS PAID WHERE IT MATTERS, NOT ON RENDER. The probe is a third Shopify call, and the customer-profile coupon list runs the check up to 15 times per render — so that one path alone passes { checkOrders: false }, skips rows already resolved (used_at IS NULL), and its write uses MAX(COALESCE(shop_used,0), ?) so the fast path can never downgrade a spend the thorough check already established. Every money guard and the nightly walk pay the extra call.

📌 AND IT NOW RECORDS THE SPEND INSTEAD OF JUST FLAGGING IT. When the order query is what found it we know exactly when the charge billed, so used_at is stamped with the order's date, not now — retiring the row from the walk, giving every other guard a local answer without a Shopify call, and making the registry's used state finally mean something. Per Tyler, a code sitting on a queued charge is legitimately not yet used; it only counts once the charge goes through, which is precisely when an order exists. loyalty.js's "keep it" branch also stopped requiring exists — a code spent and later deleted was falling through to the refund. Tests 271 → 279.

STATE IS DERIVED IN SQL, NEVER STORED AS OPINIONvoided_at → returned, used_at → used, else active. Deriving it once means the list and the drill-in can never disagree. Totals are computed over the whole tenant, not the filtered page, so the header cannot contradict the list the moment someone filters. Each row also carries its shape (pooled / owned / legacy) — the same distinction the void router uses, surfaced so a human can see which era a code is from. The drill-in asks Shopify live (shopifyDiscountStatus) and, when it cannot, reports checked: false rather than rendering a failed check as a clean bill; ?live=0 is an honest opt-out for a fast hop from the list. READ-ONLY BY DESIGN — no writes at all in this step; a browse surface that cannot mutate cannot break a live store.

🕳 Test-authoring trap worth keeping: the first cut of the Shopify-check test did UPDATE integration_settings … — but seed.js creates no such rows, so it changed nothing, the route correctly skipped the call, and the test "failed" against working code. Use an upsert. Tests 265 → 271.

🚨 AND THE MIGRATION FOUND FOUR PHANTOM CODES. Re-minting the nine legacy pooled codes as owned discounts surfaced four more live codes that did not exist in Shopify at all — including one visible on Tyler's own rewards page, which would have been refused at checkout. Verified properly rather than trusting a null: a known code resolves via codeDiscountNodeByCode, a bogus one returns null, those four returned null. Cause: discountRedeemCodeBulkAdd is ASYNCHRONOUS — it returns a job id, and the pooled minter recorded success on submission without ever checking the job landed. Some never did. The owned minter is synchronous and returns the node id, so it cannot fail this way. All four re-minted and bound to their owners. Every live code (13) is now an owned, customer-bound, single-use discount; zero pooled.

📌 DECISION (Tyler): keep used codes in Shopify; browse in Hermes. One discount per redemption does mean one admin entry per redemption — the volume that pooling originally existed to avoid. Deleting used codes would keep the list small but destroy the ability to ask Shopify "was this spent?", which is the standing Shopify-is-coupon-truth check that exists because a private dead-ledger once caused a real double-refund. So the discount list grows and stays correct, and the Hermes registry becomes where anyone actually looks. Tests 264 → 265.

⚠️ usageLimit IS DISCOUNT-SCOPED, NOT PER CODE. The pooled minter's own comment asserted the opposite as fact — "usageLimit on a code discount applies PER CODE" — reasoning from the REST price_rule wording, where a rule had one code. The GraphQL schema is explicit: usageLimit is "the maximum number of times the discount can be redeemed", asyncUsageCount is "the number of times that the discount has been used", and DiscountRedeemCode has no usageLimit field at all — so "each code usable once" is simply not expressible in a pool. Live consequence: the $25 pool held 8 codes sharing usageLimit: 1. The first customer to check out with any of them would have consumed the entire pool, and the other seven would have failed at checkout after their owners had already spent their points. Caught before it ever fired — asyncUsageCount was 0 on all four pools.

⚠️ And no pool could bind a code to anyone: every one was customerSelection.all, so any code worked for anybody who got hold of it. Both faults die with the same change.

THE FIX: one discount per redemption, owned by the customer who paid. shopifyCreateOwnedRewardCode creates a DiscountCodeBasic per mint with customerSelection.customers.add: [customer gid], usageLimit: 1 (safe now — the discount hosts exactly one code), appliesOncePerCustomer: true, and the standing combinesWith rule (shipping only; two reward codes never stack). customerId is already the Shopify customer id on both callers (storefront passes the app proxy's logged_in_customer_id, admin passes customers.shopify_customer_id), with a lookup fallback; an unresolvable customer still mints — a single-use unowned code beats refusing a redemption whose points were already checked — but the caller is told.

⚠️ VOIDING NOW ROUTES THREE WAYS, and the order matters because two of them store a gid: gid:// in price_rule_id = pooled (delete one code — deleting that node would void every outstanding reward of that value); a DiscountCodeNode gid in discount_code_id = owned (delete the node, correct because it hosts one code); numeric = legacy REST. Owned codes must not go through shopifyDeleteDiscountCode: it only removes the price rule when the rule title equals the code, and these are titled FLIP Reward $25.00 — REW-…, so the rule would survive and keep reading as live in the admin.

🕳 Two bugs the tests caught during the change. The failed-deduct rollback still called shopifyPoolDeleteCode(…, poolId, …) after poolId ceased to exist — a ReferenceError that would have stranded a live free coupon every time a deduct failed, and node --check cannot see it. And the ledger row id keyed off discount_code_id, colliding across mints — the same class of failure as the earlier lrd_<tenant>_null collision, from the other direction. It now always keys off the code, which is unique per redemption and what the row is actually about. The old test asserted the bug as a guarantee ("usageLimit is PER CODE"); it now asserts the real contract, plus a new test proving a pre-2026-08-08 pooled code still deletes only its own code and never the pool. Tests 262 → 263.

⚠️ THE CARD IS A LID, NOT A DRAWER — the fix Tyler caught. The first cut printed the CODE in the card's description line; the widget's own decorator keys off a visible code and injected .rsa-slot wherever it found one, so Apply to Subscription / Use at checkout / Copy code / Return for points sat permanently under every coupon. Rivo shows none of that on a card: its card is name + "Spent N Points", full stop, and .rsa-slot exists only inside the modal. Never put a code in card text — doing so silently recruits the whole button stack onto it.

💡 AND THE REAL WIN: RIVO'S OWN MODAL RENDERS A HERMES CODE. window.renderPointsPurchaseModal(obj) accepts a hand-built points_purchase shape and renders it correctly — right title, the code in its input, Apply to cart — and crucially it emits .rsa-slot, which this widget then decorates as normal. Verified live on REW-9A0FA20E13AE, a code Rivo has no knowledge of: modal opened, code matched exactly, all four actions present. So we never built a lookalike modal; we open Rivo's. Falls back to the clipboard if Rivo isn't loaded, so a click is never dead. Spent coupons still open — Rivo's do, and the modal is where a customer reads why.

🔒 Unrecognised modes now fail to shadow, not on. start() stands down only for the exact strings off and shadow, so a typo (ON, true, enabled) previously fell through every check and behaved as fully on — silently replacing a live customer surface because someone fat-fingered a setting. Normalised client-side, and the config test now guards the channel rather than the value: the key must be served at all (the widget merges only keys already in its own defaults, so a key that stops being served is silently ignored and the flip would quietly revert), and the value must be a mode the widget actually understands. Reverting is one line: set it back to shadow and redeploy — the widget fails open by construction, so customers land back on Rivo's blocks. Tests 262.

⚠️ It ships in shadow and that is the whole point. off / shadow / on, defaulting to shadow: it fetches, renders nothing, and reports what it would have replaced — which container, how many rows Rivo is showing, how many codes Hermes holds. That side-by-side is the measurement the first attempt never had, and it is the thing that decides whether the swap is right. ?hermes-rewards=on|shadow|off overrides per session, so the flip can be trialled on a real signed-in account without a deploy and without any customer seeing a change. Mirror → shadow → flip, the house pattern. A test asserts the default is shadow and never on — an accidental default would switch every member's page to an untested surface with nobody having decided to.

⚠️ FAIL OPEN, AND IT IS VERIFIED, NOT ASSERTED. Rivo's block is hidden only after our data loads and our list mounts. Exercised in a browser against a stubbed proxy: with page-data returning 500 in on mode, nothing rendered and Rivo's rows stayed on the page; with good data but no container found, nothing was injected anywhere. The failure we must never ship is a customer staring at an empty rewards page because our request 401'd — today a failure only means our decoration doesn't apply, and that asymmetry now holds by construction.

⚠️ CAUGHT IN REVIEW, WOULD HAVE BEEN A STORE-WIDE COST: widget.js loads on every page of the shop, and the first cut fetched page-data unconditionally — one request per pageview per visitor, including logged-out browsing that can never have a reward list. Now nothing is fetched until a Rivo rewards surface is actually present; verified at zero requests on an ordinary page across all six retry ticks. The retries exist because Rivo renders after its own fetch, so the surface usually doesn't exist on first run — looking once would never fire, and looking too early is the hydration race that bit the last attempt. Also fixed before shipping: the shadow report counted the <template> as a rendered row and said 4 where a human sees 3 — an off-by-one in the one number the mode exists to report. Still Rivo's: points earning and referral mechanics; this is the coupon list and redeem only. Tests 261 → 262.

Three things it catches that nothing else could. (1) The blank shell. The client is plain JS inline in <head>, not in the app bundle — deliberately. build.js ships the app as a hashed app.<hash>.js loaded defer, and if that bundle or vendor React 404s the page renders blank; a reporter living inside the bundle would never run. That exact failure has already been felt here (it is why build.js retains two old bundles). A 12-second watchdog beacons a BOOT FAILURE for sessions the in-app reporter is structurally incapable of describing. Do not move this block into the babel script. (2) Render crashes. React does not route render errors through window.onerror, so a single bad render unmounted the whole tree and left a white screen — indistinguishable from the app being down, with nothing sent. A Boundary now reports the crash and offers "Back to hub" / "Reload" instead of a dead page; recovery beats a reload because the crash is usually one tab's data and a reload drops an agent back at their start page mid-ticket. (3) Unhandled rejections and console.error, deduped, with a hard 30-send-per-session cap so a render loop can never flood the collector.

🔒 PII is the part that made this different from the game. Hermes renders real people — names, emails, order numbers, ticket bodies and REW- codes that are literally money — and the collector is a shared dashboard. Every outbound string passes through scrub(): emails, shpat_/Bearer tokens, reward codes and 6+ digit runs are redacted, in that order (the digit rule last, or it chews the tail off a token and leaves a recognisable stub — there is a regression test for exactly that). The reference client JSON.stringifys every console.error argument; here that would hoover a whole customer object into the collector the first time anyone logged one, so objects are recorded as [object] and nothing more. Context is deliberately coarse: role, rank, tenant and current tab — never email, name or id. Honest limit, recorded rather than glossed: a scrubber only catches things with a shape, and a customer's name has none — which is why shape-less PII is kept out by never collecting it rather than by filtering it.

🔐 Separately: PUT /api/integrations/:service now has tests. The admin gate itself shipped 2026-08-07 (todo_1321) — this session found it was asserted by nothing, so it sat one refactor away from silently coming off. Every other integrations test PUTs as an admin during setup and would pass against completely unguarded code. ⚠️ The new tests boot with requireAuth: true, because the gate reads if (deps.requireAuth && …) and is inert under the suite's default harness — a test written without that option passes against no gate at all. Verified by mutation: with the gate commented out, two of the three go red; api.js was then confirmed byte-identical to the committed version. What is at stake is a money bug reached through a permissions bug — an empty string on a credential field is a delete, and a blanked Shopify token makes checkCouponsInShopify return early and swallow throws, so spent discount codes keep reading as live with nothing logged. Tests 251 → 261.

Rendered from Flip CMS/APP_BREAKDOWN.md · this page is generated — edit the markdown, not the HTML
print this page for a PDF