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/breakdownin 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-tenantapp_configtable and the at-risk queue rescores immediately. In-code defaults inserver/lib/risk.jsapply 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.
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:
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.
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).
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).
| Table | Key fields | Notes |
|---|---|---|
tenants | id, name, shopify_domain, plan | plan defaults 'internal' |
team_members | tenant_id, name, email, role, phone | roles: wellness_coach, cx_lead, retention, admin |
customers | tenant_id, name/email/phone, sms_opt_in, first_order_at, ltv_cents, email_opens_last3, rivo_customer_id/points/points_value_cents/tier | loyalty denormalized onto customer |
products | tenant_id, title, servings_per_unit, default_daily_dose | drives the ring |
subscriptions | tenant_id, customer_id, product_id, status, cadence_days, next_charge_at, paused_reason, status_changed_at | statuses seen: active, skipped, paused, cancelled |
orders | tenant_id, customer_id, product_id, quantity, total_cents, placed_at, delivered_at | delivered_at anchors the ring |
shipments | tenant_id, order_id, carrier, tracking_number, status, last_scan_at, eta | statuses: label_created, in_transit, customs, delivered, lost, stalled |
tickets | tenant_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_rules | tenant_id, topic, route_to_role, fallback_member_id | 5 seeded topics |
touchpoints | tenant_id, customer_id, channel, topic, owner_id, scheduled_at, booked_by, booking_link_token, status | self-serve booking mints lnk_ token |
conversation_notes | tenant_id, customer_id, touchpoint_id, author_id, body, tag, source | tags: pain_point / win_factor / feedback / note; source: manual / save_flow |
save_flows | tenant_id, customer_id, run_by, reason, offer_made, outcome, ltv_at_risk_cents | outcomes: saved / follow_up / cancelled |
integration_settings | (tenant_id, service) PK, credentials JSON, status, last_test_at, last_sync_at, last_error, sync_state | sync_state holds resumable crawl cursors per resource; secrets never returned unmasked |
webhook_events | tenant_id, service, topic, status, detail, received_at | audit trail: ok / rejected / error — feeds the Connections screen |
subscription_events (v0.5.0) | tenant_id, customer_id, subscription_id, from_status, to_status, at | status transitions + skipped charges (deterministic se_rcch_<chargeId> ids); powers repeat_pauser + renewal_skipped |
app_config (v0.5.0) | tenant_id PK, config JSON | per-tenant risk tunables edited on /breakdown |
queue_cache (v0.7.1) | (tenant_id, category) PK, payload JSON, computed_at | precomputed 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.
lib/ring.js)supplyDays = (quantity × servings_per_unit) / dailyDoseRING_START_BUFFER_DAYS — people don't start the day the box lands): daysLeft = supplyDays − daysSinceStart; pct clamped 0–100; ring reads full inside the grace window.servings/gapDays per gap → median, clamped 0.1–5.0/day. Needs ≥2 delivered orders, else label dose. Result: a customer who under-doses (Tom: 0.68/day vs 1.0 label) stops getting nagged at the wrong cadence.ring.dailyDoseUsed vs ring.labelDose.lib/risk.js) — rule-based v1, every point explainableQueue 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).
| Signal | Weight | Fires when |
|---|---|---|
supply_depleted | 30 | ring ≤ 0 days AND nothing dependable in flight |
shipment_stalled | 25 | in-flight shipment, no carrier scan ≥ 48h |
first_order_stall | 22 | exactly 1 order, day 30–45 since it, no repurchase |
repeat_pauser | 22 | ≥2 skips/pauses in 90 days (from subscription_events — the "essentially pausing" pattern) |
negative_open_ticket | 20 | any open ticket with negative sentiment |
renewal_skipped | 20 | a skipped charge ≤60 days on a non-cancelled sub (skips live on charges — the sub stays "active") |
subscription_paused | 18 | sub status paused (detail carries the reason) |
supply_low_no_order | 15 | ring ≤ 25% AND >0 days AND no reliable inbound AND next charge won't beat depletion |
lapsed_buyer | 12 | bought before, no live sub, quiet 60–365 days — the win-back pool |
email_disengaged | 8 | opened 0 of last 3 emails |
/breakdown (per-tenant app_config overrides; the queue rescores immediately).shipment_stalled + supply_depleted + renewal_skipped + repeat_pauser = 100/high.lib/saveflow.js) — reason → say-this script → matched offers| Reason key | Label | Offers |
|---|---|---|
results | Not seeing results yet | Free wellness-coach session · Dosing & timing adjustment plan · 2-week pause with auto-resume |
price | Too 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) |
overstock | Too much product on hand | Skip next shipment · Move to 8-week cadence · Swap to smaller count |
shipping | Shipping keeps going wrong | Free reship + carrier upgrade · SMS tracking · Review address & carrier routing |
medical | Side effects / medical concern | noPush — Pause immediately (no save attempt) · Full refund on current bottle |
other | Something else | Listen & log · Route to teammate · Offer a pause |
offerAllowed only accepts offers on the reason's own list; the medical list contains no retention offers, so a discount on a medical reason is impossible, not just discouraged. Loyalty points can never unlock it either (tested).price only, if redeemable balance ≥ $5.00, "Redeem $X in points now" is prepended as the lead offer (zero margin). Fabricated amounts are rejected (offer string must match the customer's actual balance).pain_point; saved→win_factor; follow_up→note; cancelled→feedback. These feed the insights rollup.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).
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).
server/api.js — one fetch-native handler: UI + API + webhooks, identical on Workers and local)| Method | Path | Purpose / notes |
|---|---|---|
| GET | /api/queue | At-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/vips | Best 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/thread | Full Gorgias conversation fetched live (chat-style thread modal) |
| GET/PUT | /api/config | Per-tenant risk tunables for /breakdown (weights, multiplier, tiers — validated) |
| GET | /api/customers/:id | Score + 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/plays | The script library (PLAYS) |
| POST | /api/saveflow | {customerId, reason, offer, outcome} → validates offer against play + loyalty, records, auto-writes notes. Invalid offer → 400 |
| GET | /api/insights | painPoints / winFactors / feedback / saveRates |
Integration routes:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/integrations | All services: status, masked creds, field specs, webhook URL + topics |
| PUT | /api/integrations/:service | Save credentials (masked round-trips keep stored secrets) + auto test-connection |
| POST | /api/integrations/:service/sync | Run a sync chunk (?pages= override); returns counts + remaining; resumable via cursors |
| POST | /api/integrations/recharge/webhooks | One-click webhook registration — the server registers its own receivers with the stored token (Recharge has no webhook admin UI); idempotent |
| GET | /api/integrations/rivo/sample | Diagnostic: raw vendor payload passthrough (?qs=/?email=) — found both Rivo API bugs |
| GET | /api/webhook-events | Recent webhook audit log (?limit=) |
| GET | /api/products | Products usage-sorted with orders/customers counts + ring_ready flag |
| PUT | /api/products/:id | Set dose facts (servings_per_unit, default_daily_dose) — validated positive; enables the ring |
| POST | /webhooks/:service/:tenantId | Signature-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.
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.
| Service | Credentials (Connections screen) | Webhook security | Sync | Webhook 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 days | HMAC-SHA256 base64, timing-safe — Notifications-page secret or app client secret | customers + orders, since_id cursor crawls, resumable | orders/ → customer+product+order; fulfillments/ → shipment (delivered stamps the ring anchor); customers/* |
Recharge (recharge.js) | API token, webhook client secret | HMAC-SHA256 hex, timing-safe | cursor-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 token | shared-token gate | two-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 them | points/updated → customer rivo_* columns |
Gorgias (gorgias.js) | account domain, email, REST API key, optional shared webhook token | shared-token gate | tickets, 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 gate | none — the Logistics API 2024-07 has no order-list endpoint; tracking flows via Shopify fulfillments | shipment events → exception→stalled (churn-in-waiting) |
Twilio Voice + Whisper (voice.js) | not yet on Connections screen | X-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).
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:
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.
web/dashboard.jsx (852 lines, ESM React component)The first clickable prototype (light sage/fern "Chardizy" theme). Has features the branded UI dropped:
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.
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).
| File | Covers |
|---|---|
core.test.js | supply 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.js | E2E 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.js | Flexport exception→stalled, Recharge HMAC + cadence + order mapping, Rivo normalize (both payload shapes) + lever threshold, TwiML, Twilio signature algorithm, voice grant shape |
connect.test.js | Shopify 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 ;).
charge/updated (skips arrive instantly)| # | Where | Issue | Status |
|---|---|---|---|
| B1 | seed.js c1 (Marisol) | Order delivered before placed + in-transit shipment on a delivered order | ✅ FIXED 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 |
| B2 | risk.js isStalled | A 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 |
| B3 | voice.js transcriptToNotes | Stale model id | ✅ FIXED v0.2.0 → claude-sonnet-5 |
| B4 | voice.js verifyTwilioSignature | Non-constant-time compare | ✅ FIXED v0.2.0 → crypto.timingSafeEqual |
| B5 | voice.js outboundCallTwiml | Unescaped XML interpolation | ✅ FIXED v0.2.0 → escXml() on all params |
| B6 | api.js | Every thrown error → 400 | ◐ PARTIAL v0.2.0 — explicit 404s (unknown customer/service/route), webhook + sync errors → proper 500; core-route catch-all still 400 |
| B7 | api.js POST routes | No input validation | ✅ FIXED v0.2.0 — unknown customers → 404 (tested), outcome enum enforced, channel/topic/body required |
| B8 | README.md | Stale test count; references chardizy-data-model-spec.md — file not in the zip | ◐ README refreshed for v0.2.0; the missing Postgres spec still needs regenerating |
| B9 | UI stats | 30-day retention hardcoded 96.4%; save-rate denominator hardcoded | ✅ FIXED v0.4.0 — removed with the live wiring |
| B10 | duplicate HTML | Root copy vs web/ copy will drift | open (delete the root copy) |
| B11 | db.js id() | Not collision-safe at scale | ✅ FIXED v0.2.0 → prefixed crypto.randomUUID() |
| B12 | insights | GROUP BY exact body — free-text manual notes fragment rollups; needs canonical topic tagging (LLM classify) for real data | open |
✅ 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).
APP_KEY (30-day cookie), /webhooks/* exempt (signature-verified). Real auth (Cloudflare Access on a custom domain, session-derived tenant instead of the x-tenant-id header) is the P0 upgrade.* with all headers/methods.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)
x-tenant-id header) — it guards customer PII and integration credentials/rewards once and the zero-margin offer lights up/api/voice/token, TwiML app endpoint, recording callback → Whisper → tagged notes (adapters ready + hardened; needs the HTTP surface + Twilio/OpenAI/Anthropic keys — add Twilio to the Connections screen)lnk_ tokens are minted but nothing consumes them — customer-facing slot picker + SMS send (opt-in gated)?tenure= already supports it)dashboard.jsx into the live UI: Calls & notes tab, Cohorts retention heatmap, Playbooks (automation rules)flip-my-life-retention.html (B10); stall detection for never-scanned shipments (B2); regenerate the missing Postgres data-model spec (B8)tenants.plan exists)risk_signals catalog doubles as the feature store) — current weights are hand-setThe 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.
| Dept | The report (slides) | The login can DO | Data source |
|---|---|---|---|
| Fulfillment ✅ live | Delivery performance, carriers + pie, claims scoreboard, money left on the table, loss/gain | File claims, reship, export Excel/PPTX/PDF | Flexport + Shopify + mined CS disputes |
| Sales | Revenue by channel (store · wholesale · Amazon · distributors), AOV, cohort repeat rate, sub vs one-time mix, top movers | See every channel side-by-side; discount/price experiments; wholesale account book | Shopify + Recharge today; Amazon/distributor feeds when connected |
| Marketing | CAC 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 performance | Manage/pause ads and budgets from the app; VOC pipeline — pain points & wins mined from CS become the messaging brief | Ad platforms (to connect) + cac_cents scaffold + the dispute/notes miner |
| Finance | Store P&L, margin by product, refund exposure, per-customer ROI + store-average break-even, claim recoveries, LTV:CAC | Set per-variant COGS, review refund outcomes, export books-ready sheets | The profitability engine (costBasis/orderProfit) — already computes all of it |
| Inventory | Consumption & seasonality (real days-per-bag vs label, cohort split — already collected), stock runway, reorder forecast by month | Demand forecast → PO planning; low-stock alerts | consumptionStats + Shopify inventory levels |
| Customer service | Ticket volume/sentiment trend, save rates by offer (already computed), win-backs, response times, team performance, heat cases | Everything the Flippers dashboard does today — this IS the home team | Gorgias-mirrored tickets + save_flows + touchpoints |
| Social media | Channel growth, engagement, post/creator performance, social sessions → orders | Post scheduling hooks; creator shortlist from the VIP/affiliate overlap | Social platform APIs (to connect) |
| Affiliates | Referral revenue, commissions owed, sample-shipment log (the ~300 unattributed fx_direct sends become visible here), top-partner leaderboard | Recruit/pay partners, mint referral codes, track sample→revenue conversion | Rivo 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 watch | Re-run syncs, rotate creds, purge caches, feature flags | integration_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).
fmtWhen took strings only: handed the picker's Date it fell through to String(x), so the Confirm button read "Confirm call · Thu Sep 10 2026 16:42:00 GMT-0400 (Eastern Daylight Time)" and the booked toast the same — a raw JavaScript date in a customer-facing team tool. (2) The Tasks page and the reminder strip printed the touchpoint's topic SLUG (shipping, billing) because the schedule modal stores TOPIC_SLUG[topic]. THE FIX. fmtWhen accepts a Date (x instanceof Date ? x : new Date(…)); a topicLabel(slug) helper inverts TOPIC_SLUG and the Tasks row, the strip and its toast use it (Shipping issue, Billing & account; a free-text task keeps its own words). PROOF. The pinned test's source pins grew two lines (the Date branch, the label call on the Tasks row); suite 1,252. Live: the Confirm label reads Confirm call · 09-10-26 4:42 PM, the Tasks row 📞 Billing & account. No server change.team_members roster — which is EMPTY on production, so every booked call had owner_id NULL and appeared on nobody's Tasks page. THE FIX. (1) The modal has a datetime-local picker (plus In 1 hour · Tomorrow 10:00 · Next week chips) and sends an ISO instant; the server refuses a past time or a label (400) and normalises the rest; a plain task keeps its plain due date. (2) Text is offered only when the customer has a phone AND sms_opt_in (the modal fetches the customer's own payload); the reason shows otherwise. (3) The owner is a select defaulting to me; the server's rule is explicit > routed > the booker — a follow-up always has someone to remind. (4) The booking link is GET /book/:token, a public brand-skinned page (no ids on the wire): the customer picks a date and time in their own time zone (tz travels with the form), POST stamps scheduled_at, writes a customer_activity row (kind touchpoint, actor customer), and the link is one-use (410 afterwards, 404 unknown). The modal shows the minted link with Copy and 📥 Send to their inbox (FLIP HQ inbox message with the link as its button). (5) Reminders, Level A: GET /api/tasks/due?within=N lists the signed-in person's overdue + due-soon follow-ups; a DueReminders strip at the app root polls it every minute and rings ONCE per new item — a toast, a two-note chime, and a desktop Notification when the browser allows it (a 🔔 button asks) — then stays at the top with Open · Snooze 10m · Done; seen and snoozed live in localStorage. Level B (web push while Hermes is closed) is Ship 3. (6) /complete now needs the owner or an admin (403); Tasks rows show the channel, the time, and awaiting their pick for an unbooked link. PROOF. Pinned test test/schedule-book.test.js on the real server: the past and a label are refused, an instant is stored as ISO and owned by the booker when nothing routes it (the production condition, routing deleted) or by the teammate picked; the booking page renders without sign-in, refuses the past, books 2:30 PM at −300 min as 19:30Z, writes the activity row, is one-use, and lands on the owner's task list with its time; due-soon is per person with overdue flags and a wider window; completion is 403 for a teammate, 200 for the owner and an admin. Built by a haiku builder from the design, applied by a haiku executor, shipped by the guarded script in a quiet window. Suite 1,252 (+4).GET /api/customers/{id} answered the scored shape plus hand-picked fields and never carried shopify_customer_id — nor id, which the modal posts back — so the modal's gate failed for EVERY customer, and a one-field fix would have enabled Send and then 404'd. The server already re-reads the account by id and already answers no_store_account on the sent-list read; the client gate was the only blocker. Also found: the only 📥 button lived in the ≤900 px pop-up; the desktop conversation rail and the full profile had no inbox door at all. THE FIX. One server line adds id, shopify_customer_id, recharge_customer_id, rivo_customer_id and sms_opt_in to the payload (null, never undefined); the modal honours the server's flag; ProfileView gains the 📥 Inbox door beside the email, mounted on the full payload (d, never the whitelisted queue shape). AND THE RULE TYLER SET: only the options that are actually available for customers should be shown. Apply credit now fetches the payload on open and offers subscription credit only when the customer has a Recharge account AND an active line, refund only when they have an order, and says "Nothing to credit" when neither — the Apply button stays disabled; the profile passes an availability map to its condensed action card so win-back (needs an email), reship (needs an order) and credit hide when impossible. The at-risk queue's card still shows every door (it has no payload) and the modal gates inside. PROOF. A pinned test on the real server: the payload carries the ids and nulls; POST /api/inbox with the payload's own id answers 201, the sent-list reads back 1, a customer without an account gets no_store_account: true and a 409 on compose; the sources read as patched. Live: sansingc@bellsouth.net composes from the pop-up AND the full profile; her Apply credit shows refund only. Built by a haiku builder from the design (blueprint v2), applied by a haiku executor, shipped by the guarded script in a quiet window. Suite 1,248 (+3).hq.js tierBlock()); the page kept two older paths — storefront.js overview() took the tier from the customers.rivo_tier mirror column (stale, and NULL for many) or, pre-flip, from the vendor summary, and storefront-proxy.js pageData() computed the next rung from hardcoded 30,000 / 75,000 thresholds with no idea whether the member subscribes. MEASURED 2026-09-10, read-only: 14,256 active subscribers in Hermes's mirror, 898 of them mirrored as something other than FLIP Fam (613 with no tier at all) — every one of them saw a wrong tier and a wrong upsell. THE RULE (Tyler, 2026-07-29, loyalty.js tierAt): an active subscriber is FLIP Fam; everyone else sits where lifetime spend puts them. THE FIX. tierBlock moves into loyalty.js as the one shape every surface shows (HQ delegates to it); overview() derives the tier the way HQ does — the subscription mirror OR a queued charge already fetched for the page says subscriber, lifetime spend says the rung — and no longer lets the vendor's tier override it (the vendor still feeds the points balance pre-flip); pageData() takes its next rung from the same rule, so a subscriber never sees a rung above FLIP Fam. Display only: points, earn rules and redemptions are untouched, and the subscriber flag the overview now returns is never fed to computeEarn. WHAT IT DOES NOT FIX, filed: the mirror itself under-links subscribers whose tier column is stale (todo_3534 — the queued-charge signal covers the rewards page meanwhile), and the stale column still feeds admin, recruiting and affiliate readers. Pre-flip, the page and Rivo's own widget can now disagree about a subscriber's tier; the page is the one that follows the owner's rule. PROOF. A pinned test: an active subscriber whose column says Member reads FLIP Fam with no next rung; a cancelled subscriber whose column says Fam and whose vendor summary says VIP reads Platinum by spend while the vendor's balance still comes through; a brand-new member starts at Member; a subscriber the mirror never linked but with a queued charge reads FLIP Fam on the storefront exactly as in HQ; HQ's tierBlock and loyalty's answer identically on every rung; the sources read as patched. The existing storefront suite stays as it was — its member already had a queued charge on file, which the new rule reads as the active line it always implied. Built by a haiku builder from the design (blueprint v2), applied by a haiku executor, shipped by the guarded script in a quiet window. Suite 1,245 (+4).customers/update deliveries on 2026-08-29 recorded D1_ERROR: LIKE or GLOB pattern too complex: SQLITE_ERROR; the todo inferred a 50,000-character email against SQLite's default pattern limit. THE REFUTATION. Shopify caps an address at 254 characters and production's longest stored email is 104, so no real payload reaches 50,000 — but Cloudflare's D1 limits page says "Maximum characters (bytes) in a LIKE or GLOB pattern | 50 bytes", and a read-only probe on production settled it: a 51-byte pattern answers the exact error, a 50-byte one answers a count. The alt-email probes in server/lib/upserts.js (the catch-all byAlt and the duplicate-healing twin) wrapped the address as %"<email>"%, so any address of 47+ bytes threw the whole ingest; 13 stored customers already carry one, and the 18 rows are Shopify's retries of the same few updates until it gave up. The same cap sat under the master search's alt-email fallback, its address pattern, the tile search, the affiliates-desk search, the Shopify admin block, the creators list, the rewards-admin and coupon-member searches, the help-center search, the Flexport return-id claim check and the refund-credit coupon lookup — a pasted long email or address 500s each of them. AND ONE THE HARNESS FOUND ON ITS OWN: the site chat's confirm step (server/lib/chatbot.js) checked for a replayed confirmation with %"confirm_hash":"<32 hex>"% — 52 bytes, so the first real chat confirm on production would have thrown this error (measured: the tenant has no production chat turns yet, so no customer had hit it); the new D1-faithful test harness reddened its existing test in the first build round, which is exactly what it is for. THE FIX (server/lib/like.js, one rule, 12 files, 41 patches): identity lookups use instr() with a plain needle — no pattern limit, and no wildcards, which also retires a latent wrong-twin match (_ in an address is a LIKE wildcard); search-shaped patterns clamp their term to the 50-byte budget by bytes (a longer term matches on its first 48 — a superset, never a throw); the coupon lookup uses instr(upper(...)). THE TRIPWIRE. openDb() registers a D1-faithful like() on node:sqlite (same cap, same error text, the length checked before NULL operands as SQLite does, same case-insensitive %/_/ESCAPE semantics), so the suite now fails exactly where production fails — the todo's own proposed test (a 60,000-character email completes) would have gone green for the wrong reason. PROOF. A pinned test: the harness throws at 51 bytes and answers at 50; a 72-byte alternate address still finds its customer without a duplicate and the twin heal still fires at that length; every search survives a 60-byte term; no LIKE parameter over 50 bytes is ever bound; the fourteen sites read as fixed in source. Production, read-only: 164,242 customers, 13 with a 47+-character address, 458 at 40+. Built by a haiku builder from the design (blueprint v2), applied by a haiku executor, shipped by the guarded script in a quiet window. Suite 1,241 (+4).fx_direct_orders as a portal replacement for whichever customer matches the ship-to name and zip — and a creator send is a direct Flexport order. The send flow already flags those rows (is_creator_send, backfilled once for 116 of them), and the delivery report and the case file already skip the flag. Three readers never did: the customer profitability bundle (service.js bundleFor, the reships query) subtracted every gift's units × average COGS plus its shipping from the customer's profit and counted it in portal_reships, and the two "made whole by a Flexport-portal replacement" sets for the Shipping-Issues bucket treated a gift as a remedy. THE FIX. The three queries filter COALESCE(is_creator_send,0)=0; nothing else moves — no schema, no backfill, the report and the case file unchanged. PROOF. A pinned test seeds four customers with the same paid order and different direct orders on file (none · a gift · a real replacement · both): the gift leaves profit_cents exactly where it was and portal_reships at 0, the real replacement still costs what it costs, and with both on file only the real one counts; a second pin fixes the three query strings and the version. Built by a haiku builder from the design (blueprint v2), applied by a haiku executor, shipped by the guarded script in a quiet window. Suite 1,237 (+2).A_SECTIONS key find). Type two characters and it searches every customer by name or email — the $0 never-ordered ones included, which the VIP list (the richest ~2,000) never showed — and lists at most thirty, richest first, with an honest line under the box (Searching… · 12 matches · showing the first 30 — narrow it down · Nobody by that name or email). Each hit is the VIP row: mark, tier, lifetime value, tenure, orders, and the same five actions — 🪪 View profile, 💬 Message, ✉ Invite, Log a note, 📞 Log a call. The only profile it can open is the affiliate card (AffiliateProfileSheet); the customer-service profile and the customer-service search are unreachable from it, pinned. ② THE DOOR. GET /api/affiliates/customers?q= behind the affiliates gate (a customer-service agent or a packer gets 403, signed out 401) → server/affiliate-inbox.js searchCustomers(): an exact email answers through idx_customers_email_lower without a scan; anything else is a case-insensitive name-or-email LIKE, ordered by lifetime value, capped at 30 whatever limit asks. The row carries exactly id, name, email, lifetime value, orders, first and last order, tier, funnel mark and whether they have a store account — never the assigned owner, address, phone, notes, tickets, dispositions or touchpoints. ③ NO SCROLLBARS (cmp_4739e5e09f): the desk's chip strips hide their bar and keep the 8px room under it. Built by a haiku builder from the orchestrator's design (blueprint v2), applied by a haiku executor, shipped by the parent's guarded script in a quiet window. Tests: a pinned file test/affiliates-search.test.js (the gate, the whole-list search on the seeded database, the field set and the forbidden fields, the cap, the UI pins). Suite 1,235 (+3).listQueuedCharges now carries the subscription it bills (purchase_item_id), its kind (subscription / onetime), its unit price, its line total and its Shopify variant — so a portal can draw one card per order with the right bags and split that order's reward across those bags only. Every /hq/me item also carries every_days beside the wording of its cadence. ② Schedule and delay by bag. POST /proxy/hq/schedule and /delay accept ids (or one id): only those lines get the new date or cadence, and a line that is not the member's is a 403; without ids they still move every active line, and the answer now says which (scope: lines | all). ③ A swap is one update on the existing subscription. swap-line (and next-order with scope all) no longer creates a new subscription at today's price and cancels the old one — it PUTs the new variant, product and titles on the line together with the line's own price stated explicitly; if Recharge answers with a different price the price is put back with a second update, and a refused update changes nothing and says so. The old path had been re-pricing every grandfathered member who changed a flavor. Nine new tests (test/hq-r25.test.js); the one old swap test that asserted create-then-cancel is gone. Suite 1,232.① 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 button — Reply 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.
daily_cap now defaults to 0 = no cap; a positive number still caps for anyone who wants one (the test that sets it to 1 and expects a 429 is untouched), the count is still recorded either way, and the composer says "No daily limit". ② THE ARROW THAT BROKE THE ROW — "a few of the arrow functions are messing things up when pressed." Reproduced in the browser: the ▾ on each VIP row is CallOutcomeMenu, and opening it appends four buttons (Voicemail · No answer · Busy · Callback set) to the row's action column; with gridTemplateColumns:'1fr auto' the name column had no floor, so it collapsed to one letter per line — "Dawn P" rendered vertically, the email one character tall. Two fixes: the menu is gone from this row (below), and both affiliate rows now use minmax(210px,1fr) auto so no action can ever crush the name again. ③ THE PHANTOM TEAMMATES — "when pushing the reach out button, it's putting a bunch of random employees on there that don't exist. And there's like customer service stuff within that button." Exactly right. The sheet listed Product results & dosing → Dana Reyes · Shipping issue → Miguel Santos · Billing & account → Miguel Santos · Subscription change → Sasha Kim · Thinking of canceling → Sasha Kim. Those three names came from a hard-coded const TEAM in the client, copied from the DEMO FIXTURE in server/seed.js; nobody by those names has ever worked here (production app_users: Scott, Chardo, Jenny, Alexis, Amy, Cassy, Chris ×2, Starr, Lindsey, Tyler). The name was never real routing either — the SERVER picks the owner from routing_rules and hands it back, and the confirmation has always named whoever it actually was. So the label could only ever be a lie, and it is deleted rather than repaired; the sheet now reads "Pick the topic. The confirmation says who it went to." AND THE CUSTOMER-SERVICE CONTROLS LEFT THE AFFILIATES ROW ENTIRELY (Tyler's original framing: "he doesn't necessarily need to have all the bells and whistles of the customer service layout"): no call-outcome menu, no scheduled reach-out. What Scott has on a row is what a recruiter needs — the funnel stage, ✉ Invite, Log a note, and the profile behind the name; the CS tools are all still one tap away there and unchanged on the CS desks. A side effect worth having: CallOutcomeMenu fetched /api/cs/settings once per row, so the VIP list fired 25+ identical requests on every render — those are gone with it. ⚠️ STILL OPEN, AND THE OWNER'S CALL: the demo fixture is IN THE PRODUCTION DATABASE — team_members holds Dana Reyes, Miguel Santos, Sasha Kim and "Ops Admin", and routing_rules routes real topics to them, so a booking made anywhere in Hermes would still be assigned to a person who does not exist. The client no longer advertises them; the rows themselves need deleting or re-pointing at real teammates. Filed. Tests: three pins inverted (no menu, no reach-out, and the grid floor asserted), the CS-desk count moves 4 → 3, the cap default and its bounds. Suite 1,200.prospect → invited → replied → active / declined (AFFILIATE_STATUSES, the client's AFFILIATE_MARKS, both pins moved). A new 🤝 My creators section on the affiliates desk lists everyone carrying a mark, by stage with counts, with the same mark control, ✉ Email, and the profile; the ★ VIP list row gains ✉ Invite (claimed or not — recruiting is not customer service), and marking someone Prospect keeps them in the list before any mail. THE INVITE (InviteSheet): three editable templates (first invite, follow-up, welcome) with merge fields — {first_name} {name} {tenure} {orders} {points} {tier} {sender_first} — filled from the customer through GET /api/affiliates/invite/preview, edited freely, sent through POST /api/affiliates/invite. THE MAIL (server/recruiting.js): a personal-looking paragraph mail, no logo, the opt-out line and the sender on every one; through the suite door with from: scott@flipmylifewellness.com, sender_name: Scott · FLIP My Life, replyTo Scott, tag: affiliate-invite and an idempotency key. ⚠️ Until Appolis allow-lists Scott's address (todo_3212 on the appolis board: the door derives From from the app key today, and the domain must be verified for sending on a custom return-path subdomain so the company's existing mail is untouched), every invite leaves as Scott · FLIP My Life <no-reply@hermes.appolis.app> with Reply-To Scott — the row stores what the door STAMPED (stamped_from, stamped_address), the toast says it, and nothing pretends. RESTRAINT: a daily cap per sender (50, editable), declined refused with 409 and no mail, no email on file refused, a failed send is a recorded failed row that moves no mark; a sent invite moves prospect/none → invited (never backwards), logs an email touchpoint so the VIP boards count it as contact, and the customer's history shows every invite (GET /api/affiliates/outreach). Settings (GET/PUT /api/affiliates/recruiting/settings, in app_config.recruiting, the rest of the config untouched): sender, reply-to, cap, opt-out line, templates — edited on the My creators section. Every door behind the affiliates gate (Scott 200, a support agent 403). Schema: affiliate_outreach + idx_affiliate_outreach_customer (applied on production by hand before the deploy, counted by the deploy's check; pins 72 tables / 100 indexes). Tests +6 (test/recruiting.test.js: merge fields and the opt-out line, the door call shape and the stamped row and the touchpoint and the failed-send row, the three refusals and the cap, the gates and the new marks, settings validation and app_config preservation, the client pins). Suite 1,200.HW.work().done() removed a card the instant its work ended — a person who had been watching "Creating the Flexport order · 3.2s" saw it vanish and had to infer success. Now done(result) on a card that was ON SCREEN resolves it in place into ✓ Done · <result> · in 4.0s (the caller's words through named(label, fn, { done }), else the work's own label), holds it 1.6 s (HW.HOLD_DONE), then removes it; a card that never earned its after still ends silently — the quick action confirms on its own control, and a second pop-up for a 200 ms save would be the noise rev 6 forbids. The reply send names its outcome honestly ("Reply accepted by Gorgias" — accepted ≠ delivered, the toast says the rest; "Reply delivered to the chat window" for site chat). The proof page's card demo resolves the same way and its coverage table gains the criterion-12 row. Tests: the three card tests that asserted instant removal now assert the resolved state and the hold, one new card test (result text, done-twice no-op, the hold to the millisecond, the silent quick path) and one wiring pin (named() → done(opts.done)); reduced motion unchanged (no new animation). Attested against rev 7 with the served proof page. Suite 1,194 (+2).flipper-cinematic-slides.html → docs.appolis.app/hermes/features) kept its changelog rows current from v2.78.0 on, but its OPENING and CLOSING slides still said v2.54.0 · 323 tests green · 68 releases from 2026-08-17 — the two slides a reader actually sees first and last. They now read v2.134.2 · 1,191 tests · 344 releases (194 since 2026-07-24, 111 since v2.54.0); the header comment names the real source of truth; the changelog slide's label moves with the ship. THE TRIPWIRE: test/deck-version.test.js reads web/hermes.html's APP_VERSION, the deck and the breakdown, and asserts (1) every 🐬 HERMES · v… label and the foot line carry APP_VERSION, (2) the changelog slide's label ends at APP_VERSION and a row for it exists, (3) every N tests green figure equals the Suite N figure in the NEWEST changelog entry. Because npm run deploy runs the suite, a version bump without the deck now fails the deploy instead of shipping a stale showcase — the exact failure todo_2314 described. Historical labels ("v2.52.0 → v2.54.0", the overhaul slides) stay: they describe that period truthfully. v2.55–v2.77 rows are still not folded in (declared on the slide; the one-hour curation stays open on todo_2314). Suite 1,192 (+2).ensureRollups, which re-rolled TODAY and YESTERDAY whenever their rollup was more than 10 minutes old — most of the time, since the cron rolls every 30 — and rollupDay wrote its ~370 rows per day with one INSERT each (~250 ms of D1 round trip apiece: the cron's own portal rollup step logged 141,904 ms for two days). Then the business block walked all 302,801 orders for its date window through idx_orders_flexport (tenant_id=?) — 3.1 s per window, three windows per gather (EXPLAIN QUERY PLAN on production). So an open that missed the 20-minute payload cache could wait two minutes and more. TWO CHANGES: (1) rollupDay builds every row first and writes the day in one db.batch — the DELETE plus multi-row INSERTs of 9 rows each (11 columns × 9 = 99 bound parameters, under D1's cap of 100) — atomically, so a half-written day can never be read; the return carries statements. (2) A plain open trusts a rollup under 45 minutes old (the cron re-rolls every 30; fresh=1 still forces it) and the payload cache lasts 45 minutes instead of 20; the 10,40 lane gains a portal warm step that rebuilds the default since activation window right after the rollup, so the tab reads a cached payload nearly always. The orders walks stay for now: an idx_orders_placed (tenant_id, placed_at) index was built and tested in this ship and flipped five pinned D1 plans in test/d1-tile-plans.test.js (the delivery-report joins started seeking orders by date first — plausibly better, but the pins say otherwise and the real planner decides, so it leaves this ship as its own follow-up with the five plans to re-measure). With the warm step those walks now run on the cron's clock, not a person's. Tests: +1 (portal-report.test.js: one batch per day, every statement ≤ 99 parameters, the DELETE in the batch, every row landed; a 20-minute-old rollup is NOT re-rolled by a plain gather, and maxAgeMin: 0 still re-rolls). Suite 1,190.cron referral sweep t_verdant: {"checked":4,"rewarded":0,"errors":4}. worker.js called referralSweep(db, tenant_id, {}) — {} where the third parameter, the FETCH, belongs (since the v2.31.0 baseline commit of 2026-08-08). Every shopifyDiscountStatus call threw fetchImpl is not a function, and the catch beside it — "a wobble is never a verdict" — counted the TypeError as a vendor wobble and moved on, so the tick line looked like Shopify flakiness for a month. Production referrals: invited 4, blocked 3, rewarded 0 in the table's lifetime. The suite was green because test/referrals.test.js hands the function a real stub; nothing exercised the Worker call site. THE FIX: the step passes globalThis.fetch.bind(globalThis) like its neighbour (stale subs) does; both catches in referralSweep now record first_error (the first failure, 160 chars) and the return carries it, so the tick line names the cause the next time something breaks; a new test pins the call site (the string must be in the referral step and the {} shape must be gone) and proves a non-callable fetch is still counted AND named (/not a function/). Nothing changes for a working sweep: errors still counts, first_error is null. WHO IS OWED: the fixed sweep asks Shopify about the four invited codes on its first tick after deploy (limit 10) and awards the advocate for any code that was spent — the claim-first pattern and the referral_<id> ledger key make it idempotent, so no second payment is possible. The clock-out records what that first tick paid. Suite 1,189 (+1).createRechargeOnetime had sent both since r15-C (the "Buy once" door, never exercised on production — zero onetime rows since activation). It now sends add_to_next_charge alone, so the onetime rides the address's next queued charge — the box the member is looking at; the reply still carries the date. The onetime test pins the body. Suite 1,188.cancellations and active_end, and the payload did not carry the new bases. Now the window payload and the summary carry active_start and churned, and the sentence reads "N churned (cancelled or expired) ÷ M active at the start — Recharge's definition". The cached era payload is cleared on deploy so the number changes at once. Suite 1,188.POST /proxy/hq/next-order { op: add|remove|swap|qty, scope: once|all }: all = the subscription itself through the existing doors (add-line, remove-line, swap-line, box); once = only the queued charge, with the two per-charge tools Recharge actually has — a onetime (a one-off line on the box's address and date) and a skip (the line sits out that one charge). Add once = a onetime at the store price (like Boost your box); remove once = skip; swap once = a replacement onetime at the line's own subscriber price plus a skip; quantity once (1–6) = a onetime of the difference going up, a onetime at the new quantity plus a skip going down. The replacement is created before the line is skipped, and a failed skip deletes the replacement again — nothing half-done. Every once-change is remembered per member (hq_member_data kind once) under a key: /hq/me lists them so the card can tag "this order only" lines until the box ships, and { op: 'undo', key } unskips and/or deletes — only the member who made the change can spend the key. New Recharge helpers unskipCharge and deleteRechargeOnetime; flow edit_next pairs with the door in the rollups; the door joins the Box changes family. Churn, aligned: Tyler asked whether churn matched Recharge's; it did not — v2.131–v2.132 divided by the end-of-window base. Now churn = subscriptions that went to cancelled or expired in the window ÷ subscriptions active at the start of it (Recharge's formula), with active_start and churned on the business block, the deck, the workbook and the dashboard's "how every number is counted". Tests +6 (test/hq.test.js: once add/remove/swap/qty, all delegation, refusals + memberData) and the churn fixture re-pinned (33.3 %, not 25 %).maxItems is outside the structured-output schema subset. The caps on themes / claims / actions moved out of the schema into code (the prompt states them; the store slices), section became a plain required string, and the failure was already a visible row — but a failed analysis used to block that answer forever. Now an error row is RETRIED after 15 minutes (the lane's next-but-one tick) and REPLACED by the judgement when it succeeds; the queue shows the failure in between and "pending" counts it once it is retriable. Both rules are tested (the failure test grew; suite stays 1,182).portal_feedback (one row per analysed answer) and server/portal-feedback.js: for each survey answer with no row yet, the pipeline gathers the MEMBER'S OWN EVIDENCE — their portal rows 24 h before and 1 h after the answer (sections with time spent, failed requests, browser errors, account loads, flows opened and whether they completed, support actions, cancel-flow steps) plus two account facts (active subscriptions, tickets in the last 7 days) — and asks the cheapest adequate model (claude-haiku-4-5, held to a JSON schema) for sentiment, themes from a fixed list, a one-line summary, each claim with a verdict that may cite ONLY the evidence given (supported / unsupported / unclear, with the fact and its numbers), and up to three concrete actionables typed fix / copy / guide / feature / praise with effort and owner. A 4–5 star answer with no comment is filed by rule (praise, no model call); a 1–3 star answer with no comment still goes to the model, because the evidence alone often says where it hurt. Every call rides lib/claude withLedger (purpose portal_feedback); a run stops when the day's AI spend reaches $1; a model failure is a row that says so; no key → left pending and visible. Runs on the :10/:40 lane (10 per tick) and by hand. Doors: GET /api/portal/feedback?status=&from= (admins + Insights; rows newest first, the themes / verdicts / statuses since activation, the spend, and the bill BEFORE a run — pending × ≈$0.004 and today's spend against the cap), POST /api/portal/feedback/run (admin; answers with what it processed and spent), PATCH /api/portal/feedback/:id { status: planned|done|dismissed|new, note } (admin). The Portal dashboard's "What members say" card gains the queue: analysed / pending / spend strip, "Analyse N pending (≈ $X)" (admins), filters, and per answer the stars, sentiment, verdict badge, summary, themes, each claim with its evidence, the actionables with type · effort · owner, a note field and Plan / Done / Dismiss. The one real answer so far ("Not very user friendly. Hard to find my subscription, and cannot figure out how to delete from my upcoming order.") is the first case the pipeline judges on production. Tests +5 (test/portal-feedback.test.js: evidence, the rule branch, the model branch with a scripted Messages API, the ledger, the cap, failures, the doors) → total in the clock-out. The Hermes Helper's guided steps will be drawn from the "guide" actionables this queue accumulates.app_config.portal.launched_at (else the first portal event); nothing before it is read, and a "vs before" comparison appears only when a whole equal period fits inside the era before the window — otherwise the deltas say "no comparison yet". The pre-portal before/after slide is gone; in its place the business numbers per day and per ISO week from the day the portal went live (cancellations as distinct subscriptions, in-HQ cancels and saves, orders, revenue, tickets, referrals, redemptions, SMS, reviews). More data gathered: the beacon may now send dwell (time a section stayed open), entry (where a visit came from) and flow (a self-serve flow opened: address, schedule, pause, ship_now, swap, add, remove, payment, profile, order_cancel, vote, review, sms, cancel, inbox_reply …); the rollup pairs each flow opened with the door that completes it (same member, within 10 minutes) → opened / completed / abandoned per flow; records the section a member was on when they opened the support form or the chat (help_after); counts members who hit the same failing request twice or more (retry_fail); the survey funnel (asked → answered → "not now"). Client rows per member per hour raised 300 → 500. The dashboard: a new Hermes tab 🏠 Portal (admins; the Insights tile now opens it and shows how many alerts wait; the deck stays behind its Full report button) reading GET /api/portal/dashboard?window=… — the same gather as the tile and the deck: alert banner (a request failing ≥ 5% over ≥ 20 calls, account-load p95 > 8 s, ≥ 2% failed loads, a script error seen by ≥ 3 members, ≥ 20 errors today), eight KPI cards with sparklines and deltas, where members go (views, share, average time), who and how (phone share, skin, pages per visit, returning, entry referrers), what they do (every request with members and p95, page actions), flows opened → completed → abandoned with the cancel funnel, is it working (failing requests with members, repeated failures, browser errors with network drop-outs marked, account-load p95 per day), what members say (distribution, response rate, every answer), is it easier and where they ask for help, business since activation (daily bars, the weekly table, the churn arithmetic spelled out), and the definitions. ?fresh=1 (admins) re-rolls today and rebuilds the window; the cache is 20 minutes otherwise. Measured on the first day of real data (176 members, 211 visits, 61% on phones): payment-methods failing 45 of 428 loads, account loads averaging 4.6 s with a 51 s worst case, the cancel flow opened 44 times, one honest survey answer — the numbers the dashboard opens on. Tests +3 (portal-report.test.js rewritten around the era rule: 15 tests) → 1,177. The theme half (dwell, entry, flow events in both skins) ships as FLIP HQ round 22; the survey-response pipeline (validated complaints → actionables) as v2.132.0.bizWindow and the week-by-week series now count DISTINCT subscription_id; the Definitions slide says so; the test seeds a repeated event and proves it counts once. Tests 1,174 (one strengthened). The CS reporting design's skips_over_time-style counts elsewhere in Hermes still count rows — filed.portal_events (one row per thing the portal did or saw: view/action/error/boot from the theme's beacon, door written by the app proxy itself for EVERY /proxy/hq/* answer with status and milliseconds, survey from the member) and portal_daily (rollups per day × kind × name × phone/desktop/classic/retro, with p95). Doors: POST /proxy/hq/telemetry (batches ≤ 50, ≤ 300 client rows per member per hour, names validated, details cut at 300 characters, the server's clock; the answer carries survey.due, so the theme learns when to ask without another call) and POST /proxy/hq/survey (1–5 stars + a line, or "later" — asked from a second visit, not again for 90 days, "later" holds 7). The :10/:40 lane rolls up today + yesterday every tick and sweeps raw rows older than 120 days once a day (survey rows kept for good); a day is marked computed by a sentinel row so an empty day is never re-rolled. The report (server/report-portal.js, cached 40 min like delivery): the hero with ▲/▼ against the window before; WHO (members signed in, visits, returning, adoption vs active subscribers, phone/desktop, skins, page views by section); WHAT (every door counted on the server, self-serve marked); IS IT WORKING (failed requests, browser errors per 100 views, failed account loads, slowest doors' p95, the last week's errors and failed requests verbatim); DO THEY LIKE IT (the survey: average, distribution, promoters/detractors, verbatims); IS IT EASIER (self-serve actions vs support tickets per family, per 100 active subscriptions, this window / the window before / either side of the launch; the cancel funnel from hq_member_data churn); BEFORE AND AFTER THE LAUNCH (the same L days either side of portal.launched_at in app_config, else the first portal event: cancellations, per week, churn rate = cancelled ÷ (cancelled + still active at the end, reconstructed from status dates and the event log), cancelled and kept/paused inside HQ (direct), orders, revenue, orders per 100 active, tickets per 100 active and by tag family, referrals landed/rewarded, redemptions, SMS sign-ups, reviews — with the caveat printed: around the launch shows what changed, it does not prove the cause); cancellations week by week with the HQ share; a Definitions slide that says how every number is counted. Surfaces: GET /report/portal (+ .xlsx ten sheets, .pptx eight slides) for admins and Insights access — no share grants yet (the grant door is bound to delivery until the reports registry lands); GET /api/report-summary?report=portal feeds the new 🏠 Customer Portal tile on Insights (members ▲/▼, self-serve · failed %, satisfaction, cancellations · churn; a failure is shown, never a spinner); /api/it/health gains a portal block (24 h requests, failures, errors, failed loads, last error); admin PUT/GET /api/portal/launch and POST /api/portal/rollup. New server/lib/report-shell.js = the deck frame (the delivery deck's CSS, rail and fit engine lifted verbatim); delivery still carries its own copy — moving it is the golden-test refactor (CS-REPORTING-DESIGN §7.2), filed. Prod: the two tables applied and counted by the deploy's schema check; the launch date set to the live theme's publish (2026-09-04 19:45 UTC). +12 tests (test/portal-report.test.js, executing on a real seeded database). The THEME half — the beacon in both skins, the survey card, the feedback row — ships as the next FLIP HQ round; until it lands the report carries the server's door rows only.-- line comments but not / / blocks, so every table preceded by a block comment began with /* after the split, failed the CREATE filter and was silently never created — 47 of 49 tables built, every surface rendered, the tests passed (node:sqlite parses block comments fine, so the environment that runs the TESTS is not the one that runs the DDL), and the only symptom was a real person unable to receive a login code. report_grants and reply_attempts exist in production only because they were applied by hand. 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.
gm_332770032 + gm_332770814 on one, gm_332787342 on another, gm_332869716 + gm_333018625 on Tyler's). Four other duplicate shapes were checked and came back clean: no Hermes customer row is missing its twin id (43 of 43 carry one, so no echo can hide from the correlation), no repeated rfc_message_id inside a conversation, no exact-body repeat on any conversation carrying a Hermes row, and the 72 same-words-twice groups elsewhere in the last month are all vendor-side chat rows with no Hermes row involved ("Sign in to continue", "What are your delivery options?") — Gorgias's own data, not ours to touch. 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.
hq/profile reads customers.birthday (Hermes' own column — MM-DD or MM-DD-YYYY, the yearly sweep matches the first five characters) and POST hq/profile { birthday } writes it — MM-DD, MM/DD[/YYYY] and ISO YYYY-MM-DD accepted (ISO becomes MM-DD-YYYY), an empty string clears it, anything that is not a date is a 400 and writes nothing, a birthday alone never touches Shopify, and a store record that is not there fails with a 409 instead of a silent no-op. FLIP HQ's Your information gets a month + day picker (no year asked) that shows only when the profile answer carries birthday. Referrals: referral.pipeline rows carry email (the friend's address the advocate sent the link to) so the card can show it. +1 test → 1,149. (Same shift, theme side: breakdown rows say Shipping and Tax only when Recharge has priced them — never "tax" on a guess; the initials open My account; the hub slider starts at 1.8 s; the policy pop-ups render the site's own Terms & Conditions / Privacy / Shipping pages by handle, Shopify's policy settings as the fallback.)hermes.twin_appended and no hermes.twin_uploaded, and the attachment Gorgias holds on message 333018625 is our own signed /a/ link; and the conversation shows hm_3fe159b3… (ours) beside gm_333018625 (the mirrored echo of the same words) — the 10:06Z inbox-originated message doubled the same way. (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).
hq/me maps the catalog's base row (5 per $1) to the member's tier through loyalty.tierAt — FLIP Member 5 · Platinum 7.5 · VIP 10 · Fam 10 per $1 — and names it (per: "per $1 · FLIP Fam rate"); every other row and the shared EARN_RULES list are untouched (tierEarnRules, pure, exported). +1 test → 1,148. (Same shift, theme side: every sign-in door — the HQ wall on both skins and the store header's pop-up — now goes through Shopify's documented /customer_authentication/login?return_to= route, because the Sign-in-with-Shop component ignores a return address and left members on the hosted account page; the TikTok link is the HQ sections' default.)at (its place in the list and the date on the right) and its preview ("You: …" when it is the member's); the newest agent reply is agent_at, the only thing that can make a row unread — a member's own words move the conversation up but never mark it unread; a conversation the member opened and nobody answered yet shows too. Offers and letters keep the day they were sent. Attachments: /inbox/thread messages carry files (name · type · size · url) as the same signed, expiring public links the vendor twin uses (/a/:message/:idx, 30 days, R2-backed public rows only; nothing when the host has no signing secret — never a broken link); /inbox/reply takes attachments[] (up to 3; 5 MB each; photos, PDF, plain text; base64 in the JSON body), stores them in R2 under the conversation (hq/inbox/<ticket>/…), rows them on the message through the intake path the contact form uses (so the help desk and the Gorgias twin see them) and answers attachments: n; a store that cannot take the bytes fails the whole reply with a 502 and nothing is written. SMS: POST /proxy/hq/sms-signup { phone } — E.164 (ten digits assumed US), the number saved on the Shopify customer (Shopify's uniqueness refusal is the member's answer), SMS marketing consent recorded on Shopify (customerSmsMarketingConsentUpdate, SUBSCRIBED · SINGLE_OPT_IN — the store's own record, which the SMS platforms read), Hermes' sms_opt_in/sms_opt_in_at through the same setter the consent webhook uses (Shopify's timestamp), and the 150 points at once under the SAME ledger key the flat-rule sweep uses (sms_<member> → rve_<tenant>_hermes_sms_<member>), so whichever door gets there first it is paid once; already:true the second time. Catalog: handle + description (the product description's first sentence or two, tags stripped, ≤ ~170 characters, null when there is none) — FLIP HQ shows the store's own words on the boost slide instead of a guessed bullet list. +3 tests → 1,147.referral.pipeline rows now carry code (the friend's FRIEND- code), next_nudge_at (ISO, the exact moment the email reminder opens again), nudge_reason (soon · max · ordered) and nudges_left, so FLIP HQ shows a real pipeline per friend — Claimed → Reminded → Ordered — with Remind by email (Hermes sends the friend their code again), Text and Email (the member's own phone, prefilled with the friend's name, code and the one-tap link) and Copy the code. The windows: 1 h after the claim (was 24 h), 24 h between reminders (was 72 h), five in all (was three) — the member can text the friend any time anyway, so a long email gate protected nothing. The 429 names the exact time (UTC) and carries next_at for the storefront to show in the member's own clock. 📥 INBOX: the composer takes an optional picture (https only) that rides as the same hero figure the founder pop-up delivers, so an offer or an update looks designed beside its text; inbox rows carry an offer's code so the page shows a copyable chip. +1 test → 1,144./inbox/deliver takes seen:false (the theme now hands every inbox-bound pop-up letter over on the first HQ load, photo included, before the weekly trigger shows it — so the letter is in the inbox from day one, unread) and /inbox/read / /archive accept block_id, so the pop-up being shown marks its inbox copy read. The inbox renders a letter the way the pop-up looked: photo beside the text (on top on phones), the title, the letter, the button. An existing copy follows the pop-up: delivering again refreshes it in place when the letter changed (a copy delivered by an older build gains its photo and sender), marks it read when seen, and never writes a second row — measured on the master account 2026-09-05: the first copy had arrived minutes before the photo-carrying build. +3 tests → 1,143. 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.
listQueuedCharges in server/integrations/recharge.js) now carries subtotal · discounts · shipping · tax beside the total, straight from Recharge's own charge — so the storefront can strike the original (total + discounts) and show the discounted total wherever the next order's price appears, and the reward line can say what it saves. No new door; the same /hq/me and rewards reads simply carry four more fields. 🧪 +1 test → 1,130 (the breakdown rides along with the total and the code).worker.js keeps its OWN list of path prefixes that reach the API (line ~229) and /a/ was added only to api.js's isApi list, which gates the LOCAL server the tests run against; every test passed and production served a bare asset 404. (2) Gorgias shows only attachments uploaded through its own endpoint — an external URL on a message's attachments array is accepted and never rendered. 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. uploadGorgiasAttachments — POST /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).
attachments.r2_key, public=1) — and then nothing showed it anywhere: the thread page and the message shaper only ever drew images that carried a vendor URL (source_url), non-image files were not represented at all, and the twin got a footer line naming the file. A real customer's Flip Review.jpg from 14:21Z was invisible in both systems. 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 amount — sweepRefunds 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.
me()): a Promise.all over page data (the Rivo/loyalty overview + referrals + the Shopify primary domain), the Recharge charges, member data and the streak with NO timeout on any leg, then a SECOND, SERIAL Recharge call (rcContext + listSubscriptionsByCustomer) plus two D1 reads — so one slow upstream stalled the whole first paint until Shopify's app proxy gave up on its own. A normal call measured 1.9 s wall time on the tail the same evening; a hung one had no ceiling at all. 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 @.
HERMES referral — $15 off (friend) pool discount had usageLimit 1 for the WHOLE discount — shared by every code in it, the same defect the owned reward codes fixed on 2026-08-08 — and shopifyEnsureRewardPool never set appliesOnSubscription, which Shopify defaults to FALSE; a subscription cart refused the code. That is the failure Tyler hit; the shared usage limit was the next one waiting. 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/refer → 404; 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 customers — none · 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.
GET /proxy/hq/payment-methods (cards on file — brand, last4, expiry — and which one the box bills to, read from the subscription's address), POST /proxy/hq/payment-method (bill every active line's address to one of the member's own cards; a card that is not theirs is a 403), POST /proxy/hq/payment-update-link (Recharge emails the secure card page — card numbers are never typed in HQ, PCI stays with Recharge/Shopify; a wrong template name fails loudly with Recharge's text, never silently), GET /proxy/hq/addresses (the Recharge shipping addresses the subscriptions actually use, in-use flagged) and POST /proxy/hq/address (edit one — street, city, state, ZIP required; someone else's address is a 403). recharge.js gains listPaymentMethods / setAddressPaymentMethod / sendPaymentUpdateEmail. Four new tests (ownership refusals + the happy paths); the suite passes. 🎨 Both FLIP HQ skins (preview only): the payment modal lists the cards with "Use this card" and "Email me a secure link"; the shipping-address modal is a real in-HQ form on the subscription's own address; the account-page and portal links are gone. What still leaves HQ by nature, on Tyler's ruling: Shopify checkout for a brand-new box, carrier tracking pages, social-follow earn links, sign-out. Same day, theme-side only (classic-account d44fe84, retro-pop): 📝 Ratings & Notes became REVIEWS — Tyler: "I don't think I care for a private notes journal… this can allow people to facilitate a Junip review." For every product a member has ordered, Junip's own review block (with its Write-a-review form) renders inside HQ and is hydrated with junip.init() after render — reviews land on the product page, and nobody leaves HQ; the island now carries product_id + handle per order line. 🚪 The storefront's account icon opens HQ's My Account — the "little side pop-up" was Rivo's app embed hijacking the icon (data-rivo-account-link, href #rivo); on the two preview themes the icon now links to /pages/flip-hq-preview#/account and a small script undoes Rivo's rewrite and swallows its click. Card brands read as words ("Shop Pay", not SHOP_PAY) — live-read from Tyler's account: two Shop Pay methods •••• 4914, one in use. 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 twin — POST /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 webhooks — ticket-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.
D1 DB is overloaded. Requests queued for too long. 26 seconds after the cron fired. The retry: that read now goes through withD1Retry (server/lib/comms-lane.js) — backpressure errors only, three tries at 3 s / 6 s / 12 s, each retry logged; every other error still throws exactly as before; nothing else on any lane changed. The load, measured with wrangler d1 insights (top queries by rows read, last 24 h): (1) SELECT id, shopify_customer_id, ltv_cents FROM customers WHERE tenant_id=? AND gorgias_customer_id=? — 9,505 runs a day reading 159,192 rows each, 1.5 BILLION rows a day, because no index existed on gorgias_customer_id; idx_customers_gorgias (tenant_id, gorgias_customer_id) was hand-applied on production at 05:45Z (178 ms to build) and the lookup fell from 116 ms to 0.1 ms. (2) The hub's tile counts — COUNT(*) FROM tickets t LEFT JOIN customers c … EXISTS (SELECT 1 FROM tickets o WHERE o.customer_id=t.customer_id AND o.status='open' …) — ~950 runs a day at 1.18 million rows and 2.3 s each: the plan showed the correlated subquery walking idx_tickets_status for every open ticket. idx_tickets_customer_status (tenant_id, customer_id, status) was hand-applied at 05:50Z; the same count re-run verbatim on production went from 821 ms / 541,893 rows to 30 ms / 16,519 rows. Both indexes are declared in server/schema.js (the customers one in MIGRATION_INDEXES, because its column is itself a migration column — declaring it in the CREATE block aborted every local database open below it, which the suite caught). Together the two remove roughly 3–4 billion of the ~6.9 billion rows D1 was reading per day (design §11.1 #26's flagged cost) and most of the queueing that killed the tick. The tile queries' remaining cost (the LIKE scans and the customers join on the COUNT branches) is filed for a follow-up rewrite. 🧪 suite green (+1 test: the retry retries only backpressure, with growing pauses, and rethrows anything else). VERIFIED: both index plans and timings on production; the suite; the served version. ASSUMED: that the 06:07Z tick runs to its log line — recorded in the next entry when read.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 (SELF → flip-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, 1–9 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).
reconcile() was the only reason that pass wrote a payload (286 copies confirmed, deadline_hit: true). A drain of 11,000 copied balances cannot wait behind the coupon walk. THE CHANGE. wrangler.jsonc declares a fourth trigger, **23,53 **; worker.js dispatches it to a lane that runs only the reconcile (budget 300, the payload stored where the IT tab reads it, one log line carrying the buckets, the pile, how many copies were re-read and how the pass ended — deadline, breaker, vendor errors, failed heals). Thirteen minutes after the :10/:40 tick, so the link step that mints new copies has run; not a multiple of five, so it never collides with the /5 tail sync; seven minutes clear of the :00/:30 lane. The step is removed from the :10/:40 list, and every step on every lane now logs its elapsed milliseconds — a lane that starves its tail is only diagnosable if the tail says who ate the budget. TESTS one source pin in test/loyalty-provenance.test.js: the trigger declared and dispatched, the truth lane running reconcile with the drain budget and storing the payload, the Rivo lane no longer carrying it, the elapsed-time log on every step. Suite 641/641. THE FIRST TIMED PASS OF THE RIVO LANE (20:10Z, from a live tail) shows where the budget went: rivo events 78s · rivo coupons 212s (24 pages plus 40 Shopify verifications) · rivo links 46s · checkout expiry 1s · loyalty shadow 14s · refund sweep 20s · referral sweep 0.5s — about seven minutes before a last step would even start, and longer at :10 when the coupon walk is a full one. VERIFIED AFTER DEPLOY: wrangler lists four schedules after the deploy (/30, 10,40, */5, 23,53). The first :23 pass, read back from production: exact_pct 65.13 (raw 57.78 among Rivo-read rows) · compared 30,981 · self_asserted 10,803 (down from 11,098 — the full budget of 300 copies read and every one confirmed exact) · buckets {column_lag 1,977 · never_read 1 · self_asserted (drifting) 369 · open 0} · unverified 1 · ended clean: deadline false, breaker false, vendor errors 0, failed heals 0. On its own lane the loop used its whole budget inside the clock; at this rate the pile empties in about 18 hours. ⚠️ wrangler.jsonc is gitignored — the trigger lives in the local file and on Cloudflare, not in git (trap noted on the deploy trap note).linkRivoMembersLocally links a member and sets rivo_points from our own ledger sum, stamping it — by design, so 9,842 fresh links did not read as phantom drifters. For those rows "column = ledger" and "column + later events = ledger" are true by construction, and every scorer to date counted them as agreements. Measured on production: 11,378 of 30,968 compared members (37%) — the 9,743 rows one hand-run statement stamped 2026-08-19 18:05:53, plus 1,635 rows in 369 smaller groups the link-locally cron stamped one pass at a time (a shared stamp = one bulk statement; a real Rivo read stamps each member at its own millisecond). The tile's 100% leaned on them. THE CHANGE. customers.rivo_points_source ('vendor' = a live Rivo read, written by updateLoyalty; 'ledger' = copied, written by linkRivoMembersLocally; NULL = written before the column existed, read as vendor). reconcile(): rows with source ledger are self_asserted — out of raw exact, out of column_lag, out of the worst-drift list, in the denominator, and in the truth sample regardless of drift, ordered after the genuine questions (non-self drifters first, then the copies — moved ones first, then oldest stamp); the loop now runs whenever anything needs a vendor answer, including a book whose only questions are unmoved copies (the old drifted > 0 gate would have skipped them — caught by the tests); when Rivo answers, the vendor's number replaces the copy (updateLoyalty writes column, tier, value, stamp and source='vendor'), then the row is exact or a genuine, named disagreement — never "pending", because nothing was ever read that could lag. truth.checked_self counts them; unverified counts vendor-sourced drifters only. THREE ADVERSARIAL LENSES BEFORE SHIPPING, all refuting, all taken: a copy's fresh link stamp earns no mid-flight excuse (the link cron runs seconds before reconcile, so every new copy would have waited two passes); a copy whose member Rivo no longer has (404) retires as vendor_missing and leaves the compared book instead of gating the flip forever; the reported pile is the post-loop count, so the gate clears the pass that empties it; the blocker never invents a rate ("0 re-read last pass — the Rivo truth lane is not draining it" when nothing drained), tolerates the link cron's churn (clears under 0.1% of compared, floor 20), links itself to the score blocker ("Rivo-read rows alone score about N%"), and does not fire post-flip when there is no vendor to ask; the loop has a six-minute clock and stops on the first failed heal write (a D1 cap hit would otherwise burn the remaining Rivo calls for nothing), and the payload says how the pass ended (vendor_errors, breaker_tripped, deadline_hit, heal_failed); healed copies leave the truth cache so they never evict a real question; "raw column equality" is relabelled raw equality among Rivo-read balances because raw exact now excludes copies. readiness() exposes self_asserted and exact_pct_raw in its ledger summary. The cron's truth budget is 300 per pass while the pile drains (~19 hours at 48 passes a day if every call succeeds; the 120ms gap and the 3-strike breaker still protect Rivo). THE TILE carries the meaning without a hover: the header reads N% confirmed · M% unread while a pile exists, the gold line says N copied from our ledger, not yet confirmed by Rivo — counted as wrong until read (K re-read last pass) (and no vendor left to read them after the flip), the legend explains it, and vendor_missing members are named as out of the book. PRODUCTION DATA, hand-applied before the deploy (deploy does not apply migrations, todo_2318): ALTER TABLE customers ADD COLUMN rivo_points_source TEXT (pragma-verified) · backfill source='ledger' for every linked row whose stamp is shared by 2+ rows — 11,378 rows · the 9,743 space-format stamps normalized to ISO (same instant; zero left) · 123,979 linked rows stay NULL (vendor). TESTS test/loyalty-provenance.test.js (8, real SQL): both writers stamp their provenance; a 236-member book with 200 explained, 1 open, 30 unmoved copies and 5 moved copies scores the copies as neither exact nor explained (headline honestly under the gate), puts all 35 copies in the sample even the unmoved ones, asks Rivo about every one, replaces each with the vendor number and source, and scores 99.6% on the next pass; a copy Rivo disagrees with becomes a named open drifter carrying the vendor number, never a "pending revoke"; a 404'd copy retires and the book shrinks by one; a copy linked ten minutes ago is read this pass; readiness links the pile to the score, says "0 re-read last pass" honestly, tolerates churn, clears at zero, ignores an old payload and stays quiet post-flip; a deadline already past ends the pass with nothing counted as read, a dead vendor trips the breaker and the payload says so; the tile copy. The cap tests learned the new bucket. Suite 640/640. VERIFIED BEFORE DEPLOY the new aggregate and sample queries verbatim on production (timings in the STATE note). EXPECTED AFTER DEPLOY — and this is the honest part: the tile drops from 100% to roughly 60–65% red with 11,378 copied from our ledger in gold, then climbs about 1% per pass as 300 copies are re-read every half hour; the readiness gate shows the pile as a blocker until it reaches 0; when it does, the percentage means what it says. READ BACK — first pass on v2.89.0, 19:54:41Z, from queue_cache on production: exact_pct 64.17 (raw 56.87 among Rivo-read rows) · compared 30,975 · drifted 2,930 = buckets {column_lag 1,976 · never_read 0 · self_asserted (drifting) 668 · open 0} · self_asserted 11,098 (286 confirmed exact and replaced by the vendor read this pass, 6 newly linked) · truth {checked 286, checked_self 286, exact 286, vendor_errors 3, breaker_tripped false, deadline_hit true} · unverified 0. The clock earned its keep on its first pass: the 19:40 invocation only reached reconcile at ~19:48 — the nine steps before it took eight minutes — and without the six-minute stop the invocation would have been evicted before writing. The two passes before (18:40, 19:10) never reached reconcile at all. Follow-up in v2.89.1: reconcile moves to its own cron tick so the drain never waits behind the coupon walk, and every step now logs its elapsed time.ping(), which lives in the app shell and not in TeamView, so it threw ReferenceError: ping is not defined after the account had been written — the add landed (Scott St.John · Fulfillment · ["fulfillment"] · 18:36:01Z on production), the confirmation did not; toggleApp had the same latent throw. (2) Nothing went out to him, by omission: no invite email existed (the remaining scope of todo_2305), and the toast that failed would have promised "they sign in with this email and pick their own password" — false since sign-in went code-only in v2.79.0. THE FIX. server/lib/report-emails.js gains renderInviteEmail — same shell and Hermes mark as the code mail so the two read as one door: "Hi Scott — Tyler added you to Hermes for Flip My Life — on the Fulfillment team, with access to Fulfillment.", then the one thing to do: open hermes.appolis.app, enter this email, a six-digit code arrives here, no password to remember. The app name stays Hermes (where they sign in) and the tenant brand is named as the company, unlike the code mail, which brands by tenant. server/api.js: teamInvite() renders and sends it through the Appolis mail door (tag team-invite, sender Team, so the From line reads Hermes · Team); POST /api/users sends it after the insert and answers { user, invite: { sent, from, address } | { sent:false, reason } } — the add never depends on the mail, and it is awaited, not waitUntil'd, because an admin is owed the answer; new POST /api/users/:id/invite re-sends under the same chain-of-command rule as editing (401 / 403 / 404 pinned). A localhost origin never leaks into the link. web/hermes.html: the shell hands onToast={ping} to TeamView, which defines the ping it calls; the success toast says what actually happened ("invite sent to scott@… — they sign in with a code emailed to that address" or "the invite did NOT send (reason)"); a ✉ Invite button on every manageable roster row re-sends, wrapped in named() with a busy state. TESTS test/team-invite.test.js (6): the template (who / for which company / what / how, never a password, the Hermes mark, a bare payload still forms a sentence); add → exactly one email with the right tag, sender, subject and body; resend → 200 and a second email, a department user → 403 and no email, unknown id → 404, signed out → 401; no mail door → 201 with sent:false and a reason; a throwing or refusing door → reported, never thrown; source pins for the toast wiring, the honest messages, the absent password promise, and the button. Suite 632/632. VERIFIED on production D1 before the fix that Scott's row exists and that no code or invite had been sent (login_codes empty for the day). NOT DONE BY THIS SHIP: Scott's own invite — he was added before it existed and the server never emails on its own; Tyler presses ✉ Invite on his row (or Scott simply enters his email at hermes.appolis.app).reconcile()'s own arithmetic: 2,915 column-lag (vendor balance + the events that landed after its last read = our ledger, to the point — Rivo's own Celebrated Loyalty Anniversary +500, ~200 members a day, every day, waiting for the 15.7k/day balance walk to reach them), 83 never re-read (no rivo_points_synced_at stamp since stamping began 2026-07-31, so the proof cannot run), 5 open. reconcile() counted "lag explained" only INSIDE its 1,500-row truth loop; all 1,500 rows it saw were explained, so lag_explained froze at exactly 1500, the other 1,503 were scored unverified, truth.checked read 0 (the loop never reached a row that needed a vendor call) and the headline was pinned at (27,953 + 1,500) / 30,956 = 95.14 — a ceiling no ledger health could lift. Whole-book: 99.72. Connections measures the pipe (connected, last sync under 2h, no webhook rejections — Rivo is polled, so zero webhook rows is normal); it never measures agreement, so its green was correct. The runbook's "unverified frozen high = the cron lane is dead" was wrong for this shape — the lane had run three minutes earlier (note_1600 corrected). THE FIX (server/loyalty.js). One whole-book aggregate runs the loop's exact predicate over every drifter (715ms over 135k linked members on production) and yields three buckets — column_lag / never_read / open — carried in the payload; lag_explained is now that whole-book count; explained rows are excluded from the truth sample in SQL, so the 150-call budget lands on the rows that actually need a vendor answer (88 on production, 1.2s to select) and heals them through the existing truth match (updateLoyalty writes the balance AND the stamp). sample_cap / sample_seen ride along so a cap is never silent. ⛔ Not the fake-100% mechanism: explained rows stay in the denominator — only their COUNT moved, from "what the loop happened to see" to "what is true". THE STAMP FORMAT (found by the five-reader sweep before shipping). applied_at > rivo_points_synced_at is a byte-wise text compare, and production carries 9,744 stamps in SQLite's own YYYY-MM-DD HH:MM:SS shape — every one of them the identical 2026-08-19 18:05:53, i.e. one hand-run datetime('now') on the day cutover step 4 was measured; no writer in the repo produces that shape. 'T' (0x54) sorts after ' ' (0x20), so against a space-format stamp every event dated that day — including ones hours before the stamp — counted as post-stamp, after_sync came out too big, and a genuinely explained member read as open. All three CTEs now compare against replace(stamp, ' ', 'T') (the stamp side only, so applied_at's index stays usable). Measured on production: the open bucket drops from 5 to 2 (2,922 explained / 83 never-read / 2 open of 3,007). THE REVIEW'S CATCH — SELF-ASSERTED BALANCES (five adversarial lenses, one refuted). linkRivoMembersLocally links a member and sets rivo_points from our own ledger sum, stamping it — by design, "our ledger's number, not a vendor read". For those rows "column + later events = ledger" is true by construction. Measured: exactly one bulk-stamped group exists, the 9,744 rows hand-stamped 2026-08-19 18:05:53 — 31% of the compared book; 927 of the 2,922 column-lag rows and ~8,800 of the raw "exact" rows are in it. The review proved the OLD and NEW scorers score them identically — this ship did not change the standard, it lifted the count from a 1,500-row sample to the whole book — so the 99.7% headline and the readiness gate's exact_pct ≥ 99 now lean on rows nobody at Rivo ever confirmed. Deliberately NOT changed here (it changes the cutover gate's standard — Tyler's call): filed as todo_2425 with a provenance-column design. What this ship does instead: the payload carries hand_stamped (non-ISO stamps = that group), the tile tooltip says it in words next to the raw column-equality number, and the changelog says it here. Taken from the review, small and tested: the truth loop's vendor budget is 0 unless the provider is Rivo — after the flip getLoyalty returns the native provider, whose summary() is the ledger, and a truth loop against it would certify every drifter against itself and pin 100% by tautology; truth.provider is in the payload. The 24-hour truth cache trim is now age-aware (it kept the 2,000 numerically-highest member ids and never aged anything out, so at the cap a freshly-asked low id was evicted the same pass it was written). ledgerAward stores applied_at as ISO-Z whatever shape it is handed (an order's placed_at carries an offset, which sorts on the wrong side of a Z stamp under text compare — dormant until the flip's native earn sweep). SAMPLE_CAP is one constant bound as a parameter; the loop guard matches SQL on an empty-string stamp; pending_revoke never expiring is filed as todo_2426; two writer defects that manufacture false open rows (embedded-admin adjustPoints moves the column without the stamp; mergeCustomers drops the loyalty mirror) as todo_2424. THE TILE (web/hermes.html). The second line says the three buckets a human can act on — 2,922 catching up · 83 never re-read · 2 open on the day it shipped (2,915 / 83 / 5 before the stamp fix) — with a tooltip legend for each, the raw column-equality number and the at least 9,744 balances asserted from our own ledger caveat; the gold count of rows nobody adjudicated is labelled not adjudicated this pass while Rivo is the provider and unverifiable — no vendor to ask after the flip (the legend drops its Rivo sentences then too, keyed on truth.provider); and it falls back to the old N drifting · N unverified line for a payload from before the buckets existed. TESTS test/loyalty-reconcile-cap.test.js (11, real SQL on node:sqlite): a 2,000-explained-drifter book — over the cap — scores lag_explained 2000 not 1500, buckets exact, the truth sample holds only the 8 unexplained rows and every one is asked about, the never-read three are healed and stamped, headline ≥ 99, no explained member is named; budget 0 → unverified = 8 exactly; no fetchImpl → buckets still come back; the sample SQL carries the exclusion and 20 explained + 1 confirmed scores 20/21, never 20/20; the tile source pins the copy and the fallback; a space-format stamp with a same-day pre-stamp event is explained, not open; 2,000 explained + 40 confirmed scores 98.04 — under the gate, so lag alone can never silence the readiness blocker; a masked member (moved after the stamp, but not by the right amount) lands in open and is named — the mutant that excuses "stamped and something landed after" dies on it; ledgerAward stores an offset timestamp as ISO-Z; after the flip the truth loop makes no calls and excuses nothing; 1,600 unexplained rows with a zero budget → the loop saw exactly 1,500 and the hundred beyond the cap are still counted unverified; the truth cache drops stale and at-less entries, keeps the newest 2,000, and a fresh cached answer still adjudicates without a call. Eleven tests; suite 626/626. VERIFIED the shipped SQL verbatim on production, read-only: the whole-book aggregate with the stamp normalization reads 3,007 drifting = 2,922 explained / 83 never-read / 2 open in 469ms (the pre-normalization form read 3,006 / 83 / 2,918 with an 88-row sample in 715ms and 1.2s); the amended compared/exact/drifted aggregate with hand_stamped = 30,963 / 27,955 / 3,008 / 9,744 in 234ms. READ BACK AFTER DEPLOY — the first pass on the new scorer, 18:17:03Z, from queue_cache on production: exact_pct 100 (raw column equality 90.28) · compared 30,968 · drifted 3,009 = buckets 2,924 catching up / 83 never re-read / 2 open · sample_seen 85 · truth {checked 85, exact 85, provider rivo} · unverified 0 · hand_stamped 9,744. Every one of the 85 rows the old scorer never reached was confirmed exact by Rivo and healed in place (column + stamp written from the vendor read), which is why the headline landed above the 99.7 predicted: the two "open" rows were column-lag of a shape the arithmetic could not prove, and Rivo agreed with the ledger on both. The worst-drift list still named the never-read members on this pass because it is computed before the heal; it clears on the next. DELIBERATELY NOT CHANGED: the writer that left 83 rows unstamped — a stamp asserts "this value was true at the vendor at this moment", which an ingest cannot claim; the truth check now reaches those rows and stamps them from a real vendor read.arr.length and f.name and showed one word, and sign-in / add-person / chat reply answered by a busy label alone. Rev 6 exempts a screen only where it already shows a SPECIFIC labelled state in the place the content will appear; none of these had one. 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.
await; only the first load and the ⟳ Force button had indications. ✅ A catLoading flag is now set before the request leaves and cleared in finally (failure too); the chips are disabled while it is in flight; and a centred "Loading Win-back…" line with a ring renders on the list itself, naming the category — the indication lives on the thing that is waiting, per the rule. A test pins the order (flag before request), the finally, the disabled chips, and the in-place line. 🧹 Also from the audit: the comment above api() still described the retired top bar; it now describes what the counter actually does (diagnosis, nothing drawn). ✅ Tests 587 → 588.web/wait.js now renders nothing for the counter: its paint hooks are deliberate no-ops, and a test pins that there is no hw-bar, no hw-ambient, no code that could draw one, and exactly one card factory — HW.work(), a named wait the caller asked for. The counter itself stays, silently, for diagnostics: every request's path and age in HW.state().inflight, and a console warning naming any single request that passes 30 seconds — a 5-second ticker that runs only while something is in flight and stops when nothing is. 🎯 CARDS CENTRED IN VIEW. The named-wait cards (product-send submit, Seed from Gorgias) move from the bottom-right corner to the centre of the viewport, one column, min(420px, 100vw − 32px) wide so they fit a phone, pointer-transparent around them so nothing behind is blocked that should not be. 🏛 THE HERMES MARK. The boot loading screen was showing /brand-mark.svg — which is the tenant's brand (the white-label seam), i.e. Flip My Life. It now shows /icon-512.png, which is Hermes: the favicon, the PWA icon, the app's own header image (alt="Hermes"), and the logo the invite email uses. A test pins the asset and forbids the tenant one; the proof page's replica is corrected the same way. 📄 THE PROOF PAGE is rewritten a third time to match what is true: the loading screen (with the right mark), a view's own loading state demonstrated against a quick action with the readout proving no second indicator appears anywhere, the counter's diagnostics shown live (paths and ages, nothing drawn), the centred card, the failure case, the real "N of M" importer, a poller proven absent from the in-flight list, the reduced-motion readout, and the coverage table rewritten around the rule: each view indicates its own loading in place. 🔎 AND THE RULE'S PRECONDITION IS BEING CHECKED, not assumed. Removing every ambient indicator is only honest if every screen indicates its own wait — so a read-only audit of every tab and sheet (does it show loading text, a spinner, a skeleton, or a busy control while its primary data is unloaded?) was launched alongside this ship; any view found painting an empty list or stale data with no indication gets its own loading state in a follow-up, not a new global indicator. ✅ Tests 588 → 587 (the ambient-indicator tests were replaced by absence pins; net one fewer). The cmp_progress0001 attestation is re-filed against this shape.Hermes · Sign-in <no-reply@hermes.appolis.app> — while Resend reports the subdomain verified (Tyler set all four up; all five domains verified), falling back to the apex address loudly when not. It proved it with five real sends that arrived in Tyler's inbox from their own addresses. cmp_34f01cf47c moved to rev 3 with a new criterion: no hard-coded From in any preview. The Appolis lane then staged a tested patch in this tree for the Hermes lane to verify and ship — and this is that ship. The mail wrapper in worker.js stops silently dropping key (idempotency — a retried send returns the original id and sends nothing twice) and replyTo, both accepted by the door since v0.19.0 but never named in the fixed destructure; it hands back the From the door actually stamped (from, address, fallback, deduped) so an attestation can quote it; it logs a loud warning when a mail left from the apex; and it gains mailIdentity(surface), which asks GET /id/mail/identity what our mail will leave as right now. The invite panel's payload carries from_identity, and the panel renders the address the door reports — plus the door's fallback warning when there is one — instead of the literal <no-reply@appolis.app> it had carried for eleven days. Three new tests pin the wrapper signature, the wire From, and that the literal is gone. ✅ Verified as staged, untouched: 585/585 before a line of mine was added. 🚦 THE THIN BAR, REMOVED. Tyler, of v2.81.0: "I don't like the thin bar thing at the top. You know that from other experiences. Get rid of it and it is broken in some ways. It just kind of sits and stays on the top of the page forever… Intuitive progress bars, loading screens, icons and pop-ups only." Three answers. (1) The hairline is gone — web/wait.js draws no bar; a test pins its absence. The ambient indicator is now a small corner pop-up — "Working…" with a live clock — and because a pop-up is heavier than a hairline it earns a longer threshold: 1.5 seconds, not 400ms. After 12s it explains itself (a cold server). (2) A real loading screen on first open. Between first paint and the session resolving the shell used to draw a half-built page; it now shows a full-viewport "Loading Hermes…" with the mark and a ring, and after 2.5s says "First start after a quiet spell can take a few seconds while the server wakes up" — which is precisely the delay Tyler noticed. (3) "Forever" is now diagnosable. The counter is sound — there is exactly one begin/end pair, in finally; the second "begin" a grep found in the bundle was a comment — so a bar that never left was reporting a request that never ended. The driver now records which paths are in flight and for how long (HW.state().inflight), and warns in the console when any single request passes 30 seconds, naming it. A 25-second live tail during this shift saw no traffic (no tab open), so the specific request is not yet identified; the next time it sits, the console will say what it is. ⚠️ Said plainly: the pop-up is still driven by the same counter, so if a request genuinely never ends, the pop-up will genuinely stay — and that will be true, not a glitch. The proof page (/progress.html) is rewritten to match: no bar section, a loading-screen demo, and the pop-up demonstrated against a quick action that provably raises nothing. The prior attestation of cmp_progress0001 is superseded by a fresh one against this shape. ✅ Tests 582 → 588./proxy/hq/vote now credits 50 points through the loyalty provider seam — Rivo today, the native ledger after the flip, the same provider.award() call either way — on the FIRST vote for a poll; a re-vote replaces the choice and pays nothing more; a provider failure keeps the vote and tells the client award_error so the screen says "counted, points pending" instead of pretending. Poll ids are validated as YYYY-MM. 🔔 A pref kind + POST /proxy/hq/pref (allowlisted keys: orders, lab, points, marketing, labpref) so the dashboard's notification and Top-Flavor preferences finally persist, echoed back as hq.prefs on /me — and the UI says exactly what that is ("saved to your FLIP HQ preferences"), never that a toggle silences a real email nothing sends yet. 📊 GET /proxy/hq/poll returns the current month's poll id, its close and results dates, and REAL tallies for this month and last, counted from the votes themselves. Tests 578 → 582 pass. 🎨 BOTH FLIP HQ SKINS SHIPPED THE MATCHING THEME WORK (preview themes only — classic-account 60a23c6, retro-pop; live untouched): a busyUntil() helper keeps a control busy until its request actually settles — Remove / Apply / Return on the reward line now say "Removing…" instead of finishing silently (Tyler: it "took a second and didn't indicate anything"); votes show what actually happened; every personal-data write (rating, note, program day, preferences) toasts the server's answer and a failed rating reverts its stars; live ratings no longer fabricate +25 (ratings' points remain Tyler's call); Ratings & Notes runs on the member's real products — last order for the quick-rate row, every product ever ordered plus the current box for the journal, keyed by an alphanumeric title slug with a real "Ordered N×"; the Top Flavor poll is the calendar month with real close/results dates and last month's winner from the new endpoint; Exclusive Content renders blocks Tyler adds in the theme customizer (title, subtitle, video link, thumbnail, unlock date) with date gating and an in-page YouTube/Vimeo player, and an honest empty state until the first drop; the payment modal links to the subscription portal instead of showing a fabricated card.hq/catalog now carries subscription_price — cycle-one price computed from the selling plan's pricing policy (percentage off, fixed amount off, or fixed price; new subscriptionPrice() helper, tested on all three shapes) — so the add-to-box picker and the start-a-box builder show what a subscriber actually pays (FLIP 7: 54.98 → 49.48, which is exactly the member's real Recharge line), never the one-time price; a one-time price only appears as a labelled fallback when a product has no policy. 🖼 Each item carries the VARIANT's image before the product's — the FLIP 7 product image is an all-flavors animation, which is not what a Chocolate Courage tile should show; verified live: 9 items, 9 distinct images. ✅ /me gains earn_status, one entry per Ways-to-Earn rule from the member's own events ledger (new earnStatus(), pure and tested): one-time rules (sign-up, SMS, the social follows, review) read done from the Rivo source OR a native Hermes claim note; yearly ones (birthday, anniversary) re-open a year after the last award and say when; "Start a subscription" is done for any active subscriber; "VIP tier upgrade" is done at the top tiers; orders and referrals never close. 🎨 BOTH FLIP HQ SKINS SHIPPED THE MATCHING THEME WORK (preview themes only — classic-account e9e4ba2, retro-pop — live theme untouched): product art is contained and never cropped, tile text gets room; every picker price is the subscription price, labelled; subscription lines show the variant's own image; rewards are managed from the subscription itself — the next-order card shows the applied REW- code with Remove, or "Apply a reward" listing the member's live codes with Apply and Return-for-points, all through the storefront's existing apply / remove / return doors (ownership and the REW- prefix re-checked server-side; no new write semantics), and a fresh redeem offers to apply itself; Ways-to-Earn rows read "Done ✓" or "Again <date>" and stop linking out. Tests 559 → 578 pass.web/wait.js — DOM-free at its core, its clock injected — is loaded by the app AND by the proof page, so /progress.html runs the exact code the app runs. api() now brackets every request with HW.begin()/HW.end(), the end in a finally, so a route added tomorrow is covered by construction and a bar can never outlive its work. ⏱ RESTRAINT FIRST. Nothing paints unless a wait outlasts 400ms; a quick action answers on its own control and shows no bar at all. Once painted it holds 350ms so a burst of short calls cannot flicker. This is rev 4's sharpened criterion 1 — Kosmos learned it from a bar that painted on nearly every call and became furniture. 🔕 POLLED WORK IS INVISIBLE BY CONSTRUCTION. background:true opts a call out of the driver entirely, and the five periodic loaders — customers (30s), dashboard (45s), VIPs (60s), Studio chat (8s), Studio job poll — pass it on their interval path only, so their FIRST load (a wait the person is actually kept for) still counts and their refreshes never do. The version check already used raw fetch and never touched it. 🔢 REFERENCE-COUNTED, NOT A BOOLEAN — two requests in flight and one finishing keeps the bar up; a fake-clock test drives every case: under-delay paints nothing, over-delay paints and un-paints, two-then-one stays up, the hold stops flicker, a call arriving during the hold does not re-paint, extra end()s never go negative. 🏷 LONG WORK IS NAMED. HW.work({label}) draws a small Hermes-styled card with a live elapsed timer and an honest estimate — the last measured duration for that label on this device, stored locally, and no number until one has been measured: never a fake percentage. It sits on the two product-send submits (the request that creates the real Flexport order) and on Seed from Gorgias. fail(reason) replaces the card with the reason and a Dismiss; done() removes it the instant the work ends. 🔘 THE CONTROL SAYS SO ITSELF. Seed from Gorgias — which walks every ticket and can run many seconds — had no busy state at all; it now disables and relabels on the element for the duration and restores in finally, on failure too. Sign-in, sign-out (v2.80.2's wall) and Add person already did. 📊 DETERMINATE WHERE THE TOTAL IS GENUINELY KNOWN — and it turned out one place qualifies. The Studio media importer uploads dropped files one at a time behind a boolean importing that rendered a bare "Importing…". The total was known the whole time. It now reads "Importing 3 of 7 · hero.mp4" with a filled bar — the app's one real N-of-M. ⚠️ AND WHAT DOES NOT APPLY IS SAID OUT LOUD, ON THE PAGE: inside a single file there is no byte-level progress, because Hermes's other uploads travel as one JSON body through fetch, which exposes none; and every batch job (bulk gifting, backfills, discount sweeps) runs inside one server request, so the client cannot see N-of-M for those. No fake job is shown to look complete — that is the directive's own instruction. 🌗 REDUCED MOTION: the bar becomes a still mint line and the card's dot stops pulsing; the indicator is stilled, never removed. 🎨 HERMES'S OWN STYLE — mint→gold, the app's surfaces and radii; Appolis's bar was the reference, not the component. 📄 THE PROOF PAGE — https://hermes.appolis.app/progress.html — runs a genuinely slow fake operation for every pattern: the thin bar vs a 150ms quick action that provably paints nothing, two overlapping requests with the live count, the labelled card with named steps, the failure case, the real "N of M" importer, a live 2s poller that provably never paints, a reduced-motion readout, and a plain coverage table saying which app surface uses which pattern and which do not apply. 🛡 /wait.js is short-cached (5 min) so a fix reaches every open tab within minutes; /progress.html is never cached. ✅ Tests 562 → 574. 📌 NOT YET ATTESTED in this entry — attestation follows the served-page verification, with the From-line half of the mail directive still blocked on Appolis (todo_2322) and unrelated.catch and reloaded anyway — but a reload with the cookies still alive silently SSOs the person straight back in, which is the v2.80.1 bug wearing a new coat. Now the wall is REPLACED by the reason ("Sign-out did not go through… You are still signed in") with Try again and Stay signed in. A spinner that outlives its work is a lie; a reload that pretends the work happened is a worse one. ✅ Tests 561 → 562, and the new one pins the ORDER — busy before request, request before reload — plus that the failure branch is not an empty catch. 📌 Also reported, not fixed here: first-load slowness across the board — Tyler: "some speed issues on the first load for everything… even the first sign out took a second." That is Cloudflare cold start plus a ~770KB bundle; it is exactly what cmp_progress0001's shared indicator exists to make honest, and Hermes has not built that yet (/progress.html still 404). ✅ And the thing this whole run could not verify is now verified by the owner: "I was able to log in with the one-time code that was sent to me." The mail leg works end to end./api/auth/logout expired only Hermes's own cookie (fmluser). The suite-wide appolis_id cookie on .appolis.app — the one Appolis sets when you sign in anywhere in the suite — survived untouched. The client's logout then reloads the page, the reload runs the boot sequence, and the boot's silent SSO leg (/api/auth/appolis, in place since v0.72.0) found that surviving cookie and signed him straight back in. Admin → hub greets → "Log out" in the launcher → click it → same loop. The sign-in wall never rendered because user was never null. ✅ Logout now expires BOTH cookies — fmluser on / and appolis_id on Domain=.appolis.app — two Set-Cookie headers, since the one-cookie helper could not carry them. ⚠️ Said plainly: that ends the shared suite session, which is what "log out" means when the session is shared; a fresh code at any app's door gets it back. 🛡 AND THE WALL NOW SUPERSEDES THE HUB BY CONSTRUCTION. The sign-in wall renders at z-index 70 and the hub above it, so if the launcher were ever open at the instant the session resolved to nobody, the person would see a map with "Log out" in it and no way in. user === null now closes the hub unconditionally, regardless of how it got open. ✅ Tests 559 → 561: one asserts both cookies are expired and that the revoked token no longer resolves to anyone; one pins the hub-shut rule. 📌 The sign-in-first requirement in Tyler's words — "the one-time password login request should supersede everything and be the first thing that someone sees when entering Hermes signed out" — is now true by two independent mechanisms rather than by hoping the SSO leg fails./proxy/hq/me now derives vip_tier and tier_progress through loyalty.js's own tierAt() + threshold constants (new tierBlock() helper) instead of trusting the stale rivo_tier mirror column or re-hardcoded cent thresholds — an active subscriber comes back FLIP Fam with no next-tier upsell, and non-subscribers climb by lifetime spend with honest need_cents. ⛔ AND /me NO LONGER LIES WHEN AN UPSTREAM DIES: the loyalty and subscription legs (and the Recharge items listing) used to catch-and-degrade into a live-looking "no subscription" 200 — the exact shape that showed a real subscriber an empty box wearing sample data. A failed leg is now a 502, which both dashboard skins already answer with their honest fail-card + retry; absence stays a 200. Also in this pass: the box route's line-id sanitizer regex typo (/D/g stripped only the letter D — now /\D/g), delay can no longer compute a next-charge date in the past when the current one is stale, and hq/catalog items now carry their product's selling_plan_id so the theme can start a brand-new subscription through cart + checkout for members who have none. Tests 553 → 559 pass (tier ladder both shapes, the 502 outage path, the sanitizer, the past-date guard, the selling-plan field). 🎨 THE TWO FLIP HQ SKINS SHIPPED THE MATCHING THEME-SIDE FIXES THE SAME DAY (preview themes only — classic-account 638f30e + 6ecc73d, retro-pop 4acbe91; live theme untouched): Edit Schedule now preselects the member's real cadence and only sends a frequency when it actually changed — the first radio was force-checked, so a date-only save had been silently rewriting every 2- and 3-month subscriber to monthly billing; the owner's real address and email are gone from the public assets (a member with no default address gets "no address on file", the live address modal links to the Shopify addresses page instead of a fake save, and the delivery map is labelled from the member's own city); the tier chip and ladder never keep the sample tier once live, match the server's tier by prefix, and fill between pips from lifetime spend; a live member with no subscription gets a real catalog builder that starts one through cart + checkout on each product's selling plan instead of a prototype whose Save saved nothing; live-but-empty renders honest empty states everywhere the sample box used to leak; the retro Rewards page finally paints the live points balance (it read "0" for everyone) and its points-bar fill gets the display guard the storefront's div:empty rule demanded. Verified in the owner's signed-in session on both served preview pages — the tier bar reads FLIP Fam at 100% where it had read Member at 0%.Set-Cookie, with no token in the body. Hermes already owned a proven code system in server/lib/report-access.js (6-digit CSPRNG, SHA-256 at rest, 10-minute life, 5 attempts, salted per subject), so this reuses those primitives instead of writing a second, weaker one. ⛔ A CODE NEVER CREATES AN ACCOUNT — the address must already be an app_user, the same rule the report grants and the Appolis SSO leg both keep. ⛔ AND THE DOOR IS NOT AN ORACLE. It is unauthenticated and anyone can knock, so a stranger, a real teammate, a rate-capped address and a mail outage all receive the same 200 at the same speed: the lookup, the insert, the render and the send all ride waitUntil and the response leaves before any of them start. This is the lesson the report door paid for — identical bodies separated by half a second is not a uniform door. ✅ The password route stays on the SERVER, deliberately, exactly as Appolis did when it shipped codes ("built alongside passwords, not instead of them"): nobody is asked for one, but a mail outage cannot lock the company out of its own app. 🐛 AND THE TESTS EARNED THEIR KEEP IMMEDIATELY — the first run caught an INSERT binding five columns to four values. In production that would have failed silently: the uniform door returns 200 whatever happens inside, so the only symptom would have been that nobody, ever, received a code. 🎛 "I SHOULD BE ABLE TO PICK AND CHOOSE WHAT DEPARTMENTS/FEATURES EACH PROFILE HAS ACCESS TO." v2.78.0 read the fulfillment desk off the person's DEPARTMENT, which conflated two different things — where someone belongs, and what they may open. department goes back to meaning belonging (it still picks their home screen) and access becomes its own column: a JSON list of areas an admin ticks on and off per profile, on both the + Add person form and the roster card. A Marketing person can hold the fulfillment desk; someone in the Fulfillment department can have it taken away. ⚠️ NOBODY LOSES A DOOR THE DAY THIS SHIPS — access NULL means "never set", and accessOf() then answers with the exact v2.78.0 behaviour (the old non-admin tab list, plus the desk if their department said fulfillment). An explicitly empty list is a real answer, and the shell renders a plain "no areas have been switched on for you yet" instead of bouncing them around an empty tab list. ⛔ AND THE PICKER REFUSES TO LIE: areas whose routes are still gated on the admin role come back enforced:false and are drawn disabled and labelled admin only, because a tick box that changes nothing is a control reporting success without doing the job. They light up as their routes are converted (filed). 🗑 The client no longer derives access at all — v2.78.0 kept a second copy of the rule in the browser and pinned it with a drift test; the better fix was to delete the duplicate, so the server resolves access and ships the ANSWER, and a test now asserts the copy has not crept back. ✅ Tests 541 → 553./api/sends/ and /api/creators/ route checked only "is someone signed in" — twelve routes, not one role check among them — while the 📦 Fulfillment tab was withheld from non-admins by the CLIENT alone (a tab list and a redirect in web/hermes.html). One of those twelve is POST /api/sends/:id/submit, which the code itself comments as "the one route that ships real product" and which reaches flexport.js:250, "THIS SHIPS REAL PRODUCT." Measured on production: six Customer Service members could each have shipped product with a single authenticated request. Nothing logged it as unusual because nothing looked. This is the only admin surface in the app where that was true — the other 58 role !== 'admin' gates are genuinely server-enforced. ⚠️ AND THE OBVIOUS FIX WAS THE TRAP. Adding role !== 'admin' would have locked out precisely the person this work exists for: admin is the ONLY level that opens Fulfillment, and it also opens Connections — the live Shopify, Recharge, Flexport and Gorgias credentials — plus Discounts, IT, Team and VIPs. There was no middle setting, so "add him to the fulfillment team" had exactly two outcomes: he sees nothing, or he sees every credential the company owns. ✅ THE DEPARTMENT NOW CARRIES THE ACCESS. canFulfillment() in server/lib/auth.js passes overall admins and anyone whose department reads as fulfillment; all twelve routes are gated on it server-side. The 📦 tab joins a fulfillment user's TABS, and the redirect guard is now DERIVED from TABS instead of a hand-kept list — which also fixes a quiet pre-existing bug where a non-admin who clicked Agora, a tab the app was already drawing for them, was bounced straight back to the Support Hub. ⛔ The public parcel page /t/{token} stays deliberately unauthenticated: that link goes to the creator and the token is the only credential. 🚪 AND THE BUTTON THAT NEVER EXISTED. POST /api/users has supported rank + department + a full chain of command since 2026-07-17 and nothing had ever called it — six GETs to list the roster, zero creates. The Team tab could edit and remove people it had no way to create, so the only route onto the team was being synced in from Gorgias. There is now a + Add person form: email, name, rank, department, with the chain of command mirrored from the server and a plain-English line saying what that access actually opens before it is granted. ⚠️ IT ALSO SAYS WHAT DOES NOT HAPPEN: nothing emails the new person (an invitation is filed as todo_2305), and a non-admin is created with no password — they set their own on first sign-in — so the toast tells the admin exactly what to send them. Leaving that unsaid is how an added teammate sits locked out wondering what their password is. ✅ Tests 536 → 541, and both new guards were mutation-proven: delete the gate on the submit route and the support-member test goes red on "a support member must never reach the route that ships real product"; change one regex and the drift test goes red. 🪤 THE DRIFT GUARD EARNS ITS PLACE — the client draws the tab from its own copy of the department pattern and the server decides who may call the routes, so a silent divergence would render the tab and 403 every click. A test pins the two regexes to the same source string.ml.tax_estimated, so it removed itself the moment nothing on the slide was estimated. No UI change was made at all. 📐 THE MEASUREMENT THAT MADE IT SMALL. The code comment quoted "NULL on 288,759 of 289,405 rows" — true of the whole orders table and irrelevant to this slide, overstating the job by four orders of magnitude. The set that actually drives the paragraph is the FAILED-DELIVERY orders the money table is built from: 639 all-time, of which 566 already carried real tax. Only 73 did not. Also verified the paragraph’s own claim before trusting it: ingest coverage went 12/392 on 08-10, 411/425 on 08-20, and 100% every day from 2026-08-21 — so the capture was already fixed and only a backlog remained. ✅ THE BACKFILL: all 73 pulled from Shopify totalTaxSet (the same field the ingest writes, so the backfilled rows are indistinguishable from synced ones), written with tax_cents IS NULL in the WHERE so the job is idempotent and cannot clobber anything the live sync filled in meanwhile. 73 rows written. ⚠️ AND THAT WAS NOT THE WHOLE JOB — SEE THE CORRECTION BELOW. 60 carry tax totalling $423.31, 13 were charged a genuine $0 — recorded as 0, not NULL, because zero is a measurement and null is an absence. Measured effective rate on the set: 5.81%, against the 5.4% blend it replaced — close enough to look harmless, which is exactly why per-order truth matters. 🛡 A GUARD EARNED ITS PLACE MID-JOB. Every order’s Shopify total was cross-checked against our stored total_cents before its tax was written — and one refused: #281172, Shopify $164.25 against our $104.90. Not a wrong match: the order was EDITED after ingest (edited:true, originalTotalPrice $104.90 = ours exactly), and its tax is $9.30 on both the original and current figure, so the value was never in doubt. Written as a documented, per-order exception rather than by loosening the check for all 73 — an exception you can read is safer than a check you weakened. ❌ CORRECTION, SAME DAY, CAUGHT BY LOOKING AT THE LIVE PAGE. The first pass claimed “0 of 639 failed orders still estimated” and it was wrong: the disclaimer was still on the report. The cause was mine — I reproduced the money query’s scope by PARAPHRASING it. The report scopes with (o.flexport_order_id IS NOT NULL OR o.placed_at >= '2025-11-22') and I used only the first half, so every failed order after the ShipBob cutover without a Flexport id was invisible to me. The real set is 647, not 639, and 6 more orders needed tax. Fetched and written the same way (5 carrying tax totalling $30.69, 1 a genuine $0); re-measured with the query’s OWN predicate: 0 of 647 estimated, and the paragraph is confirmed gone from the live page with the money slide rendering 133 rows beside it. ⭐ THE LESSON: when a query is the authority, copy ITS predicate — do not paraphrase it. A narrower reproduction returns a clean, confident, wrong answer, and my server-side “proof” that the paragraph could not render was exactly that. The page was right and the proof was wrong. ⚠️ A SECOND TOTALS DISAGREEMENT surfaced in round 2, and it is NOT the same bug as the edited-order one: #232891 is cancelled-and-refunded, never edited, Shopify total $102.39 against our $105.21 — the difference is exactly totalDiscounts ($2.82), so our stored total for that order is GROSS of discount. Its tax ($5.00) is independent and was written; the discrepancy is filed. ⚠️ SIDE FINDING, FILED NOT FIXED: that order exposed that nothing re-syncs an order edited in Shopify after ingest — our stored total for #281172 is $59.35 stale. Scope unknown; filed rather than guessed at. 🧹 The comment that misdirected this is rewritten to say what is true, including a warning not to trust a headline count over the rows the query actually returns. The estimate BRANCH stays — it is still the honest answer for any future order arriving without tax, and deleting it would replace a labelled estimate with a silent zero, which is the bug the block exists to prevent.📡, every em-dash became –). 1,238 corrupted lines, compiled by build.js into the served bundle and live for a day: every tile icon on the launcher was garbage, the tab title read “HERMES — Every Channel”. HOW IT HAPPENED, from the evidence: the release was a 40-line server change; hermes.html’s only intended edit was the one-line APP_VERSION bump — and the commit shows 3,531 changed lines in that file. The write that bumped the version destroyed everything else: the signature of default-encoding PowerShell (Get-Content | Set-Content without -Encoding utf8), which reads ANSI, writes a BOM, and mangles every non-ASCII character in between. The server files in the same commit are clean — different tool, different fate. THE REPAIR IS PROVEN LOSSLESS, not eyeballed: the double-encode was reversed character-by-character (cp1252 map, zero unmappable characters) and diffed against v2.76.1’s file — exactly two real differences: the version string and a trailing newline. So nothing legitimate was lost by restoring; the FLIP HQ lane’s actual work (hq.js, storefront-proxy.js, all four changelog entries) was never touched by the corruption and ships on intact. 🧨 THE TRIPWIRE, so this class can never deploy again: test/encoding-tripwire.test.js reads the BYTES of the UI file and every mail template — no BOM, no double-encoding signatures, and (the inverse guard) the real 📡/—/’ characters still present, so a “fix” that strips the emoji fails too. It runs inside npm run deploy before wrangler, so a corrupted file now fails CI instead of failing Tyler. Mutation-proven: re-corrupting the file goes red. All 530 tests were green while the app was visibly destroyed, because nothing looked at the bytes. Something looks at the bytes now. ⚠️ FOR EVERY SESSION: never edit web/hermes.html (or any file with non-ASCII content) with default-encoding PowerShell. Use Node fs, or pass -Encoding utf8 on BOTH the read and the write. If the tripwire blocks you, your editor corrupted the file — restore from git and re-apply; do not edit the test.POST hq/swap-line {id, variant_id}: change WHAT ships without touching WHEN it ships. The obvious implementation — PUT the new variant onto the existing Recharge subscription — has a billing trap: Recharge does not reprice a subscription when its variant changes, so swapping a cheaper bag for a dearer tub would silently keep charging the old price. Instead the route CREATES the replacement line (priced by Recharge's own plan settings, the exact mechanism add-line uses) on the OLD line's address, cadence, next-charge date and quantity, then CANCELS the old line. Create goes first on purpose: if the cancel then fails the member briefly holds both lines — visible and recoverable — where cancel-first could empty the box. Guards: ownership (403), swapping a line to itself (409), swapping to a variant already on another line (409). ✅ Tests 528 → 530: the created line carries the old line's address/interval/date/quantity verbatim and the cancel provably lands AFTER the create; the refusal matrix sends nothing to Recharge.hq/catalog asked Shopify for sellingPlanGroupCount { count }; this store answers on an API version where that field is a plain Int, so the whole query was rejected and the add-to-box picker rendered empty. Found by calling the live endpoint instead of trusting the schema in my head — it named the error exactly ("Selections can't be made on scalars"). It now asks for sellingPlanGroups(first: 1) { nodes { id } }, which means the same thing and is stable across every API version.GET hq/catalog — the addable set, taken from Shopify itself (any active variant whose product carries a selling-plan group is by definition sold as a subscription), cached an hour per tenant because it sits on a customer-facing path and changes on the order of weeks. POST hq/add-line — creates a subscription on the address, cadence and next-charge date the member's existing lines already use, so a new flavour rides along in the same order instead of opening a second shipment; refuses a variant already in the box (409) and quantities outside 1–24. POST hq/remove-line — cancels one line, ownership checked server-side, and refuses to remove the last one: emptying the box is a cancellation, which has its own save flow and its own churn record. ✅ Tests 525 → 528: the created subscription carries the anchor line's address_id / interval / date verbatim, a duplicate variant sends no POST at all, and the last-line guard fires before any cancel call leaves.rgLoad/rgAdd script are removed — the People tab does everything they did and does it with names, states, per-person actions and honest mail reporting. Two surfaces doing the same job is how they drift apart. A test now asserts nothing is left referencing the retired controls, because a leftover handler pointing at a control that no longer exists is a console error on load. ⚠️ THIS RELEASE IS v2.74.0 AND NOT v2.73.0 BECAUSE ANOTHER SESSION WAS IN THIS REPO AT THE SAME TIME — the FLIP HQ lane committed and deployed v2.73.0 while this work was in flight. Nothing was lost (their commit is an ancestor of this one and their work was already live and verified before this deployed), but two sessions editing one folder is the thing the lane rule forbids, and it is recorded here rather than quietly renumbered. ⚠️ THE GUARD WAS REPOINTED, NOT DELETED. report-share.test.js held the test that remembers why the first share panel shipped unparseable and silently dead. Its subject is gone; the hazard is not — whatever script sits on that slide is still interpolated into a template literal inside another one. So it now parses the PANEL script instead. Deleting a guard because its subject moved is how a lesson gets lost. 👀 VERIFIED BY DRIVING IT ON THE LIVE REPORT, not by reading the code. The panel opens · three tabs · From line reads “Hermes · Delivery Performance ‹no-reply@appolis.app›” · the preview iframe is sandbox="" and genuinely filled · the People tab renders a person with their name and job, five actions, and a state that reported “the email did NOT send — not sent, you asked for the link only” when a link-only grant was created, which is the honest-failure behaviour working · Remove arms on the first tap (“Remove for good — tap again”) and does not act · the Problem column header reads “Problem (click to read)” and 108 of 136 real money rows carry a ticket id, so the click-through works where there is a conversation and stays plain where there is not. 📬 AND A REAL MAIL WAS SENT AND READ BACK OUT OF THE INBOX — which is the only thing that can settle an email change. Confirmed in the delivered message: the greeting renders (“Hi Master Flipper,”), the new ignore line is present in BOTH the HTML and the plain-text part, the plain-text twin is complete and matches, the card ships as width:100%;max-width:600px (the phone fix is live), and the contrast pair #D7DAE6 / #AFB5C9 is what actually arrived. ⚠️ WHAT THAT MAIL DID NOT SETTLE, AND SO IS NOT ATTESTED. The mail connector reports the sender as no-reply@appolis.app with no display name field, so the From line could not be quoted verbatim — and cmp_34f01cf47c asks for exactly that, verbatim, because a template is not evidence. Kosmos has an open suggestion (sug_8523034ccf) saying the per-app derivation is committed but not live on Appolis, which would mean the display name is not landing yet whatever this app passes. Hermes’s half is done and tested (the surface rides on every send, and a test reads worker.js’s own signature because a wrapper that drops the field fails silently) — but the directive is NOT attested until the From line has been read with human eyes.sandbox="" — an email cannot run scripts and neither should a preview of one · two-tap arming on every destructive control instead of any dialog · saving re-fetches, because only lines that DIFFER are stored so what you typed and what is kept are not always the same thing · and errors carry their body, so a refusal with a machine-readable reason arrives as something the panel can branch on rather than prose it can only print. 🆕 THE PEOPLE TAB IS NEW GROUND FOR HERMES. Grants were a list of bare addresses; they now carry a name and a job, which is what makes a roster readable past about four people and what lets the invitation greet somebody. Per person: revoke / let them back in · copy link · edit details · resend · remove, deliberately not the same shape — remove is the only destructive one, so it is the only one tinted. Reinstating says its side effect out loud (their previously confirmed devices stay signed out and they will need a fresh code), because silence there reads as a broken fix. Resend will not send twice inside a minute and reports that honestly rather than as a fresh send. The roster state is decided by the SERVER, so the panel and any other caller cannot disagree about what somebody’s status is — “invited” used to be true of everybody forever, which made the column worth nothing. 2️⃣ THE PROBLEM COLUMN NOW OPENS THE CUSTOMER’S MESSAGE, exactly like “Customer service says (click to read)” on the carrier-vs-customers slide. ⚠️ TWO TRAPS CAUGHT BY READING BEFORE TYPING, EITHER OF WHICH WOULD HAVE SHIPPED LOOKING FINE: ① the report payload is version-cached (40 min, cron-prewarmed) — adding a field without bumping v means every cached report keeps serving rows with no ticket id and the column stays dead, which after a deploy reads as “it didn’t work”. Bumped 25→26, and a test now asserts the cache guard and the payload stamp are the SAME number. ② the money query picked its ticket for the category with a bare LIMIT 1 and no ordering; adding a second unordered subquery for the id could have resolved to a different ticket on an order with more than one, so the Problem label would have described one conversation and opened another. Both are now ORDER BY t.id, and the test inserts three tickets with ids deliberately out of insertion order so it can actually fail. 🏷 COMPLIANCE cmp_34f01cf47c FOLDED IN, and it found a live near-miss. Every send now names its surface, so the From line reads “Hermes · Delivery Performance” rather than the app alone — a person asked to type a six-digit code can tell WHICH door they are opening, which is a security property, not decoration. ⚠️ worker.js’s mail wrapper destructured a fixed list of fields, so sender_name would have been dropped in silence: the send succeeds, and the From line simply never changes. It was checked only because the Kosmos lane hit the same shape on Appolis’s side and wrote it down. There is now a test that reads the wrapper’s own signature. The sign-in code’s subject also stops being generic — “your Delivery Performance sign-in code”, not “your Appolis sign-in code”, which the directive rejects by name. The invitation gained a greeting (falling back to the address, so it never opens “Hi ,”) and an ignore line, both carried into the plain-text twin, which is DERIVED from the same fields rather than written a second time. 🧹 Also: FIELD_META and the token legend moved to live beside the fields they describe, so a field added later appears in the panel grouped and labelled by construction instead of by somebody remembering to edit a second file — a test asserts the two cannot drift. One shared inviteVars() now builds the variables for every caller (first send, resend, preview, test), after they each built it by hand and the preview quietly drifted from the send. ✅ VERIFIED: tests 497 → 508, and 19 of 19 new protections mutation-tested individually — each deleted, its own named test watched go red, then restored. That includes the load-bearing one (remove the saved wording from the send and “the saved wording is what the real invitation actually sends” fails) and the compliance one (drop sender_name from the wrapper and the signature test fails). One mutation had to be rewritten because it broke the file rather than the behaviour — a mutation that causes a syntax error proves nothing. ⚠️ AND ONE THE TEST SUITE CAUGHT ON ME: the first version of the Problem cell emitted showMsg(''+escH(r.t)+'') — the quotes were stripped by my own patch script, and two adjacent string literals is a syntax error that kills the entire inline script, exactly the failure note_1843 records from the first share panel. It failed in CI instead of on the page only because a test parses the rendered script. That test has now paid for itself twice.max-width:600px to max-width:100%, which looks like the textbook answer. Re-measured on the live page: still 600px, still overflowing by 244. WHY. In table layout a specified pixel width behaves as a MINIMUM. width:600px forces the ancestor table up to 600 regardless of the viewport, and max-width:100% then resolves against that grown ancestor — 100% of 600 is 600. The percentage was doing exactly what it was told, against the wrong number. AND A SECOND CAUSE THE FIRST PASS MISSED ENTIRELY: the FOOTER table carried width="600" too. Even with a perfect card, that one table held the whole document at 624px on its own. Both had to change together, which is only visible if you enumerate every element wider than the viewport rather than fixing the one you suspected. THE FIX: every fixed table is now width="100%" with width:100%;max-width:600px — fill the space up to 600, shrink below it. Verified live at a 380px viewport: card renders 341px, overflow 0, against 600px and +244 before. The <!--[if mso]--> 600px wrapper is untouched and load-bearing, because Outlook desktop ignores max-width entirely and would otherwise render the card full-bleed. ⚠️ THE LESSON, AND IT IS THE SAME ONE TWICE IN ONE DAY: A FIX IS NOT VERIFIED BY BEING PLAUSIBLE. Both this and the preview-clamp bug were shipped or nearly shipped on reasoning that was sound and wrong, and both were caught only by measuring the running page. Tyler’s instruction — "actually look at it instead of guessing by 'proving' visuals with code only" — produced three real defects in one sitting, two of them in code that had already shipped green. Layout is not knowable from source. It is the product of a specific engine at a specific viewport, and the only honest way to check it is to render it and measure. 🧪 Guarded by a test that rejects any width:600px declaration, requires the responsive shape on BOTH tables, and requires the mso conditional to survive. Both halves mutation-verified. Tests 497.max-width:600px on the card table — a max-width equal to the width can never shrink, so the card stayed rigid at 600px on every phone. ⛔ THE FIX IN THIS RELEASE WAS WRONG AND DID NOT WORK — see v2.71.3. It changed the card to max-width:100%, which reads as obviously correct and changes nothing: re-measured on the live page afterwards, the card still rendered 600px and still overflowed by 244. A specified pixel width acts as a MINIMUM in table layout, so the ancestor table grows to 600 and the percentage then resolves against the grown parent. Shipped, re-measured, still broken — the second measurement is the only reason that is known. ⚠️ THIS WAS PRE-EXISTING, NOT NEW — it has been in every invitation this app has ever sent. It went unseen because the narrow-screen @media block looks like mobile was handled: it reduces the padding and drops the headline to 27px, and it does fire. It was reflowing a table that could not move. A media query that runs is not a layout that adapts, and reading the CSS would have reassured you either way — only rendering it at 380px told the truth. 🧪 Guarded by a test that asserts max-width:600px appears nowhere in the rendered email, mutation-verified. Tests 496 → 497.max-width:100%. Its column clamped it to 572px — measured live, not reasoned about — and the invitation email carries its own @media only screen and (max-width:620px) block. So the preview was below the email’s mobile breakpoint at every setting: the Desktop/Phone toggle changed nothing, and Desktop silently rendered the PHONE layout. Proven by forcing the frame to 700px in the live page and watching the headline go from 27px/33px to 31px/37px and the card reach its true 600px. ⚠️ WHY NOTHING CAUGHT IT. The width came from CSS resolved against a container that only exists in a real browser, at a real viewport, inside a real flex column. There is no assertion over server-rendered HTML that could have known 572, because 572 is not in the HTML — it is the outcome of laying it out. Every unit test was green and the panel was wrong on screen. That is the same family as the access gate whose door rendered perfectly over a table that did not exist: the artefact was right and the running thing was not. THE FIX. The iframe now renders at its TRUE width (700px desktop — deliberately above the 620px breakpoint, since a real desktop client shows the 600px card inside a wider viewport — and 380px phone) with max-width:none, and a scaling stage shrinks the PICTURE of it to fit the column via transform: scale(). The email therefore measures itself against a truthful viewport while still being fully visible. Height now comes from the rendered content instead of a guessed 1180px constant, so the footer is neither clipped nor trailed by a band of dead ground — both of which read as "the preview is broken". ✅ VERIFIED IN THE BROWSER THIS TIME, on the live report: the editor mounts, all 18 fields populate, the preview iframe genuinely fills (14,099 chars of HTML in, 10,464 rendered), no error state, the slide carries nofit so it is not shrunk, zero false "edited" badges and no unsaved-changes pill on a clean load. And the readability numbers were re-taken from the rendering engine rather than from arithmetic — card background #20202C with border 0px / none (the thin line is genuinely gone), body copy #D7DAE6 at 11.55:1, small print #AFB5C9 at 7.88:1, against the 7.3:1 and 4.77:1 they replaced. 🧪 Tests 494 → 496, and the two new guards were mutation-tested: put the Desktop width back to 600 and the breakpoint test fails; restore max-width:100% and the clamp test fails. The breakpoint is read out of the rendered email rather than hard-coded, so changing it in the template cannot leave the guard quietly asserting the wrong number.border:1px solid #2E2E3D on the card table — removed, the rounded corner kept. The body copy was #A9AEC2 on the #20202C card at 7.3:1, which is survivable, but the small print carrying the expiry and security sentences was #858BA4 at 4.77:1 and 11px with 1.4px of letter-spacing — that was the line nobody could read. Body is now #D7DAE6 (11.3:1) and small print #AFB5C9 (7.9:1), with every size raised: the opening 15→17px, the contents 14→16px, the steps 14→15px, and the expiry and security lines 12.5→14px. Nothing below 12px survives anywhere in the template. The headline was left exactly as it was, because Tyler said it was fine. ⚠️ THE PART THAT WOULD HAVE MADE THIS A NON-FIX: every colour in this template is declared three times — inline, in the @media (prefers-color-scheme: light) block, and again in the [data-ogsb]/[data-ogsc] Outlook rewrite block. Changing only the inline one leaves the other two re-pinning the old value, so the fix would have been invisible in precisely the clients most likely to need it. The patch swapped by hex across all sites and reported the count (6 muted, 7 faint); the test asserts the dead colours appear nowhere, including the overrides. ✍️ SECOND, THE WORDING IS NOW HIS. Tyler: "incorporate this editor feature and the whole shebang on the last slide of the delivery performance report for me so that I can adjust the email and send it out directly from there." All 18 fields of the invitation are editable from the last slide of the report, beside the share panel rather than on a slide of their own — a composer one slide away from the send button is two places, not one. Each field shows an edited badge and a one-click revert, the preview re-renders as you type, and Desktop/Phone widths are a toggle. There is a Send me a test button that mails the current draft to your own address carrying no grant token, so a rehearsal can never hand anybody access. 🔍 FOUR THINGS IT WOULD HAVE BEEN EASY TO GET WRONG, AND WHAT WAS DONE INSTEAD. ① The preview is rendered by the same function that sends. A preview that re-implemented the template would be free to drift from it, and being able to trust what you see is the entire point of the panel. ② Only fields that actually DIFFER are stored. Saving a full copy of every field would freeze that admin behind a snapshot of the template taken the day they first opened the editor — including its typos — so later improvements to the shipped wording would never reach them. ③ The field text is now escaped, not just the variables. These fields used to be written by this file and are now typed by a person: a stray < would otherwise become markup and break the layout for every recipient, with no warning to whoever typed it. ④ It is stored in app_config, not a new table — deliberately, one day after a table that was never created shipped a gate that rendered perfectly and was completely dead. Nothing to create is nothing to fail to create. 📬 AND A HALF-WIRED PAIR THE WORK UNCOVERED. The grants list SELECTs last_mail_at and last_mail_error, and the one-time-code path writes them — but the path that actually sends the invitation never did. The panel had a mail-status column that would have stayed blank forever, which reads as "nothing has been sent" rather than as "nobody is recording it". Both are now written on success and on failure. Exactly the shape note_1843 calls a pair shipped half-wired: the reader shipped, the writer did not. 🧹 Also swept while in there, per the standing rule that no build uses a bare window.confirm: the reset action asks through a styled dialog instead. And the share slide now carries nofit — every slide in this deck is zoomed down until it fits the window, which is right for a chart and wrong for a form, where it would have rendered 13px inputs at about 8px and reproduced the very readability complaint this release set out to fix. ✅ VERIFIED: tests 473 → 494, and all 12 of the new protections were mutation-tested individually — each one deleted, its own named test watched go red, then restored. That includes the load-bearing one: with the saved wording no longer passed to the send, "the saved wording is what the real invitation actually sends" fails. An editor that saved and previewed beautifully while the outgoing email ignored every word of it would have looked completely finished. ⚠️ NOT VERIFIED: the editor has not been driven in a real browser — dev servers cannot be started from this session, so the panel is proven by a render-and-parse test rather than by clicking it. The email itself was rendered through the real renderer and looked at.sug_4558742517; drafting that directive was itself the adversarial read. Six of the seven were in code that was already written, tested and believed done. All are closed, and the list is the useful part: ① THE SIDE DOOR. /report/delivery/msg — which returns customer message bodies and customer PHOTOS, the most sensitive payload on the report — checked the grant and never the confirmed session. The page and both exports had moved behind the code; the JSON route had not. Its own comment said "gate it the SAME way as the page itself". It did not. ② THE ORACLE WITH A STATUS CODE. A wrong address always got 200, but a matching one could get 429 or 502 — knock six times and a 429 tells you who is invited, and any mail outage turned the door into an address-confirmation service. ③ THE LATENCY ORACLE. The matching branch ran a COUNT, an INSERT, a render and a live mail call; the other returned instantly. The send now rides waitUntil and the response leaves first. ④ A CSPRNG WITH A FALLBACK. newReportToken caught a missing crypto.randomUUID and minted the share token from Date.now() + Math.random() — a guessable credential, produced silently, in the app that wrote the house rule against a catch that degrades. An unavailable CSPRNG is an outage, not a downgrade. ⑤ THE REFERER LEAK. The gated page links to portal.flexport.com on every order number, and no Referrer-Policy existed anywhere in the codebase — every click handed the full URL, share token included, to a third party’s logs. ⑥ THE EDGE ANSWERING FOR US. No Cache-Control on the page or either export; a gate the CDN can answer around is not a gate. ⑦ EVICTION ON REASSIGN. Correcting a typo’d invite address left the WRONG person’s confirmed browser working for the rest of its 30 days while the admin believed they had moved the access. ⚠️ AND ONE THAT IS WORTH MORE THAN THE OTHERS: A FILE HEADER CLAIMED A TEST THAT DID NOT EXIST. The first app to comply would have gone looking for the proof, failed to find it, and either faked the requirement or stopped believing the file. Related and caught the same way — the eviction fix shipped UNTESTED: a mutation run replaced the check with AND 1=1 and the entire suite stayed green. A fix nothing can fail is a fix nobody can rely on. Both now have tests that go red on mutation. ✅ VERIFIED: tests 473/473, every new protection mutation-checked individually. The migrate route was fixed on the way past — it stripped -- comments but not / /, so it was silently creating 47 of 49 declared tables, including both of this feature’s. 🤝 THE MAIL DOOR CAME FROM ANOTHER LANE. Hermes has no transactional sender and deliberately did not grow one — Appolis already owns the suite’s mailer, and a second Resend key would mean a second sending identity and a second deliverability reputation. The Appolis lane shipped POST /id/mail/send (v0.19.0) to the contract Hermes was already calling, and folded in two things this lane asked for: the provider’s REAL message id comes back rather than a synthetic one (so a bounce can later be matched to a grant), and report-code is kept off any queue because a code with a 10-minute TTL is useless if it arrives five minutes late.orphan_lost_no_carrier / orphan_disputes_no_carrier) and the page says which: “no carrier recorded at all” is a data gap; “below the 5-delivery cut” is a display threshold. Lumping them together is what made the line unanswerable. ⚠ Cache bumped v24→v25 because the payload GAINED FIELDS — third bump in three versions, and the rule keeps earning it. Tests 450/450.CARRIER_S read shipments.carrier, which on 17 rows holds "FLEXPORT" — the FULFILLER, not a carrier. carrierNorm folds FLEXPORT to NOT RECORDED, and the carrier table deliberately excludes NOT RECORDED, so the match could never succeed and the parcel fell through to the orphan counter. CARRIER_S now prefers o.carrier — the authoritative delivery carrier, and the same column CARRIER_O and therefore the carrier TABLE already key on — falling back to the shipment only when the order has none. ⚠ THIRD TIME THIS EXACT CLASS HAS BITTEN (note_1843): ShipBob read as a carrier when it is a fulfiller · GROUP BY bound to the base column instead of the alias · and now the fulfiller’s name sitting inside a carrier column. Ask what a field MEANS, not what it is called. And the footnote itself was part of the failure: it asserted a CAUSE the code had no way to establish. It now states only the fact it knows — the row matched no carrier in the table — because a stated blind spot only beats a phantom row if the statement is true. 🧾 "THE SALES TAX IS DIFFERENT, BASED OFF OF WHERE THE CUSTOMER LIVES." Also right, and it goes deeper than v2.67.0. US sales tax is DESTINATION-SOURCED, so no single blended rate can be correct — fixing the denominator yesterday made the estimate less wrong, not right. Hermes now captures the REAL per-order figure from Shopify’s total_tax on every synced order, so newly ingested orders stop being estimated at all. ⚠ ZERO IS A MEASUREMENT, NULL IS AN ABSENCE — the same distinction that caused the original bias, from the other side. "0.00" means an exempt buyer or exempt state and stores 0; an absent field stays NULL and the order stays honestly unknown. The upsert COALESCEs so a redelivery or a partial feed can never wipe a figure already captured (the sms_opt_in_at shape). The footnote now names the destination reason out loud instead of implying the tax was owed and merely unrecorded. ✅ Tests 449/449 (+8). Every protection mutation-checked — and one mutation failed to fail, which was itself the finding: Shopify sends money as STRINGS, so '0.00' is truthy and a truthiness check is indistinguishable from the != null check on every real payload. A numeric-zero case was added, and only then does the guard actually get pinned. A mutation that passes is not a passing grade for the code; it is a gap in the test.flexport_order_id already appeared in all five queries — but only inside the scope predicate or a correlated subquery WHERE, and a WHERE reference projects nothing. The column had to be added to five SELECT lists and threaded through four field-picking mappers (money.rows plus the three inlined browser payloads), none of which carry anything they do not name. On the claims table the id must come from fc. and not o.: that join is a LEFT JOIN, ~9 of 152 claims match no order row, and fc.flexport_order_id IS the join key — so taking it from the claim keeps the unmatched ones linkable. ⚠ THE LINK IS BUILT FROM THE ID, NEVER FROM THE ORDER NUMBER. order_label is a display string regexed out of a composite Flexport reference (#285913DELIVERRSPLIT7988541391149) — it is derived, not a key, and a URL built from it would 404. ⛔ AND IT IS NULL-SAFE, WHICH IS THE WHOLE SAFETY STORY. The report’s scope predicate is an OR on the ShipBob date floor, so orders with no Flexport id are admitted BY DESIGN. Measured on production before building rather than assumed: 11,314 of 12,569 orders in the last 30 days carry one (90%), 36,229 of 37,937 at 90 days (95%), 134,935 of 176,870 at a year (76%). Those rows render as plain text exactly as before — a link to /orders/null/detail is worse than no link, because it looks like it works. ⚠ A prior audit claimed “only 9 orders in the last 30 days carry a flexport_order_id” and that NULL was the majority case; that came from a stale header comment written during the dead-feed outage and the live numbers refute it — the feed was repaired. Checked before building on it. 🔁 THE PAYLOAD CACHE VERSION WAS BUMPED 22→23, and that is not housekeeping. Preset windows are cron-prewarmed and served for 40 minutes. Without the bump every warm window keeps serving payloads that have no flexport_order_id in them and the links silently do not appear — which post-deploy reads as “the fix didn’t work” rather than as a stale cache. Same lesson as the cost_basis bump in v2.67.0, one version apart. ✅ Tests 441/441 (+3), and each of the three protections was mutation-checked INDEPENDENTLY: revert the server render and only the link test goes red; drop the id from the disputes mapper and only the browser-payload test goes red; delete the NULL guard and only the dead-link test goes red. The browser block is also re-parsed with new Function because it is assembled inside a server template literal — the helper is written with string concatenation and no regex for exactly that reason. 📌 DELIBERATELY OUT OF SCOPE, and said out loud rather than quietly skipped: the .xlsx cannot carry a clickable cell today — server/lib/xlsx.js emits only t="n" and t="inlineStr" cells, writes no <hyperlinks> element, no per-sheet .rels part and no styles.xml, and Excel does not auto-linkify inline strings on open. Shipping a bare URL string dressed as a link would be the dishonest version. A labelled “Flexport URL” column is the honest option if the fulfilment team wants it in the workbook — Tyler’s call.costBasis computed the effective rate with AND tax_cents > 0 — the average among orders that WERE taxed — and that rate was then imputed onto every order whose tax we never captured, including the ones that owed nothing. Measured on production: of the 646 orders where tax was ever recorded, 192 (30%) are exactly zero; excluding them gives 6.31%, including them gives 4.69%. So the old rate over-deducted tax by ~34% on every imputed order and UNDERSTATED profit — on the document handed to Flexport. Across the 294,778 orders with no captured tax ($27.16M), the imputed tax line falls $1,713,525 → $1,273,603. That is not found money; it is an estimate that was biased high by ~$440k. ⚠️ AND THE FOOTNOTE NOW SAYS WHAT IS ACTUALLY TRUE. The old wording blamed capture (“orders whose per-order tax was never captured from Shopify”), which implies the tax was OWED and merely unrecorded. The real caveat is the SAMPLE: Hermes holds a per-order tax figure for 646 of 295,424 orders — 0.2% (nothing has ever written tax_cents; it is declared in the migration list and written by no code path). The slide now states the sample size, the blended rate, and the exempt count out loud, and admits the average is wrong in both directions on any single order. 👻 THE GHOST OWNER — a new value accepted by an old predicate. v2.65.0 taught the Gorgias normalizer to write '' when a ticket is DELIBERATELY unassigned. IS NOT NULL is TRUE for '', so three untaught SQL predicates read an unassigned ticket as a human owner; it won the MAX(opened_at) latest-owner race, was provisioned as an app_users row with a blank email, and collected customers. Measured on production: ghost u_7c0e1aec…, created 2026-08-20 10:02:42 — the day v2.65.0 shipped — holding 630 real customers, the 8th-largest book on the tenant, with 2,399 tickets carrying the sentinel ready to make more. Fixed in human(), which is applied to BOTH the row and the MAX subquery, so one change covers both; the agent-performance report loses its blank-named row; and auth.createUser now REFUSES an empty email outright so no future caller can mint another. ✅ NOTHING WAS LOST, AND IT IS PROVEN RATHER THAN HOPED. 757 customers have the sentinel as their newest assignee-bearing ticket: 592 on the ghost, 8 unassigned, and 157 still on a real human. Had resync=true ever run since the ghost appeared, all 757 would be on the ghost — those 157 are the proof it did not, so only the fill-NULLs-only mode ran and the 592 were already unassigned before the bug. Restoring them to unassigned is exact, not lossy. ⚠️ There is no audit trail either way: customer_activity holds 16 rows, none about assignment, so owners are RE-DERIVABLE and never RECOVERABLE — worth knowing before the next incident. The repair itself is staged, not run, and awaits Tyler. ✅ Tests 438/438 (+10), every new one mutation-checked: restore AND tax_cents > 0 and the rate test goes red; drop <> '' from human() and the owner test goes red. ⚠️ The cost_basis cache is versioned and held a DAY — v was bumped 1→2 deliberately, because leaving it would have served the old biased rate for up to 24h after deploy and left the new sample fields undefined. There is a test for that too..catch for the endless “Crunching last 365 days…”. That was half the story and the less important half. /api/report-summary CAUGHT EVERY EXCEPTION AND ANSWERED 200. } catch { d = null; } if (!d) return json(200, { fulfillment: null, days }); — while /report/delivery, fed by the SAME gather and throwing the SAME ReferenceError, 500’d loudly. The browser’s api() helper only throws on !r.ok, so the client error branch I added in v2.66.4 was unreachable code. Hardening the client while the server still answered 200 would have left the tile spinning exactly as before. WHY 200 WAS PROVABLY THE WRONG ANSWER: gatherDeliveryData has exactly two exits — the cache hit at :132 and return out at :807 — and both are unconditionally truthy; a tenant with zero shipments still receives a full object. So a falsy result means an exception was thrown and nothing else, which makes {fulfillment:null} an error signal dressed as success. It now returns 500 with the real message, and the tile prints it. Belt and braces on the client too: a window that loads and comes back empty now says so (“that is an answer, not a wait”) instead of falling through to the loading string. 🧮 ALSO FIXED — the mirror of the crash, in the same feature. orphanDisputes was declared, carried onto the payload and printed in the footnote, and never incremented: the if (m) on the dispute fold had no else while its twin on the lost fold did. merged comes from a query carrying HAVING COUNT() >= 5 while the dispute pull has no such filter, so every dispute on a small carrier had nowhere to land — and the page told Flexport “and 0 disputes” as a fact. Production had been reporting orphan_disputes: 0 on every window, which is how it was caught. ⚠️ AND A CORRECTION TO THE AUDIT ITSELF. Its top finding claimed every shared link 500s because /api/admin/migrate never creates report_grants (the SCHEMA splitter strips -- comments but not / /, so both tables added this release begin with / and are filtered out). The MECHANISM is real and worth fixing — but the claimed consequence is not live: both tables exist in production, because they were applied by hand via wrangler d1 execute exactly as STATE documents. Checked before acting rather than after. Filed as a latent defect for the next fresh tenant, not a fire. ✅ Tests 428/428. The new one monkeypatches the gather to throw and asserts the route answers 500 carrying the message — a test that would have failed on every build since the route was written.days or from/to for a grant holder, and never did. It was that every link on the page rebuilds the URL and all of them dropped ?k=: the four window buttons, both export links, and the custom-date form (a GET form with no hidden field, so Apply threw the token away too). So the grant authorised precisely the one URL the viewer was sent, and the first navigation returned {"error":"admin only"}. A credential that survives only until the first click is not a working share. Fixed by threading the token through all seven surfaces — and only ever ECHOING a token the caller already presented, never minting or inferring one, so an admin (who travels on their session) still has no token printed anywhere in their own navigation. 🧮 AND THE MIRROR OF LAST NIGHT'S CRASH, in the same feature. orphanDisputes was declared, carried onto the payload and printed in the footnote — and never once incremented, because the if (m) on the dispute fold had no else while its twin on the lost fold did. The same botched patch produced both halves: v2.66.2 shipped an increment with no declaration (the crash), and a declaration with no increment (a wrong number). It matters because merged is built from a query carrying HAVING COUNT(*) >= 5 while the dispute pull has no such filter — so every dispute on a small carrier had nowhere to land, and the page told Flexport "and 0 disputes" as a fact. Worse in the inverse case: orphan disputes with zero orphan lost parcels rendered no footnote at all, making the blind spot invisible rather than stated. ✅ VERIFIED AGAINST PRODUCTION, not just seed data. The cron warm recomputed on the fixed build at 06:51 and the cached payloads read orphan_lost = 3 (30d), 4 (60d), 4 (90d) — matching an independent D1 count exactly — while the two windows still holding pre-v2.66.2 caches read null, which is the half-ship proved from data rather than from source. The same cache table dated the outage precisely: every window last computed 04:30–05:30, v2.66.2 deployed 05:47, so the report was down on every window for ~65 minutes. ✅ Tests 427/427 (+10 this evening), every new one mutation-checked: strip kAmp and the link test goes red; delete the else and the dispute test goes red. ⚠️ Two of my own assertions were wrong first and corrected before shipping — a NaN check that matched the page's own isNaN(, and a token-leak check that flagged the share panel legitimately building copy-links. Audit your own finding before reporting it applies to tests as much as to claims.{"error":"orphanLost is not defined"}. MY BUG, shipped in v2.66.2's own phantom-row FIX. The patch that removed phantom rows added else orphanLost += 1; and its sibling declaration for orphanDisputes — but the declaration for orphanLost itself never landed. A regex-substitution patch script applied two of its three edits. WHY IT SHIPPED GREEN, and this is the whole lesson: the crashing branch is DATA-DEPENDENT. It fires only when a LOST parcel belongs to a carrier absent from the carrier table — and that table carries HAVING COUNT() >= 5, so any carrier with 1–4 deliveries in the window qualifies. That is not exotic, it is near-certain over a year. It simply did not occur in the 30-day window I verified by hand, so the fix I shipped to stop phantom rows became the crash that stopped the page. ⚠ THE REAL FINDING IS THE TEST SUITE, NOT THE TYPO. 417 tests passed on a build whose main report threw a ReferenceError on load, because nothing had ever executed the report end to end. test/report-share.test.js has a case literally named "a live grant opens the report" — it proves the grant TOKEN resolves and never fetches the report. The new test/report-render.test.js does the dumbest possible thing instead: run the real route against a real seeded database and demand a 200 with a page in it. Proven by mutation: delete the declaration and all five new tests go red — including the plain "renders at all" case, because the SEED DATA already contains an orphan lost parcel. The most trivial test imaginable would have caught this the day it shipped. 🔍 SECOND DEFECT, found while fixing the first: v2.66.2's footnote was only half-shipped. The payload never set orphan_lost/orphan_disputes while the template read d.orphan_lost — so the "stated blind spot" that entry claimed to have delivered could never have rendered even once, crash or no crash. A field the UI reads that the payload never sets fails silently by design. Now wired, and asserted by its own test. 🔇 THIRD DEFECT — the tile that spins forever, and it is the house rule verbatim. .catch(()=>{ setSum({}) }) swallowed the error and fell back to the loading string, so a 500 rendered identically to "still working". api() already throws with the server's own message; the tile was discarding it. It now shows "This report failed to load"* with the real error text. A step that cannot do its job must fail loudly, not degrade into a spinner — every instance of this in this project has been hidden for weeks by exactly that shape. ✅ Tests 422/422 (417 + 5). Also corrected on the way past: the first version of the empty-window test "found" a NaN bug that was the page's own inline isNaN( matching a naive regex — it now asserts non-finite numbers on the PAYLOAD, where the claim is actually checkable. Verifying your own finding before reporting it is part of the job.carrierNorm records the ruling verbatim and ends with the rule that matters more than the list: if a new pair appears, ASK — do not extend this from a hunch. Tests 417/417.GROUP BY carrier resolves against the FROM clause first, and orders HAS a real column called carrier — so the grouping bound to the RAW spelling and carrierNorm() only relabelled rows afterwards. The probe is decisive: SELECT 'CONSTANT' AS carrier … GROUP BY carrier returns 15 rows, not 1. The HAVING predicate had the same defect and was excluding NULLs only by the accident of NULL <> 'x' being NULL. The verifier then corrected the auditor honestly, which is worth recording: on the 365-day view the rendered table is IDENTICAL with or without the fold, because both split carriers are the FASTEST and the sort is worst-first, so they sit below the cut either way. The damage was on the windows nobody tested — 30d rendered BETTER TRUCKS 407 against a true 589, and UNITED DELIVERY SERVICE 252 against 359, roughly 30% of each carrier's parcels dropped, with rows #12/#13 tied at 3.4d so which spelling survived was non-deterministic between runs. Fixed by grouping on the expression. Verified on production, 30-day window: BETTER TRUCKS now 589, UNITED DELIVERY SERVICE now 365, 13 rows, nothing truncated. ⛔ THE LIMIT IS GONE, AND IT WAS THE MOST DANGEROUS LINE ON THE PAGE. LIMIT 12 sat under ORDER BY avg_days DESC — worst-first — so the cut always deleted the FASTEST carriers. Between 9,049 and 14,328 parcels (7–11% of the window) were missing from a table captioned as every carrier with 5+ deliveries, and the ones removed were precisely the carriers that make the numbers look good. In front of a vendor who can check their own volumes, a silent truncation that flatters them is the worst possible shape of error. There are only ~16 carriers; they all show now. 👻 AND THE PHANTOM ROWS TYLER SPOTTED HAVE A MECHANISM. "The unknown row makes no sense… And the flexport row doesn't make any sense either." When LIMIT deleted a carrier's deliveries, the lost-parcel fold re-created the row with deliveries: 0 and the CS fold then poured complaints into it — which is how a working carrier (UNITED DELIVERY SERVICE, 5,333 parcels at 4.2d) rendered as "0 deliveries · 0d · 4 lost · 24 never received", an infinite failure rate, and how the FLEXPORT row survived being renamed NOT RECORDED. A carrier with nothing delivered is not a row in a delivery PERFORMANCE table. Those parcels and disputes are now counted in a footnote that says so out loud rather than silently dropped — a stated blind spot beats both a phantom row and a quietly smaller total. 📉 THE TREND CHART WAS SHOWING A DRAMATIC IMPROVEMENT THAT IS PURE ARITHMETIC. It buckets by placed_at but counts only orders that ALREADY have a delivered_at, so the newest buckets contain only the parcels fast enough to have landed. Measured: the final weekly point was 99 parcels at 3.03d against a 5.46d steady state, with the daily tail running 5.3 → 4.9 → 4.5 → 3.8 → 3.1 → 2.6. And the page auto-scrolled straight to that edge on load. The chart now opens at the beginning so the shape is read whole. ✅ WHAT THE AUDIT CLEARED, worth recording so nobody re-checks it: the trend merge is NOT averaging averages — foldBuckets weights by bucket volume, the monthly buckets sum exactly to the headline (11 buckets → 131,663), and the rounding costs 0.0007 days. The cohort split reconciles exactly (26,618 + 105,045 = 131,663). julianday() and strftime() DO parse the mixed ± offsets, so TRANSIT itself is correct and the string bounds cost 3–11 rows at each edge. 📋 FILED, NOT FIXED — the reconciliation tier, deliberately deferred rather than rushed: the claims tiles do not sum (168 submitted vs 92 + 73 + 0 = 165, with a RETRACTED bucket unaccounted); two different "Flexport paid us" figures appear on one page ($141 on the tiles, $31 on the money slide) because claims window on created_at while everything else windows on placed_at; the aging tile reads 114 on both the 30-day and 365-day views because it is as-of-today and never says so; ?days=365 actually covers 294 days thanks to the scope floor and the page never prints its real start date; the PowerPoint truncates the trend to 20 weeks while carrying the full window's label. Tests 417/417 throughout.SHIPBOB, found 57 rows all safely below the floor, and declared the report clean. ShipBob is the FULFILLER, not a carrier — it shipped through FEDEX and AMAZON LOGISTICS US, and Tyler spotted exactly that in front of the fulfilment team. This is the project's own standing lesson ("an empty result to the wrong question looks exactly like an empty result to the right one") and I walked into it anyway. THE ACTUAL ROOT CAUSE — THE FLOOR WAS THE WRONG DATE. flexportCutover is the oldest order carrying a Flexport id (2025-10-31) = when Flexport STARTED. But ShipBob kept shipping alongside it until 2025-11-21T23:33:55Z, so a floor set at the Flexport start admits ~3 weeks of the old warehouse. Measured: 345 orders on ShipBob-only carriers were sitting past it. The tell is unmistakable once you look at eras rather than names — FEDEX (17,309 lifetime) and AMAZON LOGISTICS US (17,308) are both stone dead since November, ZERO since December, while VEHO, ONTRAC, USPS, UPS and DHL all span BOTH eras, so no carrier name alone can separate them — only the date can. THE FIX KEEPS THE OVERLAP INSTEAD OF THROWING IT AWAY: scope is now flexport_order_id IS NOT NULL OR placed_at >= 2025-11-22. All 345 leaking orders have flexport_order_id IS NULL, so this excludes every one of them while KEEPING the 13,001 genuine Flexport orders placed during the overlap — which naively moving the floor to November would have discarded. ShipBob is gone for good, so the date is a historical constant. Verified against production: ZERO rows for FEDEX / AMAZON LOGISTICS US / SHIPBOB under the new scope. 🚛 AND THE CARRIER TABLE ITSELF WAS SPLITTING REAL CARRIERS IN HALF. Tyler: "The unknown row makes no sense. We know who every single carrier is. And the flexport row doesn't make any sense either. They don't ship anything out directly." Both true. BETTERTRUCKS, DHL and LASERSHIP each appear ONLY between 2026-07-10 and 2026-08-11, while BETTER TRUCKS, DHL ECOMMERCE and ONTRAC run continuously either side — that is not four carriers arriving and leaving together, it is one month of some ingest path writing names in a different format. A single carrierNorm expression now folds the certain pairs (BETTERTRUCKS→BETTER TRUCKS, UDS→UNITED DELIVERY SERVICE) at all nine grouping sites, and a null carrier or the literal FLEXPORT becomes NOT RECORDED and is excluded from the ranked performance table rather than ranked inside it. ⚠️ DHL vs DHL ECOMMERCE and LASERSHIP vs ONTRAC are deliberately NOT folded — DHL Express is genuinely a different service from DHL eCommerce, and LaserShip merged INTO OnTrac rather than being a typo for it. Silently merging two real carriers' performance is a worse error than showing them apart, so those two await Tyler's word. 🧪 AND THE SHARE PANEL FROM v2.66.0 NEVER WORKED — it did not PARSE. A backslash-escaped quote cannot survive being nested inside two template literals: onclick='f(\"x\")' reached the browser as onclick='f("")', an empty string that broke the concatenation, so the entire script failed to parse, rgLoad was never defined, the list sat on "loading…" forever and the button did nothing. Two symptoms, one cause. Rebuilt with no inline handlers and no escapes at all — buttons carry an index and listeners attach after render, which makes the whole class of bug structurally impossible rather than merely fixed. THE REAL FAILURE WAS THE TEST GAP, and it is closed: every unit test passed because nothing ever rendered the page and asked a JS engine whether the output was valid JavaScript. It does now — new Function(script) against a real render, plus assertions that no onclick= and no escaped double quote survive into the panel. A panel that cannot parse now fails CI. Tests 415 → 417..xlsx / .pptx), and /report/delivery/msg — the conversation drill-down. Grepping the rendered page for runtime calls returns exactly ONE fetch(), so there was nothing else to unlock. And the claim photos need no work at all: dlv_imgs holds direct uploads.gorgias.io URLs, which load straight from the browser with no Hermes auth in the path. HOW IT WORKS: a new report_grants table, one row per person — never one shared link for everyone, because the entire point is that a single person can be cut off without disturbing anyone else. The token rides as ?k= and the page propagates it into the drill-down fetch itself (a shared viewer has no session, so without that the conversation popup would 403 for exactly the people the share exists to serve). Admins see a Share panel on the report — add an email, optional expiry, copy link, revoke — and a shared viewer does not receive that markup or its script at all. ⚠️ SAYING PLAINLY WHAT THIS GRANTS, because it is real customer data: the drill-down opens customer conversation transcripts and their photos. That is precisely what the team needs to argue a claim, and it is still PII. So the design leans entirely on containment rather than on hope — read-only by construction (there is no write path on any of the four routes), checked INSIDE each route and never in the global /api/ gate so it cannot widen by accident, revocable instantly, optionally dated, and every view stamps last_seen_at + seen_count so a link that travels further than intended becomes VISIBLE rather than merely theoretical. A link is a bearer credential — whoever holds it is the holder — and no schema changes that, which is exactly why the surface it opens is kept this small. The panel says so on screen too. VERIFIED: tests 406 → 415. The nine new ones are deliberately mostly REFUSALS, because the refusals are the feature: revoked is dead immediately · expired is dead · a grant is scoped to its tenant AND its report · a guess never reaches the database · no token still 403s exactly as before. The load-bearing one is 🔒 THE BOUNDARY — it asserts a live token opens the report AND the claims drill-down, then asserts it does NOT open /api/customers-dashboard, /api/discounts, /api/pipeline or /api/report/grants. If any of those ever returns 200, the token has quietly become a general Hermes login. A separate test proves a viewer cannot mint or revoke access and hand the report onward. Panel scoping verified by rendering both ways: the admin page carries the controls, the viewer page carries the drill-down and the token propagation and none of the share markup or script.scopeSql ← flexportCutover, the oldest order carrying a Flexport id = 2025-10-31T10:15:35-04:00) admits nothing placed earlier, and every carrier=SHIPBOB order — 57 of them, 2025-07-19 → 2025-10-26 — predates it. Proven by running the report's OWN predicate for 365d grouped by carrier: zero SHIPBOB rows, and all 20 FROM orders o sites verified FX-scoped. WHAT HE ACTUALLY SAW was the PROVENANCE line, which on 365d rendered "134,877 Flexport · 3,223 other fulfilment in this window." That second bucket is not a second warehouse — it is post-cutover orders whose Flexport ID has not been backfilled, averaging 5.73d transit against Flexport's 5.68d. Five hundredths of a day apart is one operation with an incomplete ID stamp, but the word fulfilment implied a vendor, in front of the people whose vendor it is. The line now reads "138,100 orders, all placed since the warehouse changeover · 3,223 of them are still awaiting a Flexport reference number (same operation — the ID backfill has not reached them yet)". The number stays visible on purpose: a stated blind spot beats a confident silence. AND THE ONE PLACE THE WORD ACTUALLY RENDERED: the customer-profile delivery view drew a "ShipBob era" stat tile (web/hermes.html:3690-3697) whenever a customer's history reached past the changeover — a dead vendor's name on a performance screen, and quietly misleading too, since those legacy parcels average 9.9d against today's ~5.3d and read as a carrier problem when they were just history. Tiles removed; the era field still computes (the per-order tracker uses the same idea) but no longer gets a surface. VERIFIED: tests 406/406; the 365-day report re-rendered and grepped — "ShipBob", "SHIPBOB", "shipbob" and "other fulfilment" are all absent from the output. ⛔ NOT TOUCHED, deliberately: the per-order tracker era label (server/api.js:3970) — single-order context on an individual old order, not a performance report; flagged rather than widening scope unasked.TILE_CHANNELS is back to chat/contact_form/email and the Social DMs tile is off the board. ⚠️ THE COST IS KNOWN AND ACCEPTED: 5,729 open social tickets are once again on no tile. Recorded, not forgotten. WHAT WAS KEPT DELIBERATELY: SOCIAL_CHANNELS and the socialOnly branch in tileWhere survive as a documented restore hook, and the note on TILE_CHANNELS carries every measurement from the day it shipped (open social 5,733 · unowned 5,725 · unowned+awaiting-agent 2,512 · all carrying new message · only 67 touched in the prior 7 days) so the revisit is a re-add rather than a re-investigation. The tests were INVERTED, not deleted, and say plainly in their header that the shelved state is a DECISION — because yesterday's lesson was that a test can pin a bug as a feature, and the cure for that is not fewer tests but tests that explain themselves: "a failing test after a deliberate change is a question, not an instruction to revert." The iOS safe-area half of v2.64.0 is untouched and stays. ✅ AND v2.63.0 IS NOW FULLY VERIFIED — the webhook payload is OBSERVED, not inferred. Tyler unsubscribed his own number as a live test. 5 deliveries on customers_marketing_consent/update, ALL status ok, ZERO parsed as sms=unknown — the failure signature that was being watched for — covering both directions: 4 unsubscribes (his at 13:36:50, logged sms=unsubscribed (changed)) and 1 genuine new subscribe at 12:26:54. The REST payload shape guessed at in v2.63.0 was right. His row now reads sms_opt_in=0 with sms_opt_in_at preserved at 2025-07-30 — exactly the designed opt-out behaviour — while the new subscriber reads sms_opt_in=1 stamped 2026-08-20T08:26:52-04:00, i.e. genuinely payable at cutover. Population moved 22,116 → 22,114 (−3, +1); payable-if-flipped-now still 0. ⚠️ AND A TRAP THAT DID NOT BITE, BUT WOULD HAVE: Shopify sends this timestamp with a numeric offset (-04:00) here and Z elsewhere — the same mixed-format hazard that makes any raw substr analysis of orders.placed_at silently wrong. The rule is safe only because BOTH sides run through datetime(). Verified: datetime('2026-08-20T08:26:52-04:00') → 2026-08-20 12:26:52 UTC, and 0 unparseable dates across all 22k stamped rows. VERIFIED: tests 410 → 406 (the four social-surfacing assertions became two shelved-state ones plus collateral-damage checks that email, chat and contact_form still reach Urgent). Production after deploy: Urgent back to 7, social matching no tile.reply_attempts table recording every attempt, sent or not — ticket, Gorgias id, customer, actor, channel, outcome, Gorgias's own delivery verdict, and the error. Its own table deliberately: customer_activity no-ops without a customer_id and 5,430 open social tickets have none, and webhook_events is the INBOUND log whose failure rate is a health metric — outbound rows there would corrupt the daily checks in note_1600. Recording is best-effort (a reply must never fail because we could not write the note about it) and the catch re-throws, so the toast an agent sees is byte-for-byte what it was: this adds a record, it does not change UX. (2) THE PINNED-SENDER GUARD COULD NOT FIRE ON 93% OF REPLIES. assertPinnedSender's non-email branch checked only !source.to?.[0] — but source.to is built as [inbound.source.from], and the .find() that picks the inbound already requires m.source.from to be truthy. The guard was unreachable by construction. Meanwhile the field that is genuinely unvalidated is the SENDER: from: inbound.source.to?.[0] || undefined. If a platform inbound ever arrives without source.to, JSON.stringify DROPS the key and Gorgias stores a message with no sender — accepted, never delivered, precisely the silent-non-delivery shape note_1601 documents, on the branch carrying ~93% of reply traffic. It now refuses loudly, per the house rule "no inbound to mirror = loud refusal, never an undeliverable accept", and the test asserts nothing was posted — refusing after sending would be no fix at all. (3) UN-ASSIGNMENT COULD NOT BE EXPRESSED. upsertTicket writes assignee_email=COALESCE(?,assignee_email), so null means "leave it alone" — which made a DELIBERATE un-assignment in Gorgias indistinguishable from a slim payload that merely omitted the field. A ticket taken off an agent stayed on that agent's member tiles forever. Now three-way, copying the pattern trashed and from_agent already use two lines below: field absent → null (keep) · explicitly null → '' (clear; the tile predicates already read '' as unowned) · present without an email → null, because that is a shape we do not understand and guessing would silently unassign somebody. Assignee earns the extra branch because it is the one nullable field here that gates who can SEE a ticket. VERIFIED: tests 403 → 410. The new ones cover the refusal (and that nothing is posted), the normal mirror still sending, all four assignee cases, and the family-consistency check against trashed/from_agent; the existing HTTP reply test now also asserts a reply_attempts row with outcome='sent' from Gorgias's probe rather than from the 201. ⚠️ A JS backtick inside the SQL template literal silently terminated SCHEMA and broke every test file at once — worth knowing, because the symptom (all 33 suites failing) looks nothing like the cause. ⛔ STILL OPEN AND DELIBERATELY NOT TOUCHED: the Urgent 7 → 195 decision from v2.64.0 is Tyler's call and was not pre-empted; the mobile PASTE complaint remains unexplained (two hypotheses raised, both falsified) and must be re-asked on the current build rather than assumed fixed; and no SMS-consent webhook has been DELIVERED yet, so v2.63.0's payload parser is still inferred rather than observed.TILE_CHANNELS was ['chat','contact_form','email'], and every tile that can hold an inbound ticket carries channels: true. So 3,721 Facebook Messenger and 2,012 Instagram tickets were ingested correctly, stayed open in Gorgias, and could satisfy NO tile predicate in this app — 42% of all open tickets, reachable on no screen. Nothing errored. That is why it survived for months: a ticket matching zero tiles looks exactly like no ticket at all. ⚠️ ADDING THE CHANNELS WAS ONLY HALF THE FIX, and measuring first is what caught it. Every social ticket carries the new message tag (3,718/3,721 and 2,012/2,012) and 5,725 of them are unowned — and neither fact finds a home: unread wants that tag but is member-gated, unassigned accepts only the newish/urgent tags. The channel change alone would have surfaced just the ~549 carrying a cancel/address tag and left ~5,180 exactly as invisible as before — a fix that looks complete and is not. So there is a new Social DMs tile: teamWide (gating it would re-hide what we just surfaced), social-channels-only, unowned, awaiting-an-agent, newest-first. Production count: 2,512. It is deliberately its own tile rather than poured into Unassigned — Unassigned is leadOnly and oldest-first, so 2,512 conversations would have buried the handful of ownerless cancellations it exists to catch, and hidden them from every agent who is not a lead. Owned social tickets are NOT in it; the channel fix routes those to their owner's normal tiles, which is where they belong. 🔴 THE NUMBER TYLER NEEDS TO SEE: the Urgent tile goes 7 → 195. Those 188 are genuine cancellation and address-change requests made over Messenger and Instagram that no one has answered — real money, ignored because the app could not display them. They are correct to surface, but they change Urgent's character from a few live deadlines to a backlog, and most are old (only 67 social tickets of any kind were touched in the last 7 days). This is a one-line reversal if the team would rather Urgent stayed clean and social cancels lived only in Social DMs — every one of the 188 is unowned, so all 188 already appear in Social DMs regardless and nothing would be lost by excluding them from Urgent. Flagged rather than decided, because it is a workflow call about how the team works a queue, not a technical one. 📱 AND THE iOS HEADER, same deploy, same team, same complaint thread: .topband applied env(safe-area-inset-left/right) and no safe-area-inset-top, while index-compiled deliberately draws the app under the status bar (apple-mobile-web-app-status-bar-style=black-translucent + viewport-fit=cover). On a 430x932 iPhone the entire header — Back, the search field, the avatar — sat inside the ~59px inset with the Dynamic Island over the middle of the search box: "the search bar area is to high on the screen and unusable due to it being covered by my time and date." Only two elements in the whole app handled the top inset and neither was the header. Fixed on .topband (both breakpoints) and on the ≤640px full-bleed .modalcard, whose sticky close ✕ sat at roughly y 20–52 for the same reason. It propagates for free — --bandh is measured under a ResizeObserver, so .sheet and .omnipanel follow the taller band. ⚠️ The PASTE half of that report is NOT fixed and is NOT explained — two hypotheses were investigated and both falsified (the Omnibar is a normal controlled input on onChange, which iOS paste does fire; and the user-select:none label rule was tested empirically and does not block paste, since user-select is not inherited and editable elements are exempt). It may simply have been unreachable under the Island. Re-ask before claiming it. VERIFIED: tests 393 → 403, including a guard asserting an unowned social DM matches at least one tile — the silent-failure mode this whole change exists to kill. Measured read-only at production scale before shipping: Social DMs 2,512 · Urgent 7 → 195. ⚠️ One existing test had pinned the bug as a feature — connect.test.js asserted "closed + social-channel cancels excluded" with an Instagram cancellation in its fixture. It was inverted rather than deleted, and its comment now records why.CUSTOMERS_MARKETING_CONSENT_UPDATE created on https://hermes.appolis.app/webhooks/shopify/t_verdant, re-listed afterwards to confirm 6 topics now live on that URL. Registered through Hermes's OWN app credentials deliberately — a subscription created by any other app (an MCP connector, say) would deliver HMAC signatures Hermes rejects. BACKFILLED 22,116 SUBSCRIBERS. Walked 313 pages of Shopify customers holding a phone number and wrote consent into customers. The walk independently reproduced the segment counts exactly — SUBSCRIBED 22,116 / UNSUBSCRIBED 1,423 / NOT_SUBSCRIBED 23,250 — via a completely different query path than the one that produced them, which is the confirmation that matters. D1 after: sms_opt_in=1 on 22,116, all 22,116 stamped, 0 opted-in-without-a-date. Earliest consent 2024-12-11, latest 2026-08-20T04:37:36Z — and the vendor awarded its most recent "Sign Up to SMS" at 04:38:00Z, one minute later, so our backfilled timestamps line up with Rivo's own award clock to the minute. UNSUBSCRIBED and NOT_SUBSCRIBED were deliberately left alone: their consentUpdatedAt is an unsubscribe time, not an opt-in, and stamping it would have been a lie. Safe to re-run — every write is COALESCE(sms_opt_in_at, …). Index checked BEFORE writing 22k rows: idx_customers_shopify(tenant_id, shopify_customer_id) exists, so each UPDATE is an index seek rather than a 157k-row scan — the same discipline that the alt_emails incident taught. ✏️ CORRECTING MY OWN NUMBER, because the backfill made it measurable. v2.63.0 and its commit message say the unbounded rule would have paid "22,116 × 150 = 3,317,400 points, about $33,000". That was an ESTIMATE and it was ~46% too high: the rule also requires rivo_customer_id IS NOT NULL, and only 15,191 of the 22,116 subscribers are linked members. Measured in production now that consent is real: unbounded → 15,191 members = 2,278,650 points = $22,787. The lesson is unchanged and the guard is unchanged — but a "do not redo" record carrying an inflated number is exactly the decay this project keeps getting bitten by, so the measured figure replaces the estimate. ⚠️ AND THE GUARD IS NOW GENUINELY LOAD-BEARING. Before the backfill sms_opt_in was 0 everywhere, so the unbounded rule was harmless by accident. It is not any more: 15,191 linked members are opted in TODAY, and the ONLY thing between them and a payout is the sms_opt_in_at >= flipped_at bound. Measured against production: unbounded 15,191 · flipped 2026-08-01 → 38 · flipped now → 0. Query reads 289,683 rows in 204ms; the live rule carries LIMIT and is narrower. ⛔ STILL UNVERIFIED, SAY IT PLAINLY: no consent webhook has been DELIVERED yet — they only fire on change, and organic opt-ins run ~48/month, so one is expected within a day. The payload body shape is therefore still INFERRED from Shopify's REST customer schema, not observed. The parser accepts both the REST (state/consent_updated_at) and GraphQL (marketingState/consentUpdatedAt) shapes precisely so that either is handled, but the first live delivery must be checked against webhook_events before this is called done. Klaviyo's half of the consent reconciler is deliberately NOT built — vendor choice is reopened.SUBSCRIBED; the exact figure via customerSegmentMembers is 22,116 SUBSCRIBED / 1,423 UNSUBSCRIBED / 23,250 NOT_SUBSCRIBED out of 152,075. Meanwhile customers.sms_opt_in read 0 on 100% of 157,758 rows, and the sms_signup reward — 150 points — matched nobody. WHY THE FLAG WAS DEAD: grep -rniE "smsMarketingConsent|sms_marketing|accepts_marketing|marketingState" server/ returned nothing. Hermes never read the field, because Shopify REMOVED sms_marketing_consent from the customers/update payload in API version 2025-01 and CUSTOMERS_UPDATE was our only customer topic. From that version on we were blind to every opt-in and opt-out, and the only symptom was a column that stayed zero. THE FIX IS TWO HALVES, AND ONE HALF ALONE IS WORSE THAN NEITHER: register CUSTOMERS_MARKETING_CONSENT_UPDATE, and give it its own dispatch branch — its header topic is customers_marketing_consent/update, which does not start with customers/, so registering it without the branch would have produced a webhook that registers cleanly at Shopify, returns 200 forever, and silently does nothing. That is the failure shape that hides for months, so there is a test asserting the handler does not return ignored topic. ⚠️ AND THE GUARD, WHICH IS THE REAL CONTENT OF THIS VERSION. The sms_signup rule matched the standing state (sms_opt_in=1) with no date bound — the identical defect fixed on first_subscription_renewal at v2.62.1, still sitting in the tree, and harmless only by accident because nothing ever wrote the flag. Wiring the feed in without bounding the rule would have paid every subscribed customer at once: 22,116 × 150 = 3,317,400 points, about $33,000, drained 200 at a time on a 30-minute cron over ~2.3 days — so it would not even have looked like a spike. The vendor has paid this reward to 184 members in total, ever (custom_action='Sign Up to SMS', 27,600 pts, first 2026-06-04, most recent 2026-08-20T04:38Z — it is still paying). A ~120× overshoot. THE GUARD IS SHOPIFY'S CLOCK, NOT OURS. sms_opt_in_at stores Shopify's own consent_updated_at, and the rule requires it to be >= flipped_at. This distinction is the whole thing: our clock would have answered "did we first HEAR about this after the cutover?" — and 97% of the subscriber base is a bulk import dated 2025-07-30T15:35Z, so the first consent event to touch any of those rows would have stamped today and made them all eligible. Shopify's timestamp puts them where they belong, before the flip. Three further defences: it fails closed (no date → not eligible, never silently eligible today); it COALESCEs rather than overwrites, so a redelivery or a later no-op touch can never drag an existing opt-in date forward into the payable window; and an opt-out clears the flag but keeps the date, because "once ever" is enforced by the ledger row, not by this column. The payload is parsed in both the REST webhook shape (state, consent_updated_at) and the GraphQL shape (marketingState, consentUpdatedAt) — the two Shopify surfaces disagree and the live webhook body had not been observed yet, so guessing at one was not worth a silent no-op. VERIFIED: tests 384 → 393, the nine new ones covering the $33k guard, fail-closed, redelivery, opt-out, both payload shapes, dispatch-not-ignored, and topic registration. Measured read-only at production scale before shipping: 157,758 customers, sms_opt_in=1 on 0 of them, so the change lands inert — and flatRulesSweep is separately gated on flipped_at, which is not set. Two existing tests were updated rather than deleted: they seeded an undated opt-in and now correctly earn nothing, which is the guard doing its job. NOT YET DONE, DELIBERATELY: the webhook still has to be registered (POST /api/integrations/shopify/webhooks) — deploying does not register it — and the first live delivery should be checked against the parser, since the exact body shape is inferred rather than observed. And the 22,116 existing subscribers are still sms_opt_in=0, so Hermes cannot yet gate a send on them; a backfill from Shopify's records (carrying each customer's real consent_updated_at, which keeps them all pre-flip and therefore unpayable) is the next step.subscription_milestone IS the first-subscription-renewal reward. Its shape matched what I built — 9,929 awards across 9,929 distinct members, never more than one each — so the rule was right. THE POPULATION WAS NOT. My detection ("has ≥2 Recharge-billed orders") matches 34,758 members, 3.5× the vendor's 9,929, because Rivo's awards only begin 2026-06-25: it paid whoever qualified when the rule was switched on and has paid forward since. A retroactive rule would therefore have paid ~25,000 people who renewed long ago and were never awarded — ~12.4M points, about $124,000 — to customers who never knew they were owed it. That is precisely what Tyler ruled against on 2026-08-17 ("nobody needs to get awarded points that they don't know about... it will just cause problems"), and the same reasoning that made the flip waive the make-goods rather than pay 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 PAID — flatRulesSweep 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).
FLAT_RULES was a constants table with no caller — the rewards page promised birthdays, reviews, SMS signup and social follows, and nothing in the app awarded any of them. Measured against the vendor's own feed, Rivo never awarded review either: zero events, ever — a promise neither system was keeping. ⚠️ CORRECTION, 2026-08-19: this entry originally said the same of sms_signup. That was wrong. Rivo files that reward under source='custom_action' with custom_action='Sign Up to SMS', not under source='sms_signup' — 183 members paid at 150 points each, 37 of them this month. It is live and earning today, and because nothing populates our sms_opt_in column it will stop paying at the flip. Filed as a cutover blocker (todo_1730). ⚠️ 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.
getLoyalty() could only ever return the Rivo adapter, so EVERY points movement asked the vendor — redeem, return-for-points, agent adjustment — even after the flip. The flip set flags that changed nothing about who actually moved the points. So the hour Rivo's key lapsed, all of it failed, and two paths deleted the customer's Shopify code BEFORE the failing call. No flag could fix that; only a second provider could. This was the blocking dependency for the whole cutover list. 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 TODAY — flipped_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.
POST /api/loyalty/flip was one request away from firing: the readiness gate is GREEN today, any of the four admins could send confirm:'FLIP', and force:true skipped every check — while the source comment claimed "force overrides, on Tyler only", which the code did not enforce at all. It is genuinely one-way: nothing in this codebase can clear flipped_at or un-waive a make-good. TWO CATCHES, both deliberately awkward. (a) You must NAME the liability — acknowledge_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-held — rankOf() 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.
APP_KEY and STUDIO_KEY move from vars to Wrangler secrets (no version bump — only where the credentials live changed; nothing observable). 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.
/api/my-connector now forwards the person's own signed appolis_id cookie to Appolis. The connector() helper in chardizy/worker.js takes a second idToken argument, passed from server/api.js using the headers.cookie regex already used there. 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.
/webhooks/rivo/:tenant and /webhooks/flexport/:tenant accepted unauthenticated writes from the public internet, because tokenGate fails open when no webhook_token is configured and production had none on either. 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 COLUMN — orders.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.
/internal/mcp and /internal/overview are gated on the shared ID_SECRET and then take the SUBJECT from an unsigned header/query (x-appolis-email, ?email=), so holding the transport secret meant acting as anybody. That secret is also the HMAC key for every appolis_id SSO cookie, its plaintext sits in three repos' git history, and appolis/ROTATION_RUNBOOK.md already treats it as burned — so "holds ID_SECRET" is not "is a trusted caller". Worse, these doors are reachable from the open internet, not only over a service binding. 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.db — 68,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).
executeFlip credited every accrued shortfall automatically in one unattended pass, and flipped_at refuses to run twice — a single irreversible payout of 106 rows / 28,871 points nobody had asked for. Now: one UPDATE stamps waived_at, no ledger events are written, and no customer's visible balance moves on flip day. The rows SURVIVE — they are the only record of what the vendor under-paid, and that settlement is being handled with Rivo separately. Rescore-reset also refuses to delete waived rows. Also confirmed by Tyler: active subscribers do earn 10/$ regardless of lifetime spend, so tierAt() is CORRECT and the shortfalls are real — 91 of the 106 rows (25,529 pts) are subscribers Rivo paid at the lifetime-spend ladder instead of the Fam rate. Tests 321. (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).
from → our to, customer's to — the support address — → our from); outbound-created threads fall back to a new optional sender_address field on the Gorgias Connections card (set to the proven support@ for t_verdant), then the API login as last resort with the probe reporting honestly. PROVEN END TO END: inbound-shaped test ticket from the test account → shipped sendGorgiasReply → delivery: sent, from support@flipmylifenow.com → confirmed landed in the tyler@spartanstudios.com inbox via Gmail. ⚠️ Return-label emails ride createGorgiasOutboundEmail with the same failing sender — they now use sender_address, but PAST labels may never have arrived. Tests 318.sendGorgiasReply never sent source.type — REQUIRED by Gorgias's CreateMessageSource schema — so replies could 400 in an agent's face (the employee's error, verbatim: "choose one source type from: aircall, api, app, chat…"); it also echoed the ticket's channel as via (via = how a message ENTERS Gorgias, i.e. api for us — the outbound-email function had this right all along). Now: email-like tickets (email/contact_form/help-center/api) reply by email with source.type:'email'; chat/social tickets MIRROR the customer's own inbound source (their from becomes our to — the only address those platforms can deliver to); no inbound to mirror = loud refusal, never an undeliverable accept. And accepted ≠ delivered: the app toasted "Reply sent" on any 2xx while Gorgias's actual send is async and fails silently — a reply Tyler sent from Android "succeeded" and never arrived. The send now probes the message 1.5s later and returns delivery: sent|failed|pending; the toast says which. (2) THE HUB'S RED WAS REAL BUT STUCK: 424 Shopify + 19 Recharge webhook deliveries failed in 24h — every one D1_ERROR: D1 DB is overloaded, escalating daily since the pipeline reconnect tripled volume (0.5%→2.4%→4.5%) — yet the board also pinned red for ~14.5h AFTER recovery (flat 24h count, no recency) and could NEVER show green on a 12k-hook/day service (>3 absolute). The classifier is now recency-gated (>3 fails AND last fail <2h = red; ended burst decays), "never synced" is its own amber state instead of silently healthy forever (the lander_engine trap: last_sync_at && … short-circuited staleness for exactly the services that never synced), on-demand keys (anthropic/fal/openai/replicate/lander_engine) read "ready" instead of claiming a sync they don't have, and the board is built from registry.SERVICES, not settings rows — a deleted row used to vanish ("connector gone" rendered as "nothing wrong"), fal/openai/replicate/basecamp never appeared at all, and the non-service loyalty config bucket squatted on a permanently-amber tile no action could clear. /api/pipeline (the /connections page) judged the same connectors by DIFFERENT rules — Shopify green there while the Hub showed red — the active-burst red rule is now shared (sync-recency bands stay deliberately stricter on the ETL page), with a cross-reference comment at both sites. Webhook handler: jittered retry backoff (150–400ms, herds no longer march back in lockstep), failures now record their topic (424 topic-less rows made the burst undiagnosable), and recordWebhook is best-effort on both paths — bookkeeping too jammed to write can no longer turn a SUCCESSFUL ingest into a phantom 500 + vendor redelivery. (3) "100% EXACT" WAS FAKE MATH: the loyalty tile read 100% over 1,069 drifting members (true figure: 93.4%). reconcile()'s "mid-flight" excuse window was 7 DAYS — the sync cron touches stamps constantly, so EVERY drifter was excused unverified and subtracted from the denominator: exact/exact = 100, forever green, and the 99% cutover blocker could never trip. Window now 1h; only VENDOR-CONFIRMED pendings leave the denominator; unverified drifters count AGAINST the score, are reported as their own unverified field on the tile, and drain through the 150-call truth budget + 24h cache over successive runs (healing stale columns as they verify — the drift itself was proven to be snapshot lag, 830/830 columns matched the ledger at their sync stamp). New 8% unverified cutover blocker so an unverified pile can never again hide behind a pinned number. The ledger tile's label finally has its coral band (it had mint/gold only — 93% rendered gold). THE MISSED-TRIPWIRE LESSON: the daily checks watched feed FRESHNESS, which stayed green throughout because vendors retry — the failure RATE had no check. It does now (note_1600). Tests 314→317 (fake-100% regression · registry-board contract · chat-mirror + loud-refusal). 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).
flip-cms.flipmylife.workers.dev — the workers.dev subdomain retired in the 2026-07-17 rename to appolis. From that day both vendors delivered into a dead hostname: Recharge's five hooks simply failed forever (last event 07-14, their retry horizon), and Flexport eventually pruned its six entirely (its list showed ZERO). flexport_order_id stamping — which rides Flexport's Order.* webhooks — decayed through July exactly on schedule. This is the per-app-key-rotation lesson wearing a new coat: a rename must audit every consumer of the old name, and webhook registrations are consumers nobody sees. 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).
/report/delivery?days=30 as "not reporting correctly at all". It wasn't the report — it was three faults stacked underneath it, and finding them took measuring rather than reading. 🔌 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.
/api/discounts/newest (offset paging, dedup on append, hides itself at the tail; pinned by a no-overlap test). (5) Lifecycle columns scroll — 50 rows per column (was 8) inside their own scroll wells, header and money totals fixed while the list moves. (6) The drill-in centers properly: it was a hand-rolled bottom-anchored overlay — the exact transformed-ancestor popup bug fixed twice before — and now rides Centered, the body-portal that class of bug forced into existence. The wallet sheet was born on it. Tests 304 → 305. (Bulk board deliberately untouched: Tyler asked for design options first — mockups are the next deliverable, not code.)(shopify_customer_id=? OR rivo_customer_id=?) inside a LEFT JOIN's ON clause — SQLite cannot apply the OR optimization there, so despite both columns being indexed it scanned the 138k-row customers table PER COUPON ROW (1,600 × 138k). The wallet and lifecycle queries carried the same flaw. And this re-writes v2.45.1's story: the "pre-existing sync-window" queue 500s were this — each deck click reset D1 and every concurrent request died with it. The sync-window hardening (todo_1541) stays worth doing, but the acute cause was the deck. 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.
queue → discounts → queue, eight seconds apart, then reload after reload. Cause: adding a tab takes THREE places — TABS (who may open it), DESTS (the hub menu), and TAB_KEYS, the hash whitelist that tabFromHash() resolves against, where any unknown key falls back to 'queue'. v2.45.0 wired the first two and missed the third, so the hashchange listener bounced the click the instant the hash landed. One word fixes it; the comment above the list now names all three places so the next tab cannot repeat this. The accompanying "Could not load the live queue: 500" was the separate, pre-existing sync-window behaviour: his reloads landed inside the 08:01–08:02 cache rebuild, where a missing slice triggers the ~90k-buyer synchronous recompute no Worker request survives — noted on the board, not touched here. Diagnosed remotely by measurement, not reproduction: local server green on every route, live queue-cache blocks proven fresh, client crash ruled out by zero telemetry errors, then the crumb trail named the guard. Tests unchanged at 304. 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.
GET /internal/lander-abandoned, gated and shaped exactly like the order feed. This is the step a lander can never see for itself: its last owned event is the CLICK on the buy control, and the cart, the checkout and the abandonment all happen on Shopify. It works at all only because AbandonedCheckout carries customAttributes — verified against the live schema before a line was written — so the lander/ad tags the buy link stamps on the cart survive into it and abandonment is attributable per lander AND per ad. ⚠️ completedAt is NOT always null in that connection: a checkout abandoned and later completed still appears, and counting it would double it against its own order — inflating abandonment and deflating conversion at once. It is returned FLAGGED and Kosmos drops it, the same division of labour as test/cancelled orders. Same privacy allowlist (lander + ad only, asserted against the raw response body), same newest-first pull, same 502-rather-than-a-short-list rule. Tests 291 → 294./internal/lander-orders reported 0 lander-attributed orders — which reads as "the landers sold nothing" and is a number someone would act on. It was an artifact. The query sorted CREATED_AT ASCENDING and capped at 5,000 orders; flipmylifenow runs ~335 orders/day, so a 30-day window is ~10,000. It read 13 → 27 July, exhausted the cap, and never reached the fortnight in which the landers existed (they went live 08-08). Fixed with reverse:true (newest first, so truncation drops the harmless end) and the cap raised to 10,000. The receipt already carried truncated:true, which is the only reason this was caught rather than believed. Pinned by a test that asserts the sort direction, proven by mutation.GET /internal/lander-orders, ID_SECRET-gated like the other internal routes (404, never 401 — the route does not admit it exists). Kosmos ties ad spend to real money by reading the lander and ad cart attributes off Shopify orders, and it now ASKS HERMES for them instead of holding its own Shopify token. Why that way round: this is a Dev Dashboard app, so its access token is minted by the client-credentials grant and EXPIRES EVERY 24 HOURS — a copy in Kosmos would work for one day and then die, and Kosmos would have to duplicate the whole mint-cache-refresh machinery. A second copy of a live store credential is also a second thing to rotate and a second thing to leak (appolis todo_1059). The credential stays here; the service binding already existed. 🔒 Minimal by design: the response carries only when an order happened, what it was worth, whether it counts (test/cancelled flags), and the TWO attribution attributes — no customer, no email, no address, no line items, and none of the other apps' cart attributes that ride on live orders (igId, the free-gift tracker). The privacy test asserts against the RAW response body rather than parsed fields, so a leak anywhere fails it, and both that allowlist and the auth gate are proven by MUTATION — reintroducing either fault turns the suite red. A Shopify failure mid-pull returns 502 rather than the orders fetched so far, because a short list that looks complete would understate revenue while appearing healthy. ?scopes=1 reports the GRANTED scopes so a gap is a fact rather than an assumption. Tests 279 → 290.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).
usage_count / asyncUsageCount only moves for a normal web checkout. When Recharge bills a subscription it applies the discount to the order and the counter stays 0 — proven on four live PAID orders Tyler pulled (#291931, #292004, #292146, #292217), each carrying its REW- code with the money actually taken off, each still reading usage_count 0. Rivo separately deactivates its own codes (endsAt = the order's timestamp, to the second) but that is Rivo's housekeeping — it lags (one of the four hadn't been done), and it never happens at all for a Hermes-minted code. 🚨 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.
GET /api/discounts (list: search, state filter, paging, tenant-wide totals) and GET /api/discounts/:code (drill-in). Shopify's discount list cannot answer "whose code is this?" — because until today nobody owned one: every reward sat in a shared pool with customerSelection.all. Now each code is its own customer-bound discount, and this is the surface that shows it. Load-bearing, not cosmetic: Tyler chose to KEEP used codes in Shopify rather than delete them (deleting destroys the ability to ask Shopify "was this spent?" — the standing coupon-truth rule that exists because a private dead-ledger once caused a real double-refund), so the Shopify list grows on purpose and this is where anyone actually looks. STATE IS DERIVED IN SQL, NEVER STORED AS OPINION — voided_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.
nativeVoid routed by shape, while the storefront's returnCode always called the REST deleter — which removes the price rule only when its title equals the code. Owned discounts are titled FLIP Reward $25.00 — REW-…, so a storefront return would delete the code and strand an empty discount in the admin forever — exactly the clutter this was meant to prevent. Both paths now share deleteCodeInShopify, so they cannot drift again: pooled → drop one code (never the node — it hosts everyone's), owned → drop the whole node, unknown/Rivo → REST as before. markCouponReturned likewise shared. 🚨 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.
nativeVoid deleted the Shopify discount and credited the points but never touched rivo_coupons — the book every customer-facing surface reads. The coupon only disappeared later, when the liveness sweep happened to notice the discount had vanished and set shop_missing. In that window a customer saw a returned code as usable and would be refused at checkout — the same shape as the invisible-code incident earlier the same day, pointing the other way. The storefront's own return path had always set refunded_locally; the two now agree, and page-data's coupon query filters on exactly that column, so the row leaves the list immediately. Marked after the points land: if the award throws, the void reverts and the customer keeps a coupon they were not refunded for. The ledger row survives as history — the registry must still answer "what happened to this code?". Verified by mutation: with the mark removed the new test goes red, which is the point — it asserts through couponBook (the real reader, now exported for exactly this) rather than checking a column and hoping. Tests 263 → 264. ⚠️ 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.
native_rewards_list flipped shadow → on (Tyler's call, after reviewing it on the real signed-in page). Hermes now draws all three storefront reward surfaces: the page's Rewards block (#rivo-lp-my-rewards), Ways to Redeem (#rivo-ways-to-redeem), and the profile slideout's REDEEMED REWARDS list (one of thirty .rivo-slideout-page-content pages inside #rivo-profile-main). This is the only way a Hermes-minted code can ever be seen by the person who paid for it — Rivo cannot render what it did not mint, which is precisely how customers spent points today for codes that appeared nowhere. ⚠️ 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.
native_redeem on. The earlier attempt (todo_1330/1331) tried to inject our coupon into Rivo's own state and let Rivo draw it — abandoned on evidence, because the visible list renders from some other source inside Rivo with no stable contract. This does the opposite and therefore does not inherit that failure: the widget draws its own list from page-data (which already returns balance, coupons, rewards catalog, charges — signed and live) and places it where Rivo's was. We never need to know where Rivo's list comes from. Redeem posts to /proxy/redeem and redraws from the server, never from a guess. ⚠️ 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.
telemetry.appolis.app (Quantum Flip is the reference client; this is the second app wired and the first handling real customer data). 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.
<title> STILL CARRIED THE OLD SLOGAN. Caught in the post-deploy check of v2.34.1, which is the only reason it was caught at all: the eyebrow removal verified clean, but the served HTML still contained "Every Customer. One Brain." once and the new slogan zero times. Why it hid. HERMES_SLOGAN sets document.title at RUNTIME, so the browser tab was correct the instant the bundle ran — it looked right in the preview, and it looks right to anyone using the app. The <title> tag in the head was never updated, and that is what shows before the bundle runs and the only slogan anything reading the raw markup ever sees. The runtime assignment was quietly masking a stale value rather than fixing it. Fixed, with a comment on the tag pointing at the constant so the next person changing one knows about the other; the runtime assignment stays, because it is what makes the two converge if they drift again. The wider lesson, and it is the same one this project keeps paying for: a value that is written in two places and reconciled at runtime will pass every in-app check while the source of truth is wrong. Verify the artifact that ships, not the screen. ⚠️ Also earned a new deploy trap. The v2.34.1 deploy printed Current Version ID: 35c790a6… and reported success while /api/version kept returning the PREVIOUS bundle and version — cache-busted, with no cache headers on the response. A second identical wrangler deploy fixed it. A deploy is not done when wrangler says so; it is done when /api/version agrees. That check is in the daily list precisely for this and it earned its place. Verified live afterwards: v2.34.2 / bundle e9eb011eeb, the served <title> reads the new slogan, zero occurrences of the old one, zero of the removed eyebrow, and the page references the new bundle. Storefront re-checked because this release touches the routing hot path every request crosses — all ten widget routes answer (page-data/config 200, the other eight 401), widget.js 200, legacy alias 401 not 404.<title>, and it is the better line for what this app actually is: the point of Hermes is that email, SMS, the storefront, subscriptions and the loyalty program all resolve to one place. Changed in exactly ONE spot (HERMES_SLOGAN), which is what v2.34.0's constant was for — the hub lockup and the browser tab both moved with it and cannot disagree. The "Everywhere you can go" eyebrow is gone, and its whole row went with it rather than being left half-empty: the lockup already says what the screen is and the tiles say the rest. The ☆/esc hint survived the cut and moved up into the lockup row, in the empty space between the wordmark and the ✕ — it is the only place the ☆ pin is taught anywhere, and the ✕ only covers the closing half of what it says. Net effect is one less row of chrome and the tiles starting higher. ⚠️ The hint and the ✕ ride ONE right-aligned group, deliberately. Giving each its own marginLeft:auto splits the free space between them and leaves the hint floating mid-row; putting the auto on the hint alone breaks the ✕ on phones, where the hint is display:none and the auto vanishes with it. The group owns the alignment so both cases hold. Verified in the preview at both widths (not by measurement — the owner asked for the pane, rightly): 860px wide shows logo, wordmark, the new slogan, business name, and hint+✕ grouped right with the eyebrow row gone; at 328px the hint and business line drop away as designed, the ✕ stays pinned right, the slogan is unclipped and the grid holds its 3-across phone rule.FlipMark, already the header's, so there is one logo and not a second copy to drift), the HERMES wordmark, and the slogan beneath it in gold. The slogan was not invented — it was already Hermes's own line, sitting in the <title> since long before this: "Every Customer. One Brain." — Hermes's answer to Kosmos's "Order out of chaos." What changed is that it is now a value rather than a string typed in two places: HERMES_SLOGAN feeds both the lockup and document.title, so changing it once changes it everywhere and the tab and the hub cannot disagree. A third line appears only when a business is actually named — BRAND.display_name from the v2.32.0 seam. Hermes is multi-business, and on a white-labelled instance which company am I in matters more than the product name does; it is hidden entirely when unset rather than printing a placeholder. ⚠️ Kept straight on purpose: the slogan is the PRODUCT's and stays put on a white-labelled instance — tenant identity is display_name, and the two must never be confused, which is why they render as different lines in different weights. The ✕ moved into this row and is pinned to the right edge. Verified by measurement at both widths, since the pane would not composite for a screenshot: at 1280×800 — logo 46×46 at 13px radius from /icon-512.png, all three lines present, ✕ on the same row and flush right, no horizontal overflow; at 328×735 (the width every mobile bug on this board was filed at) — no horizontal overflow (scrollWidth exactly 328), the slogan visible and not clipped, the ✕ visible and clear of the wordmark, and the tile grid correctly falling to its 3-across phone rule. 251 tests green.inset:0, its own scroll, with the content held to a 1100px column so it does not letterbox on a wide monitor. Losing the backdrop loses click-outside-to-close, so the ✕ is load-bearing, not decoration — it sits in a sticky header beside the title; Esc still works. The two start-page rules split by rank and deliberately never both fire: an admin lands on the hub map, because an admin's job spans every department and no single dashboard is theirs; anyone below admin goes straight into their department's dashboard, because putting the launcher in front of it is just a door in front of a door. department is FREE TEXT an admin types on the Team roster — there is no enum to switch on — so the mapping is keyword-based with a fallback, and it was built against measured production data, not assumption: the only values that exist are "Customer Service" (6 members) and none-at-all (4 admins), so Customer Service → Support Hub is the live case and the rest of the table (fulfilment, insights, IT, products) is groundwork for departments Tyler adds later. ⚠️ The home is CLAMPED to what that user may actually open. A non-admin can only reach Support Hub / Board / Agora / My Files, so sending a Fulfillment dept_user to the Fulfillment tab would have the existing access guard bounce them straight back — a redirect loop, not a home. Until non-admin access widens (a permissions decision, deliberately not taken here) every non-admin still lands on the Support Hub, which is exactly the behaviour Tyler described. Back now shares one definition of "your landing tab" with the start-page logic, instead of its own hardcoded copy — otherwise the two disagree and Back returns someone to a tab they never opened. A deep link is never buried: any hash carrying tab=, customer=, thread= or order= means somebody was sent somewhere, so the launcher stays shut; only a bare entry opens it. One layout regression caught and fixed by measuring rather than eyeballing: at the old card width the auto-fill grid resolved to 5 columns, so a group of 5 filled its row exactly — full screen, the same 132px minimum gave 7 columns and a ragged right edge on every row. Raised to 186px, which restores the rhythm at 5 columns and makes bigger targets on the surface people now land on first. That fix had to go on the inline style, not the stylesheet: .hubgrid carries an inline grid-template-columns and an inline style beats a stylesheet rule — !important would have won but would then also have beaten the phone media rules (this selector is more specific), silently breaking the 4-across and 3-across phone grids. Verified by reproduction at 1280×800 for both roles: admin auto-opens the hub full-bleed (measured 1280×800, opaque, 5 columns, ✕ present), ✕ closes it, the header Hub button reopens it, Esc closes it, #tab=vips opens with the launcher shut, and a Customer Service member lands on #tab=customers with no launcher and only their four permitted tabs in the nav. 251 tests green./api/my-flippers, /api/flippers-dashboard/, svc.myFlippers, svc.flippersDashboard, the queue-block cache key, and the nav tab id that is every non-admin's home screen. So the job was never a rename: renaming those to Hermes would only swap one tenant's brand for another's. The rule now enforced: internal identifiers are permanently NEUTRAL, display strings are tenant data. Routes, functions, cache keys and tab ids became customers; a new server/lib/brand.js resolves the tenant's own vocabulary, and the defaults are generic, not Hermes-branded — an unconfigured tenant reads as a plain retention app, never as somebody else's. Seven additive tenants columns (display_name, logo_url, brand_primary, brand_accent, support_email, customer_noun, customer_noun_plural); brand rides GET /api/auth/me so the first paint already has the right words; GET/PUT /api/brand reads open and writes admin-only — gated deliberately rather than by inheritance, because that exact omission on PUT /api/integrations/:service is a live finding (todo_1321). The front end reuses the existing UI_TEXT override mechanism rather than inventing a second one, so a per-label <Ed> edit still wins over the noun. FML's visible copy does not change at all — the UI never displayed "Flipper" (that tab reads Support Hub), so the neutral default matches today's wording exactly; setting customer_noun to Flipper is now an opt-in, not a migration. Four ways this could have broken a live user, all closed: ① a stale edge-cached bundle calling the old routes — LEGACY_PATHS rewrites all four for one release, since a 404 body is still JSON and would have failed silently, exactly as the storefront widget did for a week; ② the flippers response field — served alongside customers for the same one release; ③ the tab key 'flippers' sitting in every existing user's saved nav bar in localStorage, where an unknown key renders nothing — rewritten on read, so the rename cannot look like a deleted Support Hub; ④ #tab=flippers, a shareable link in bookmarks and pasted messages — aliased rather than silently dumping people on the at-risk queue. Job 1, the dead names: Chardizy is gone from code, prose, User-Agents (Flexport/Gorgias/Rivo now identify as Hermes/1.0 (Appolis)) and the package name; web/dashboard.jsx deleted after proving it dead (nothing references it and build.js reads only the shell); the shell itself is now web/hermes.html. Local dev keeps working either way — HERMES_DB is the name, CHARDIZY_DB still honoured, and an existing data/chardizy.db is reused rather than silently starting from an empty file that would look like data loss. Two outward-facing brand leaks fixed, not just renamed: the delivery report footer and the PPTX deck footer hardcoded "FLIPPER · FLIP MY LIFE WELLNESS" — the most outward-facing thing the app produces — and now read one brand_name resolved once in gatherDeliveryData, so the two renderers cannot drift. Deliberately kept: the flipper.appolis.app retirement redirect (still routed, webhooks exempt), the 'flipper' Appolis-ID entitlement slug (dropping it would lock out anyone whose grant still says it — audit the grants first), and the flipper-studio R2 bucket. Infrastructure names left alone on purpose: the D1 database chardizy, the worker flip-cms. Both are invisible to every customer, D1 has no rename operation, and renaming it would mean migrating 703 MB with the live store on it — cost real, benefit zero. 8 new tests (test/brand.test.js), and one of them immediately earned its keep: it caught a bulk rename that had silently turned LEGACY_PATHS into a no-op mapping every path to itself. *npm test now globs test/.test.js instead of listing files by hand — the old explicit list meant a new test file was invisible unless someone remembered to add it, which is how two modules previously shipped with none. 251 tests green* (243 + 8). ⚠️ Not yet deployed; the additive migration must run after deploy (POST /api/admin/migrate) — safe in either order, because getBrand degrades to neutral on a pre-migration row rather than throwing on the bootstrap path.POST /api/loyalty/native-redeem, admin-only, reversible in one call) sets native_redeem alone — never native_earn, never flipped_at, both verified absent in production afterwards. That splits the program exactly where he asked: codes come from Hermes into pooled Shopify discounts (one standing discount per reward value holding every code, usageLimit: 1 per code so a spent one stays listed with its count — the ID.me shape he described, versus Rivo's one-discount-per-redemption flood), while points stay Rivo's, deducted through the vendor with a note we write. The four bugs, in the order they surfaced. ① nativeRedeem could never find a reward. It matched loyalty_rewards.id (lrw_t_verdant_rivo_669087) while every caller sends the vendor id (669087) — and the storefront runs it through digits() first, so it could never match. The pre-check accepted either and waved it through; the mint refused with "not in the catalog". Latent forever; 100% fatal the moment the switch was thrown. ② Only one native redemption could ever succeed. The redemption row keyed off discount_code_id, which a pooled mint sets to null on purpose — so every pooled mint produced the identical id lrd_<tenant>_null and every one after the first died on the primary key. Caught by writing the test, before a customer met it. ③ A minted code was invisible everywhere. nativeRedeem wrote only to loyalty_redemptions, while every display — account page, storefront lists, couponForMember, the admin profile — reads rivo_coupons, which Rivo will never populate for a code Hermes minted. The code existed in Shopify, in the pool, and in our ledger, and appeared nowhere: it flashed on screen from the redeem response, then vanished when the list refreshed. ④ And it would have been deleted. syncRivoCoupons's sweep removes anything the vendor feed does not return — which is every Hermes mint by definition. Native rows are now marked hermes: and the sweep skips them; a test mints two, runs a complete sweep against an empty vendor feed, and asserts both survive. Also v2.29.0 — the read-through. The coupon book is a 30-minute mirror, so it is authoritative about a code's death but not its birth: measured live, a code minted 02:58 against a mirror last walked 02:41 produced "We couldn't find that reward code on your account." Apply/return/reactivate now ask Rivo for that single code on a miss, re-verify ownership against Rivo's own answer, and backfill — with tests proving a stranger's code is still refused and that a Rivo outage leaves a miss as a miss. Proven live end to end: reward resolved → pool created ($20 off coupon → gid://…1712021831981) → REW-D844BEBB1921 minted into it → points deducted via Rivo → code visible to the customer. 239 tests green. ⚠️ Redeeming on Rivo's own hosted rewards page still goes to Rivo — only Hermes's own surfaces mint natively. Closing that needs the widget to intercept that click./apps/sub-points/ proxy was handed to this app on 2026-07-31 and repointed at hermes.appolis.app/proxy — but only five routes came with it (page-data, redeem, apply, remove, return). The widget calls nine. The other four returned this door's terminal 404 for a week, and because a 404 body is still JSON the widget never raised an error: it read "no charges, no state" and rendered "No upcoming subscription orders found on your account." That single gap produced every storefront symptom — no order picker, no reactivate offer, and returned coupons that never got scrubbed out of Rivo's own list. Diagnosis, in order: a 9-minute wrangler tail on the old worker during live use caught 13 requests, *all /ext/overview, zero /proxy/* — nothing routed there any more; then curl https://flipmylifenow.com/apps/sub-points/page-data returned Hermes's own payload and /config returned Hermes's "please sign in to your account first", a string that exists nowhere in the old worker. (An earlier claim in-session that the old worker still served this path was wrong — it rested on another lane's probe rather than a direct test. One curl settled it.) Ported: GET /proxy/config (widget copy, signed but deliberately login-free — the popup needs it before it knows anyone), GET /proxy/subscriptions (the order picker + win-back list), GET /proxy/returned (the scrub list), GET /proxy/code-status, POST /proxy/reactivate-apply (restart a cancelled subscription and attach the reward to its regenerated charge). Hermes does it better than the worker did: deadness is now one indexed SELECT over rivo_coupons — which already carries used_at/revoked_at/refunded_at/refunded_locally/shop_used/shop_missing, maintained by cron — where the worker needed a Rivo round trip plus a six-per-paint Shopify sweep that could never finish on a large book. Two bugs fixed in the porting, not merely moved: (1) the win-back gate now turns on no upcoming orders alone — the worker also required "nothing active", which dead-ends the exact customer it exists for, one holding a stale ACTIVE subscription whose charge date has passed (that is Tyler's account); safe because reactivateApply enforces the real invariant itself, refusing outright if any queued charge exists. (2) listQueuedCharges now returns items as objects with an explicit null title instead of a bare array of title strings — the string form is what made the account tab print the literal word "undefined" under the Aug 14 order, and fixing it at the source fixes every surface at once. Nothing here needed Recharge to go away — quite the opposite: every primitive already existed and was already in production use (applyDiscountToNextCharge, activateRechargeSubscription, setNextChargeDate, listSubscriptionsByCustomer). The storefront now talks to one door, which is precisely what makes a later provider swap a change behind the seam rather than a storefront rewrite. 12 new tests in test/storefront-widget.test.js, including one that asserts no widget route 404s so this exact failure cannot recur. 233 tests green. Verified live after deploy: /config 200, the three member routes answering the sign-in gate rather than 404.todo_1318, found by following Tyler's second, much sharper report: "I redeemed 1500 points for a $15 off coupon and it does not show up on Hermes… the redeemed code does not show up on the customer profile rewards tab either… but all the older redeemed codes do." Old data present, new data absent, points still moving — that is a sync that hit a wall, not a display filter. The bug. syncRivoCoupons walked Rivo's /points_redemptions feed forward from page 1, maxPages 12 × perPage 100 = 1,200 rows. That feed accepts no sort parameter and returns oldest-first. So the moment the feed outgrew 1,200 redemptions, every pass re-read the same ancient rows and stopped — re-stamping seen_at on history while nothing new ever landed. Measured in production before the fix: the last walk covered exactly 1,200 rows spanning 2026-06-03 → 2026-07-31 21:50, the mirror held 1,279 coupons, and 24h of activity showed 56 redemption events against 21 mirrored coupons. Tyler's own newest mirrored coupon was dated 07-31 — the ceiling, not a coincidence. His -1500 / points_purchase / "Redeemed a Reward" event at 20:06:12 was present (points ride a different feed on the */5 lane, which is exactly what disguised this as a UI problem for a week). The fix. The feed reports its own length in links.last, so read the newest window instead: page 1 (cheap, and it is what reveals the length) plus the last maxPages-1 pages. Cost is now independent of feed growth. full:true still walks everything and runs once an hour at :10, because only a full walk may authorise the delete sweep — a windowed walk has not seen the rows it would be deleting, and letting it sweep would have destroyed ~1,200 live coupons. That guard is the most important line in the change and it has its own assertion. Verified in production, not inferred: first walk on the new code (21:41) took the mirror 1,279 → 1,461 rows — 182 redemptions recovered — coverage moved from 07-31 to 2026-08-07T21:06, and Tyler's REW-1A0B390E7FD9 · $15 off · 1500 pts · 20:06:12.930Z landed, matching his points event to the second. New test drives a 15-page feed through a 12-page budget: the newest page is mirrored, complete is false, the sweep does not run, an unreached row survives — then a full walk covers every page and only then sweeps. 221 tests green. ⚠️ STILL OPEN, different lane: the storefront's "no active subscriptions to apply it to" (including reactivate-and-apply) and the Rivo discount stacker both live in the rivo-sub-apply / rew-discount-locker workers and are untouched by this.todo_1318: the storefront's Rivo discount stacker and Sub Apply had stopped working, already-used rewards were showing, and they suspected this Hermes session had thrown the loyalty cutover. Investigated with 18 agents (4 mapping lanes, then an adversarial refuter per candidate). Fourteen candidate mechanisms were proposed; all fourteen were refuted — none survived. Then Tyler read /api/it/health on production and settled it outright: there is no loyalty entry in services[], and only executeFlip can ever create that row — so the flip has never run. Every connector reads connected with last_error: null, which also kills the blanked-credential family of causes. Two supporting findings worth keeping: the flip has exactly one writer (loyalty.js:837) behind one route demanding an admin identity and a literal confirm:'FLIP' and a green readiness gate, with no cron/webhook/MCP/settings path to it (registry.SERVICES has no loyalty member, and saveSettings replaces rather than merges, so an unrelated save could only ever clear those flags); and **the live /apps/sub-points/ surface is the rivo-sub-apply worker, not Hermes* — the probed not_logged_in body appears nowhere in this repo, and the theme loads that worker's widget directly. Hermes's own /proxy/ storefront is built and waiting but is not what the shop talks to, so Hermes deploys could not have moved it. (An earlier claim in-session that Hermes served that path was wrong — a code comment describing intent, read as deployed fact, and corrected.) Logged as todo_1320; the best dated candidate for stale rewards remains 08-06's removal of the 5-minute coupon walk, which is not this session's work. (2) The real bug the sweep found, now fixed. PUT /api/integrations/:service — which writes the tenant's Shopify / Recharge / Rivo / AI credentials, and where an empty string is a delete — sat behind the global session wall with no role check at all, unlike essentially every other mutating route in the file. Any signed-in person could silently blank a live token, and a blanked Shopify token is precisely the shape that makes the coupon-liveness walk fail closed and quiet. v2.25.0's lead tier turned that from theoretical into real by adding non-admin logins. Now admins only — not even leads, by instruction. Its Recharge webhook-registration sibling got the same gate for parity (its Flexport twin already had one). New tests: a member is refused and the stored token is proven untouched* (the blank-is-delete path never runs), a promoted dept_head is still refused, the admin still succeeds and the value actually changes, and webhook registration refuses a non-admin. 220 tests green.HUB_TILES is a flat list with a row and every view reads it, so the hub and the jump bar inside a full view are literally the same list. Sub-filters survive only inside Urgent (cancellations / address changes) — the waiting tile's ship-category split is retired. The lead tier. VIP retention and Unassigned now belong to dept heads, managers and admins — auth.isLead, riding the rank ladder that already existed, so no new role and nothing to migrate. It gates the counts in the payload (an agent's dashboard never carries them), the ticket list, and the VIP board; the lead flag is part of the dashboard cache key so an agent can never be served a lead's cached payload. VIP retention became a real page — it used to render inline at the bottom of the hub, loading the whole call board on every visit, while clicking its own tile opened a broken TilePage asking the ticket endpoint for a tile it does not have ("unknown tile"). It now opens like anything else, and loads nothing until asked. Lost & damaged and Wrong item come off the hub by instruction — the server keeps counting them for Insights and the fulfillment tile. The Next-up card walks both ways (Tyler, mid-build): Skip became ‹ / › with wrap-around, because passing the one you wanted used to be unrecoverable without a reload. One real bug found by testing the new gate: api() treated 403 exactly like 401 and tore down the session. Harmless while 403 only came from admin routes nobody else could reach — but with agents and leads sharing the app, an agent following a stale link to a lead board was signed out of Hermes. Reproduced in a browser, then fixed: 401 means your session is gone, 403 means it is fine and you are simply not allowed. A permission guard now also bounces a forbidden tile URL quietly back to the hub, so nobody meets a 403 they could not have avoided. Verified by reproduction at 1280 and 328px as agent, lead and admin: tile order and the coral alarm, direct-open with no intermediate step, sub-filters in Urgent and nowhere else, Tasks appearing and disappearing with its count in both the hub and the jump bar, the agent seeing six tiles and no VIP/Unassigned, the deep link bouncing without signing anyone out, zero horizontal overflow, no clipped labels. 220 tests green, including new coverage of the Urgent union and its two filters, a retired sub value being ignored rather than applied, and the lead gate opening and shutting on a single rank change..herostats rule, leaving one orphaned tile in front of all the work. It is now the first screen it should have been: Next up, then the four band totals. The whole chain came out, not just the pixels. A 19-agent audit (3 mapping lanes, then one adversarial refuter per claimed-dead symbol) established that flipperStats had exactly one reader, and — importantly — that the surviving refutations were circular: each piece was only "still used" because the others were still there. The verifiers said so themselves ("the correct unit of deletion is the whole set… not line 1539 alone"), while the two refuters aimed at flipperStats and its route independently failed to find any outside consumer. So all four went together: the UI block + its stats/period state and fetch, GET /api/flippers-dashboard/stats, svc.flipperStats, its export entry, and — the one that was quietly costing money — the cron warm at service.js:1539, which ran a 400-row body_text scan with ~10 regexes per body, per rotating member, every tick, to prime a cache nothing read. Two shared .statgrid media rules went too, but only after being measured rather than reasoned about: Creator Sends (the sole remaining .statgrid, via .fulstats) was captured at 880px and 620px before the edit and re-measured after — 3 columns, 8px gap, 30/21/21/21 display sizes, identical, and re-checked at 328px. Nothing of substance is lost: the Insights report builder already carries both halves over the whole book — REPORTS.pain_themes and the agent leaderboard's save_rate_pct — riding the same THEMES table, which is where a customer-service Insights view should pick them up. The one behaviour that lived only here is written down where it died: a no-theme fallback that kept unmatched free text as its own normalized bucket, where pain_themes drops anything matching no regex — so novel complaints are invisible to it until someone adds a pattern. Filed for the future Insights view rather than left as dead code. Verified by reproduction at 1280 / 620 / 328px: hero gone, Next up first on screen, scoreboard 4-up then 2-up, /stats no longer requested at all, zero horizontal overflow, Creator Sends unchanged. 220 tests green./hub-options.html — each in both views (main tab / inside a tile), each responsive, with a 📱 Phone / 💻 Desktop toggle that renders any option inside a 390px frame so both treatments can be compared from a desk (media queries answer to the real window, so without it a desktop can only ever see the desktop branch). Tyler picked B · Scoreboard for both views and asked for C's Next-up bubble on top of the main tab. Shipped exactly that. What it replaces: the board printed all eleven tiles at once in four stacked grids — the whole day arriving as a wall, in priority order, with nothing at the front of it. Now: ① a 🎯 Next up card names the single oldest thing still waiting on you, with Open / Skip; ② four band totals (on you / closing / in motion / the floor) are the whole day in one glance; ③ tapping one unfolds its sections beneath, and anything at zero drops to a ghost chip instead of taking a full row. Inside a full view the scoreboard shrinks but stays — four across at every width — so you can cross bands from anywhere, and the chip row beneath now carries only the open band's sections rather than all ten (on a phone that used to mean scrolling sideways past six places you didn't want to reach the one you did). The server half is where the care went. hubNextUp() merges three sources that each keep a different clock: an unanswered ticket waits from opened_at, an unread reply waits from updated_at (when they spoke, not when the ticket opened — a 2020 ticket answered this morning is not your oldest problem), and a booked follow-up waits from scheduled_at, but only once it is due. Two traps in one function: SQLite writes 2026-07-03 00:00:00 while ticket rows carry ISO-with-Z, and those do not compare as strings — hence julianday() for the due filter and a parsing merge for the cross-source sort. It returns a short list, not one row, so Skip advances with no round trip. It rides the existing 60s stale-while-revalidate dashboard cache, so the hero costs no extra request. Band accents/labels moved onto HUB_BANDS, and the two hand-kept copies of the section chip row (TilePage + TasksPage) collapsed into one HubSectionNav. Verified by reproduction in a browser at 1280, 375 and 328px (Tyler's own device width): correct totals, band swap, ghost chips, jump-bar band change, Skip advancing, zero horizontal overflow and zero clipped labels in all three, both views. Dead DashTile + its .dashgrid phone rules deleted; two stray files removed from the repo root. 220 tests green, incl. new next-up ordering assertions (unread keyed off the reply, the due task slotting between, a 2099 follow-up excluded).{quantity, logisticsSku} — no names (verified on Tricia's reship 149795503: DDRM5XUYKT3), and a logisticsSku is warehouse language that matches nothing in our catalog. GET /products/{logisticsSku} is the missing hop: it returns {name, merchantSku}, and the MERCHANT sku is what product_variants knows. So resolution is two hops — logisticsSku → Flexport product → OUR product + variant — rendered exactly like the order's own items list (Product — Variant ×qty), with the warehouse SKU demoted to the tooltip and Shopify's "Default Title" placeholder suppressed. Per-SKU lookups memoize for 24h (product data is static); an unknown merchant SKU falls back to Flexport's own product name, and an unreachable Flexport falls back to the stored unit count — the chip never degrades to a bare code. $0 Shopify reships render product · variant from the mirror. Verified against Tricia's real reship: 1× DAZRDRSNMV3 → 1545CMFML99 → FLIP 7 Caramel Macchiato Crush · Starter and 2× DDRM5XUYKT3 → 1590FFML13 → FLIP 7 Vital Vanilla · Starter. Also caught in the same pass: a JSX comment placed inside a .map() return broke the build (babel line numbers are offset from the file by the <script type="text/babel"> start — map them before hunting). 188 tests green (the contents test now mocks the REAL API shape: nameless line items + the product endpoint + a SKU our catalog doesn't carry).refunded_cents was READ by the profit model but never WRITTEN by anything — and financial_status was null on 94% of orders (17,879 of 288,665), so refunded orders rendered no chip AND the profit math counted money we gave back as revenue. Three fixes, in the order money flows: (1) the Shopify order normalizer now captures the refunded amount from either shape Shopify reports (sum of successful refund transactions, else total_refunded_set, else a full-refund fallback) plus refunded_at, and the order upsert persists both (COALESCE — a refund can grow, never silently clear); (2) a new refund sweep on the loyalty cron lane walks orders Shopify touched since the last pass (updated_at_min — a refund bumps it), cursor-paged and resumable, so refunds reach the mirror without re-reading 288k rows, and it never rewrites an unchanged row; (3) opening an order sheet writes the live payload's refund state back to the mirror, so any order an agent looks at heals itself. Profit math hardened too: partially_refunded with no amount was silently counted as zero — it now estimates and flags itself, and a refund can never exceed the order total. The list chip carries the amount (refunded $30.00) so nobody opens an order just to learn how much. THE REPLACEMENT COLLISION: the "likely replacement" heuristic offered the same reship to EVERY order inside its 45-day window — Tricia's single Feb 3 portal reship was claimed by both her Jan 2 and Jan 17 orders. Now only the CLOSEST preceding order may guess (any paid order in between owns it instead), a reship already linked to any order is never guessed anywhere, and portal reships gained fx_direct_orders.replaces_order_id so they can be linked at all. New POST /api/orders/:id/replacement turns a guess into a fact for both kinds (Shopify $0 reship or Flexport portal), with link:false to undo a mistaken confirm; the order sheet grew a 🔗 Link to this order button on every guess and an unlink on every confirmed link. 3 new tests (both refund shapes + profit drop + clamp · sweep writes and is idempotent · Tricia's exact two-order shape: only the closest guesses, linking ends the guessing, unlink restores it). 187 tests green.detailLoaded:false, collapsing the header's product·plan line to "$X lifetime" and re-loading the timeline — every 20 seconds, on every profile view. Fixed keep-stale-while-revalidate: a quiet flag on the tick's loads (never blank, never error-wipe), and the renders keep product/plan/fulfillment on screen while the flag cycles. THE PROXY DOOR (server/storefront-proxy.js): Shopify's app-proxy signature scheme implemented in Hermes (sorted bare-joined k=v pairs, hex HMAC, 600s timestamp window, tenant-routed by shop) with the platform's trusted logged_in_customer_id as the identity; /proxy/page-data aggregates overview + referral stats + activity + tier progress + the captured 13-rule catalog — and serves a PUBLIC PITCH (rules/tiers/friend-offer only) to signed-out visitors; actions (redeem/apply/remove/return) are the SAME storefront handlers as /ext/\* — one brain, third door. THE SURFACES (rewards-hub theme extension, flip-retention-12): the full rewards PAGE section (hero count-up balance, tier journey with animated progress toward the next tier, ways-to-earn grid, redeem cards, coupons, the refer-a-friend box with copy-link + live stats, activity feed — entrance choreography, homepage palette) and the FLOATING LAUNCHER app embed (bubble + pop drawer with balance/top rewards, auto-suppressed on the page itself). App proxy REPOINTED worker→Hermes in the same release; verified through Shopify's own front door live (their real signature passing our verification, the pitch JSON answering anonymously). The old injected widget on Rivo's hosted page retired with the repoint — that page's replacement is this build. Known fast-follow: the page's coupon rows don't yet carry the apply-to-subscription picker (the account-page block still does). 2 new tests (signature gate: valid/tampered/stale/unknown-shop · visitor pitch + action refusal). 184 tests green.server/referrals.js + referral_links/referrals tables: one share link per member (FLIP-XXXX slugs); the friend lands on a PUBLIC branded capture page (/l/refer/:slug — the first surface in the new brand direction: cream/burgundy/brick palette sampled from the homepage artboards, animated) and their email mints a single-use $15 code from the standing pool (the ID.me machinery — used codes stay listed). Better than Rivo in three ways: attribution rides the friend's UNIQUE minted code (survives devices/browsers — no cookies); the advocate's 1,500 flows through the loyalty SEAM (vendor writes it pre-flip so the one-writer contract holds, ledger writes it post-flip — the engine never changes on flip day); qualification asks SHOPIFY whether the code was spent (coupon-truth doctrine, cron sweep on the :10/:40 lane) with claim-first crediting so a retried sweep can never double-pay. Fraud guards: self-referral (email match), existing customers (anyone the book knows with orders), one referral per friend email (unique index — races lose loudly), and a last-line spender-is-the-advocate check at qualification. Advocate stats ride /ext/referral for the account page + the coming rewards page. 2 new tests (link stability + idempotent code + both guards · sweep pays 1,500 ONCE on Shopify-said-spent with deterministic notes). 182 tests green.server/storefront.js) re-doored onto organs Hermes already owns — the coupon BOOK is the ownership map AND the returned/dead ledger the worker kept in KV; balance/redeem ride the loyalty seam and flip with the tenant** (no separate storefront switch on flip day); charges ride the same recharge module the agent tools use. /proxy/* deliberately did NOT port: it exists to patch RIVO's hosted rewards page, which dies at the flip and is replaced by the redesigned Hermes rewards page — porting it would be building throwaway code. Handlers: overview (balance/tier/coupons/charges/redeemable catalog in one paint), redeem (native or vendor via the seam, local-first book write), apply (ownership through Recharge's own eyes + the mirror fallback that recreates a missed code from its face value, single-use), remove (prefix-guarded — promo/advocate discounts shown but never touched), return (the SAME claim-first sequence as the agent-side refund: ask Shopify first, claim the book row, free from any queued charge, kill the Recharge copy, void in Shopify, then credit — a lost response can never double-pay). THE COVERAGE GAP HEALED: the audit found 143 members Rivo calls FLIP Fam with NO recharge_customer_id at all — healRechargeLinks (new admin route) finds their Recharge account by email, links it, and pulls their FULL subscription history; driven live same-day (first 100: 89 linked, 136 subscriptions landed, 11 = stale Rivo tier claims with no Recharge account). Extensions repointed and released (flip-retention-11: both blocks' API host → hermes.appolis.app; the CLI rebuilds from src at deploy). Live-proven with a minted customer token: 401 without, balance/tier/5-affordable-rewards with, stranger-token isolation. Two bugs the port surfaced: (1) a PRE-EXISTING preflight killer — the global OPTIONS handler returned json(204, {}), and a 204 with a body THROWS on Workers, so every cross-origin preflight 500'd (invisible until the account blocks called cross-origin; fixed to a bodyless 204 + CORS, pinned by test); (2) the first heal pass linked 138 members while every subscription stayed glued to a STUB TWIN row — upsertSubscription's update-branch never reassigns customer_id — so the healer now runs mergeRechargeCustomer (moves every child row onto the email-canonical customer, dissolves the stub), pinned by a stub-twin test. 3+1 new tests. 180 tests green. THE COVERAGE GAP'S TRUE SHAPE, THEN CLOSED (143 → 5): the stub-twin theory died on live data — the real mechanism is the SAME HUMAN existing twice, the Fam/Shopify identity under one email and the Recharge/subscription identity under another (mcarden@1220.com ↔ irishcarden@gmail.com, proven by the shared Recharge account id). A bare link stamp or email-matched merge can never see across two emails; the healer now proves same-human via the shared recharge_customer_id and runs the FULL mergeCustomers (children move, identity folds, the dup dissolves — the Amy Allan machinery). Driven live: gap 143 → 5 in six minutes; the residual 5 are Rivo tier claims with no Recharge account at all (stale — they correctly earn spend tier post-flip). Every healed member's subscription history now feeds subscriberAt, so the earn engine pays their true Fam rate from day one.rivo_points column stale (the ledger matched Rivo's live balance TO THE POINT); 12/50 = Rivo's event feed surfacing events DAYS after applied_at (watched one member's missing burst arrive mid-investigation); 1/50 = a real over-count (an un-re-read reversal). Fixes: rivo_points_synced_at stamp on every column write; reconcile is now two-phase truth-scored — mid-flight members (column older than the ledger's newest event, or either side moved <7d) classify PENDING and leave the denominator; the rest get budgeted live per-member verification against Rivo (24h cached), where a truth match HEALS the stale column on the spot, feed-lag classifies pending_feed, and only a member matching NEITHER side counts drift_confirmed. THE SHADOW'S BIGGEST MISS CLASS WAS OUR OWN SUBSCRIBER-STATE READ: the inline SQL compared mixed-timezone timestamps AS STRINGS, counted checkout-created/reactivated subs as pre-existing (22 of 26 our-fault rows), went blind on NULL started_at, and a reactivated sub kept its stale cancelled_at forever (COALESCE never cleared it). Fixes: one shared subscriberAt() — epoch-normalized, strict born-BEFORE-the-order rule, and the append-only subscription_events log (already recording since batch-era!) overrides stale row stamps — now used by BOTH the shadow scorer and nativeEarnSweep (which would have over-paid real points post-flip); the upsert clears cancelled_at on reactivation. DISAGREEMENTS NOW CLASSIFY (class+vendor_rate on loyalty_shadow): ours_wrong_fixed heals to agreement via rescoreShadow (cron + on-fill); provable vendor mispays = rivo_wrong and AUTO-APPEND to the make-good ledger (proof-gated: pre-order evidence via subscriberAt, spend tiers by arithmetic); odd_base and unclear stay capped. THE GATES: shadow readiness = adjusted agreement (raw + rivo_wrong) ≥99% over 200+ with odd_base ≤2% and unclear ≤1%; ledger readiness = truth-scored exact_pct ≥99% with a pending ceiling (8%) so a stuck sync still blocks. Also: admin rivo-sync grew a deep=N background force-heal (waitUntil — a disconnecting client no longer aborts the walk; the cron's 2-page drip stays). 4 new tests pin subscriberAt's rules (incl. the timezone trap), the reactivation cancel-clear, rescore/make-good classification, and the truth-phase heal. 176 tests green. LIVE OUTCOME, SAME DAY: THE FLIP GATE WENT GREEN — ready: true, zero blockers. Ledger truth-scored 100% (raw 96.64%; 322 mid-flight pendings excluded honestly; only 5 disagreements survived to the vendor truth check and Rivo's live API confirmed our ledger exact on ALL 5 — healed on the spot; 0 confirmed drifts). Shadow adjusted 99.62% over 2,644 orders (raw 97.73%): after a strictness pass (the fog guard: a sub cancelled-today with unprovable timing never counts — re-judged from scratch via ?reset=1), 7 disagreements healed as our old read's fault, 50 stand as provable Rivo mispays (7 under-pays joined the make-good: now 55 rows / 15,191 pts), 10 odd-base (0.38%, under the 2% cap), 0 unclear. Diligence follow-ups, none gate-moving: audit Recharge mirror coverage (a few "vendor overpaid" rows may be subs our mirror never ingested), and the odd-base rows mean post-flip those orders earn slightly more than Rivo paid — by the program's own published base. The flip remains Tyler's manual switch (POST /api/loyalty/flip, typed confirm).conversation_notes with source='rivo' — manual adjust, both redeem paths, apply/remove-coupon-on-charge, coupon refund — burying the human notes under machine noise that ALREADY lives in the activity trail (rivo_events). All six echoes removed (doctrine comment left at the adjust route: loyalty actions live in the activity trail, never in notes). Kept deliberately: source='return' warehouse-arrival notes ("OK to process the refund" — an actionable prompt, not an echo), household/consumption source='profile' notes and reship notes (rare, human-meaningful). The 14 existing rivo-source rows purged through the app's own DELETE /api/notes route. The collapse: the shared Detail notes section now shows the 4 newest with a house dashed Show all N notes toggle; expanded renders EVERYTHING inside a maxHeight:230 scroll region (the LoyaltyPanel idiom) — so the box has a hard ceiling in both states, on the queue rail, the full ProfileView, and the thread side-rail alike (one renderer serves all three). The header shows the total count when collapsed. 172 tests green.#customer=<id> hash deep-link — which worked perfectly in a direct browser tab (verified in the live DOM) but NOT from inside Shopify admin: outbound links from embedded apps bounce through admin's leave-confirmation redirect, and URL fragments are dropped on that hop — Hermes opened at the bare origin and booted to the default tab. Query strings survive redirects, so the deep link now has a QUERY form: new one-shot ?profile=<id> handler in the main app (the ?pipeline= house pattern — fires once, calls the existing openProfile, then strips the param via replaceState so back/refresh don't re-open; the hash it writes survives the login wall's reload, so a fresh browser still lands on the exact profile after sign-in), and the embedded button now links /?profile=<id>. Verified live in the browser: /?profile=c_… → profile overlay mounted, param stripped, #customer= hash in place. 172 tests green.#customer=<id> hash deep-link (zero new main-app code; the hash survives the login-wall reload, so a fresh tab lands on the exact profile after sign-in). Adversarial client-side trace + deep-link recon ran as a parallel workflow; the trace also ruled out the scarier theories (edge-redirect body drops, App Bridge fetch-interception conflicts) against the live probe. 172 tests green (new assertions: fresh ledger in the adjust response, the vendor-balance nudge, the deep-link button, in-panel feedback, separator-proof parsing).server/shopify-admin.js: AUTH = Shopify App Bridge session tokens (5-min HS256 JWTs) verified with zero new secrets — the token's dest claim picks the tenant whose integration_settings shopify creds own that store, and THAT tenant's client secret verifies the signature (aud = its client id). White-label falls out for free: a new merchant's store maps to their own tenant and their own app credentials, same shell. The page: readiness tiles (shadow agreement, ledger-vs-vendor, catalog, subscription counts, flip gate), customer search, and a customer panel (vendor balance beside the Hermes ledger, recent activity, live coupons, subscriptions with product titles joined in). ONE write in v1: grant/deduct points — through the SAME getLoyalty provider seam the app itself uses, actor recorded as shopify:<user id> in the vendor note; redeem/void stay in Hermes until asked for. Shell rules held: styled ask() overlay (never window.prompt), Cache-Control: no-store + client id injected per tenant, CSP frame-ancestors locked to admin.shopify.com + the shop. Caught pre-ship by checking the schema against the queries: the subscriptions panel selected started_at/cancelled_at/product_title — columns that don't exist (real ones: status_changed_at, paused_reason, product_id joined against products.title). Wired through both gates (worker.js forwarding + the local dev server's identical gate in makeServer) and dispatched ABOVE the Hermes session wall — its auth is Shopify's, not a Hermes cookie. App config repointed: application_url → https://hermes.appolis.app/shopify-admin, embedded = true. 4 new tests (token gate: bad sig/wrong aud/expired/unknown shop/garbage · data + grant through fake vendor with actor asserted · zero-delta refused · CSP/no-store/client-id on the shell). 172 tests green.links.last pointed at 528 — so the tail-follow read NOTHING new since the cap was crossed; every pass burned its page budget and recorded no error. Fix in syncRivoEvents: perPage 100 → 250 (verified honored live — lastPage 528 → 211, ~6 months of headroom before the cap looms again), the deep cursor clamps into the readable range (a jammed cursor past the cap could never decrement — regression-tested), and tail errors now RECORD (last_error in the cursor payload) instead of vanishing in a catch{break}. Healed on deploy: 996 rows in one pass, Tyler's activity current through the minute, the forward shadow gate flowing again (136 matches in one pass). Durable fix horizon noted on the board: prove newest-first paging before the cap returns. 168 tests green.APPOLIS_APP_KEY landed on Hermes as a secret, and the intake call sends APPOLIS_KEY = APPOLIS_APP_KEY || ID_SECRET — so Hermes began authenticating to Kosmos's intake with a key Kosmos has never heard of → 404 → every in-app report died. Fix: the intake call sends the SHARED ID_SECRET explicitly. Lesson for the suite: a per-app key rotation silently breaks sibling integrations still expecting the shared secret — audit every x-id-internal consumer when keys rotate. (2) See-through button — the live DOM showed the v2.0.2 "fix" computing to rgba(0,0,0,0): it referenced var(--bg), WHICH DOES NOT EXIST in this app (the page background variable is --ink), and one invalid layer voids the entire background shorthand. Fixed with split properties + the real variable (backgroundColor: var(--ink) under the goldSoft tint gradient) — candidate values were tested live in the real DOM until computed styles proved opaque, then ported to source verbatim. (3) Boundary breaks — never a text-wrapping problem: measured live, the admin Assign-to flex row was 516px wide in a 385px rail (+148px past the edge) and the VIP-owner select overflowed +110px. overflow-wrap cannot touch flex rows or selects, which is why v2.0.2 changed nothing. Fixed: the row wraps (flexWrap + minWidth:0 + maxWidth:100%), both selects clamp (maxWidth:100%, flex: 1 1 120px). Live re-measure after: zero bursting elements. Post-deploy verification, not post-deploy hope: the tab was reloaded onto the shipped v2.0.4 bundle (same conversation restored via hash) and re-measured — opaque button, zero bursts, on production code. The v2.0.3 version channel + cache-busting reload made that reload trustworthy. THE STANDING LESSON, twice-paid now: UI fixes are verified by REPRODUCING in a browser, never by grepping the bundle for markers. 168 tests green.CF-Cache-Status: HIT on a response that says Cache-Control: no-store — that header governs browsers, not the assets cache). A colo serving the shell stale defeats everything downstream: the update chip compared the running bundle hash to the SHELL's hash — same stale shell, same hash, chip never fires — and a plain reload fetches that same stale shell, whose old bundle name still resolves because build.js keeps the last 2 bundles. Net effect: an open session can neither detect nor escape an old build. THE FIX: the version truth moved out of assets entirely. build.js now writes the bundle hash into WORKER CODE (server/buildinfo.js), which updates atomically on every edge the moment a deploy lands. New public GET /api/version (above the session wall — a hash discloses nothing) serves it; the update chip now compares against THAT, and its Refresh button navigates with a cache-busting query (/?v=<now>) instead of a plain reload, so it punches through any stale shell. One-time cost: sessions opened before this build still run the old chip, so they need ONE manual hard-refresh; every deploy after this one is self-detecting and self-escaping. 168 tests green.var(--goldSoft) — a translucent tint — so scrolled content rendered straight through it. Now composited over the page background (linear-gradient(tint,tint), var(--bg)) = identical look, fully opaque; zIndex raised and a seam shadow added. (3) Boundary breaks in the conversation side profile. The rail squeezes the full profile into a narrow column, and long unbroken strings (codes, emails) plus wide grid children burst the container. overflow-wrap: anywhere on the rail container (it INHERITS, so one setting covers every descendant) + minWidth: 0 + overflowX: hidden as the backstop. 168 tests green. All three checked off the board.subtotal_price verbatim — Rivo simply uses Shopify's field, which is exactly what computeEarn uses; and "round to nearest" is exactly Math.round, already pinned by tests. This also closes the loop on the old confusion: subtotal_price is ALREADY discount-net, which is why "subtotal minus discounts" scored 0.01% — it was double-subtracting. Comments in loyalty.js and the order normalizer rewritten so nobody "fixes" the base later. It also reframes the 5 remaining odd-base shadow disagreements: they are NOT a different base rule (there isn't one) — likely order-edit or refund timing; parked as diligence, not a blocker. Comments-only change; 168 tests green.loyalty_makegood table, populated from the shadow — 48 rows, 13,274 points (~$132) owed to active subscribers Rivo under-paid against the published Fam rate, each row carrying the full evidence string. Credited at flip, exactly once (unique member+order key, deterministic ledger-event ids). THE EARN WRITER (nativeEarnSweep, on the :10/:40 cron): pays new orders from the certified engine — tier rate × subtotal, subscription state and prior spend read at order time, expiry stamped one year out per the program. It refuses to exist before the flip (skipped:true unless the tenant's loyalty settings carry native_earn + flipped_at), which IS the mirror-until-flip contract: Rivo stays the only writer, nobody is double-paid. Awards append to the SAME ledger the balance is certified on (rivo_events, source hermes, deterministic ids) — so history is continuous across the switch with no seam and no seeding step. MILESTONE BONUSES NATIVE: loyalty_milestones claim-before-credit table — 2,500 at $300, 5,000 at $750, once per member EVER, enforced by a unique key and tested across separate sweeps. This replaces the Shopify Flow bonuses (which are invisible to Rivo's API). THE SWITCH ITSELF: POST /api/loyalty/flip — admin + typed confirm:"FLIP", refuses while the readiness gate is red (force exists, logged), credits every make-good, stamps flipped_at/native_earn/native_redeem, and returns the manual steps that remain (Flow bonus flows OFF, Rivo idle-then-uninstall, repoint the customer app). Flipping twice is refused. 3 new tests pin the guarantees: nothing writes before the flip · exactly-once after (deterministic ids) · make-goods and milestones can never double. 168 tests green. Readiness gate at this deploy: shadow 97.25% raw / 99.63% against the rules; still accumulating toward the 200+/99% bar it enforces.usageLimit: 1 — every code is single-use natively, used codes remain in the admin list with their count, and the planned delete-on-use webhook wire is CANCELLED as unnecessary. Voided (returned-for-points) codes still delete — they must stop being redeemable. (2) THE FAM DISAGREEMENT SPLIT IS UNAMBIGUOUS: all 38 subscribed BEFORE the order, zero after. So the timing theory is dead — these were active subscribers at order time, Rivo's own tier label says FLIP Fam, our engine paid Fam 10×, and Rivo paid them 7.5× or 5×. Rivo under-paid 38 of Tyler's subscribers against his own published rules. Counting those as CORRECT (they are, per the rules), effective agreement = (2,119 + 38) / 2,179 = 98.99% — the 99% gate is effectively at threshold, with 22 disagreements left to classify. The cutover question is now a POLICY question, not an engineering one: the gate was "match Rivo," but Rivo doesn't match Tyler's rules — cutting over on our engine would pay subscribers what the program actually promises. 165 tests green; native_redeem still off.discountRedeemCodeBulkAdd (add codes into a discount) and discountCodeRedeemCodeBulkDelete (remove ONE code) — so mint and void both work per-code without touching the pool. BUILT: shopifyEnsureRewardPool (find-or-create "FLIP Rewards — $X off", combinesWith locked at BIRTH — orderDiscounts:false / productDiscounts:false / shippingDiscounts:true — which makes one-reward-per-order NATIVE and erases the 60s stackable window the REST mint had), shopifyPoolAddCode, shopifyPoolDeleteCode (resolves the redeem-code id, deletes exactly that). nativeRedeem now mints INTO the pool (created on first use, remembered on loyalty_rewards.pool_discount_id); nativeVoid routes by shape — a gid:// in shopify_price_rule_id means pooled → delete the CODE; legacy per-code rows still delete the node. Getting that backwards would delete the entire pool: it is commented, routed, and tested. TRADE-OFFS ACCEPTED (recorded on the pool helper): no per-code customer lock (codes are random 12-hex; ownership enforced by our apply/return flows) and single-use rides on deleting the code on use rather than a native per-code limit. SHADOW STATUS at this deploy: 2,397 scored · 2,179 matched · 97.25% agreement, current and healthy. The 60 disagreements decoded: vendor rate lands EXACTLY on 7.5/5 on the same subtotal = TIER disputes, and 38 of 60 are members Rivo itself labels FLIP Fam yet paid lower — Rivo contradicting its own labels (matches Tyler's report of early-program tier errors persisting). Open question queued: whether the gate should be "match Rivo" or "match THE RULES." native_redeem stays OFF. 165 tests green.computeEarn, and it sits essentially at the old gate. The residual is a discount rule we have not identified, NOT a fault in the tier logic or the rate table. DEFINITIVELY KILLED: the net-of-discount theory. On discounted orders, subtotal scores 88.01% and subtotal-minus-discount scores 0.01% — it is essentially never right. discount_cents is captured but must never enter the earn base. ALSO TESTED AND REJECTED, all measured so none is ever retried: a first-order-subscriber grace window (85.27%, worse than strict); the hybrid base (42.49%); "Rivo's early bugs explain it" — no, all volume is June (92.09%, n=12,112) and July (89.50%, n=10,919) and the buggy months total 17 orders; and "mismatches cluster on stale-tier members" — only 126 customers are wrong on every order, 269 of 2,115 misses (12.7%), real but small. SO THE GATE MOVED. The historical replay applies today's facts to yesterday's orders and can never certify this engine. New loyalty_shadow table + shadowScoreNewOrders() / shadowMatch() / shadowScore(): every new order is scored the moment we see it — using the subscription state and prior spend true AT THAT MOMENT — then matched against what the vendor actually awarded. It runs on the :10/:40 cron and writes nothing to any balance; it is a measurement, not a mechanism. readiness() now refuses the cutover until forward agreement >= 99% over at least 200 live orders, and says so in blockers[]. The readiness test proves the gate both BLOCKS and OPENS. 165 tests green.subscriptions.started_at / cancelled_at added, captured from Recharge's created_at / cancelled_at in normalizeRechargeSubscription, COALESCE-wired through the upsert so a partial payload never wipes them, and backfilled from Recharge for all 71,906 subscriptions (288 paced pages, 1,798 batched CASE updates applied in 9 chunks — one file was too big for wrangler to import). Coverage: 71,906 of 71,968 rows have a start date, 50,787 a cancellation. RESULT: replaying with TRUE active-at-order-time scores 90.82% (20,933/23,048) — against 85.30% for ever-subscribed and 79.85% for currently-active. The timeline was the missing fact, and the jump is the proof. Four signals were tested and rejected before this one, all recorded so they are never retried: ever-subscribed 85.30 · active-now 79.85 · subscribed-at-or-before-order 81.37 · this-order-is-a-sub-order 80.66. STILL SHORT of the 95.4% gate by ~4.6 points. The residue is the OTHER miss class from the direction analysis — 565 UNDER-payments where our prior-spend running total is lower than Rivo's lifetime figure, so tier boundaries land late. That is an order-history completeness question, not a formula one. Nothing writes points. 165 tests green.computeEarn(), tierAt(), milestoneBonus() and FLAT_RULES in server/loyalty.js — pure functions: no db, no vendor, no clock, so the replay can run them over 23k historical orders and the tests can pin every boundary. 4 new tests cover the tier edges ($299.99 vs $300, $749.99 vs $750), Fam beating spend at every level, 7.5x rounding rather than flooring, and milestone bonuses firing once on the crossing (including one order that crosses BOTH). 165 green. SHADOW REPLAY — 23,076 real awards through the real function, zero writes: 19,659 exact (85.19%). Two questions settled by it: (1) the earn base is the subtotal, definitively — subtotal scored 85.30% against subtotal-minus-discount at 42.45%, so Rivo does NOT net off order-level discounts; (2) the "Rivo paid nothing" class that dominated the first sample of misses was a sampling artifact — only 28 of 23,076 (0.1%) actually earned zero. THE REMAINING 14% IS NOT THE FORMULA, IT IS THE TIER RECONSTRUCTION. Asking "does the award match ANY of 5/7.5/10" scores 99.07%; asking "does it match the rate MY reconstruction predicts" scores 85.30%. The formula is right; knowing who was in which tier WHEN is not solved. Prime suspect, and it was flagged as a risk before the replay ran: the replay tested whether a subscription EVER existed, not whether one existed AT THAT ORDER DATE — so a customer who subscribed later is wrongly paid Fam 10x on their earlier orders. Prior-spend completeness is the second suspect (our orders table may not reach back as far as Rivo's lifetime figure). NOTHING WRITES POINTS until the replay clears — that is the whole point of building it this way, and it is the same gate that caught the balance formula double-counting clawbacks in v1.4.3.subtotal_cents / discount_cents for all 23,249 orders that carry a loyalty award — pulled from Shopify 250-at-a-time by id (~93 paced calls, every one usable, zero missing subtotals) and applied as 775 batched CASE updates. Then tested Tyler's published tier rates against what Rivo actually paid, on 23,076 real awards: 22,861 matched a tier rate exactly (99.07%) — 5× on 6,328 (FLIP Member), 7.5× on 846 (Platinum), 10× on 15,687 (VIP + Fam). THE FORMULA: points = round(tier_rate × discounted_subtotal_in_dollars). No per-tier rounding quirks, no hidden multiplier — one rule, three rates. This is now PROVEN against production data rather than inferred, which matters because the inference attempt one version earlier concluded the exact opposite (that the rate was flat) off a scrambled current-tier grouping. The 215 unmatched (0.93%) are the expected edge: a tier crossing mid-order, promo multipliers, or a manual adjustment landing on the same order. Also added the two indexes the certification needed and the app will keep needing: rivo_events(tenant_id, order_id) and orders(tenant_id, shopify_order_id) — the join between awards and orders had no index at all and was hitting D1's CPU limit outright. REMAINING before the engine can write: reconstruct tier-at-order-time from cumulative spend (the 5/7.5/10 split is known, but which member was which tier WHEN is not yet derivable), then computeEarn() as a pure function, then shadow-compare before a single point moves. 161 tests green.total_cents — proven by matching 23,047 real order_placed awards against order totals: only 99 matched. orders now carries subtotal_cents and discount_cents, populated from Shopify's subtotal_price / total_discounts in normalizeShopifyOrderFull and COALESCE-wired through both the INSERT and UPDATE paths so a payload lacking them can never wipe a backfilled value. Columns added to prod D1 and to MIGRATION_COLUMNS. THE PROGRAM, from Tyler (2026-07-29) — this is the spec, not an inference: earn rate is the member's TIER rate per dollar — FLIP Member (lifetime $0-299) 5 · FLIP Platinum ($300-749) 7.5 · FLIP VIP ($750+) 10 · FLIP Fam (subscribers, not spend-based) 10 — with milestone bonuses of 2,500 at Platinum and 5,000 at VIP. Flat rules: SMS signup 150 · social follows 50 · birthday/review/anniversary 500 · 1st subscription renewal 500 · referral 1,500 (friend gets $15 off). Redemption: $5-$25 at 500-2,500 pts, max $25 and one coupon per order. A CORRECTION TO THE PRIOR ANALYSIS: v1.4.3's reading that the rate is flat because "the max ratio is ~10 for every tier" was an ARTEFACT — customers.rivo_tier holds the CURRENT tier, so grouping historical orders by it scrambles the rate. It genuinely is tier-multiplied, which means reproducing an award needs the tier at the time of the order, not today's. Tyler's spec also identified the two sources that matched no Rivo rule: shopify_flow and vip_tier_upgrade (both 2,500-5,000 pts) are the tier milestone bonuses, run through Shopify Flow because Rivo could not do them — so replacing Rivo means rebuilding those flows, and they are invisible to its API. STILL OPEN before the engine can be trusted: backfill the subtotal on historical orders, reconstruct tier-at-order-time, then pin the exact rate and rounding across all 23,047 awards and shadow-compare computeEarn() against what Rivo actually paid — the same certify-before-flip discipline that caught the balance formula double-counting clawbacks in v1.4.3. 161 tests green.customers.rivo_points — a snapshot refreshed only opportunistically, which therefore cannot certify anything. Verdict: a plain SUM of EVERY event matched 1,045 of 1,076 members exactly (97.1%), and it BEAT the old revoked-excluding formula (1,036). That is the proof that Rivo represents a revocation as its own offsetting event, not by invalidating the original — so the revoked_at IS NULL filter this app shipped was DOUBLE-COUNTING every clawback. Removed from both ledgerBalance and reconcile. Where the ledger still disagrees it is almost always LOW (39 low vs 1 high) — members whose earning predates the feed, which only reaches 2026-03-12. Coverage is good: of members carrying a balance, only 21 have no history mirrored. This also vindicates the earlier 94.13% reading — that was the stale snapshot column, not a wrong ledger, exactly as suspected. EXPIRY ANSWERED (Tyler): points last ONE YEAR from issue. Every credit already carries expires_at (47,644/47,644), so the field is reliable and needs no deriving; because the feed starts 2026-03-12 the first expiries land ~March 2027, at which point the SUM must begin excluding expired credits or every balance drifts high — recorded in the formula comment. ALSO CORRECTED: the "events table grew 5x to 248,805 rows" claim in v1.4.2 was wrong — that figure is the sync cursor odometer, not a row count; the table holds 49,139 rows. The v1.4.2 root cause (the unindexed lower(email) scan over 153,567 customers) is unaffected and stands. 161 tests green.GET /api/loyalty/readiness (admin, zero vendor calls, so asking never costs Rivo anything): switch state, catalog counts, our minted coupons, ledger accuracy, whether the mirror finished its backfill, an explicit blockers[], and an honest still_vendor_owned[] naming what a flip does NOT remove (the points balance, earning rules, VIP tiers, the storefront widget). FIRST LIVE READING — correctly NOT READY, and the reason is real: the events mirror is still backfilling (page 214 of 468, 25,380 events stored, ~4h left at 25 pages per half-hour), so ledger balances are genuinely partial and the gate blocks the flip. Working exactly as designed. CAVEAT ON THE 98.56% FIGURE (7,193/7,298 exact): it compares the ledger to customers.rivo_points, which is only refreshed opportunistically (profile opens, link backfill) — so it is a smoke alarm, not a certificate. The worst drifts are mostly ledger-HIGH against a vendor: 0 column, whose likeliest cause is a stale column, not a wrong ledger (a genuinely partial mirror biases balances LOW, and exactly one member shows that shape at −5,673). Proving it properly needs a live per-member vendor read, which is precisely the vendor load we are not spending while Rivo is live. Revisit after backfill completes. 2 new tests; 161 green.loyalty_rewards table, seeded FROM Rivo (Tyler: "it would obviously have to learn everything from Rivo, get an accurate layout, point structure"). Import is idempotent on (tenant, vendor, vendor_reward_id): updates prices/names in place, disables rewards the vendor drops rather than deleting them (a redemption in our book may still point at one). Values stored in CENTS so money never rides a float. Live: all 5 rewards imported ($5/$10/$15/$20/$25 at 500–2500 pts). (2) OUR MINTING — shopifyCreateDiscountCode(), shaped EXACTLY like a live Rivo code captured from the store first (fixed_amount, value "-25.00" in dollars, customer_selection: prerequisite with prerequisite_customer_ids locking it to one customer, once_per_customer, usage_limit 1, title = the code). A near-miss would behave differently at checkout, so it was copied, not guessed. If the code create fails, the price rule is rolled back — that orphan is the exact thing that read as a live discount in the admin all evening. (3) OUR BOOK — new loyalty_redemptions table holding both Shopify object ids. Deliberately NOT the rivo_* mirrors: those are overwritten by the sync and swept of rows the vendor no longer lists, so nothing we author could safely live there. (4) THE UN-REDEEM RIVO NEVER HAD — POST /api/loyalty/void-coupon deletes both Shopify objects and credits the points, claim-before-credit so a lost response can't pay twice. Clean by construction: we created both objects, so we can remove both. ORDER MATTERS, and it is the deliberate kind: mint first, deduct second. A failed mint after a deduct leaves the customer short with nothing and cannot be undone (the vendor has no un-redeem — the reason we're here); a failed deduct after a mint is recoverable, so the coupon is deleted and the redemption row removed. Tested both ways. OFF BY DEFAULT. The vendor redeem path is untouched until a tenant sets native_redeem on its loyalty settings. 5 new tests (exact shape, orphan rollback, catalog import/update/disable, full redeem→void round trip, failed-deduct cancellation); 159 green. Tables created in prod D1 by hand — note D1 does NOT run openDb()'s schema, so new tables need an explicit wrangler d1 execute --file.POST /internal/loyalty-touched the instant a customer redeems, applies, removes or returns (1.2s leash, best-effort — their action is already committed); Hermes catches BOTH mirrors up on the spot via waitUntil and busts the balance memo, so the agent view is current within a second or two. Same shared secret + shop-picks-the-tenant rule as the sibling route. (2) ON VIEW — opening a profile now kicks a background ledger-tail pull when the mirror is over a minute stale (the coupon list already did this); the list still returns instantly. (3) HEARTBEAT — a new /5 * cron lane runs the cheap half (tail pages + coupon walk, no deep re-walk, no Shopify verification, no link backfill, so it always finishes). This is the safety net for pings lost to a deploy or timeout — and the only thing that catches work done in Rivo's own admin, where nobody pings anyone. (4) LIVE PANEL — the loyalty panel re-polls every 20s while on screen and refreshes immediately on tab focus, paused when hidden, and never yanks a date-filtered or paged-deeper view out from under an agent. Verified live: bad key → 404, foreign shop → 409, real ping → 200 with the ledger re-synced 5 seconds later. Also live: the reconcile tile is now self-populating from the cron — 98.56% exact, 105 drifting (up from 98.47% at ship, so the deep re-walk is already healing stale revoked_at). 2 new tests; 154 green.server/loyalty.js: a provider interface (summary/award/redeem/rewards/earningRules) with Rivo as the first implementation. Every route now calls the provider, never integrations/rivo directly — swapping in an in-house engine is now "write a second provider," zero route changes. (2) The ledger stands up — ledgerBalance() computes the balance from OUR mirrored events (SUM(points) over non-revoked rows). Formula proven against live data: spends already ride the feed as negative events (subtracting rivo_coupons double-counts), revocation stamps the original row (no offset event). Profiles now carry ledger_points + ledger_drift; the panel shows "⚖️ verified against the Hermes ledger" when they agree and a quiet amber chip when they don't. (3) Reconcile, continuously — reconcile() scores the whole book every cron pass into an IT-board tile (exact %, drifting members, worst offenders). Measurement trap found and fixed the same hour: comparing against ALL customers produced 15,091 phantom drifts — those rows' rivo_points=0 is the ingest DEFAULT, never a synced value. Scope = members with a real Rivo link (rivo_customer_id set, written only by a live read). Live baseline: 7,243 linked members, 7,132 exact (98.47%), 111 drifting. (4) Sync hardening (what the verifiers demanded) — (a) BURST-PROOF TAIL: the steady-state re-pull now covers every page the feed grew since the last run (was a flat 2 — >200 events in a half hour leaked the middle pages PERMANENTLY); (b) DEEP RE-WALK: revocations mutate old events in place, sometimes weeks later, and the tail never revisits old pages — a rolling cron-only cursor re-reads 2 old pages per pass and wraps (whole book every ~5 days), healing stale revoked_at; (c) SWEEP GRACE: the coupon delete-sweep now spares rows seen in the last 2 hours, protecting local-first mints from being eaten before Rivo's feed surfaces them. (5) Writes land in OUR book first — redeeming inserts the coupon row immediately (keyed exactly as the sync keys it; ON CONFLICT merges vendor truth later, the grace window protects it). Every point-moving route fires a post-write tail sync via waitUntil, so the activity feed and ledger are correct in seconds, not at the next cron. NOT done (deliberately): local-first EVENT rows — awardPoints discards Rivo's response and the event id isn't verifiable from code, so a local insert couldn't dedupe (verifier-refuted); the tail sync gets the same result through the id-keyed path. 4 new tests (formula+reconcile, burst tail+deep cursor, sweep grace, local-first merge) — 152 green. NEXT PHASES: expiry semantics (unverified whether Rivo emits expiry feed events — the reconcile tile will show it as drift growth), rewards catalog table, native Shopify code minting, then the earning engine.returnCreditFor() reads the compensating credit out of our own mirrored rivo_events (the proof was already there: "rivo-sub-apply return of REW-F25578D7BC04", 2500 pts, 22:20:20), so the agent now gets "already returned for points on Jul 24, 6:20 PM — 1000 pts are back on their balance". No return credit found → says what IS known and what to do ("grant them manually"), never a guess. Either way the row is marked so it leaves the coupon list. Deliberately keyed on the LEDGER, not a vendor call — any loyalty system we swap Rivo for has an events feed, so this survives the swap (todo re: replacing Rivo in-house). Also adds inbound POST /internal/coupon-returned so the rewards app tells us in seconds instead of at the next cron: same shared secret as our outbound notice, and the shop picks the tenant rather than anything in the body. Verified live: bad key → 404, foreign shop → 409, real → 200. Backfilled Tyler's 2 stuck rows. 148 tests.rivo-sub-apply) that decided liveness from Rivo — and Rivo has no un-redeem API, so it lists refunded redemptions as live forever. That app now reconciles against Shopify itself (its v1.45, which also closed a real double-credit: the ghost's Return button paid the points out a second time, and its Apply button could mint a fresh Recharge discount). This release adds the fast path: after a successful awardPoints, notifySubApply() fire-and-forgets a POST to that app's new /internal/code-dead, so the coupon disappears in seconds rather than at its next check. Fire-and-forget on purpose — the refund is already committed and correct, and the other app self-heals, so an unreachable rewards app must never fail or slow a good refund (tested). Config rides deps.subApply from RIVO_SUB_APPLY_KEY (its own secret, never that app's TRIGGER_SECRET) + RIVO_SUB_APPLY_URL; we send shop_domain and the receiver refuses anything that isn't its own store, which is what keeps this multi-tenant caller out of a single-store, tenant-less keyspace. Only 2 codes were ever Hermes-refunded (both Tyler's tests) — both backfilled by hand. 1 new E2E test (notify shape + secret + shop, and a refund still 200s when the rewards app is down); 146 green.codeDiscountNodeByCode → null) while still sitting in the merchant's active discounts, indistinguishable from a live one. shopifyDeleteDiscountCode now removes the empty rule too, gated on title match + zero surviving codes, so a rule hosting other codes is never touched. Verified live: rule 1710630469933 → DELETE 204 → 404 GONE. A full sweep of all 788 REW- price rules found 0 other orphans — that one was created by this very bug and was the only piece of litter. Three regression tests cover delete-both, shared-rule-untouched, and already-gone-is-success.<Detail> was never passed onToast / onOrder / onRefresh (the at-risk card's copy has them), so every confirmation and every error from the Rivo panel was swallowed. His refund actually SUCCEEDED — 2,500 points credited at 23:47:42, exactly once, correct marker — he simply never saw it, and then "verify" appeared to delete the coupon when it was really just filtering out the one he'd already refunded. (2) The refund request was far too slow to answer. It re-synced the entire coupon feed first (9 Rivo pages + 40 Shopify lookups), so the browser gave up mid-flight while the server carried on. The authoritative check is the single live Shopify lookup (~0.8s); whole-feed freshness is the cron's job, and Shopify verification is now cron-only (verify option, off by default). (3) A refunded coupon was left SPENDABLE. The void step only deleted the Recharge mirror, but Rivo's codes live in SHOPIFY — so the customer got their points back AND a working coupon. Refunds now delete the real Shopify discount code (shopifyDeleteDiscountCode, via the lookup redirect's price-rule + code ids). Tyler's outstanding REW-F6D4464B03E7 was voided by hand — verified dead. 142/142..catch() swallowed it, and the sync quietly stored nothing. Batch size is now derived from the column count (6–7 rows), never a flat number. (2) The Rivo syncs were starved by the cron budget. They sat at the END of the 30-minute run — the exact starvation the report-warm comment at the top of scheduled() documents from 2026-07-11 — so the earlier sweeps ate the invocation and the mirrors were never reached. They now have their own cron trigger (10,40) with their own budget, each sync in its own try so one failure can't starve the next, and background refresh failures are logged instead of swallowed. PROVEN LIVE: the :40 run stored all 875 coupons and 3,580 events, and the events cursor moved for the first time since it stalled. 142/142.used_at: null even for codes that were spent, but every REW- code exists in Shopify as a price-rule discount that counts its OWN usage. New shopifyDiscountStatus(code) (REST lookup → 303 → the discount's usage_count) answers three states honestly: exists + usage_count>0 = SPENT, exists + 0 = still good, 404 = the code is GONE from the store. Wired in three places: the coupon sync verifies a batch of 40 per pass (daily re-check, oldest first, and an unresolvable code is left UNKNOWN rather than guessed), the coupons list drops codes the store no longer has and greys out spent ones, and the refund route asks Shopify live before crediting a single point — that one call is the difference between a correct refund and paying the customer twice. FOUND ON TYLER'S OWN ACCOUNT: all 55 of his coupons are gone from Shopify — precisely the "showing up in Hermes but not on the actual site" he reported. Marked, and his list is now clean. 142/142.seen_at and, after a COMPLETE walk, sweeps rows Rivo no longer returns — previously a deleted coupon lived forever in our mirror and kept offering a Refund button ("ones that are able to be refunded that are not showing up on the actual site"). (3) A refund now REFRESHES the coupon mirror before judging — a rare, deliberate action can afford ~9 page reads to be certain, so it can't act on a 30-minute-old row; and the coupons list kicks a background refresh whenever the mirror is older than 5 minutes. (4) Points update live: the profile used to re-read Rivo only when the loyalty link was MISSING, so any balance change made in Rivo's admin never appeared. It now always re-reads (memoed 60s), the memo is DROPPED on every points mutation so a redeem/refund/grant shows immediately, and a fixed falsy-check means a customer who spends down to 0 finally shows 0 instead of their old balance. (5) The remove-from-charge error: removing a coupon from a charge that no longer has one is now a plain success ("already had no coupon"), and a charge that has left the queue says so instead of "does not belong to this customer". 141/141.POST /api/admin/rivo-sync?what=events|coupons&pages=N) so a mirror can be driven on demand instead of waiting for the cron. AND the coupon row now carries its full action set where an agent will actually find it: an ON ⟨date⟩ badge when that code is sitting on a subscription charge, ✕ Off charge to detach it, → To charge to place it on an upcoming one, ⧉ to copy, and ↩ Refund — previously "remove from charge" only appeared in the moments after a fresh redemption. 140/140.ThreadModal had NEITHER of the two things the order sheet already had: no client cache and no neighbour prefetch — every reopen and every ‹ › paid full Gorgias latency again. Now: a 90s client cache, the prev/next conversation prefetched the moment one lands (so arrow-nav is instant), the Gorgias fetch memoed 90s server-side, and the pain-point mining write moved off the response path onto waitUntil — it was making the agent wait on bookkeeping. Sending a reply busts both caches so you always see your own message. (2) LOYALTY HISTORY: View more pages further back 25 at a time, plus a date-range finder (from → to) to jump to a specific window, with the count shown. (3) COUPON MANAGEMENT: their coupons are now listed in the Rivo section (with used/refunded/revoked state) and can be managed — remove from a subscription charge (the code survives, it's only detached) and ↩ Refund for points (takes it off any charge, voids the code, credits the points back with a marker naming exactly what it undid). Guards: never a spent coupon, never twice (the refund is CLAIMED before the credit, so a lost response can't double-credit), never a non-REW code, and a genuine failure releases the claim so a retry works. New rivo_coupons mirror (small feed, refreshed whole each cron pass). 140/140.redeemReward() was dead code that would have 405'd anyway (it POSTed to /customers/{id}/redemptions, a catch-all alias that allows only GET/PUT, and read a discount_code field that doesn't exist). Now it works end to end: 🎁 Redeem their points for a coupon lists the live Rivo reward catalog (the 5 point rewards: $5/500 · $10/1000 · $15/1500 · $20/2000 · $25/2500 — flat 100 pts = $1), marks what they can afford and highlights the MOST their balance can get, spends the points on confirm, and shows the real REW- coupon with a ⧉ Copy button. If they have queued Recharge charges it then offers to put the coupon straight on one — the rivo-sub-apply flow, in-app: ownership-checked, one-discount-per-charge respected, Recharge's refusals translated into plain English. Every redemption and apply lands as a profile note with the agent's name. The write contract was verified live WITHOUT spending anyone's points (bogus customer → CustomerNotFound; real customer + impossible reward → CannotSpendPoints; wrong param → reward_id is missing): it is POST /points_redemptions {customer_identifier, reward_id}, coupon returned as code. (2) Order sheet: the Flexport read now fires IN PARALLEL with the Shopify one (it never needed to wait — the row already has the id), so first load drops a round trip. (3) Loyalty activity shows a SUMMARY of the reason (Rivo's plumbing prefixes stripped, first sentence, capped) with the full text on hover. ALSO FIXED, pre-existing: several Recharge routes read creds.api_token while Recharge actually saves access_token — the 🏠 subscription address manager was dead in prod for exactly this reason. All Recharge token reads now accept either. 138/138.GET /points_events is a GLOBAL feed and EVERY customer filter on it is silently IGNORED — proven live, byte-identical rows for a real member id, no id, and a bogus id (page 1 opens with jeff@rivo.io's TikTok follow). filter[customer_id], q[customer_id_eq], customer_id, email and ?include=points_events were all probed: all ignored. So the "activity" list was strangers' data. THE SAME VENDOR TRAP already documented on Rivo's /customers list — now documented on this endpoint too. FIX: mirror the feed into a new rivo_events table (cron walks it BACKWARD from the newest page so recent activity lands first, then steady-states on the tail; deterministic ids = idempotent) and serve each profile's activity from D1 by member id — correct, private, instant. Until the mirror reaches a member the UI says "Syncing loyalty history from Rivo…" instead of borrowing someone else's. Kevin's two real events (+2,500 Shopify flow, +2,499 order — his exact 4,999 balance) verified against the live feed and seeded to prod. (2) SPEED: the sheet was slow on EVERY open because an order without a stamped Flexport id re-probed Shopify + up to 4 Flexport external-id candidates each time — and current-era orders are never indexed that way, so all 5 calls failed, every open. That probe is now memoed 6h (hits still persist to the row), and the two live Flexport reads (order ~500ms, global returns list ~600ms) are memoed 4 min. 136/136.backfillRivoLinks, 60/run every 30 min) walks the whole customer population by a forward id-cursor — resolves each row's Rivo membership by its Shopify id (= the Rivo member id), persists the link for members, advances past non-members (404) so none is ever re-checked, and rests for 14 days once the population is walked (then re-scans for newly-added customers). Bounded so it shares the cron budget and never hammers Rivo; the on-view self-heal still lights up anything viewed instantly. 134/134.subscriber (live/paused sub on file), and the stalled-shipment lead reads subscriber-aware: subscribers keep the churn framing; one-time buyers get the same urgency in the right words — "no subscription to churn, but a lost-package complaint (and their trust in the store) is in waiting if it does not move." (Win-backs already had their own non-churn framing — same principle, now applied to actives.) 132/132.detectInPlaceResend reads the signature off any Flexport order fetch; recordInPlaceResend persists TWO shipment rows keyed strictly by Flexport shipment id (new columns kind/replaces_shipment_id/detail, migrated local+prod) — the lost row keeps status='lost' forever, the resend row carries kind='resend' + the story JSON. Detection runs at BOTH Flexport touchpoints (order-sheet open + the delivery cron). Surfaced everywhere the truth matters: 📦 banner in the order sheet's delivery card (lost SKUs → replacement shipped → new ETA vs original promise), "RE-SENT IN PLACE" rows in the profile's Replacements ledger + crisis counts, overdue/potentially-lost measured against the RESEND's ETA (not the already-failed original promise), live ship_status reads the resend not the historical lost row, the delivered-heal never erases a replaced package's lost record, and the shipping_unremedied crisis signal counts an in-place resend as "made right". 278526 backfilled in prod — open it and the story is there. 131/131.width=1500 and the shrinker only ADDED width when missing — so the CDN served the full 5.80MB file (over the 4.5MB Claude ceiling). Fix, two layers: (1) the shrinker now OVERRIDES any pinned width larger than 800 (smaller widths kept — never upscale): the same file at 800 is 1.83MB; (2) if bytes still exceed the cap (dense master / un-resizable), one automatic step down to width=400 before a clean skip reason — no Claude call ever wasted on an unscannable file. (Also another PNG-in-.webp-clothing — the byte sniffer already handles that half.) Regression test steps the exact ladder. 129/129./cdn/shop/files/preview_images/. Those are the POSTER images Shopify renders for VIDEOS in the library: the poster's filename matches no File and no product image, because it's some video file's preview.image. New third lookup path in setShopifyImageAlt: when filename + product-image both miss, walk the Files library (250/page, cached per batch) matching the URL against every file's preview-image URL, and set the alt on THAT file — the storefront poster inherits it. Fix notes read "applied in Shopify (video preview)". Regression test uses Tyler's exact failing filename. 128/128. Re-run ⚡ Apply 👁 scanned and the 7 land.remaining (open items with no vision draft and no skip marker — strictly falls every pass, so the loop always terminates). ALSO FIXED a pre-existing cost bug the new test exposed: batch scans were RE-scanning skip-marked items on every click (the "don't re-try SVGs" comment was never enforced) — batches now honor the skip marker; the per-item 👁 stays the deliberate retry path. One batch op runs at a time (the other buttons disable). 127/127.vision_only mode on the apply-alts route filters to vision-sourced drafts, applies up to 40 per click, and reports remaining so the toast says "12 more remain — click again"; every fix note now names the drafting model (👁 claude-haiku-4-5). Proven end-to-end against a faked Shopify Files API in the suite (vision drafts applied via fileUpdate, filename draft verified untouched). ALSO fixed the phantom "3-minute test hangs": a failed assertion skipped server.close(), leaving the runner alive — the new test closes in finally. 126/126.fl_img_05.webp threw a Claude 400 — PROVEN cause: the file is a PNG uploaded with a .webp name (Shopify CDN served PNG bytes; the code trusted the extension and told Claude webp). The media type now comes from the image's MAGIC BYTES first (jpeg/png/gif/webp sniffer), then the CDN header, extension last — and formats Claude can't read get a clear reason instead of an API error. (2) New sticky "⚡ auto-apply scans" toggle beside the model picker: every successful CLAUDE result goes straight into Shopify + marks the item fixed (fix note names the drafting model) — including vision drafts from earlier passes that were never applied (no re-scan cost). ONLY 👁 results ever auto-apply; filename drafts still need the manual ⚡ approval. Apply failures keep the draft and report why. 123/123.lib/vision.js grew a validated MODELS list (typo'd/foreign model ids get a clear 400, never a silent fallback to a different bill); every drafted item stamps extra.alt_model, and the 👁 badge tooltip names which model drafted it. 122/122.fetchGorgiasThread now does ONE quiet retry on 401/5xx (400ms gap) so single hiccups never reach CS; genuine auth breakage still surfaces because the retry fails identically. The app's views were ALREADY URL-addressable (#customer= / &thread= / &order= / &sub= restore on load — the back-button work paid forward), so the missing piece was making the link SENDABLE: every sheet bar (customer profile, Gorgias conversation, subscription, order) now carries a 🔗 button that copies a canonical hermes.appolis.app link to the exact open view — critical in the installed PWA, which has no address bar to copy from. Self-contained confirmation toast; clipboard-blocked browsers get a copy prompt. Recipient just needs to be signed in to Hermes. Tyler hit the Rivo custom-action form (fixed points value, completed-action name, etc.) and challenged the route — a deeper probe found source:'manual' is valid on POST /points_events (Rivo's own error: "You must supply a points or credits amount for a manual points event"), i.e. the proper admin-adjustment vehicle with an arbitrary amount and zero Rivo-app setup. awardPoints now posts {customer_identifier, source:'manual', points_amount, internal_note}; the custom-action plumbing (adjust_action_name field + error mapping) is removed. SMOKE TEST PASSED same hour (supervised, Tyler's own profile, net zero): 8550 → +1 → 8551 ✓ → −1 → 8550 ✓ — points_amount confirmed, negative deducts, both ledger events carry the reason note. The Rivo controls are LIVE for CS. ROOT CAUSE FOUND BY LIVE API PROBE: the Merchant API endpoint Hermes shipped with (POST /customers/{id}/points_transactions) never existed — everything under /customers/{id}/* aliases the customer route (405 on POST), so every in-app grant/deduct since day one failed; that's the "mini bar did not work." The REAL contract (discovered via OPTIONS + validation-error probing, zero mutations): POST /points_events with customer_identifier + source:'custom_action' + the EXACT name of an earning method that exists AND is enabled in the Rivo app; points_amount carries the amount, internal_note the reason. SHIPPED: (1) awardPoints rewritten to the real contract + new listEarningRules (verified endpoint GET /earning_rules — FML's program has 13 rules, no manual-adjust action yet); (2) the points route takes the action name from /connections (adjust_action_name, default "HERMES Adjustment") and returns ACTIONABLE copy when Rivo says the earning method is missing; (3) UI — the TOP Rivo loyalty panel now carries the fast functions (+ Points · − Points · 🎛 Rivo box →) and the broken mini bar is RETIRED; (4) the 🎛 Rivo box modal = everything-Rivo for the open customer: balance/tier/lifetime, grant/deduct, redeem-as-save when eligible, live ways-to-earn list. ⚠️ ONE TYLER STEP TO ARM IT: create + enable a Custom Action named exactly HERMES Adjustment in Rivo → Program → Ways to Earn (or set your own name on /connections) — until then the buttons explain exactly that. First live grant to be smoke-tested together (±1 pt) after the action exists. The Agora portal pill now opens agora.appolis.app (custom domain provisioned today) instead of the workers.dev URL — part of the suite-wide "workers.dev is never user-facing" sweep ahead of Tyler renaming the account subdomain flipmylife → appolis.ACTIVE + 6 CANCELLED uppercase rows beside the lowercase majority, and every downstream check compares lowercase (the SQL live_subscribers aggregate, the ✖ SUBS CANCELLED badge, the cancelled-banner guard) — so those 742 customers read as sub-less/cancelled at the top while the per-sub list further down showed the live sub. FIX at three layers: (1) upsertSubscription normalizes status to lowercase at the ONE write door (sync + webhooks); (2) prod data normalized (status=lower(status) — 21,095 active / 49,742 cancelled, clean); (3) the badge check lowercases defensively. If one sub is active, nothing on the profile can call the customer cancelled anymore. (Claude Code / Tyler — the company-card merge key). Tyler's steer: Spartan Studios is HIS company; Flip My Life Wellness (the Hermes business) gets its OWN Agora team, packaged into their account. New tenants.appolis_team_id column (schema.js + deploy/schema.sql + prod ALTER, additive); /internal/overview now ships it as teamId on each business row, which is exactly the key the Kosmos v6.45 company cards merge on — one FML card, Agora + Hermes buttons. PROD DATA: registry team team_fml "Flip My Life Wellness" created (Tyler = Owner until FML's admin holds an Appolis ID — ownership transfers then); t_verdant.appolis_team_id = team_fml. Also corrected seed-era junk on the tenant row: shopify_domain verdant-gut.myshopify.com → flipmylifenow.myshopify.com (read live from the connected Shopify integration; nothing in code reads the column — display/reserved only). (Claude Code / Tyler — the Studio-UI half of Phantasia todo_648; engine shipped as Phantasia v0.6.0). Built in THIS page's canonical Studio components (the drift rule: Phantasia re-exports them, Kosmos proxies the same shell — one build, three apps). (1) 🏛 House rail: any non-house workspace's 🪄 Pipeline tab lists the studio's published pipelines above its own, each with ▶ Use this pipeline → POST /api/studio/ai/pipelines/:id/use clones it into the runner's workspace (their takes, winners, billing) and opens it; microcopy says the house examples ride along and stay the studio's. (2) 🏛 Publish toggle (house workspace's admin only, keyed off the new is_house flag on GET /pipelines — presentation-only, the server 403s everyone else): Publish-to-everyone / Published in the open-pipeline header. (3) 🏛 badges: house clone + published markers on list rows; an open clone carries a sealed-refs hint pill. Also in this deploy: prod tenant rename "Verdant Gut Co." → "Flip My Life Wellness" (t_verdant, name column only — Tyler's steer; the Kosmos companies strip reads it live via /internal/overview). (the "loading slow" bug) (Claude Code / Tyler). Tyler felt the app "loading the content slow" after today's five deploys. ROOT CAUSE: the edge caches index-compiled.html, and every build DELETED all old hashed bundles — so right after each deploy, a minutes-stale cached shell pointed at a bundle that no longer existed → script 404 → blank/half-loaded app until the cache revalidated. Five deploys = five breakage windows. FIX, both ends: (1) build.js keeps the last 2 old bundles as a grace window — stale HTML now loads the previous working version instead of nothing, and the refresh watcher swaps it forward; (2) the Worker stamps Cache-Control: no-store on every HTML response (run_worker_first means every page passes through it), so shells are never held stale — hashed bundles/vendor keep their year-long immutable cache untouched. Verified live: previous bundle still 200 after the new deploy, HTML no-store, new bundle immutable. LESSON for every Workers+Assets app that deletes old hashed bundles: deploy-time cache windows turn "stale HTML" into "broken app" unless old bundles get a grace window.Hero Shot: v2! → Hero Shot v2.png); text assets (ad copy, briefs) save as .txt of the words themselves. Assets cap at 50MB so the one-shot copy stays well inside Worker memory. Route POST /api/studio/ai/assets/:id/to-myfiles sits behind the Studio's master-admin gate like everything else in that block. 121/121.#tab=huddle deep links fall back to the default tab. 🛰 GET /internal/overview (todo_664, note_649 steer #12): machine-only endpoint gated by the shared ID_SECRET (x-id-internal; 404 otherwise, mirroring Agora's) returning {app:'hermes', businesses:[{id,name,role,users,customers,connectors}], stub} — a business admin sees their business, the platform master sees every business — so the Kosmos "your companies" strip reads Hermes and Agora alike. Verified live incl. Tyler's real payload (Verdant Gut Co · 152,516 customers). 120/120./webhooks/* is the sole exception: it keeps resolving on flipper because some integrations self-registered their callback against that origin while it was the primary custom domain (v0.66–v0.69), and the workers.dev URL still serves webhooks too — no live Recharge/Flexport/etc. callback breaks. Needed assets.run_worker_first so the Worker runs before Cloudflare serves static assets (otherwise pretty-paths bypassed the redirect); that surfaced a latent .html↔clean-URL bounce, so the Worker now passes CLEAN paths to ASSETS and lets html_handling resolve them (fixing a redirect loop the change would otherwise have created on /connections + /products). Verified live: flipper 301s everywhere, hermes serves 200 with no loop, webhooks pass through./api/studio/ai/assets gallery (already proxied to Phantasia), so a person sees their own creations — the house pipeline files that make a shared pipeline run are never exposed (Phantasia scopes them to the house tenant, invisible here). Images/video show a thumbnail that opens full; audio/text get an icon; each row has download (or ⧉ copy for text) + ↗ open. Gated to master admins (the same rule as the Studio itself — role ladder), so the toggle only appears for those who can reach the Studio; it self-hides otherwise. First half of the Future-Updates item 8; the Phantasia-engine half (baking house pipelines in so everyone can run them without seeing the house files) is routed to the Phantasia lane. Follow-up: attach a Studio file straight into a customer reply (needs a share-link mint for Studio assets). 119/119..appolis.app cookie — Hermes resolves it on load (POST /api/auth/appolis → /id/resolve over the new APPOLIS_ID service binding) and signs you straight in, no typing. Login-form dual-auth: if your Hermes password misses, the same credentials are tried against Appolis ID (/id/check, Kosmos's exact move) — an entitled account (hermes/flipper/* master) signs in AND gets the shared suite cookie handed back, so every other Appolis door opens too. Stricter than Kosmos on purpose: Hermes NEVER lazy-provisions — an entitled Appolis ID whose email no business has added gets a clear 403 ("ask your admin"), because with multi-business tenancy we can't guess which business a stranger belongs to; global-email lookup (v0.70.0) routes known emails to THEIR tenant. Also fixed a local-dev bridge bug the test caught: Object.fromEntries(response.headers) kept only the LAST duplicate header, so any response setting two cookies dropped the first — the bridge now emits one Set-Cookie line per cookie. Injectable deps.appolis keeps the whole flow testable without Workers. 119/119. 🎬 Studio + 🏛 Agora got the Kosmos-style accent-pill treatment in the tab bar — they read as suite DESTINATIONS, not views: Studio in Phantasia violet (the Studio's color everywhere in the suite), Agora in mint, both bordered, emoji-fronted, set off from the regular tabs (active = filled pill instead of the coral-gold underline). 🏢 Businesses panel landed in the Team tab — renders only for platform masters (everyone else's fetch 403s and it stays invisible): the business roster (founding badge, users/connectors/customer counts) + a create form that mints a clean tenant with a passwordless admin (their first sign-in sets the password). Isolation is now a TEST: the suite creates a smoke business and proves zero founding-tenant bleed, platform-unique emails (409), and global-email tenant routing. 111/111.flippersDashboard) and the hero flipperStats (a save-flow COUNT + GROUP BYs over the whole tickets/notes tables). FIX: both now do stale-while-revalidate — a stale cache is served INSTANTLY and the recompute runs in the BACKGROUND via ctx.waitUntil (threaded through as deps.waitUntil), so a mount never blocks on the counts once the book has been seen once. A force flag makes the background refresh + cron warm actually recompute (not re-serve the stale row). The 30-min cron now also warms each rotated book's dashboard (member + admin views) + month stats, so even first loads are cache hits. No behavior change to the numbers — only when they compute. 110/110.updated_at, which moves whenever the fulfillment is later touched (a tracking refresh / edit) so it drifts arbitrarily past the real delivery. FIX: deliveredAtFromOrder + the shipment last_scan_at now read the delivered fulfillment-EVENT happened_at (the actual doorstep scan), falling back to estimated_delivery_at then created_at (ship time, understates, never inflates) then updated_at as last resort; new async deliveredEventAt() fetches the event log for single-order paths. The order sheet now self-heals drift on view (if the event time is EARLIER than the stored date it rewrites delivered_at + the shipment scan; never pushes a delivery further out). Regression test added — 110/110./api/studio/ai/ and /report/shotlist/ now 403 for non-admins server-side, and the Studio tab disappears from the member tab bar (Huddle + My Files stay open to the team). Kosmos applied the same rule the same day (Studio = super-admin only). This also seeds the coming ROLE LADDER: master admin → admin → per-department levels (CS: supervisor / lead agent / agent · Creative: director / staff), one structure across every app — the full ladder ships with the operational push.ai_* tables from prod (they held only seeded defaults — the real Studio data lives in Phantasia), deleted the dead report-shotlist.js + fcpxml.js, and cleaned their CREATE/migration statements from schema.js. ai_landers stays — the ad-landers feature is still FLIPPER-side. And the admin Upload-limit knob shipped (its API landed in batch 40): a 1–100MB slider in the 💳 Billing panel (both Studio UIs) sets the per-file cap for the workspace; 100MB is the platform ceiling, admins can only lower it. 116/116.FLIPPER v0.67.0). Rolled the pair across the network the same day — Phantasia (v0.3.0), Kosmos (v1.4.0), AWS Motor Club (v0.4.1) live; Quantum Flip (v1.0) + TipLift (v0.1.0) coded, pending their own deploy paths.studio_meetings) + invite flow; the live A/V layer (WebRTC peer-to-peer for small huddles, a media server for bigger conference calls) is flagged as the next build so nobody mistakes the foundation for a working call. Also: Phantasia standalone is now a fully isolated workspace (see Phantasia v0.3.0) — the standalone studio no longer shares FLIPPER's tenant.orders.cancelled_at column, captured from Shopify at ingest (payload.cancelled_at) and backfilled for existing refunded/voided orders that never shipped. The profile order chip shows cancelled (not the wrong "late"), and the order sheet hides its Delivery section entirely when the order was cancelled with no Flexport id, no shipment, and no delivery — there's no delivery experience to show. The sheet header already read "cancelled" from live Shopify; now the list agrees.delivered (mint), lost (red, from the shipment record), late (red, undelivered 10+ days), in transit (gold, still young), or cancelled. A refunded-but-lost order now reads exactly that: 🔴 refunded · 🔴 lost.prompt() sites converted to its own .overlay/.modal styled twins and deployed; AWS Motor Club, TipLift, and Quantum Flip scanned clean — every build now complies. The batch-40 shell-order heal (which had died with the previous session before writing) was re-run to completion. FLIPPER 115/115, Phantasia 15/15.reviveLiveTrash — an open ticket the customer wrote into within 14 days counts for scoring even from the trash (98 such tickets live today; tiles still mirror Gorgias views exactly); two new scored signals, undelivered_streak (2+ paid orders ≤120d never marked delivered, weight 30 — an undelivered streak now lands a subscriber in the Shipping-issues bucket even if one miss was remedied) and cancel_intent_open (weight 30); profile + thread wear a 🗑 in-Gorgias-trash badge instead of hiding. (2) Thread ‹ › skipped tickets: the nav filtered trashed — Lea's 2 Gorgias-trashed tickets (confirmed live) made 5 read as 3. Nav now cycles ALL of a customer's conversations, trashed ones labeled. (3) Cancelled subs said "Subscription every 30 days": the Recharge product name now rides on the sub row itself (product_title, stored at ingest + backfilled from Recharge for existing rows) — cancelled subs keep their name with or without a product link. (4) Sub address management, Recharge parity in-app: 🏠 Addresses on the profile — subs grouped by their Recharge address ("combined" = same address_id), edit the address for the whole group, or move a sub to another address, with cross-customer moves blocked. (5) Replacements attach to the order they replaced: in-app reships now ingest the new $0 order immediately and stamp replaces_order_id; the order sheet shows "🔁 Replacement sent → #X" / "↩ replaces #Y" (exact links; honest "likely" heuristics for older/portal reships), and the profile's Delivery experience gained a Replacements tab — every $0 reship (click-through) and Flexport-portal reship listed like the Issues tab. (6) Order 246655's empty ghost profile: the systemic scan found the pattern — orders stranded on identity-less husk profiles (Recharge-order stubs whose ids later moved in merges); a chunked heal walked every one, re-resolving via the live Shopify order → reattached to the real profile (246655 → lea@twisted-chain.com) or upgrading the husk in place, LTV/rollups recomputed. Plus the STYLED-INPUT RULE: all 47 bare window.prompt/confirm boxes across the app replaced with app-styled askPrompt/askConfirm modals (promise twins; destructive wording auto-wears the coral danger look) — no native gray boxes anywhere, now a standing rule for every build. 115/115 tests (5 new: signals + revive, trashed-sibling nav, sub titles, address manager w/ mocked Recharge, reship link capture). Note: Tricia surfaces in the at-risk queue at the next cache rebuild (cron, ≤30 min after deploy)./api/studio/ai/ and /api/myfiles over a same-account service binding (zero-latency RPC): it authenticates the user, then forwards with identity headers + the internal key. The embedded Studio tab looks and works exactly as before — verified live. What stayed FLIPPER-side: the ad landers (they join Shopify products/variants), reading the creative's row via Phantasia's /meta endpoint + shared R2 for bytes, and writing copy through Phantasia's /messages passthrough so Anthropic keys live in one place; the /connections AI-provider cards now mirror their key into Phantasia's house-key store on save. The Basecamp-style Studio (projects/tasks/chat/calendar) stays here — it's joined to the team, customers, and Gorgias. NEW — My Files: a per-profile file space (invoices worth keeping, claim evidence, footage) with streaming uploads (100MB, the platform cap; also lifted the editor's old 50MB limit), folder chips, search, and 🔗 signed share links (7-day HMAC) — the thread reply composer's new 📎 Attach button mints one and drops it into the reply, so a file rides out on any Gorgias channel. Honest note: extraction doesn't speed up single requests (service bindings are already zero-latency) — it stops the Studio's D1 from sharing a database with 274k orders, stops Studio deploys from redeploying the live CS app, and gives every future app (Kosmos next) the whole Studio for one binding. Migration copied the AI catalog + prefs to Phantasia's D1 (asset bytes never moved — shared bucket); parity verified. Chardizy's ai_* tables kept empty as the rollback path. FLIPPER suite 110/110 (AI E2Es moved to Phantasia's suite, 13/13).sources (JSON on ai_tools, editable like everything else): the primary provider/slug plus exact-twin alternates, each with a price_mult and optional input_template override (aggregators name params differently). Generate ranks the usable sources by the variable-pricing estimate × multiplier, tries the cheapest first, and FAILS OVER to the next on a submit error — an aggregator outage never blocks a shot (the response names what failed). The job records which provider/slug/price actually ran. Runner: a Source row (⚡ Auto — cheapest is default; pin fal/replicate explicitly, per-source live price, 🔒 = no key), the rate line shows "via fal · auto-cheapest", tool cards wear a ⇄ N sources chip, and the Generate button unlocks if any source has a key. 💰 Price board in the AI Tools header: every model × every source at default settings, ✓ marking where Auto routes. Seeded alternates were live-verified exact twins only (Replicate: flux-2-pro, nano-banana-pro, veo-3.1-fast; Kling 3/Seedance 2/Seedream 5 have no Replicate twin yet — routing to an older model version to save money is not a thing we do); the 🩺 health check now probes alternate slugs too, so new twins get adopted the day they appear. Onboarding = the two aggregator house keys on /connections, once. 113/113 tests (2 new E2E: auto-route picks the cheaper source + records it; failover through a down fal onto replicate, pin surfaces the pinned source's error, keyless sources never tried).anthropicCopy honors the choice, the job records which model ran, and the price moves with it (Opus $0.05 · Fable 5 $0.10 · Haiku $0.01 via a per-model multiplier). Any tool that declares a models list gets the selector, so it generalizes to future multi-model tools. 111/111 tests.ai-providers.js PRICING map + estimateCents + pricingFor): images show per-image cost, video shows $/second × duration — e.g. Kling reads "$0.100/sec × 5s = $0.50" and jumps to $1.20 at 12s live as you drag. Resolution/quality/mode multipliers and a per-reference surcharge feed in. The Generate button shows "≈ $X" that moves with every change, and the per-job cost we record for chargeback is now this real computed figure instead of a flat catalog number. 110/110 tests (1 new: the pricing engine across duration/resolution/mode/count/refs + derived pricing). Pricing figures are editable estimates; a cheapest-aggregator finder (same model across fal/Replicate, auto-pick cheapest) is the next batch.requestVideoFrameCallback) before uploading to R2 (50MB cap — larger clips stay in your NLE and relink on export). The clip inspector shows 1920×1080 · 23.976fps · 16:9 · 5.0s, and the sequence auto-adopts the footage's resolution + frame rate (lib/fcpxml.js parametrized — real timebase, drop-frame ntsc for 23.976/29.97/59.94, per-clip rates) exactly like Premiere adopting the first clip. Export handles it: AI-generated clips get fetched by the media script; your own imports (no public URL) are relinked by their original filename with a README line listing which files to drop into media/ (they're already on your machine). Codecs the browser can't decode (HEVC/ProRes 422) still import + export — they just may not preview in-app. Also (Tyler's dashboard notes): the header now reads "{date} · Command Center" (dropped "live data"), and the four-tile risk snapshot (At-risk / Saves / Customers loaded / Save rate) shows only on the At-risk and VIPs tabs — it's gone from Insights/Studio/IT/Team where it didn't belong. 109/109 tests (1 new E2E: upload carries format metadata → FCP7 sequence adopts 1280×720@29.97 drop-frame + relinks the user file by name; 50MB guard).resolveKey: house → house key; anything else → own key only, else the profile gets a clear "no key — add your own or ask an admin to switch you to House" message. House money is now only ever spent when House is explicitly activated on a profile. 108/108.lp. RENAME (Claude Code / Tyler). Three asks. (1) Audio on the editor's V1 — a lot of generations are voiceover/music, so the editor got a parallel A1 audio lane: 🎵 Add audio from the gallery (ElevenLabs voiceovers, tracks), trim in/out, reorder, remove; ▶ Play all runs it in parallel under the video (a hidden audio element on its own clock); the FCP7 timeline.zip now writes a real audio track (lib/fcpxml.js audioClipXml, <sourcetrack>audio) so Premiere/Resolve open with the bed placed. ai_edits.audio JSON [{asset_id,in_s,out_s}]. (2) House-account / per-profile billing — new ai_profile_billing (per tenant+user mode): 🏦 House = the profile always pays from the shared /connections keys (the "house account activated on a profile"), 🔑 Own = the profile's own keys only (no house fallback), ↔ Auto = own-else-house (the legacy default, unchanged for everyone existing). resolveKey honors the mode; a 💳 Billing panel in AI Tools lets a member set their own mode and an admin set anyone's. We never store cards — the API keys ARE the payment; each key's card lives on the provider's own dashboard, and because fal is an aggregator, one fal key is effectively a single master method across nearly every image/video/audio/upscale model. (3) go. → lp. — the lander subdomain is now lp.flipmylifenow.com (engine HOST_TENANTS, connector copy, publish prompt). Verified in-browser (audio lane + A1 chip render, billing panel with per-profile toggles + the no-cards copy). 108/108 tests (2 new E2E: audio lane save→GET→FCP7 audio track with trims as frames; billing house-vs-own key resolution incl. own-only blocks without a key).ai_edits table; clips are non-destructive {asset_id, in_s, out_s, duration_s}). Start fresh or seed from a shotlist — every rendered shot lands on the timeline in scene order. Sequential preview player: ▶ Play all steps through the cut — videos seek to in_s and hand off at out_s, stills hold for their duration; the stage shows the playing/selected clip. Timeline strip = thumbnails scaled to clip length (green = selected, gold = playing), click to select. Inspector trims a video's in/out seconds or a still's hold, reorders (◀▶), or removes. ➕ Add clip from a gallery thumbnail picker. ✨ Generate missing shot stages a pre-approval card (per the standing rule) — model (⭐ video pref default, swappable, cost shown) + editable prompt → ✓ Approve; the finished clip drops in at the chosen position. 🎞 Export timeline = the FCP7 timeline.zip (Premiere + Resolve) — lib/fcpxml.js now honors the trims (in_s/out_s → in/out frames), so the real finish happens in the NLE with the cut pre-assembled. Single track for v1; multi-track, transitions, and audio come as it grows. Routes: /api/studio/ai/edits (list/create/seed), /edits/:id (get/save/delete), /edits/:id/timeline.zip. Verified in-browser (seed → editor opens → trims persist → generate stages for approval → export). 106/106 tests (1 new E2E: seed-from-shotlist → trim → reorder persistence → FCP7 export with trims as frames).ai_prefs, seeded from Tyler's real results: GPT Image 2 boards / Seedance 2.0 video): drives ⭐ badges in the tool grid, the doc's Board/Shoot defaults, and a per-prompt model select — different projects, different models; prefs update in-app or via Claude as project learnings land. Runner v2 (the Higgsfield way, from the site deep-dive): structured chips replace raw JSON — aspect-ratio chips + param selects generated from the stored Higgsfield snapshot via each tool's hf_equiv (duration/mode/sound on Kling, etc.); reference slots with a gallery thumbnail picker, real file upload (/api/studio/ai/upload → R2), and URL paste; internal asset: refs become data URIs server-side so private assets chain into i2v. 🛬 AD LANDERS: pick an ad creative + product/variant + angle → Claude (vision — it literally sees image creatives, plus the generation prompt of what the customer watched) writes a complete branded, animated, mobile-first landing page continuing the ad's story; served public at /l/:id (creative streamed at /l/:id/media), every CTA deep-links Shopify checkout via cart permalink with the chosen variant; view counter; list/copy-link/delete in the Studio. Fixed: ensureTools now tops up new defaults into existing catalogs (INSERT OR IGNORE). Synced with concurrent-session changes (breakdown.html deploy 14cf5b8c) before editing. 105/105 tests.ai_shotlists/ai_shots tables, import route accepting the skill's structure as JSON ({title, style_prefix, scenes:[{no, desc, prompts:[{label,text,duration_s}]}]}) with a paste box in the Studio, and /report/shotlist/:id — a cinematic-slides-styled deck (dark stage, orange accents, dot rail + arrows + fullscreen per the presentation standard, responsive with a mobile overflow guard) where every 15-second prompt has ⧉ Copy (style prefix prepended), 🖼 Board (renders a Seedream storyboard frame), and 🎬 Shoot (Seedance 2.0 — automatically image-to-video off the attached board when one exists) — generations poll in-page and the slide fills in with the frame or clip as it lands: the document changes with the film. Scene checkboxes are server-side (whole team shares progress; the skill's localStorage was single-browser). Revision loop: re-importing scenes carries done-state and attached renders forward by (scene, label). 🎞 NLE timeline export: timeline.zip = FCP7 XML (lib/fcpxml.js — Premiere and Resolve import it) + per-platform media-fetch scripts + README — media downloads on the user's machine from the provider CDN (never through Worker memory), clips land in scene order at their shot durations. 📡 Higgsfield watch (stay pegged to their updates): ai_catalog stores the models_explore snapshot (74 models seeded); pasting a fresh probe diffs it — 🆕 new / 🪦 retired / ♻️ param-changed models — and 🩺 Check tool health validates every tool slug against the provider's live model page (all 11 fal slugs verified OK on ship day, including veo3.1/fast). 🗑 Trash can (Tyler): deleting an asset is soft — it leaves the gallery (and detaches from shots), sits in a Trash view with ↩ Restore, "Empty trash now", and a 30-day cron auto-purge that deletes R2 bytes for real — no server space wasted on dead takes. Fixed en route: tools/health was swallowed by the tools/:id edit route (m[1] !== 'health' guard). Verified in-browser: import → doc renders (cover stats, scene slides, style slide), scene-done persists server-side, mobile 375px zero horizontal overflow, live health check 11/11 OK. 104/104 tests (2 new E2E: full shotlist lifecycle incl. attach-on-poll + timeline zip; trash lifecycle + catalog diff).HIGGSFIELD_CAPABILITY_MAP.md (which models, who owns them, the direct route to each, and which ~14 are genuinely Higgsfield-proprietary). The sidestep: fal.ai exposes the same upstreams under one key + one queue REST shape, so v1 ships fal + Replicate adapters (integrations/ai-providers.js: submit → poll → harvest outputs by shape) plus Claude as the copy engine. Launch catalog = 12 tools (FLUX.2 Pro, Nano Banana Pro/2, Seedream 5.0 Pro + Edit, Seedance 2.0 t2v/i2v, Kling 3.0 Pro i2v, Veo 3.1 Fast, ElevenLabs TTS, SeedVR2 upscale, Claude copy & briefs), each tagged with the Higgsfield tool it replaces — and the catalog is data, not code (ai_tools rows: slug/template/cost editable in-app, docs ↗ link per tool, so model drift is a 30-second UI fix). Bring-your-own-AI multi-user model (Tyler): each member can paste their own fal/Replicate/Anthropic key (🔑 My keys — probe-tested, stored per user); no personal key → the house key from /connections (fal + Replicate connector cards added); every job records key_source + est cost and the header shows 30-day spend per member on the house key — the charge-back groundwork. Storage: new flipper-studio R2 bucket (binding AI_BLOBS) — finished media is copied out of the provider CDN into our bucket (streamed back via /api/studio/ai/assets/:id/file, ⬇ download, provider URL kept as public fallback + 🔗 chaining reference for image→video/upscale flows); local dev falls back to data/blobs/. UI: tool grid by category → runner (prompt, reference image, advanced JSON) → job queue chips (5s auto-poll) → asset gallery (image/video/audio players, copy cards, delete). Tables: ai_tools, ai_user_keys, ai_jobs, ai_assets. Routes: /api/studio/ai/{tools,keys,generate,jobs,:id/poll,assets,:id/file,usage}. Verified in-browser (12 tools render, runner opens with docs link, clean no-key gating on Generate, fal/Replicate cards live on /connections). 109/109 tests (3 new E2E: catalog seed + gate, full generate→poll→stored-asset→gallery→delete loop against a mocked fal queue, synchronous Claude copy + template-edit validation).lib/vision.js — fetches each image through the Shopify CDN at 800px (keeps image tokens cheap without losing content), base64s it into a Messages API call (claude-opus-4-8), and returns ≤125-char storefront alt text; the pages using the image ride along as context. 👁 Scan per item fills the draft box; 👁 Scan images (next 10) batches (each click = one batch; scanned drafts wear a 👁 badge; SVGs are skipped with a reason and keep their filename drafts — vision models don't read vector files). Drafts are review-first: nothing touches Shopify until ⚡ Apply, so the flow is scan → skim/edit → apply → the action completes itself. Routes: POST /api/it/items/:id/vision-alt, POST /api/it/actions/:id/vision-alts (cap 15/request for Worker budgets; already-scanned and skip-marked items aren't re-billed). Cost note: all 368 images ≈ a few dollars at Opus rates. Verified in-browser (buttons render, clean 400 gate pointing at /connections when no key). 106/106 tests.syncITAction, wired into every item mutation). Alt text now covers product gallery images too: the unified setShopifyImageAlt tries Content→Files first, then matches product images by filename across the catalog (one products.json?fields=id,images fetch, cached per batch) and PUTs the alt on the product image — the fix-detail records which path applied ("Files" vs "product image"); the only unmatched images left are ones Shopify genuinely doesn't know by that filename, and those come back with the combined reason. Pipeline copy updated. 105/105 tests.shopifyGraphql + setShopifyFileAltByUrl: files() search by filename stem, fileUpdate mutation; product-media URLs come back with a reason and keep the guided path) — plus "⚡ Apply drafts to next 25" batch (each click approves one batch; 40-cap keeps subrequests safe); applied items auto-mark fixed with the alt recorded. Action reclassified auto. 🩺 System health strip on the IT board (GET /api/it/health): every connector as a card — green/amber/red dot, last sync recency, last error, 24h webhook ✓/✕ counts with the last problem on hover — the master-control feed, live. 🗜 In-app image compression on the 100KB+ items: the Compress button proxy-fetches the image (/api/it/fetch-image, host-whitelisted), re-encodes to WebP in the browser (canvas; quality then dimension steps until ≤100KB) and downloads it correctly named — swap in Shopify, mark ✓. ⬇ Status re-export for the SEO team (Tyler): GET /api/it/export-seo rebuilds their own Technical Actions List sheet with the Status column reflecting the board (Not Started / In Progress / Complete / Dismissed), a Progress column ("N of M items fixed · K open"), an as-of stamp, plus a "Fixed items log" sheet — every fixed item with what was done (301s, alt texts) and when: proof of work they can verify. Verified in-browser (health strip live against real connector state, export downloads a real workbook, apply buttons render on all 368 image items). 104/104 tests.createShopifyRedirect; the click is the approval; existing redirects detected, homepage refused), item auto-marked fixed with the redirect recorded. Re-imports are idempotent (same file updates counts/descriptions; fixed/dismissed items never resurrect). Plumbing: dependency-free lib/xlsx-read.js (ZIP central directory + DecompressionStream('deflate-raw') + sheet XML — Workers-safe, handles Excel's 31-char sheet-name truncation, frozen-pane header offsets, rich-text runs), it-seo.js (classifier: 12 kinds with per-sheet column parsers), it_actions/it_action_items tables, /api/it/* routes (import/actions/items/redirect/summary). Fixed in passing: the local dev server mangled ALL binary bodies (string concat on upload, .text() on download) — node adapter now passes bytes both ways. Insights IT tile is live (● LIVE — THE BOARD: open actions, high-priority count, items to fix → #tab=it; coral border while work is open). Verified end-to-end on the real July 2026 review: 12 actions, 690 items (counts match the SEO team's own numbers: 4 broken inlinks, 6 noindex, 7 candidates, 368 alt-text), UI walked in the browser (pipeline + Create 301 + sources render). 103/103 tests.studio_projects / studio_tasks / studio_events / studio_messages; a full Studio tab (visible to members AND admins) with three views — ✓ Tasks (project columns, add/complete/assign/due, + Project), 📅 Calendar (month grid, click-a-day to add, the creative/marketing calendar), 💬 Team chat (channels = # team + one per project, 8s polling). Basecamp connector on /connections (account_id + OAuth token): integrations/basecamp.js imports projects → to-dos (active + completed, assignees matched to app_users by email) → schedule entries, idempotent by basecamp_id (re-imports refresh Basecamp fields, never touch app-side rows); cron re-imports at most every 6h, manual sync always runs. 🎨 Creative Studio tile on the Insights dashboard (gold, LIVE — IN-APP): open tasks · due this week · calendar next 7 days · chat today, opens #tab=studio; "what's WORKING" analytics joins when ad/social channels connect. New /api/studio/* routes (projects/tasks/events/chat/summary) + an E2E test. Tricia's header is now her true story: her replacement (fx_direct RYIYS0AIZX, delivered 02-07, 3 units) shipped to zip 52631 while her address book said 52625 — attached by hand; the crisis block now counts ALL paid orders ("received 2 of 10", was Flexport-id-only) and shows "N replacements sent", while the trigger + "still out" only look at orders placed ≤120d ago (pre-Flexport history has no delivery stamps — old orders can't false-flag healthy profiles). Tile reporting windows (Tyler): a master switch (30/60/90/custom with date pickers) above the tiles drives every tile; each tile carries its own override (window: master / 30 / 60 / 90 / custom…); /api/report-summary?days= now serves any window through the same gather the report uses (60 + customs build once then cache; 60 added to the gather's whitelist); the Fulfillment tile's "Open report" carries its window into the full report, and the mini-summary now leads with profit missed. Batch-21 heal walk tally: 2,354 of 2,768 unstamped orders were actually delivered per Flexport (stamped; the ~414 left are genuinely in transit/lost) — aging + fact-check numbers get much more honest at the next cache rebuild. 102/102 tests.deliveredAt on the floor — every delivery confirmation since the CSV backfill walks stopped was lost unless someone happened to open that order's sheet (the on-view self-heal). March–May ~0.4% unstamped vs June 5.9% / July 94.5%. Fixed: the handler now stamps delivered_at from the webhook payload (by order number, falling back to Flexport id), and a heal walk re-checked all 2,768 unstamped 5-120d orders against the Flexport API and stamped the ones Flexport says are delivered (#274888 → delivered 07-09, LaserShip). Sub-swap dropdown prices (Tyler): showed the one-time price; now shows what the customer actually pays on subscription — variant price minus the product's Recharge plan discount ("$49.48/sub (10% off $54.98)"). Root causes: the A/B-test twin FLIP 7 SuperShake is UNLISTED in Shopify (never in the products.json list, only reachable by id) — the swap-catalog route now pulls any plan-referenced product missing from the catalog by id (new getShopifyProduct + upsertShopifyProductWithVariants); and the twins differ ONLY by plan discount (original 10% → $49.48, twin 20% → $43.98) — visible now. Cache swapcat3. Healthy ≠ flagged: profiles with no active signals show green "Healthy right now" + white "— no active risk signals." (no more "Why flagged" label). PPTX rebuilt to mirror the PDF (Tyler: "get it to look exactly like the PDF — the PDF is perfect"): deck.js rewritten — centered layout, the orange label pill on every slide, accent-colored half-headlines, the same 10-slide set (incl. the pie's own page), the same table columns (incl. Starting profit), the money quartet + bucket tiles. Validated: python-pptx 10 slides / 13.33×7.5in / zero out-of-bounds shapes; PDF re-verified on the live payload (10 pages, money slide fits with the quartet + new column). 101/101 tests.GET /api/report-summary (reads the warm rpt_dlv_90 cache — free), mint-bordered, "Open the full report →" opens Delivery Performance. Eight styled placeholders (sales, marketing, finance, inventory — consumption's future home, customer service, social media, affiliates, IT 🧙 master control) each carry their planned-content blurb; full roadmap in the new §12b Department report roadmap. PDF cut-off actually fixed — verified against REAL live data this time (the fixture deck fit; the live one didn't): with 8 carriers the forced-visible pie + table + footnote overflowed the 900px page and flex-centering clipped BOTH ends; the fact-check/claims tables print whatever 10-row page the pager is on, and real rows are ~3 lines tall. Fix: the pie gets its own print-only page (hidden on screen, excluded from the dot rail), the table face is forced on in print, and the JS-paged tables get print row-caps (fact-check 6, claims 6 + the "why they reject us" paragraph wraps to full page width, money 8). Verified: live 90-day payload → headless Chrome → PyMuPDF, 10 pages / 10 slides, every page rasterized and eyeballed, nothing clipped. Claim gating is a full state machine now (Tyler's #278018 — in transit but showing File claim): claimState() in lib/claims.js → filed ("View claim ↗ · status", never "File" again once one exists) / open ("File claim ↗ · by date") / not_yet (in transit: "⏳ claim unlocks if undelivered by promised-by/placed+15d") / expired ("window closed"); order view renders exactly the action you can actually take. Thread ‹ › nav: full-message view pages through that customer's other conversations (server returns nav.prev/next ordered by last activity; buttons gray out at the ends). Delivery-crisis profiles (Tyler's tricia@denningfarms.com — 1 of 8 orders received, still subscribed, never refunded, yet the header read like a closed convo): profiles now compute a delivery_crisis block (≥2 aged-undelivered, or <60% delivery rate on 3+ orders) and wear a loud coral 🚨 DELIVERY CRISIS banner — "received X of Y · N still out >10 days · never refunded · STILL SUBSCRIBING & PAYING". Customer economics: break-even — the profile card now says "✓ Became profitable on order N — day D" (first cumulative-profit crossing; resets if a later refund dips it back) or "✕ Not yet profitable"; store-wide averages ("when does our AVERAGE customer turn profitable, ROI per customer") land with CAC when ad channels connect — scaffold is in. Dawn P's missing replacement (SYMYMTGQZE) + the everywhere-audit: root cause — the CSV sweep only matched customers.address LIKE zip and her address field was stale; the matcher gained an orders-zip fallback and a walk re-matched 14 rows. Dawn's landed on the WRONG duplicate account ("Dawn Porter"/ebw1060 exact-name match vs "Dawn P"/360medspa who owns #270838 — flagged as a merge candidate) — fixed by hand; her order now folds the $51.25 replacement (verified in D1). Full audit of all 976 portal replacements: 674 attributed (name-verified; a re-audit pass that trusted zip+recency alone got reverted — same zip ≠ same address), 303 confirmed non-customers (influencer/prospect samples), ~101 attributed but outside any order's 120-day window (95 goodwill/win-back sends to long-lapsed customers + 7 first-touch sends — they charge to customer lifetime P&L, not per-order loss/gain, which is correct). All rpt_dlv% caches purged (money data changed; payload stays v18). 101/101 tests.cac_cents, 0) to fold in when the channels connect. Computed in bundleFor off a daily-cached tenant cost_basis (variant COGS + per-carrier avg fee + effective tax rate) so it adds no round-trips. Per-order gain/loss chip on the order-history list: a small green +$ / red −$ on every row (replacements fold their cost into the original order and show $0). Claim-window gating (Tyler): a new lib/claims.js (window = 30d from delivery / 45d from order for never-delivered — grounded in our own filing history, every claim ever filed landed ≤34d from delivery). The delivery report's winnable now counts only orders still inside the window — the rest move to a new EXPIRED tile ("missed the claim window, gone"); live 90d shows 59 of 86 never-filed failures already expired, so the old winnable number was heavily overstated. The order view's File-claim button disappears once the window closes (shows "⏳ claim window closed" with the date). Profile speed: bundleFor fetched the customer row serially before everything else — folded it into the first parallel wave, cutting a full D1 round-trip off every profile open; added idx_touchpoints_owner for the My Flippers tasks query. Report cache v18. 101/101 tests.fx_direct_orders.cost_cents (backfilled for all 976 portal replacements from the Flexport API) + the money query now counts EVERY replacement ($0 Shopify reships AND fx_direct portal orders in the +120d window) and sums each one's own real shipping cost; loss/gain = money kept (paid − tax − refunds + claim payouts) − (original COGS + fee + Σ over reships of [order COGS + that reship's real shipping]). Multiple reships stack (Tyler's "triple whammy": lost twice + refunded anyway). Verified live: #242403 = +$113.83, Tyler's hand figure to the penny (COGS $105.78 + $46.66 + reship COGS $105.78 + reship ship $64.05 = $322.27 total vs $436.10 paid ex-tax). Money table badge now shows "📦 replacement ×N". Also confirmed the app's per-variant COGS entries were RIGHT all along — the whole gap was the reship shipping assumption. Cache v17. 101/101 tests.@page{size:<design viewport in CSS PIXELS>} — for this deck 1600×900 — with each slide locked to exactly one viewport (height:900px; overflow:hidden), so every printed page is pixel-identical to the slide on screen; print kills scroll-snap, hides controls, and swaps the gradient hero title to solid white (Chrome prints a hairline box around background-clip:text). Server-drawn lib/pdf.js + route deleted; ⬇ PDF button = window.print(). Verified Rivo-style: headless Chrome (dsf=1) → PyMuPDF → 9 pages at exact 16:9, rasterized pages visually checked (hero, carriers, money — nothing cut off). Loss/Gain was wrong — Tyler hand-checked #242403 and caught TWO formula bugs: (1) tax wasn't excluded from revenue; (2) the one-variant-per-order model undercounted COGS on multi-line orders (#242403 has 2 lines: 2× FLIP 6 + 8× Vital Vanilla; we saw only the first). Fix: new orders.line_items JSON ([[variant_id,qty],…], backfilled via Shopify walk for all 558 failure orders + missing tax), and loss/gain = (paid − tax − refunded + claim payouts) − (Σ line-item COGS + fee + reship). Formula now reproduces Tyler's math exactly given his inputs; remaining delta on #242403 is input values: app COGS entries ($12.93 FLIP 6 / $9.99 Vital Vanilla → Σ$105.78) vs his sheet ($258.22), and Flexport's own order cost ($46.66, from their API) vs his fulfillment figure ($64.05) — flagged for Tyler to reconcile on the Products page. Cheapest-vs-UPS + recommendations slides dropped (Tyler: not showing what he expected) — the churn attribution moved to the Carriers slide as a "Subs lost ≤60d" column + footnote; full economics remain in the Excel Carrier economics sheet. Cache v16. 101/101 tests.lib/deck.js consumed by BOTH lib/pptx.js (DrawingML primitives) and the new dependency-free lib/pdf.js (960×540pt 16:9 pages, dark bg, Helvetica, native text/table/pie ops, bezier pie arcs, word-wrap, emoji sanitized to Latin-1) — siblings by construction; validated with pypdf (10 pages, correct content per page) + a new structural test (xref offsets verified byte-exact). /report/delivery.pdf route. Money slide → Loss/Gain (Tyler): "Cost to us" column + tile removed; Loss/Gain = money kept (paid − refunded + claim payouts) − money spent (COGS + fees + reship COGS + 2nd fee) — the order's true net P&L; headline is now the duo Loss/Gain + Winnable (winnable column dropped, metric kept on top); green/red row coloring, "net loss" filter + biggest-loss-first sort; Excel carries loss_gain + net-loss flag. Econ slide: remedies TOTAL per carrier now shown ($X total + /parcel inline) answering "DHL can't be only $0.05" — it can: the total IS small because remedies are netted against claim payouts and spread over 11k parcels; now both magnitudes are visible. Carrier churn attribution (Tyler): new "Subs lost ≤60d" column — subscribers who CANCELLED within 60 days of a shipping failure, pinned to the failing carrier (driven from the failure set = CS messages + claims + lost shipments; correlation caveat on-slide). Live 90d: 22 subscribers lost — DHL 7 (of 11k parcels), UPS 5 of just 1.6k (worst rate again); generated ask line + Excel columns + PPTX/PDF mirror it. Cache v15, purged. 102/102 tests.fx_direct_orders) = 1.2% of 33,513 parcels, and UPS carries just 4.9% — only auction wins. The slide now leads with that reality ("the tier pitch oversold UPS"), the model's numbers stand (they were always computed on the standard flow — portal replacements never touch the orders table), and the blanket-UPS verdict gained the honest selection-bias floor note: −$157k/90d is priced at what UPS charged on lanes it WON; forcing every lane to UPS prices worse. New generated asks: the pitch-vs-reality line + "quote a real standard-tier rate card if you want us on premium carriers." New carrier_econ.manual + ups_share_pct fields (Excel + PPTX updated too). Cache v14, purged; gentle cron rewarms. 101/101 tests.#econ slide settles whether that's really cheap: TRUE cost per parcel = real Flexport fee + remedies per parcel (refunds + reships − claim payouts, accumulated per carrier in the money loop). Fees de-biased by a 1,079-order random sampling walk (~120 delivered orders per carrier via the Flexport API → fulfillment_fee_cents; ~2,700 orders priced total): DHL $12.03 → UPS $18.19 (the failure-only sample had UPS at $21.06 — sampling mattered). Per-carrier table (parcels · fee · failures/1k · remedies/parcel · TRUE cost · switch-to-UPS net), recommendations ranked by true cost with ✅ prefer / ⚠ monitor / ❌ pull-from-rotation chips + $-if-moved, an "optimal mix vs cheapest-first" savings number, and generated "what to ask Flexport" talking points. AK/HI excluded from switch math (815 of 819 AK/HI parcels are USPS — the rep's rule confirmed in our own data). Excel gets a Carrier economics sheet. Slide nav: right-edge dot rail, one titled dot per slide, click jumps anywhere; active dot tracks scroll; ↑↓ buttons kept at its ends. PDF actually fixed: every slide is an exact 11×8.5in page box (overflow clipped, per-slide print compaction) — verified with headless-Chrome print: 10 pages for 10 slides, zero clipped slides (the carriers page carries the table AND the pie). ⬇ PowerPoint export: new dependency-free lib/pptx.js (shares the xlsx ZIP packer via extracted buildZip) builds a native dark-themed 16:9 deck — editable text, real tables, DrawingML pie slices — mirroring all 10 slides incl. the econ verdict + recommendations; /report/delivery.pptx route + button; validated by opening with python-pptx (10 slides, 13.3×7.5in, tables intact) + a new structural test (ZIP walk + XML tag-balance). Cache v13. Cron warm moved FIRST in the scheduled run + made gentle (one window per run, stalest first): the warm lived at the END of the tenant loop and heavy sync phases can eat the 15-min scheduled budget, so the invocation got evicted before the warm ever ran (observed live: syncs still running 13 min into the 00:30 run, caches never rebuilt). Warm-first fixed availability (all 4 windows rebuilt in ~15s at 01:30, verified v13 payloads in queue_cache with live econ verdicts), but four back-to-back gathers at the exact cron moment — stacked on syncs + webhook traffic — briefly overloaded production D1 (Recharge webhooks 500'd, retries amplified; Recharge redelivers + the delivery sweeps backstop, no data loss). Now it rebuilds at most ONE window per half-hour (each ≤2h stale; a report hit rebuilds its own window on demand anyway). Ops lesson: heavy local D1 walks must also stay out of cron windows. Live 365d verdicts: blanket-UPS = −$507k/yr, recommended mix saves $190k/yr vs cheapest-first, best true-cost carrier = DHL in every window — and UPS posts the WORST failure rate (6.1/1k at 90d), so the data actually clears Flexport's cheapest-first on failure grounds and reframes the ask around fee-tier mix. 101/101 tests.FulfillmentRadar + MiniStat deleted, tab keys pruned. Consumption data off the Products page (didn't look good there) — the cohort-split consumption lives on Insights only. Money slide fits one screen now: the explanation between the hero trio and the tiles condensed to a single wider line (100ch), plus a scoped #money compaction — its own table cap 1300px wide (the global 920px cap was forcing the 11-column rows to wrap two-tall), 12px nowrap cells (customer wraps), tighter paddings, smaller trio/tile numbers, and an overflow-x guard; verified a FULL 10-row page fits a 1600×900 viewport (902px ≤ 900+4). New Loss/Gain column between Cost to us and Flexport cap = profit lost − cost to us (the profit left after making it right): +green / −red, computed client-side, Excel column matches. Recovered column colors: $0 recovered = red, anything recovered = green. Button color language enforced (Tyler, with screenshot): the coral→gold gradient reads "something negative is happening" and is now RESERVED for the cancel-save flow (+ non-button brand marks/logo text); every regular button app-wide flipped to the calm mint primary (--mint bg, dark ink text) — Sign in, Refresh now, schedule Confirm, thread Send, quick-action primary, Save to Shopify, login, Save changes, Auto-assign, VIP Reach out, Run report, Connections .primary, Products base button, and the Insights delivery-report link (now surface + mint border). 99/99 tests.tax_cents stays in D1 but nothing reads it. Carrier pie on the "Who is costing us customers" slide: two pill buttons flip between the performance table and a pie where slice size = share of parcels, slice color = transit speed — so the finding reads itself when the biggest slice is red; caption calls it out ("⚠ Our slowest carrier is also our most used: X carries N% at Yd"); the PDF/print view carries BOTH views. ⬇ PDF button next to the Excel export: browser print-to-PDF tuned to look exactly like the live deck — @page 11in×8.5in landscape, one slide per page, dark background preserved, pickers hidden. Consumption split by cohort (products + Insights, consumption cache v2): subscribers and one-time buyers each get their own days-per-bag (live numbers: subs ~30.7d over 117,576 pairs vs one-timers ~45.1d over 7,366 — the blend was hiding a 14-day gap); subscriber pace is measured off REAL orders so skips and moved charge dates are inherently baked in (a skipped month = the next real order lands later = the measured pace stretches); Insights shows blended + split, Products shows the split per product. Perf note: the cohort tag is applied in JS from a small subscriber-id Set — a SQL join version blew D1's CPU limit (SQLite nested-loops unindexed subqueries). Money table v4: Profit-lost column removed (metric kept — it's the yardstick), Cost-to-us now sits in its place and glows green under profit / red + ⚠ over it; refund badges spell out 💸 full refund / 💸 partial refund; new Carrier and Flexport says columns (fact-check-slide treatment: green "delivered MM-DD-YY" / "no confirmation") + carrier filter; Our response gets the full fact-check treatment (claim chips ⚖ APPROVED/REJECTED $, "no claim ⚠"). Excel Money sheet matches. Report cache v12, both caches purged, redeployed. 99/99 tests.lib/dispute-rx.js: (1) the open|opened damage token matched "unopened" (the agent's "confirm if any product is unopened for a return label") and benign "opened and tried"; (2) the damaged branch ignored the dissatisfaction guard entirely (only "missing" was protected); (3) DISSATISFACTION_RX didn't even recognize "not the right fit," "doesn't agree with me," or "satisfaction guarantee." Fix: split damage into STRONG (arrived shattered/crushed/torn/leaking — always kept) and WEAK ("arrived open," \b-anchored so it can't match inside "unopened"), and the weak signal is now suppressed by the dissatisfaction guard just like weak "missing" is. Broadened the guard to product-sentiment phrases only — deliberately excluded "not happy / not satisfied / a refund / return," because a lost-package customer says all of those too (verified against real never-received tickets so none got suppressed). Re-scanned all 3,421 flagged tickets: cleared 185 false damaged (unopened-for-return / "one bag is open because I tried it" / dissatisfaction returns), recategorized 9 mistagged damaged → missing (wrong-address / never-received), and touched zero genuine missing disputes. Damaged flags 508 → 314. #265222 now drops out of the shipping-failure set entirely (no dispute, no claim, delivered) — off the money slide, as it should be. New test/dispute-rx.test.js locks the regression (5 cases). Caches purged + redeployed. 99/99 tests.orders.tax_cents, backfilled from Shopify total_tax for all 646 in-window failure orders — 454 non-zero). The cost cell turns red with a ⚠ whenever cost to us outgrew the profit lost ("underwater"), with a new headline count and an "underwater (cost > profit)" table filter + "most profit lost" sort. The slide headline is now a trio — Cost to us (coral) · Profit lost (orange) · Winnable (orange) — replacing the old "structural loss / we eat" framing (Flexport's cap already lives in the Flexport-cap + Winnable columns). Excel Money sheet + Summary rebuilt to match (Cost to us · Profit lost · Underwater? · Flexport cap · Recovered · Winnable). Cache payload v11; stale windows purged + redeployed. COGS is still the labeled 30%-of-retail estimate until real per-variant COGS is entered on the Products page — the ⚠ note now names both figures it firms up. Also: the 👤 View Profile button on order views now closes the order and opens the full profile in one click (was: profile didn't appear until you hit X). 94/94 tests.lost shipment status → showed "not delivered · lost" and $0 cost, and polluted the failure set. Fixed 3 ways: healed the 6 delivered-but-lost shipments, every "lost" query in the report now requires delivered_at IS NULL, and the order-view Flexport self-heal closes the stale shipment + fixes the display in the same load. Failure set dropped to 364, 365d numbers firm up at winnable $16,437 / structural $5,402. 👤 View profile button on every order view header (opens the customer's full profile). Cost model confirmed = Tyler's blend (incremental P&L: refund = full margin+goods, reship = extra COGS+fulfillment). 94/94 tests.products.cogs_cents + a COGS $/bag field on the Products page; until real COGS is entered the model uses a labeled 30%-of-retail estimate. Our response column on the money slide + Excel (💸 refunded / 📦 replacement sent). Net effect on the 365d numbers: winnable $17,053, structural loss $5,473 (down from $18.8k — because at ~30% COGS, Flexport's 40%-of-retail actually covers most reship product cost; the loss is overwhelmingly UNFILED claims, not structural). These failures cost us $20,698, recovered $3,444. Cache v10. NOTE: the winnable-vs-structural split is COGS-sensitive — entering real COGS per product will finalize it. 94/94 tests.orders.fulfillment_fee_cents (Flexport order.cost, backfilled for all 763 failure orders) + orders.refunded_cents (actual refund $, 133 orders). The money-left slide is rebuilt around this: per failed order, max recoverable = 40%×affected retail + fee (Flexport's ceiling), and the report now splits RECOVERABLE-BUT-UNCOLLECTED (winnable): $20,019 — never-filed ($16.4k/305), denied ($2.8k/59), under-filed ($0.8k/49) — from STRUCTURAL LOSS: $18,769 (the ~60% of retail Flexport's cap never covers, no matter what we do). 365d: these failures cost us $38,597, we've recovered $3,218. Interactive table (Order · cost · Flexport cap · recovered · winnable · we-eat), hero tile now reads "winnable", Excel Money sheet + Summary rebuilt. Cache v9. Key takeaway for the room: the huge never-filed number is pure upside; the structural number is the case for renegotiating terms or moving carriers. 94/94 tests.orders.refunded_cents, backfilled from Shopify refund transactions for the 133 dispute/claim/lost orders — partials exact, not estimated) + replacement order value; recovered = approved claim payout; left = cost − recovered. 365-day truth: $39,531 left on the table — $30,049 never-claimed (305 orders), $5,182 denied (59), $4,300 underpaid (61) — against just $3,444 actually recovered. Interactive paginated table (filter by reason, sort by biggest-loss or biggest-cost), a headline $ hero on slide 1, Money-left worksheet + Summary totals in the Excel export. Cache payload v8. 94/94 tests.--muted #8A8FA3→#A9AEC2 and --muted2 #5E6273→#858BA4 — the old muted2 sat at ~2.4:1 on card surfaces (below WCAG anything); secondary text now clears AA everywhere without touching the color language. Order-view delivery block specifically: the DELIVERY label carries the sentiment color (mint fast · gold in-flight · coral late/lost), the undelivered status line ("not delivered yet · in_transit · Flexport promised by · last scan") rides gold/orange, the carrier name and the placed/fulfillment/total line are white. Also from the prior mini-fix: the app watches for new deploys (5-min poll + tab-focus check) and shows a centered "✨ new FLIPPER version — Refresh now" chip, so nobody tests stale bundles again. 94/94 tests.assign_cursor) so every run CONTINUES from the next person instead of restarting at member #1 — one ticket per person in rotation, across clicks. 🏖 Vacation mode: a Team-tab toggle (admin) sits a member out of the wheel until switched back; assignment SCHEDULES (working hours/days) noted as the follow-up build. Consumption & seasonality shipped (the wave-15 promise): consumptionStats (cached daily) mines every customer's consecutive same-product reorder pairs (gap ÷ bags, 18 months, LAG window fn) → per product: real-world days-per-bag vs label, per-season pace, monthly volume rhythm — Chocolate Courage: a bag lasts 19.7d real-world vs 15d label across 54,137 pairs, winter pace 15.8d vs summer 22d, peak Feb. Lives on the Products page (per-product line + 12-month bars) and Insights (Consumption & seasonality panel); the department-tailored report-format delivery is the roadmap. Order sheet ◀ ▶: walk a customer's order history with buttons or ← → keys; neighbors prefetch into a 90s cache so arrows land instantly; /live returns nav.prev/next. Profile opens in 0.35s (was seconds): bundleFor pulled SELECT * tickets — full 100KB+ conversation bodies per ticket — now explicit columns + 1,500-char tails (the queue-rebuild lesson, applied to the other big offender). Old profile-wide "Product used by" line retired (the 👥 roster on sub rows replaced it). 94/94 tests.category through mapQueueItem — which was silently dropping it, which is why cancel-save wasn't showing on every win-back (now it does). Named household roster (replaces the bare people-count): 👥 on a sub row opens an editor — people with editable names (default Person 1, 2, …), each ticking the products THEY use; one person on two products counts toward both consumption rates. Stored as customers.household_people; saving re-derives product_users + household_users so all existing ring/cadence math updates untouched; roadmap stays the customer-facing widget with per-person frequency. Profile opens instantly from the card (SWR cache: the at-risk detail's payload renders immediately, then refreshes). Left list and card scroll separately (both sticky with their own scrollbars). Force-update sets expectations ("~30 seconds, button flips when it lands"). Slideshow nav: ← → ↑ ↓ / PageUp/Down page between slides, fixed ↑ ↓ buttons ride the right edge, and the scroll wheel pages slide-by-slide (a slide taller than the window scrolls naturally to its edge first); claims-paid tile no longer overflows (rounded $ + wrap-safe tiles). 94/94 tests.customers.product_users JSON, beats household_users in ring + recommended-cadence math, notes the change; roadmap: customer-facing widget with per-person frequency. Return gates (#261004): the wrong "Return received — OK to refund" bubble cleared; returns can only be STARTED for delivered orders (UI + API), and Flexport-return adoption (live view + webhook) skips undelivered orders — a carrier return-to-sender on a lost parcel is not a customer return. Slideshow polish: ⛶ full-screen button; snap center + proximity + 100dvh + bottom padding (nothing cut off by the toolbar); dropdown options readable (dark option CSS); disputes drill-down adds claim approved/rejected filters; hero tiles now scan the whole deck — slowest carrier, LOST + worst run, disputes split, corroborated/unclaimed, claims filed $, paid vs denied $, subscriber-vs-one-timer transit. 94/94 tests.lib/dispute-rx.js): "did not come WITH a scoop" (missing accessory) and "I changed the shipping address" (customer-caused misdelivery) no longer count against Flexport; snippets snap to word boundaries with … ellipses (mid-word cuts corrupted slide + Excel). New dispute category: ARRIVED DAMAGED (own regex set) — full 43,781-body re-scan: 4,651 disputes = 4,246 never received + 400 damaged, split shown everywhere disputes appear (carriers/cohorts get separate CS: never received / CS: damaged columns each with refund/reship ✓ counts; Excel too). Customer damage PHOTOS: a Gorgias walk collects image attachments from damaged-dispute messages into tickets.dlv_imgs — 📷 marks rows, the popup links each photo, Excel gets a Photos column. Fact-check + Claims tables are interactive: 10 rows/page with ‹ › pagination, filter by carrier/category/response/claim (disputes) and verdict/reason (claims), sortable; conversations now load on demand (/report/delivery/msg) so 500 dispute rows ship light. Trend charts scroll horizontally (every bucket renders; auto-scrolled to latest). "Worst (days)" columns dropped from all sheets; "Lost (Flexport)" is just "Lost". Two new EXCLUSIVE at-risk bubbles: ⏭ Frequent skippers (active sub whose ONLY signals are skip-flavored — Colin MacVean et al.) and 🚚 Shipping issues (active sub + missed delivery + ZERO remedy: no refund, no $0 reship, no Flexport-direct replacement — synthetic shipping_unremedied signal explains the card); priority heat > shipping > skipper > churn > win-back, one bubble per customer as ever. Subs-cancelled badges now carry the cancellation date (VIP lists). Flexport live verdict on order sheets (#261004): undelivered Flexport orders fetch the live order — "⚠ POTENTIALLY LOST per Flexport — 29 days past their promised delivery (attempted 06-02-26, never completed)" replaces the mute "in transit"; also self-heals delivered_at when Flexport knows it landed. 94/94 tests.Claims-Claims_Submitted (found by pulling the OpenAPI spec) — new fx_claims table, year backfilled: 134 claims $6,828.55 · 71 approved $3,443.75 · 60 REJECTED $3,283.18 · top rejection: "insufficient proof of customer communication" (16× $976). New ⚖️ Claims scoreboard slide (filed vs paid, rejection-reason breakdown, worst-first table), claim chips on the fact-check rows (⚖ APPROVED $X / REJECTED), and the killer stat: corroborated disputes that never became a claim (186 in the year — money never asked for). Excel gains a full Claims sheet. Weekly Flexport report sweep (sweepFlexportReports, cron, $0 cost): two-phase state machine requests + ingests the Orders (21d) and Claims (210d — verdict changes flow in) reports weekly — future portal-created replacements land in fx_direct_orders automatically (name+zip customer match). Trend toggle is instant: all three granularities (daily/weekly/monthly) derive from ONE daily scan (weighted merges in JS) and pre-render as three SVGs toggled client-side. The performance saga: 180/365d windows died on D1's CPU limit — root cause after a full hunt (per-query lap timing baked into the payload): the claims→orders join had no index on orders(flexport_order_id) and read 14.4 MILLION rows; plus conversation blobs dragged through a sorter (fixed: sort first, fetch bodies for the winners). With idx_orders_flexport (+5 more indexes) and queries folded 16→10, every window now builds cold in 2.1–2.9s and serves warm in ~0.1s — fastest the report has ever been. Migration-column indexes get their own MIGRATION_INDEXES list (SCHEMA execs before ALTERs — learned via 60 red tests). 94/94 tests.?interval=, cached per choice, daily shows 21 points); Excel Trend sheet carries every bucket. Fact-check table: new Placed (order date) column; the "customer service says" snippet is now click-to-read — a popup shows the FULL conversation (subject + up to 6k chars, Esc/✕ closes); the Excel CS-disputes sheet carries the full conversation (up to 30k chars) in its own cell per row. Flexport-direct replacements found: CS members were creating replacement orders straight in the Flexport portal — invisible to Shopify. The year's Flexport all-orders export vs our DB: 976 portal-created orders (random external ids, marketplace "Deliverr"), 659 matched to customers by ship-to name+zip → new fx_direct_orders table (kept OUT of orders so LTV/order counts/consumption stay honest) and the reship-corroboration checks it alongside $0 Shopify orders. 90d proof: 128 disputed · 110 carrier-confirmed · 43 corroborated by refund/replacement (was 0 before the evidence layers). Shopify financial_status walk continues in background (newest-first — recent windows already covered). Split-shipment CSV rows (cancelled halves) correctly excluded. 94/94 tests.orders/updated webhook fires on refunds, so future refunds land in D1 within seconds; a store-wide local walk backfills history (~280k orders, refund rows only). Replacement detection: a $0 order for the same customer within 120 days after the disputed order = a reship. The report's dispute columns now read "26 (9 ✓)" — total disputes with the refund/reship-corroborated subset in green — on both carriers and cohorts tables; the fact-check slide gains an "Our response" column (💸 refunded · 📦 replacement sent), sorts corroborated rows first, and the sub-line reports "N disputed · M carrier-confirmed delivered · K corroborated". Excel: Carriers/Cohorts gain a Corroborated column, the CS-disputes sheet gains Refunded + Replacement-sent columns. Cache payload v4. 94/94 tests.tickets.dlv_flag/dlv_order_id/dlv_hit store the verdict, the attributed order (customer's most recent order ≤75d before the ticket), and the customer's own words as the evidence snippet. A new 🔎 Fact-check slide shows "Flexport says: delivered MM-DD" next to "customer service says: <the customer's actual sentence>"; the carriers AND cohorts tables both carry a CS disputes column beside Lost (Flexport) — a carrier showing 0 lost with disputes gets a ⚠. Summary stat + Excel workbook gain the disputed counts and a full CS disputes worksheet (order, customer, Flexport verdict, evidence). Mining runs as a cron sweep for new tickets (400/pass) + a local backfill over the full 45k-body corpus. Regex learned the hard way: Flexport's own notification boilerplate ("Haven't received your package yet? Let us know") lives in thousands of clean threads — the pattern requires customer-side objects (my/the/our), never "your". Report polish (Tyler): trend axis reads MM-YY months / MM-DD week-starts (was year-first); DELIVERR excluded (internal transfers, not customer parcels); cohorts table now mirrors the carriers columns (deliveries · avg · on-time · late · lost · disputed). 94/94 tests.flexport_order_id — ShipBob shipments can't leak into ANY window, even 365d (trend verified starting 2025-10, the cutover month; 113,716 Flexport deliveries in the year). Trend + cohort queries inlined with the filter (catalog reports untouched). ⬇ Excel — full drill-down: /report/delivery.xlsx builds a real multi-worksheet .xlsx in the Worker (new dependency-free lib/xlsx.js — hand-built SpreadsheetML in a stored ZIP): Summary · Carriers · Trend · Cohorts · Late deliveries (every order >9d — 2,364 rows at 90d, with customer name/email) · Undelivered 10–45d (all 372) · Lost shipments — verified against .NET's strict ZIP reader, 1.4MB in ~2s. Carrier table reworked (Tyler): worst-shipment column replaced with the on-time (≤9d) / late (>9d) / lost split per carrier — 90d: DHL 87.9% on-time / 12.1% late / 5 lost vs Veho 99.7% / 0.3% / 0. Flexport API is delivery truth: cron sweepDeliveries now asks Flexport's GET /orders/{id} deliveredAt FIRST for Flexport-fulfilled orders (Shopify fulfillments = fallback), matching the bulk-verified data. Temp walk account ops4@temp.local deleted (Tyler-approved; owned nothing). 94/94 tests.?from=YYYY-MM-DD&to=YYYY-MM-DD, cached 60 min per range, verified 05-01→06-01: 14,238 delivered · 5.4d avg). Range switching is fast now: the every-30-min cron pre-warms all four preset windows (rebuild when >25 min old; served up to 40 min) — picker clicks land on a warm cache (~0.8s) instead of the 4–8s cold build. Order-number search surfaces the ORDER itself: searching an order # in the master search shows a coral 📦 order row (placed date, delivered chip, $, customer name) that opens the full order sheet directly, plus the owning customer's row as before (searchOrders + orders array on /api/customers/search, first page only). Variant walk COMPLETE: 133,892 orders carry variant stamps — consumption stats unblocked. 94/94 tests./report/delivery, linked from a gradient button atop Insights): time-frame picker (30/90/180/365d), carriers ranked worst-first (volume, avg transit, %>9d, worst case — alias-merged so UDS/DHL flavors don't split), warehouse-era + subscriber-cohort comparison, trend line (weekly ≤120d, monthly beyond), Flexport-verified slowest-10, aging undelivered (real count: 372 orders 10–45d without delivery confirmation), LOST shipment count — all cached 10min per window (~0.8s warm, ~4.6s cold build), print = PDF. 90d truth: 34,251 delivered, 5.8d avg, 6.9% slower than 9d; DHL slowest major (7.4d avg, 12.1% slow across 11,370), USPS worst slow-share (13.1%), Veho/Jitsu/Better Trucks the stars (≤4.5d, <2% slow). 94/94 tests.inline mode; the split-view rail renders it — Recharge everything-editor, order click-through, delivery experience, Rivo, all of it; 400px rail, mobile keeps the compact card). Contact headline on every profile: "📨 Last contact MM-DD-YY · channel — THEY'RE WAITING ON US / waiting on their reply / conversation closed" with an Open→ jump (ticket-based; calls/texts fold in with the phone provider) — verified on Dominick (06-19-26, waiting on us). Member lockdown: regular members land on My Flippers and see ONLY that tab + a My profile button (name/email/password via POST /api/me — email must stay their Gorgias email) + a real Log out button (was the bare word "out"); Products/Connections links admin-only. Unassigned tile now includes ownerless cancellations + address changes (live count 10), and admins get ⚡ Auto-assign to team — round-robins 40/click across REGULAR members only, assigned IN GORGIAS (their ownership layer), our rows mirror. Remove a member (Team tab ✕): their book redistributes EQUALLY across remaining regular members (batched ≤90-id updates), open tasks fall back to topic routing, sessions revoked. Deleted-in-Gorgias tickets self-heal: thread opens and the enrich walk both mark 404s trashed (the deleted Trustpilot review ghost is gone — one-off cleaned + systematic fix). Blurb on My Flippers confirmed already admin-gated. 94/94 tests.fmtDate ("Jul 9, 2026") swept across 17 display sites (inputs/API params stay ISO); chart axis labels angled −42° with the year ("Jul 9 ’26") — no more overlap; line graph is the chart default. Ticket dates tell the truth: tiles used updated_at (moves on ANY internal touch — Dominick's Jun 19 message wore Jul 7) → new tickets.last_customer_at (Gorgias last_received_message_datetime) captured at ingest + sweeps; tile bubbles now show when the CUSTOMER last spoke; forced sweep restamped all 160 open tickets. Speed round 3: flippersDashboard's ~14 tile COUNTs parallelized + 30s per-viewer cache (warm ~0.8s; the 45s poll keeps it warm); insights 4 aggregates parallel (212ms, was ~1s+); search fast-paths — exact-email rides idx_customers_email (107ms, was 1.7s), order# drives from idx_orders_number; products list cached 10min (167ms warm; edits purge); profile orders capped at newest 300 (whales stop hauling 500+ rows); new indexes idx_orders_product/idx_orders_number/idx_tickets_status. Cold numbers still inflated while the year-long id ingest hammers D1 — remeasure after. Ingest crossed into unstamped history (+~400 ids/run at 54k/119k): March-era orders (#232539/#226492) flip to Flexport as it reaches them; cutover recomputes when it lands. 94/94 tests.GET /returns?logisticsOrderId= returned the same global list for every order, so each order sheet opened adopted the SAME return (Martin Querin's #261151) onto itself. Four orders (272782/237609/262729/275125) got false "back at warehouse" banners, notes, and refund tasks — all four labels nulled, notes deleted, tasks deleted. Matching now requires the return's fulfillmentOrderId (probed live: it IS the Flexport order id) to equal the order's own id, plus a second guard: a return already tracked on another order can never be adopted twice. Martin's order now adopts HIS return correctly (verified live — his book owner has the genuine refund task). Subscription everything-editor (Tyler): every sub row gets Edit ⚙ → one popup with price, product swap, quantity, frequency, next charge date, skip, cancel, reactivate, and delete (double-confirm) — on the full profile AND the condensed at-risk card. Bulk changes: check multiple subs → skip / change date / change frequency / cancel across all of them. Swap catalog restricted to customer-eligible products (Recharge subscription plans — the same set the portal offers; 32 variants live), used by the editor, the sheet, and the add-form. New Recharge rails: PUT subscription (price/qty/frequency/variant), DELETE subscription, GET plans (1h cache). 94/94 tests.&sub=<id>, hash-routed like the order sheet): click any subscription on a profile → live Recharge state (price, quantity, real next-charge), cadence, status history, skip/change-date/cancel/reactivate, and Swap product — pick from the Shopify-fed variant catalog, Recharge keeps dates and discounts, our product/ring tracking follows (verified live: real sub, $98.96, 20 events). + Add subscription on every profile (works for win-backs and one-timers who are existing Recharge customers): variant + every-N-days + first-charge date → created straight in Recharge, lands locally, noted on the profile. Reactivate root cause found via a failing test: the app-side cancel updated the FIRST row matching the Recharge id — a duplicate row could absorb the flip while the clicked row stayed "active" with no Reactivate button. Status flips now write to the exact row acted on (regression test in the suite); prod had exactly one duplicate pair — cleaned, zero remain. 93/93 tests./detail. Flexport id backfill COMPLETE: 28,902 orders stamped — claim links live on history. 90/90 tests.product_variants catalog synced from Shopify every pass (Starter=1 / Double Flip=2 / Weight Shredder=3 bags — inferred from titles, verified live across the catalog, editable per-variant on /products and never clobbered by sync). Every order captures its variant + units (quantity × bags) at ingest, and the replenishment ring counts bags, not line items — a Weight Shredder order is 90 servings of supply, not 30. Order-history variant walk running overnight (~274k orders newest-first, resumable cursor) so existing customers' rings correct as it lands. Remaining in the epic: consumption stats with seasonal time filtering, subscription full sheet + add/swap products (Recharge), and the Insights report builder. 87/87 tests.body_html images, not attachments (Eric W.'s damage photos verified live) — both sources now render in threads and ride the claims export. DUE badges + the cadence blurb are admin-only. Header wraps instead of overlapping (standing rule: nothing stacks/cuts off). Full-history body walk running (~6h, rate-limit-paced) — pain mining covers every conversation ever once it lands. Perf round 2: React self-hosted + immutable caching on the hashed bundle — repeat visits fetch only the tiny HTML shell (119ms). ARCHITECTURE RULE (Tyler): all customer/order actions build on Shopify/Recharge/Flexport ONLY — Gorgias stays a swappable comms layer for an eventual direct-email replacement. Next: variants epic (the inaccurate "ran out X days ago" is the variant bag-count bug), Insights report builder, thread-rail full profile. 86/86 tests.vip_owner (admins only — lead-agent tier is a planned future level). The VIPs tab = $1k+ customers with no VIP owner regardless of book assignment (so members' book VIPs returned to the pool without touching their ticket books); admins ♦ Claim VIP from the tab or assign via the profile's ♦ dropdown; a separate ♦ VIP badge sits next to the book ★. (3) VIP retention is a true tile (count = due VIPs), the admin's default bottom view, laid out like the VIPs tab rows — always the signed-in admin's own list, never a member's. (4) Pain points mine the conversations themselves: customer message text is banked on every thread-open and every bot-check sweep (tickets.body_text), and the theme engine votes once per ticket per theme across bodies + subjects + tagged notes. Coverage grows as threads are worked; a full-history body walk is available later if wanted. (5) Images render in Gorgias threads and ride the claims export as damage proof. (6) Every message view has in-tile search with the master-search reach (name/email/phone/address/order #). Insights report-builder + thread-rail full profile: next batch. 86/86 tests.build.js precompiles the dashboard's JSX at deploy into a hashed cached bundle — the browser no longer downloads a 3MB compiler and compiles 175KB of source on every visit; / serves index-compiled.html; npm run deploy = build + green tests + wrangler. Still the same $5/mo Worker. (2) Every top tab has its own URL (#tab=…) and all open/close helpers preserve the rest of the hash — the back button walks tabs, tiles, tasks, profiles, threads, and orders like real pages. (3) My Flippers inline: tiles and the task list open in the bottom section of the dashboard (own URLs, back cycles) — the book is the default view, not a permanent list. (4) Member hero replaces the command-center strip on the flippers tab: Top-5 pain points across their book (tagged notes + hot negative ticket subjects), Saves started, Saves confirmed (paid-reorder only), and Save rate with a period picker (today / week / month / all time) — GET /api/flippers-dashboard/stats. (5) Flexport id backfill ingesting (report columns were Deliverr-named: deliverr order id / marketplace order id) — claim buttons populate across the 95-day claim window. Remaining in wave 15: thread rail = full profile embed; the deep scoring pass (churn 2+ delivered gate, skip counts headline, 6-month win-backs, sentiment heat cases). 86/86 tests.NEW MESSAGE tag (app matched Gorgias's 8, not the 222 never-closed backlog); shipping tiles are new-only — worked ship tickets await the customer in each member's Waiting for response tile with filter chips (All / Lost & damaged / Wrong item / Everything else, ?sub=); order sheet hides Cancel + Change-address once fulfillment starts. Wave 14: ⇄ Merge customer (admin) — search the keeper, one click moves orders/tickets/subs/notes/touchpoints/saves, folds identity fields, deletes the dup, recomputes (the Amy Allan Gorgias-merged-but-not-here fix); Flexport Order.\* webhooks registered live (Packed/Shipped/Delivered/Cancelled → captures the Flexport order id onto our rows as orders move → one-click claim links activate themselves; unknown payload shapes self-document in the webhook activity table); returns wizard — item + quantity picker (capped at returnable), then Flexport buys the label and (checkbox, default on) the label is emailed to the customer through Gorgias as the signed-in member, threaded with the rest of support. 86/86 tests.logisticsOrderId optional) → Flexport POST /returns purchases the label; the agent gets tracking + a copy-ready label link for the Gorgias reply, and it's auto-noted on the customer. Not yet live-fired (real label purchase — first run should be a designated test order). (2) Claims: Tyler found the portal deep link (portal.flexport.com/orders/claim/{flexportOrderId}). Vendor bug discovered: the external-id lookup is unreachable for Deliverr "#…" ids (endpoint won't percent-decode; a literal # can't ride a URL — verified across three API versions). The id shape decoded anyway: #<order_number>DELIVERRSPLIT<shopify FULFILLMENT ORDER id> (Tyler added the fulfillment-order read scopes — which also unblocked live cancel gating). Resolver + orders.flexport_order_id cache stay wired; until Flexport fixes the endpoint the button falls back to File claim (search) ↗ — opens the portal with the order # auto-copied. Team members use their own Flexport portal logins; no onboarding needed. (3) Cancel-intent gating (Tyler): Run cancel-save only exists for customers honestly cancelling (open cancel ticket, skip ≤90d, or cancellation ≤60d) — hidden in the UI with a locked-state hint AND enforced server-side (400); profiles show each save as PENDING PAID REORDER → SAVE CONFIRMED ✓ with the team member's name; full credit lands only on the next paying order. 86/86 tests.customers.flagged_fraud, POST /api/customers/:id/flag) + fraud/scammer/chargeback ticket-tag heuristic — both excluded from listVips; FRAUD badge on the profile. (4) A save isn't a save until they PAY again: insights save-rates count outcome='saved' only with a paid (>$0) order after the flow — $0 reships don't confirm; unconfirmed show as "pending reorder"; hero relabeled "Saves run today · confirmed on paid reorder". (5) Unassigned/hit-list routing (Tyler's rules): tickets.from_agent captured — bot/agent-INITIATED conversations never count as new inbound (they were polluting unassigned); unassigned = new + unclaimed in Gorgias + customer in NOBODY's book; a book-owned customer's unclaimed new ticket auto-routes to the OWNER's hit list (auto-assigner in Gorgias itself = future, after Tyler talks to the team). FLEXPORT CLAIMS RESEARCH (Tyler asked): the Logistics API (latest 2026-02; we're on 2024-07) has NO claims/disputes/reimbursement endpoints — claims are support-portal-only, with a 90-day discrepancy window; the outbound lifecycle DOES have a LOST_DAMAGED terminal status that makes Flexport auto-initiate a claim. Practical path: watch for that status via webhooks + generate pre-filled claim emails through Gorgias so correspondence stays in the helpdesk. 87/87 tests./webhooks/gorgias/t_verdant?token=…). Live shape discovered: the integration body sends {ticket_id} only — the first delivery minted a ghost ticket row (top-level id missing → gorgias_ticket_id NULL). Handler hardened to accept all three real shapes: full ticket, {ticket:{…}} envelope, and id-only → fetch the full ticket back from the Gorgias API (webhook dispatch now passes {fetchImpl, creds} ctx); junk-shaped payloads are ignored, never ingested. Verified end-to-end live with a harmless status toggle on a junk ticket: two deliveries fetched back and mapped to the same internal row (ticket 103149838 → t_7f2f…, idempotent). Ticket changes now hit the tiles in seconds; the 30-min sweep remains the safety net + bot-correction pass. 85/85 tests.Awaiting Return lands on a ticket it lives ONLY in the Active Returns tile — excluded from shipping/urgent/unread/waiting — until the ticket is CLOSED, which completes the process and clears it. (2) Gorgias Bot ≠ contact: bot replies (via: rule, ...@email.gorgias.com sender) made untouched tickets read as "handled"; tiles now verify agent-spoke-last tickets against the real messages (computeAwaitingFromMessages: customer waits whenever their latest message is newer than the latest HUMAN reply). Inline on every sweep (15/page, awaiting_checked_at watermark, re-checks when a ticket updates) + POST /api/admin/enrich-awaiting backfill. Full pass over the open set: 8,495 verified → 674 flipped back to needs-agent (customers only the bot ever answered). (3) Junk reconciled: marker re-walk covered the TRUE open set (~12.8k — the first walk's resume had silently ended early on a stale cursor) → 740 ghost rows (open in D1, invisible in Gorgias views: spam/trash/hidden) marked trashed=1; unassigned tile went 30 → 5, matching their post-assignment state. (4) Thread route hardened: {thread, thread_error, customer} — the split-view customer panel survives a Gorgias failure. Baseline tile numbers pulled for Tyler's drill-down; flagged: WAITING tiles are bloated by the never-closed backlog (Amy 2,727 / Alexis 2,001 / Cassy 1,989 open human-answered tickets that just never closed) — age cutoff / auto-stale policy is the next conversation. 84/84 tests.awaiting_reply), or WAITING (agent spoke last; ball with the customer — never shows in urgent/semi-urgent anywhere). Scopes fixed: lost & damaged / wrong item / address changes / cancellations are TEAM-WIDE (every member sees all of them); hit list / unread / waiting / returns follow the Gorgias ticket assignee — a member's unread tile now matches their Gorgias unread view exactly (chardo's Needs a Phone Call tag counts toward his hit list). Shipping issues split into 4 subsections: each of lost & damaged / wrong item shows new vs waiting on customer; a customer reply pipes the ticket into the handling agent's unread responses, an agent reply sends it back to waiting. New member tile "Waiting for response": generic open tickets (non-shipping, non-return) sitting with the customer. New-channel dupes: a NEW-tagged ticket whose customer already has an open assigned ticket counts as that agent's unread response, never as unassigned/hit list (true Gorgias-side merge = future). Junk exclusion (the "20 in Gorgias, 30 in the app" unassigned bug): spam/trash flagged at ingest + a gv_seen_at view-confirmation marker swept from the system open view; open rows never vouched by a view get reconciled to junk. Sticky ownership: the cron now auto-seeds new customers to their latest conversation's assignee — existing assignments never move (no book-hopping; returning customers keep their agent). Split view on threads: ★ Profile toggle — compact customer panel (contact links, LTV, tenure, subs, recent orders w/ order #, ship-to, Full profile →) right of the conversation on desktop, above it on mobile, preference remembered; the panel (and an inline error) now survive a Gorgias thread failure. Bubbles cleaned up: no raw Gorgias tag chips — red NEW TICKET on hit list, orange AWAITING REPLY on unread. 83/83 tests.#tasksview=<uid>, Done ✓ / admin ✕ carried over), Active returns (Awaiting Return tag), and admin-only Unassigned (open NEW TICKET tickets nobody owns in Gorgias — global by nature). Messages tiles mirror the team's LIVE Gorgias views (view filters probed via a new admin-only GET /api/integrations/gorgias/probe passthrough): SHIPPING ISSUES → Lost & damaged (LOST/DAMAGED) + Wrong item (WRONG ITEM SENT); URGENT → Address changes (edit-address) + Cancellations (cancel/refund) + admin-only Hit list (NEW TICKET); SEMI-URGENT (admin-only) → Unread responses (NEW MESSAGE minus NEW TICKET), with the chat/contact-form/email channel scope their urgent views use. Visibility (Tyler's rules): members see shipping issues, address changes, cancellations, and returns scoped to their own book (customers.assigned_to — a message from your customer is yours even if unclaimed in Gorgias); Unassigned / Hit list / Unread are admin-only, and the admin dropdown drives tasks + messages + book together (?user= on all three APIs). Tiles → full-screen ticket lists on own URLs (#tile=<key>&tileu=<uid>, urgency queues oldest-first like their views) → rows open the existing full-screen thread. Plumbing: tickets.tags/updated_at/awaiting_reply/channel captured (both upsert branches; prod ALTERs applied); open tickets swept via the account's system "All" view (/views/{id}/items — the tickets list API has no status filter, and view items paginate by meta.next_items path, not next_cursor — inventing a cursor param silently ends after page 1, hit live); cron folds a 2-page sweep into every gorgias sync so tiles stay ≤30 min fresh; POST /api/admin/refresh-open-tickets = on-demand handle. New APIs: GET /api/flippers-dashboard (role-scoped counts), GET /api/flippers-dashboard/tickets?tile= (paged lists, member 403 on admin tiles). Verified live (real counts: Alexis 23 active returns / 3 hit list / 5 unread; admins see 12 unassigned) and in-browser (tiles follow the dropdown, tile→thread→back unwinds, tasks page works). 83/83 tests.seedAssignmentsFromGorgias now groups rows per target owner and updates in ≤90-id IN chunks under a 300-statement budget with a remaining flag (idempotent: correct rows drop out, repeated calls converge). The full resync then landed 15,045 reassignments in a single run. Fix 2: memory-safe cache rebuild — the full computeAndCacheQueues (score-all + 5,000-deep 'all' + categories + VIPs + every member book) hit the Workers 128MB memory cap at 8 books (exceededMemory after 195s, and the 3-min D1 hammering starved live Recharge webhooks with "D1 DB is overloaded"). The rebuild now takes a books option: the ?fresh=1 request path runs books:'none' (books rebuild individually via /api/my-flippers?user=X&fresh=1 — one invocation each, 8–45s/book verified), and the cron rotates 2 books per run (stateless round-robin keyed to the 30-min epoch — every book refreshes within ~2h; the scored array is also released before the VIP/book passes). Final books: cassyjewel 7,654 · info.starrsmith 5,836 · alynne 5,330 · lindsey.fein 5,102 · alexiswoodring 1,975 · support@ 1,755 · jeannye 567 · chardo 146 (~28.4k customers owned) — all three missing teammates now have real books. Address+order-number backfill launched (newest-first, resumable; ~274k orders overnight). 82/82 tests.cancelShopifyOrderSafe reads Shopify's fulfillment orders — already fulfilled/packed → 409 blocked with a clear message ("use a refund instead"); submitted to Flexport but not prepared → cancellation_request propagates to Flexport THROUGH Shopify, then the order cancel completes (immediately or on their release); untouched → plain cancel. No more checking two systems by hand — the order sheet's Cancel button reports exactly what happened. (2) Reship second verification: after picking the order, step 2 shows the editable shipping address ("the reship goes HERE") — Confirm & send passes it through to the $0 duplicate. (3) Order history rows now lead with the order # (products live inside the order sheet on click); missing numbers show "syncing…" until the running backfill fills them. (4) Admins can delete tasks (DELETE /api/touchpoints/:id, ✕ on task rows). Also filed: the Flippers dashboard epic (Tyler's tile design — Tasks tile → own URL, Messages tiles mirroring their Gorgias views hierarchy [Unassigned / Shipping issues / Urgent incl. hit lists / Semi-Urgent unread replies], Active Returns tile — implementation shortcut: mirror their live Gorgias views via API instead of re-implementing tag logic). Bot-assignee guard also landed pre-resync (a Gorgias automation would've become a "member" with a customer book). 82/82 tests. Gorgias walk ~39.9k and counting (archive bigger than estimated).POST /api/touchpoints/:id/complete); completion stamps completed_at, which the 105-day outreach cadence already counts as contact. (2) Order numbers were never stored — new orders.order_number captured at ingestion (webhooks + syncs), shown as gold #123456 in the reship picker and profile order history; the queued address backfill now fills numbers for history too (fields added to the desc crawl before it started — timing win). (3) Receipt v2: proper invoice layout — brand header, Receipt/Invoice block with order #/date/payment status, Ship-to + Bill-to columns, items table with SKU/qty/unit/amount, subtotal/discounts/shipping/tax/total block (live payload extended) — Tyler's example attachment didn't come through, so this matches OPP's standard receipt-invoice structure. (4) Apply credit is dollars-only in the UI (server keeps % support). 81/81 tests. Gorgias walk at ~31.5k/34k; address+number backfill chained next.member_credit_max_cents in app_config, default $25 — members blocked over it, admins uncapped + inline limit editor); Send win-back email → template picker (4 canned; queued as notes until the Klaviyo pipeline lands); Create task (was a do-nothing placeholder from the original build) → assigned follow-up: teammate picker + what/when → a scheduled touchpoint owned by that member. Fulfillment-radar action buttons open the reship flow. (3) Notes are removable (✕ per note on profiles, DELETE /api/notes/:id). (4) saveRiskConfig now MERGES — operational settings and /breakdown risk keys no longer wipe each other (latent bug caught during the ceiling work). Sub full-profile sheet + add/swap subscriptions: rolled into the variants epic (needs the Shopify variants catalog). 80/80 tests.&order=<id>) pulling the LIVE Shopify order (financial/fulfillment status, line items w/ refunded strikethrough, both addresses, note) with real actions: Cancel order, Full refund (Shopify refunds/calculate suggestion → create; never hand-built money math), Change shipping address (form; mirrors into our order row + customer default), and Receipt/Invoice PDF via Order Printer Pro — URL rule solved empirically: OPP accepts the raw Shopify order id (verified live: 200 application/pdf on #277698; the 17-digit segment in Tyler's example was OPP's internal ref). opp_token = optional Shopify-card field (set live). Flexport claims-from-app: future (needs claims-API research). (2) Subscriptions card on every profile — all subs (active/paused/cancelled) with product, cadence, next-charge date, and the Recharge-portal actions in-app: Skip next charge (queued-charge lookup → skip), Change next-charge date, Cancel (with reason), Reactivate; local rows + transition events follow. Requires write_subscriptions/write_charges scopes on the Recharge token. (3) Points→$ policy (Tyler): 2,500 pts = $25 → 1 pt = 1¢, redemption cap $25 — updateLoyalty derives value when Rivo gives none; existing balances repaired in prod → the "Redeem $X in points" save lever now fires. (4) Durations humanized: "1 year, 2 months, 3 days" everywhere (tenure + last-contact). Gorgias-corpus insights + product variants/consumption stats: next up. 79/79 tests.orders.shipping_address/billing_address (compact JSON via shared normAddress, Shopify + Recharge normalizers, both upsert branches); (2) default — recomputeCustomerRollups denormalizes the latest order's formatted address onto customers.address (newest wins); (3) profile card — SHIPPING and BILLING blocks side-by-side, a "billing = shipping ✓" chip when identical, "from their latest order · date", and a collapsible "Other addresses on file (N)" history; (4) search — the master 🔍 now matches address fragments too (placeholder updated); (5) backfill — POST /api/admin/backfill-addresses walks Shopify orders newest-first (listShopifyOrdersDesc, Link-header page_info pagination, fields-limited) so each customer's current address lands first — COALESCE-if-null + walk order = latest-wins with zero recompute queries; resumable cursor in shopify sync_state; loop script ready, queued to start after the running Gorgias walk finishes (D1 write-contention discipline). Prod columns migrated. 78/78 tests (E2E: two orders → latest = default, split billing/shipping, same flag, older address under others, search by street).assigned_to/assigned_to_name joined onto atRiskQueue/scoreCustomerSet/listVips). (2) Contact truth from Gorgias: last_gorgias_at (latest conversation) on VIPs + books; the "last contact" moniker and the 105-day outreach cadence both take the max of logged outreach and Gorgias conversations — support talking to someone counts. (3) Full-screen conversations: threads are now hash-routed (&thread=<id>) full-screen pages — browser back or the ✕ top-right closes; this is the surface the in-app Gorgias tooling grows on. (4) Paging everywhere: caches store 500-item blocks (D1 ~1MB value ceiling) — top-1,000 per list, the 'all' queue 5,000 deep; every list API takes ?offset&limit; UI renders 20 at a time with Show-more (queue/VIPs/books), and VIPs keep going past the cache via live SQL pages. (5) Master customer search: header 🔍 across name/email/phone, LTV-ranked, paged, owner chips, click→profile (/api/customers/search). (6) Partial-seed root cause fixed: the first assignee walk only covered 14.4k of ~34k tickets (capture deployed mid-pass) — that's why 3 accounts (alexiswoodring98, jeannye@, lindsey.fein) were missing and chardo's book was near-empty. Gorgias cursor reset; full re-walk running, and at completion ?resync=1 re-syncs every customer to their true latest conversation owner (pure-manual assignments untouched). Verified live: offset 2000 pages, VIP gorgias dates, search, owners on rows; cache full rebuild ~70s. 77/77 tests.flippers_<userId> per owner; ?fresh=1 recomputes; seed button busts the cache). Books are capped at the top-200 by LTV (ORDER BY before the cap — an unordered LIMIT truncated arbitrarily). Verified live: all books 94–344ms cached. Temp ops account deleted. 76/76 tests (new: 220-customer chunk-boundary book; cache-vs-fresh on outreach).assigned_to — every viewer (including admins) saw "unassigned", so admins silently overwrote each other's books. /api/customers/:id now returns assigned_to + assigned_to_name; a ★ owner badge shows on every profile for every viewer; the admin dropdown reflects the real owner. (Assignment changes were already admin-only server-side — the gap was purely visibility.) (2) My Flippers seeded from Gorgias ownership: tickets now capture assignee_email (schema + both upsert branches — the INSERT branch initially dropped it, caught by the new test hanging the runner pre-server.close); POST /api/admin/gorgias-assignments assigns each unassigned customer to whoever owns their latest Gorgias conversation, auto-provisioning keyless member accounts (claimed at first Gorgias-key login) — re-runnable, never moves an existing assignment; "Seed from Gorgias" button on My Flippers (admin). Archive re-walk running to backfill assignee data (~34k tickets, ~3h; seeds automatically at completion — driven via a temporary ops session in D1, deleted after). (3) Calling/texting decision: Google Voice has no public API — GV deep-links were prototyped and shelved on Tyler's hesitation; shipped native tel:/sms: links (profile contact strip + Flippers rows) which route through each member's own phone/GV app; options memo delivered (Gorgias SMS channel if enabled = in-app texting through the existing reply pipe; Telnyx/Twilio ≈ $10–30/mo = the only real path to in-app dialing + auto-transcription). 75/75 tests.X-Recharge-Hmac-Sha256 header is NOT an HMAC — it's a plain sha256(client_secret + body) concatenation. Our true-HMAC implementation was self-consistent with its own tests (both sides computed HMAC) and rejected every real webhook. Verify now accepts both constructions. Confirmed live within minutes: first ok webhook was a real skip ("charge 1847325639 skip → c_26a816…") — skips now land in the risk engine in seconds. VENDOR LESSON: never trust a header's name; validate against real traffic, not just docs examples. (2) Access model fixed for teams: Tyler's teammate needed the device ?key= link just to see the login page — wrong. The APP_KEY now guards ONLY the one-time /api/auth/bootstrap; the page itself is public, a login wall fronts the app (UI reloads with the session after sign-in), and every /api/* data route requires a session in production (requireAuth dep from worker.js; local dev/tests stay frictionless). Verified live: page 200 with no key, /api/queue → 401 "sign in required", bootstrap still key-guarded. (3) API-key paste bug: the member key field was type=password, inviting autofill/iOS interference that mangled pastes to a single character — now a visible monospace text input with autofill fully disabled. 74/74 tests (new: production access-model + sha256-concat verification).asUserId on replies = act-as-anyone. (3) Assignment + My Flippers — customers.assigned_to, admin-only assign (dropdown on every profile), and a My Flippers tab: the member's book scored with the full risk engine (bounded IN-clause variant of the queue scorer — instant, no cache needed), each row with score/tier, top signal, LTV, and the outreach cadence board: DUE badge when there's never been a confirmed conversation or it's been ≥105 days (Tyler's "every 3-4 months"); tap-to-call, Log call ✓ / Log text ✓ writes a completed touchpoint and rests them until next cycle. Admins get a team picker to view anyone's book. (4) VIP outreach board — same DUE badges + log buttons on the VIPs tab (via last_outreach_at on /api/vips). New tables: app_users, sessions (30-day cookie, revocable), migrated to prod D1. Verified in-browser end-to-end: bootstrap → sign in → assign → DUE → log → rests. 73/73 tests. OPEN: Recharge webhook signatures still mismatch after exhausting every implementation cause (hex/base64/case, client_secret AND access_token as keys, fresh re-registration under the current token via new ?reset=1, raw-byte HMAC input) — wire format confirmed 64-char lowercase hex; the saved client secret is simply not the signing secret. Awaiting Tyler double-checking WHICH Recharge token page the secret came from; no data is lost meanwhile (30-min sync carries everything).POST /api/tickets/:id/reply relays through Gorgias (real email out; ticket fetched first so channel/addresses are right; sender = the Connections-card API user until per-user logins land); ThreadModal gains a composer + optimistic bubbles. (2) Close/reopen tickets from the app — POST /api/tickets/:id/status mirrors into Gorgias first, then our row follows. (3) "Only CLOSED = resolved" enforced: upsertTicket now clears resolved_at whenever a ticket isn't resolved (the old COALESCE kept stale stamps on reopened tickets forever); reply-from-app also reopens our row. (4) VIP tenure bug fixed — recomputeCustomerRollups used COALESCE(first_order_at, …) = write-once, so customers first seen during the 60-day-window era kept 2026 stamps ("with you 1 month" on multi-year customers). Now MIN unconditionally + 11,990 rows repaired in prod (Barry Marburger reads 2.3 years, verified live); Safari-safe date parse in the tenure label. (5) Phone on the customer profile — tappable tel: + mailto: contact strip. (6) Recharge webhook rejects diagnosed: reject detail now names the failure — live answer: "signature mismatch" (header present, wrong saved secret) → Tyler action: copy the API Client Secret from the SAME Recharge token as the saved API key into the card's webhook-secret field. Meanwhile skips still land via the 30-min sync. Earlier same day: full doc staleness scrub (Revo/Stay AI/Chardizy-naming removed, every section brought current). 72/72 tests.charge/updated webhooks + incremental cron passes (skips_backfill_done set — no more full walks). Rivo targeted pass complete too: top ~10k LTV customers verified per-id, 5,728 holding live points balances; cron maintains both.findOrCreateCustomer minted a fresh identity-less row every time it saw a guest/POS order with no customer object and no email — and incremental re-walks of the same orders re-minted endlessly. Production had accumulated 227k orphaned husk rows (not the ~9.5k estimated from the skip bug alone). Fixed at the root: findOrCreateCustomer now returns null when a payload carries no identifiers at all, and ingestShopifyOrder skips customer-less orders (nothing retention can act on). Executed with Tyler's approval: 12,643 skip events re-pointed to their subscriptions' owners; 187,387 orphan rows deleted (batched, final round proved zero remain); 30,939 stale smeared rivo_customer_id/tier values cleared. Customer table: ~380k → 153,567 real rows (3,039 identity-less rows remain by design — they hold real guest-order history). Verified post-purge: full rescore 26s, all queue categories 200–300ms, subs top Steve Seabury 100, VIPs 72/100 with points, profile round-trip intact. 70/70 tests.POST /api/integrations/recharge/webhooks lists what exists and creates missing topics (incl. charge/updated) using the stored token — executed live (all 5 registered), plus a "Register webhooks" button on /connections; the curl instructions are obsolete. (2) Subs tab: three stacked causes fixed. (a) v0.8.0's skip crawl walked oldest-first → newest-first (sort_by=updated_at-desc); (b) 2021-11 charges NEST the customer (charge.customer.{id,email}, not flat customer_id) — the flat read made findOrCreateCustomer mint ~9.5k identity-less ghost customers and hang every skip event on them (zero overlap with orders/subs = zero signals). Fix: attribute skips to the subscription's owner via purchase_item_id (9,498/9,500 resolve), never mint ghosts, and ingestion is now INSERT OR REPLACE so the re-crawl heals mis-attributed rows through the app's own code path (bulk SQL repair was blocked by permissions — correctly). Verified: subs category went 0 → 100+, topped by score-100 customers stacking shipment_stalled+supply_depleted+renewal_skipped+repeat_pauser. ~9,057 customers skipped in the last 90 days alone. (3) Rivo: two compounding API discoveries. The customers list silently ignores every filter param (email=, filter[…], q[…], search=) and returns page 1 — the old "lookup by email" had smeared page-1-member #1's zero balance across 37,568 customers; and pagination[page] hard-caps ~250 (no sort), so bulk pages reach only ~25k of 146k members. THE KEY: Rivo's member id IS the Shopify customer id (verified: 7249222533421 both sides) → per-id GET /customers/{shopify_customer_id} is the only honest lookup. Rivo sync rebuilt two-phase: bulk pages (oldest 25k members, points-holders only) + targeted per-id refresh of the top-10k LTV customers (v2 marker heals the smeared rows). Verified live: Michael Weinstein 550 pts "FLIP Fam" exact-matches the raw API; 1,841 distinct holders and climbing; 72 of top-100 VIPs show real points (Steven Eaton 7,949 pts). (4) Speed: /api/vips rides the queue_cache (cron-refreshed) — 1.8s → 230ms; custom ?tenure= stays live. Diagnostic GET /api/integrations/rivo/sample (auth-gated raw-payload probe) stays — it found both Rivo bugs. PENDING TYLER'S OK (blocked by design): one-time purge of the ~9.5k ghost customer rows + clearing the stale wrong rivo_customer_id on ~35k rows below the top-10k refresh band (both invisible to the dashboard, just dead weight). Known limitation: Rivo doesn't expose per-customer redeemable dollar value — the "points waiting" save lever needs a points→$ conversion (derivable from /rewards; on the board). 70/70 tests.skipped, so watching subscription-status transitions (v0.5.0's design) could never see a skip. Upside discovered: skipped charges are queryable history (GET /charges?status=skipped) — unlike pauses, the pattern backfills retroactively. Shipped: (1) ingestRechargeSkippedCharge — skipped charges land as subscription_events rows with deterministic ids (se_rcch_<chargeId>, INSERT OR IGNORE → idempotent across backfill re-runs and duplicate webhook deliveries), event time = the charge's updated_at, resolved to the internal subscription via purchase_item_id; (2) Recharge sync gains a one-time full-history skip backfill phase (runs without updated_at_min so years of skips land; cursor-resumable at the proven pages=2 envelope) then folds into the regular incremental pass; (3) webhook charge/updated handled (status=skipped → event; anything else ignored) — checklist updated: register that topic or skips arrive only via the 30-min cron; (4) risk engine: renewal_skipped now fires from a skip event ≤60 days on a non-cancelled sub (the status-based path never fires in the real world; kept for compat), detail says how many days ago; repeat_pauser unchanged mechanically (≥2 skip/pause events in 90d) but reworded skips-first. Verified live run 1: 500 skips ingested from the first 2 pages — full history loop running (~500/3min, sequential, resumable). Queue cache rebuild + subs-category verification auto-runs at loop completion. 69/69 tests (sync mock grew a /charges route; new E2E: ACTIVE sub + 2 skip webhooks + 1 duplicate → 2 events, both signals fire, skipper scored).atRiskQueue bulk-loaded all 274k+ order rows into Worker memory (~128MB limit) and Cloudflare killed the isolate (HTML 503, not our JSON). Two-part fix: (1) Aggregate-first scoring — per-customer orderFacts (COUNT(*), MAX(COALESCE(delivered_at, placed_at))) via one GROUP BY; full order rows only for the trailing 150 days (ring/dose need recency, not 2023); shipments joined to recent orders only; explicit column lists everywhere. risk.js signals (first_order_stall, lapsed_buyer) consume the aggregates with full-row fallback (unit tests + bundleFor unchanged). Mid-fix TDZ bug (facts consumed above their declaration) broke 7 tests AND hung the test runner's E2E servers — moved declarations to the top of detectSignals; green-gating caught it before deploy. (2) Precomputed queue cache — even fixed, live-scoring ~90k buyers cost ~13s/request; new queue_cache table stores top-200 per category (+all), refreshed by the 30-min cron after each sync cycle; /api/queue serves it instantly (computed_at exposed; ?fresh=1 forces a rescore; cache-miss computes synchronously so a fresh deploy self-heals). Verified live: 74–363ms per category (was 13–20s), top risk Owen Self 83 (stalled + depleted + negative ticket). Note: subs category is legitimately empty — prod has 20,911 active / 48,696 cancelled and zero currently-paused subs; pause-pattern history accrues from live webhooks/syncs. MILESTONE: Shopify backfill DONE (~274k orders, complete to Aug 2023) + Recharge backfill DONE (~45.5k identities unified). Gorgias data is complete (~34k tickets) but re-walks the archive each pass — incremental marker is the follow-up. 68/68 tests (new: cache-vs-fresh semantics).#customer=<id>, hash-routed so browser/phone back works): full-screen view composing the action panel (Detail reused — save/schedule/call/notes/thread click-through) + complete order history (25 most recent w/ dates, product, total, delivered chips) + touchpoints + all notes; self-contained fetch so it works for ANY customer id, not just the loaded queue (server adds touchpoints to the detail payload). Entry points: "Full profile →" in the queue detail (desktop + mobile sheet, profile stacks above), VIP rows clickable, Fulfillment radar "Open". Enabler refactor: SaveModal/CallPanel now carry their customer IN STATE instead of looking up the queue array (which crashed for out-of-queue customers). Verified desktop + mobile incl. VIP→Jason Jimmy profile + stacked sheets + no horizontal scroll. Also this session: Gorgias backfill COMPLETE (~34k tickets total); Shopify at ~274k orders (+200k in one pass) and Recharge identity phase DONE (~45.5k unified — VIP list transformed: Barry Marburger $5,303/22 orders/active sub leads; all top VIPs now active subscribers with true lifetime LTVs); sequential pipeline continues for the remainders. Tests green throughout.pointer:coarse ergonomics (42px+ touch targets, 16px inputs to kill iOS zoom-on-focus), safe-area insets, theme-color/apple meta; (b) the queue's two-column desktop layout becomes list → full-screen detail sheet on phones/tablets (tap a customer → sheet slides up with sticky back bar; back/rotate closes) — Detail component shared via extracted props, zero duplication; (c) class-tagged responsive grids (statgrid 4→2×2, insightsgrid/teamgrid auto-fit, radarrow/viprow stack at 640), tabs horizontally scrollable, chips wrap, modals go true full-screen ≤640; (d) connections/products pages: header wrap + touch targets + iOS input guard. HARD-WON DETAIL: matchMedia/resize events are unreliable in emulated & embedded viewports — a cached isMobile boolean went stale; the hook now reads matchMedia LIVE every render (listeners only trigger re-renders) and click handlers read live too. Backfill status at deploy: Shopify history ~74k orders ingested (+40.5k this loop), Recharge identity phase 40k customers unified (still walking), Gorgias 24k+ tickets; concurrent loops caused D1 contention pushing runs past client timeouts → replaced with a SEQUENTIAL grand pipeline (shopify → recharge → gorgias; rivo cron-maintained). Tests green; deployed.mergeRechargeCustomer (upserts.js): given the Recharge CUSTOMER record (which has the email), moves subscriptions/events/orders/tickets/notes/touchpoints/save_flows from the stub onto the canonical email-matched row, backfills the recharge id, deletes the stub, recomputes rollups. Recharge sync gains a customers-first identity phase (cursor-chunked, runs before active/history phases; repairs all pre-existing stubs retroactively). Subs-category=0 during active-only data is CORRECT (healthy active subs carry no risk signals — paused/skipped arrive with history + webhooks). Also: transient() retry now covers "Network connection lost" D1 blips; history crawl chunk size settled at pages=2 (500 orders/run ≈ 3 min — 5- and 12-page runs of all-new rows outlive client timeouts; server kept finishing anyway, cursors proved it: +7.7k orders banked during "failed" runs). Broke the suite briefly via an Edit that clipped async off a function (anchor substring matched inside async function) — caught by green-gating, deploy blocked, fixed. 67/67. Backfills running: full order history (Aug 2023→), Recharge identity+history, Gorgias tickets (24k+ so far).read_all_orders scope silently see only the trailing 60 days (our oldest ingested order was exactly 60 days back — May 4). Tyler added the scope to the Dev Dashboard app, released the version, AND approved the updated permissions on the store installation (all three steps required; the released-but-unapproved state still returned truncated data). Verified live: oldest visible orders now 2023-08-07. Actions: orders cursor reset via json_set(sync_state,'$.orders_since_id',0) (customers cursor kept — that list was never truncated), Worker redeployed (in-memory 24h token cache would otherwise serve old-scope tokens), full-history crawl launched (~26k orders/60 days suggests a six-figure total; hours of resumable chunks). When it lands: true lifetime LTVs re-rank VIPs, tenure gate can rise to real "long-time" thresholds, dose inference gets multi-year gap data. LESSON for the docs: read_orders ≠ read_all_orders — the checklist now can't miss it. Meanwhile: Recharge active-first phase pulled 1,500+ live subs and keeps going; stale processes purged (9.5h zombie npm test, duplicate crawl loop, idle local server).GET /api/tickets/:id/thread → fetchGorgiasThread: ticket + messages, customer/agent bubbles). Verified on production with Roxanne Saldana's real "Refund of 9.70" ticket (4 messages incl. the agent's refund reply — the don't-double-handle cross-ref proven on our top-risk customer). (2) VIP list v2 — no longer requires a live subscription: best customers = tenure gate + (live sub OR ≥2 orders), LTV-ranked, sub status enriches when present ("repeat buyer" otherwise); auto-refreshes every 60s with a live badge. Found: the store is YOUNG — entire order history starts spring 2026, so the 180-day tenure gate returned nobody; default now 30d (?tenure= to raise as the store ages). Live top: Jason Jimmy $3,634/14 orders, all "never contacted". (3) Recharge cursor pagination — page-number pagination 422s past ~page 98 (hit live at ~24.5k subs with ACTIVE subs beyond the wall); rebuilt on cursor/next_cursor with per-resource cursors in sync_state, PLUS an active-first priority phase: status=active subs crawl before the history pass (subscribers are the point; history can take hours). Active phase runs once — webhooks + incremental updated_at_min passes keep it current. Backfill relaunched. 66/66 tests./api/queue?category= filtering server-side before the limit (client-side filtering of a top-100 slice could never surface low-weight pools — win-back read 0 until moved server-side). (2) Recharge pause-pattern tracking — new subscription_events table records every status transition (webhooks + syncs; first-sighting in paused/skipped counts); new repeat_pauser signal (22): ≥2 pause/skips in 90 days = churning in slow motion while "active". History before we started watching isn't retroactively available — the pattern builds from live transitions. (3) Ring delivery buffer — dosing counts from delivered_at + 3 days (RING_START_BUFFER_DAYS, Tyler-specified; ring reads full inside the grace window; startBufferDays exposed on ring payloads). Side effect: Marisol's seed supply_low is now legitimately covered-by-charge. (4) Gorgias cross-referencing — replacement/reship/refund-type tickets (open, or resolved ≤14d) annotate shipping/supply signal details ("support ticket on file: …") so CX work isn't double-handled; Detail panel now shows recent Gorgias tickets with status/sentiment chips. (5) lapsed_buyer signal (12) — bought before, no live sub, quiet 60–365d (first_order_stall keeps the single-order day-30–45 window) = the win-back pool for non-subscribers. (6) VIPs tab + /api/vips — long-tenure (180d+) live subscribers, LTV-ranked, with last_touch_at to prevent double-contact; Reach out (routes a touchpoint) + Log check-in actions. Flexport CLOSED: the "missing scopes" error was chasing a ghost — probing /orders/rs revealed orderId ^\d+$, i.e. this API version has no order-list endpoint at all (single-order GET + webhooks only); tracking already flows via Shopify fulfillments; card now tests /products and says so. Also: JSX missing-brace caught via in-page Babel.transform diagnosis; hero stat labels fixed. Milestone: first HIGH-tier customers live (Roxanne Saldana 83 = stalled + depleted + unhappy ticket — Gorgias data stacking as designed). VIPs/subs-category await the Recharge crawl reaching ACTIVE subs (oldest-first listing; 21.5k+ ingested, still crawling). D1 migrated (subscription_events + app_config). 65/65 tests. Concurrent-session note: built additively around the new /breakdown risk-config system (weights injectable — new signal weights auto-editable there).flip-my-life-retention.html onto the live API — queue (top 50, ?limit=50), lazy detail enrichment per selection (email/product/plan/fulfillment timeline from real orders + shipments — server now includes shipments + email/phone in responses), cancel-save reasons/scripts/offers loaded from /api/saveflow/plays (slug keys end-to-end — the M1–M5 UI↔API mismatches are gone by construction), saves/notes/touchpoints POST to the API (outcome enum exact; points-lever string matches offerAllowed format), Insights tab renders the live rollup + save-rates, Fulfillment radar derives from live queue signals, "why flagged" synthesized from the engine's explainable signals, call panel explicitly labeled demo until the Twilio endpoints ship, nav links to /products + /connections, B9 hardcoded stats removed. Incident logged: a PowerShell round-trip on the HTML mojibake'd every non-ASCII char (PS 5.1 reads BOM-less UTF-8 as ANSI) — caught immediately, fixed by the clean rewrite; RULE: never round-trip UTF-8 source through PS 5.1 Get/Set-Content. Verified locally (50 real cards — Brad Lloyd's actual stalled JITSU shipment renders with its tracking number) and live post-deploy (wired version served, mock names gone, mojibake-free). Flexport probe hardened: missing order scopes now FAIL the test with instructions (was a transient toast Tyler missed — his new token still needs ORDER read granted). Also: email/phone added to queue/detail payloads. Tests green throughout; deploys gated./merchant_api/v1/… — fixed; also 403'd from Workers egress until a User-Agent header was added (edge/WAF blocks UA-less Worker fetches) — UA now on all adapters; (b) Gorgias — domain was stored as https://flipmylifenow.gorgias.com → URL builder made https://https//…; input normalization added (gorgias + shopify domains strip protocol/paths); test probe moved /account→/tickets (account needs admin; tickets is what we read); (c) Flexport — Seller Portal tokens authenticate the Logistics API (logistics-api.flexport.com/logistics/api/2024-07/…, Bearer, version in URL) not the freight API the original build targeted; adapter re-pointed; probe = /products; discovered Flexport hides unscoped routes as plain 404s (/products 200'd with real data — "Black and Gray FML Hat" — while /orders 404'd on the same token) → test now names the missing scopes. Rivo sync cursor bug fixed (was re-syncing the same first 200 customers forever; now id-cursor-walked over buyers, pass-reset for refresh). Gorgias sync chunked (1 page/run, cursor in sync_state) for Workers subrequest limits. Vendor error bodies now surface in test failures. Scoreboard: Shopify ✓ Recharge ✓ Rivo ✓ Gorgias ✓ live; Flexport ✓ auth but Tyler must add order+parcel read scopes to the token. Backfills: Recharge 9k+ subs & orders and climbing; Gorgias tickets + Rivo loyalty looping. 59/59 tests; deploys green-gated.d1 create chardizy (id a7b17b94…398d, ENAM) → schema (14 tables) → full data import: 196,787 rows / 59MB in 23s → subdomain flipmylife.workers.dev registered via API (Tyler chose the name; the permission classifier correctly blocked me guessing one) → deployed https://flip-cms.flipmylife.workers.dev with the 30-min cron. Access gate added to worker.js: APP_KEY var (generated, saved to Flip CMS/ACCESS_KEY.txt, never in chat) — ?key= once sets a 30-day cookie; 401 otherwise; /webhooks/* exempt (signature-verified). Verified live: 401 keyless, queue/products/pages 200 with key, data from D1. Recharge fixed + connected: test probe moved /store→/subscriptions?limit=1 (403 on scoped tokens was MY probe hitting an unscoped endpoint — Tyler's token was fine); first sync then hit the Workers ~1000-subrequest/request cap → Recharge sync rebuilt chunked + incremental (page cursors in sync_state per pass, updated_at_min advances per completed pass, default 2 pages/run, ?pages= override on the sync route). Deploy-flow note: one deploy went out chained behind a red test (test-side expectation bug, prod unaffected) — deploys should gate on green. 59/59 tests. Cloudflare Access on a custom domain remains the auth upgrade (P1); local server stays as dev env.db.js now exposes one async facade over two engines (node:sqlite locally/tests via wrapNodeSqlite, Cloudflare D1 in prod via wrapD1 — D1's .bind().all()/.first() normalized to the same .all()/.get()/.run() surface); every db consumer (seed, service, lib/upserts, integrations/registry, tests) swept to async/await; api.js rewritten fetch-native — handleFetch(Request, db, deps) → Response runs identically on Workers and locally (makeServer = thin node:http adapter + static files; on Workers, statics come from the ASSETS binding). New worker.js entry (API/webhooks → handleFetch on D1; pretty paths → ASSETS; cron trigger every 30 min = scheduled auto-sync of all configured services, closing that P1 TODO) + wrangler.jsonc (nodejs_compat, assets, D1 binding) + deploy/export-data.js (schema.sql + data.sql: 196,785 rows / 30.4MB — full local db incl. credentials + sync cursors, so the deployed instance continues incrementally, no Shopify re-crawl). package.json: npm run deploy, deploy:export; wrangler installed globally (zero runtime deps preserved — wrangler is deploy tooling only). 58/58 tests green post-port; local server verified on the full live dataset (queue ~500ms). Pending: Tyler's wrangler OAuth authorization → d1 create + schema/data import + deploy + Cloudflare Access gate. NOTE: full data import + live scale wants Workers Paid ($5/mo) — free tier caps D1 at 100k row-writes/day (the import alone is ~197k). Also spotted: a 12th product arrived via live orders — needs dose facts on /products.integration_settings.sync_state, per-page persistence, remaining flag, 12-pages/run default after a 40-page run blew undici's 5-min header timeout — the crawl survived thanks to per-page cursor writes; 429 retry honoring Retry-After). Full backfill run in looped syncs: orders fully caught up ~25.9k (incremental cursor even captured single new orders placed mid-sync); customers passed 110k and counting (mostly marketing contacts). That scale forced v0.2.5 set-based queue scoring: atRiskQueue now = 6 bulk queries + in-memory grouping reusing the identical tested signal logic (E2E score assertions unchanged), and only customers with ≥1 order/subscription/ticket are scored — marketing contacts excluded (tested). Extracted shared ringFromOrders. Zero high-tier customers on Shopify-only data is expected: high (≥70) needs a third stacked signal (skipped renewal / negative ticket) which arrives with Recharge + Gorgias connections. 58/58 tests. [backfill final numbers pending — loop still running]/products: GET /api/products (usage-sorted, orders/customers counts, ring_ready flag) + PUT /api/products/:id (validated positive servings/dose) + brand-styled page with inline editing and ready/needs chips; nav linked from Connections. Test proves the full loop: ingest delivered order for unknown product → ring null → set facts → ring computes (~20 days left on 30 servings delivered 10d ago). Verified live against Tyler's real catalog (top seller: FLIP 7 Super Shake, 2,311 orders / 2,287 customers). 56/56 tests. Tyler's next manual step: fill in servings + dose on /products./api/queue crashed 400 — ringFor() returns null for products without servings_per_unit (all auto-created live products) and bundleFor set .dailyDoseUsed on it; now guarded, ring stays null until dose facts are set (regression-tested); (b) O(n²) scoring — scoreCustomer recomputed topDecileLtv (a full sorted scan) per customer; now precomputed once in atRiskQueue; (c) 11 new indexes (customers/orders external ids + email, subs/tickets per customer, shipments order+tracking, products title) — per-customer scoring queries were full-table scans at 5k rows; (d) /api/queue?limit= (default 200, 0=all) so live stores don't ship megapayloads. Queue on real data: 200 OK in ~1.1s, real stalled-shipment signals firing on real customers. Also Connections secret-field UX redo (Tyler thought his client secret was truncated): secrets are never echoed into inputs — saved ones show an empty field with "Saved — ends in xxxx · leave blank to keep" + a × to clear on save; blank-resave keeps, typed replaces (verified live). +1 test → 55/55. NOTE: demo tenant customers (Derek/Marisol/…) still mixed with real data — purge is a one-liner when Tyler wants it.shopifyToken() implements the OAuth client-credentials grant (Client ID + secret → 24h access token, minted at /admin/oauth/access_token, cached with a 2-min refresh buffer, auto-refreshed — user never touches tokens); legacy shpat_ tokens still accepted for pre-2026 apps, but a complete client-credentials pair takes precedence so stale leftovers can't shadow it. Webhook verify now accepts the Notifications-page secret or the app client secret (app-registered webhooks). Credential fields updated (either/or gating via per-service missingCreds); Connections screen shows guidance hints; blanking a field now clears the stored value (previously impossible from the UI). GO_LIVE_CHECKLIST.md Shopify sections rewritten for the Dev Dashboard flow (App URL fields there are placeholders for server-to-server use). +3 tests → 54/54. Constraint worth remembering: app and store must be in the same Shopify organization for the client-credentials grant.data/chardizy.db, CHARDIZY_DB override) with idempotent seeding + in-place column migrations; (b) single server — api.js now serves the branded UI at /, Connections at /connections, API + webhooks on one port (old two-server preview setup retired); (c) credential store (integration_settings, masked in all responses) + settings API (save/auto-test/sync) ; (d) webhook receivers /webhooks/:service/:tenant with signature verification (Shopify base64-HMAC new, Recharge hex-HMAC reused, shared-token gate for Rivo/Gorgias/Flexport) and a webhook_events audit log; (e) backfill syncs for all five services; (f) idempotent ingestion layer lib/upserts.js (external-id matching, cross-platform customer unification, LTV recompute, delivered→ring-anchor stamping); (g) new adapters shopify.js + gorgias.js (heuristic sentiment, LLM seam); (h) Connections screen (web/connections.html) — first real UI→API wiring; (i) fixes B1 (seed dates), B3 (model id → claude-sonnet-5), B4 (timing-safe Twilio verify), B5 (TwiML XML-escape), B7 (input validation + 404s), B11 (uuid ids), B6 partial; (j) 16 new tests → 51/51. Verified live: signed Shopify webhook → new customer in the risk queue → survived server restart; forged signature → 401 + logged. Next: real credentials on /connections + public webhook URL (tunnel/deploy), then wire the branded UI to the API.F:\Claude Code\Flip CMS\. Installed Node 24.18.0 LTS. Verified: 35/35 tests, live API queue/insights, branded UI + cancel-save flip flow. Recovered full build history from the Claude share link. Created this breakdown (MD + HTML). No app code modified. Findings logged as B1–B12, M1–M5, TODO board seeded.dashboard.jsx) → phone/text scheduling + routing + tagged notes + insights → cancel-save scripts + data-model spec → zero-dep Node backend + tests → "Flip My Life" branded UI → Flexport/Recharge/Rivo/Twilio-Whisper adapters → Rivo panel + order timeline → fulfillment radar + price points-lever. 35 tests green at handoff.