Skip to content

Green Wellness

Changelog

What’s new in each release of the scheduling platform

v2.97.E7current
2026-05-11Production

Added

  • 🩺 **NEW Book Now β†’ Salesforce Web-to-Lead funnel + dual-write to AuditLog.** Per Doug 2026-05-11: "the salesforce webform connected to the book now link as out main funnel for now, also duplicate the informationm for our system, which we may try to use in real live befoer we port everyhting over". While Stripe + email + SMS env vars are being provisioned on the GW Vercel project, the existing in-app SchedulingWizard (multi-step Stripe checkout) silently fails on those missing rails. New flow routes the Book Now CTA to a lightweight branded modal that POSTs to /api/leads/book-now, which dual-writes: (1) Salesforce Web-to-Lead (https://webto.salesforce.com/servlet/servlet.WebToLead with SF_W2L_OID + form-urlencoded fields) (2) Postgres AuditLog (BAA-covered, action=LEAD_CAPTURED, staffUserName=book-now-funnel). Sister of /get-started LeadForm but with modal-wrapped UX + distinct lead source (Web - Book Now vs WEB_LEAD_INTERIM) so SF reports + /admin/audit-log can split funnels. **3 new files**: src/lib/salesforce-w2l.ts (server-side W2L helper β€” function-resolved env reads per Jensine doctrine, 15s timeout, fail-open on SF errors β€” returns structured {ok, outcome, statusCode}), src/app/api/leads/book-now/route.ts (POST handler β€” 3/min/IP rate-limit, honeypot, validation, dual-write), src/components/booking/BookNowFormModal.tsx (modal with patient-type chip + name/email/phone/reason/contact-pref/marketingConsent β€” matches LeadForm styling). **Feature flag**: NEXT_PUBLIC_BOOK_NOW_MODE controls which modal opens on ?book=true. Default (unset or sf-w2l) β†’ SF W2L modal. Set to wizard β†’ reverts to legacy in-app SchedulingWizard (Doug-action when BAAs/Stripe land + he wants to test the full custom-app flow live alongside SF). **Doug-action required**: set SF_W2L_OID env var on green-wellness Vercel project (find in Salesforce β†’ Setup β†’ Web-to-Lead β†’ Create Form β†’ copy the 15-char oid value). Until then, salesforce-w2l logs [salesforce-w2l] SF_W2L_OID env var is unset β€” leads will save to AuditLog only and the form is fail-open β€” submissions land in audit_log table immediately, available at /admin/audit-log filter action=LEAD_CAPTURED + staffUserName=book-now-funnel. tsc clean.
v2.97.E6
2026-05-11Production

Fixed

  • πŸ›‘οΈ **check-no-module-init-rotatable-env regex fix β€” closes silent-bypass gap for TypeScript-typed module-init declarations.** Sister-port from cannagent v6.4845. Pre-fix regex missed export const FOO: SomeType = process.env.BAR (the : type annotation between var name and = broke \w+\s*=) AND multi-line forms where process.env.X is on the next line (the (?:[^;]*\|\|\s*)? clause assumed || on same line). Real risk: any TypeScript-typed Jensine-class trap silently bypassed the gate. Post-fix GW: 0 offenders across 18 guarded names. 7-stack cross-stack regex fix β€” sister scc/glw/inv/sureel/VRG same-day evening.
v2.97.E5
2026-05-11Production

Added

  • πŸ›‘οΈ **NEW arc-guard check-vercel-project-link.mjs β€” defense against the 2026-05-11 cannagent.ai-vs-greenlife-web .vercel/project.json misroute class.** Pre-push gate compares .vercel/project.json projectName to EXPECTED_PROJECT_NAME = "green-wellness"; fails the push if mismatched with a fix-command-in-error-message. Wired into .githooks/pre-push (20/20). Sister glw v32.405 + scc v24.605 + cannagent v6.4825 + sureel + VRG same-day. Memory pin: feedback_vercel_project_misroute_recovery.
v2.97.E4
2026-05-11Production

Added

  • 🩺 **/api/health adds aiReady field β€” closes the 4th rail of cross-stack readiness probe doctrine (paymentReady / emailReady / smsReady / aiReady).** Sister of cannagent + inv + sureel aiReady. GW uses Vercel AI SDK's anthropic/claude-sonnet-4.6 provider in /api/chat (patient intake assistant); the SDK reads ANTHROPIC_API_KEY from env at request time. Pre-fix: silent ANTHROPIC_API_KEY un-set would result in the chat endpoint streaming an error to the patient with no surface signal in /admin/health or external monitors. Post-fix: aiReady: !!process.env.ANTHROPIC_API_KEY. Doug-action gated β€” doesn't flip top-level ok (same convention as paymentReady/emailReady/smsReady). Cross-stack readiness probe matrix now closed across all stacks that use AI (cannagent + inv + sureel + GW). tsc clean.
v2.97.E3
2026-05-11Production

Added

  • 🩺 **/api/health cronActors.details[] adds canonical daysSinceLast + staleAfterDays fields** β€” closes cross-stack naming-asymmetry gap. Pre-fix: GW exposed staleDays: 0.21 (which actually held the AGE in days, e.g. 5 hours ago = 0.21 days). The field name suggested a threshold but held the computed age β€” interpreting the JSON alone required cross-referencing the EXPECTED_CRON_ACTORS registry to know what stale: true/false meant. Inv + VRG both use daysSinceLast for the age + staleAfterDays for the threshold. **Post-fix**: GW now exposes BOTH (a) daysSinceLast (the canonical age field, sister of inv + VRG) + (b) staleAfterDays (the threshold, so stale is now interpretable from the JSON alone). Legacy staleDays field kept for backwards compatibility with any existing consumers (purely additive β€” no breaking change). Pure improvement to operator-readability without breaking shape. Memory pin: feedback_cross_stack_health_field_naming_audit (queued). tsc clean.
v2.97.E2
2026-05-11Production

Added

  • πŸ›‘οΈ **Wire-in fix: check-bulk-fanout-throttle arc-guard was on disk but NOT in .githooks/pre-push chain β€” closes the 7-stack 'defense on disk but not running' anti-pattern.** Sister anti-pattern bit vrg-website (16 unwired), GW prior (7), sureel (6), inv (4). GW had scripts/check-bulk-fanout-throttle.mjs from earlier cross-stack port but pre-push hook skipped it. Pre-fix: any new Promise.all(recipients.map(send)) regression would land silently (HIPAA-grade patient-comm rails β€” Postmark/Resend/Twilio bulk-send shape). Post-fix: gate appended to .githooks/pre-push (19/19 total). Baseline 0 offenders on GW. **7-stack matrix CLOSED**: VRG (just shipped v9.7.4) + cannagent (v6.4745) + GW (this ship) + sureel + scc (v24.405) + glw (v32.205) + inv all enforcing the gate at push time. Memory pin: feedback_gate_on_disk_not_running_anti_pattern.
v2.97.E1
2026-05-11Production

Fixed

  • πŸ”§ **src/app/api/webhooks/postmark/inbound-email/route.ts:367 sendEmail(...).catch(...) fire-and-forget β†’ wrapped in after()** β€” caught by NEW arc-guard check-after-wrap-external-send (sister-port from cannagent + inv same-day). Bug class: Vercel serverless / Fluid Compute can tear down before an unawaited Promise resolves; sender silently drops β†’ no /admin/errors entry β†’ admin presses button, recipient gets nothing. **Same class as the Jensine 2026-05-11 inv welcome-email cascade.** This site (Postmark inbound-email auto-ack) responds to patient replies to GW's monitored inbox β€” if the auto-ack drops, the patient never gets a confirmation. HIPAA-grade comm path. Fix: import after from next/server, wrap the send + catch inside after(async () => { try { await sendEmail(...); } catch ... }). Memory pin: feedback_after_wrap_external_send_doctrine.

Added

  • πŸ›‘οΈ NEW arc-guard check-after-wrap-external-send (cross-stack port from cannagent + inv same-day). Locks the fire-and-forget-send class on GW with structural gate enforcement. Allowed patterns: awaited send, after()-wrapped send, Promise.all([...catch fallback]). Forbidden: standalone send*(...).catch(...) in server actions or route handlers. Wired into .githooks/pre-push after the check-no-module-init-rotatable-env gate. Post-fix: 0 offenders. Cross-stack arc continuing: cannagent + inv had it; GW + sureel + scc + glw + VRG porting same-day.
v2.97.E0
2026-05-11Production

Added

  • πŸ›‘οΈ NEW arc-guard check-no-module-init-rotatable-env (port from inv + cannagent + scc + glw same-day). Locks the stale-Fluid-Compute-instance env-var trap class on GW with structural gate enforcement. **GW-scoped ROTATABLE_ENV_VARS (18 names)** covers HIPAA-grade patient-comm rotations: Resend / Postmark / AWS SES / Twilio (A2P 10DLC re-registration) / RingCentral (patient call/SMS rail) / Anthropic (AI drafts BAA-pending) / ADMIN_SESSION_SECRET / PATIENT_SESSION_SECRET / CRON_SECRET. **Post-port: 0 offenders** (v2.97.D9 Twilio function-resolution fix earlier today handled the captures). Wired into .githooks/pre-push (strict). Cross-stack arc: inv + cannagent + scc + glw + **GW** all gate-locked structurally against the module-init env-rotation trap class β€” the class that burned 3hr on inv today (Jensine welcome-email cascade). Memory pin: feedback_env_var_precedence_cross_tenant_trap. tsc clean.
v2.97.D9
2026-05-11Production

Changed

  • πŸ”§ **lib/twilio.ts Twilio creds + client function-resolved** β€” closes the stale-Fluid-Compute-instance env-var trap on GW SMS path. Cross-stack sister of inv v401.545 + scc v24.005 + glw v31.905 same-day (Twilio function-resolution arc). Pre-fix const TWILIO_FROM = process.env.TWILIO_PHONE_NUMBER + const client = twilio(SID, TOKEN) at module load captured creds + client ONCE per warm Vercel Fluid Compute instance β€” admin env rotations (Twilio cred rotation, A2P 10DLC re-registration, BAA renewals) didn't reach existing instances until they cycled (~15min). **HIPAA-relevant**: GW patient-comm rotations are exactly the env-update class this trap eats. Fix: getTwilioFrom() reads process.env.TWILIO_PHONE_NUMBER on every call; getTwilioClient() constructs a fresh Twilio client per send (Twilio SDK constructor is cheap β€” same pattern Resend uses in the inv v401.505 fix). Updated 4 call-sites: isSmsReady() + sendSms() body (2 references to client, 2 to TWILIO_FROM). Memory pin: feedback_env_var_precedence_cross_tenant_trap. tsc clean. Closes the cross-stack Twilio function-resolution arc on GW (was 4-of-5 stacks, now 5-of-5 including GW).
v2.97.D8
2026-05-11Production

Added

  • πŸ›‘οΈ **Wired 7 existing GW arc-guards into .githooks/pre-push β€” defenses that EXISTED but didn't ENFORCE.** Pre-fix audit found 7 scripts/check-*.mjs gates present on disk + working but never added to the pre-push gate chain β€” silently allowing regressions despite the gate code being shipped. Audit shape: comm -23 (ls scripts/check-*.mjs) (grep scripts/check- .githooks/pre-push) β€” same anti-pattern that cost cannagent during prior /loop sessions (arc-guard shipped without prepush wiring = invisible defense). **All 7 verified clean at strict-zero pre-wire**: check-pii-console-leak (Vercel logs NOT BAA-covered β€” HIPAA-grade defense; same sister gate that just swept 31 sites on VRG v9.7.0/v9.7.1 + 9 on inv v401.225) Β· check-conflict-markers (3-hour deploy-stall class β€” INCIDENTS.md 2026-05-08) Β· check-cron-auth-no-x-vercel-cron-bypass (spoofable x-vercel-cron header bypass) Β· check-html-entities-jsx (rendered display fidelity) Β· check-imageresponse-cache-pattern (OG share-card cache class) Β· check-inline-form-action-tuple-discard (Doug-flagged UX: form submits that silently swallow error returns) Β· check-server-actions-async (Next 16 Turbopack "use server" sync-export build-fail class β€” 3hr deploy stall pattern, cannagent v0.57.0). Post-wire: 30 β†’ 37 gates enforced on every GW push. No code changes β€” only pre-push wiring + changelog. typecheck clean.
v2.97.D7
2026-05-11Production

Added

  • πŸ›‘οΈ check-force-dynamic arc-guard + **1 real-bug fix on src/app/admin/layout.tsx** β€” cross-stack port FROM cannagent v3.145 + inv. Catches the Next 16 prerender-cache regression class: any page/layout that calls a session-verify helper (verifyAdminSession / verifyProviderSession / verifyPatientSession) MUST also export const dynamic = "force-dynamic". Sister of VRG v9.5.11–13 Mariane 404 incident β€” Next may statically prerender layouts that read cookies(); during build the cookie jar is empty β†’ verifyAdminSession() returns null β†’ unauthenticated layout shell gets baked into the build cache β†’ real authed admins get served the empty shell until next deploy bumps the cache. **HIPAA risk**: a prerender-cache regression on /admin/* could serve admin or patient session-redirect responses to the wrong visitor β€” this gate is defense-in-depth on top of HMAC session cookie verification. **First-run on GW: 1 real violation caught + fixed in same commit** β€” src/app/admin/layout.tsx (the layout that gates all 100+ admin pages) calls cookies() + verifyAdminSession() but had no force-dynamic export. 101 pages/layouts scanned post-fix, 0 offenders. Wired into pre-push hook + pnpm check:force-dynamic script. Memory pin: feedback_force_dynamic_admin_prerender_cache (cannagent's gate origin). GW gate count: 36 β†’ 37.
v2.97.D6
2026-05-11Production

Added

  • πŸ›‘οΈ check-server-action-silent-fail arc-guard β€” cross-stack port from cannagent v6.3805 (9 ships in one session arc against this bug class). Flags bare return; and bare throw new Error() inside use-server files. Bug class: Next 16 prod silently swallows server-action throws AND silent-return; bails leave the UI with no signal β€” patient/operator clicks Save and form does nothing visible. Canonical fix: redirect with ?surface_err=code, OR return ActionResult tuple, on every failure path. Opt-out trailing comment for best-effort helpers. GW use-server surface: 1 file today, 0 violations. Memory pin: feedback_server_action_throw_masked_in_prod. GW gate count: 35 β†’ 36.
v2.97.D5
2026-05-11Production

Added

  • πŸ›‘οΈ check-formdata-absent-vs-empty-on-update arc-guard β€” cross-stack port from cannagent v6.2845 + inv v399.165. Catches FormData server actions that read field-default-empty (formData.get(X) || '') and pass into a partial Prisma update β€” partial form submissions where caller omits the field silently CLEAR the column. Cannagent v6.2845 origin: CallWrapButtons date-only chip wiped nextAction text every click. Adapted for GW's Prisma db.X.update(...) shape (vs cannagent's Drizzle db.update(table)). 0 candidate sites across src/. GW gate count: 34 β†’ 35.
v2.97.D4
2026-05-11Production

Added

  • πŸ›‘οΈ check-client-imports-no-server-only arc-guard β€” cross-stack port from cannagent v6.2505. Sister of v2.97.D3's check-use-server-exports (the cousin class). Catches use-client components importing modules that have an import-server-only directive. Cannagent v6.2305 incident: client component imported state-options from a server-only module β€” 4 consecutive Vercel deploys errored over 40 minutes before discovery. GW has 5+ server-only files; preventive port locks the class. 132 use-client files scanned, 0 violations. GW gate count: 33 β†’ 34.
v2.97.D3
2026-05-11Production

Added

  • πŸ›‘οΈ **check-use-server-exports arc-guard β€” cross-stack port from VRG + cannagent + inv.** Pre-deploy gate scans every "use server" file in src/app/ + src/lib/ and asserts every top-level export is async-function (or type-only erasure, or re-export). Bug class: Next 16 / Turbopack invalidates ALL exports in a "use server" file when ANY top-level export is non-async β€” the first client component that imports by-name fails the build with 'module has no exports at all', misleadingly. Inv live incident v229.005/v231.005: preview-actions.ts had export const VMI_ADMIN_PREVIEW_COOKIE = "..." alongside two async actions, 5+ Wen + Sea deploys failed in a row before someone noticed. Fix pattern: move const/sync exports to a sibling non-server module. GW state: 409 files scanned, 0 offenders (preventive port locks the class). GW gate count: 32 β†’ 33.
v2.97.D2
2026-05-11Production

Added

  • πŸ›‘οΈ **check-changelog-unique arc-guard β€” cross-stack port from cannagent + VRG.** Pre-deploy gate flags duplicate version strings in the CHANGELOG array. Why it matters: React's and other UI surfaces use key={entry.version} for reconciler β€” duplicate keys collapse entries silently into one render slot. 'Pushed v2.97.X β€” what changed' becomes ambiguous in git history when two entries share a version. **First run caught 4 pre-existing duplicate version strings in GW changelog** β€” v2.97.D0 + v2.97.C0 + v2.97.B0 + v2.97.30 (each appearing twice on different dates, the version-letter sequence apparently cycled). Allowlisted these 4 legacy collisions in LEGACY_DUPES to avoid audit-trail rewrites; lock NEW collisions at 0. Date-chronological check omitted (GW has pre-existing out-of-order date stamps like v2.93.80/v2.93.75; not worth the audit-trail churn). 626 entries scanned. GW gate count: 31 β†’ 32.
v2.97.D1
2026-05-11Production

Added

  • πŸ›‘οΈ **check-env-example-unique arc-guard β€” cross-stack port from cannagent v3.159 + VRG v9.6.94.** Pre-deploy gate flags duplicate column-0 env-var declarations in .env.example. Duplicate blocks carry silent drift risk: a maintainer who updates one block won't necessarily update the other. Cannagent v3.159 had 2 dupes (Stripe scaffold + Demo provisioning) carrying drift risk for ~150 ships before someone noticed. 65 GW declarations scanned, all unique. GW gate count: 30 β†’ 31.
v2.97.D0
2026-05-11Production

Added

  • πŸ›‘οΈ **check-cron-route-get arc-guard β€” cross-stack port from inv v171.945 + VRG + cannagent.** Pre-deploy gate flags Vercel cron routes that export POST but not GET. Vercel cron sends GET requests by default β€” without a GET handler, every fire silently 405s, Vercel's cron logs show 'fired', the route returns 405, no work happens, and no error surfaces. inv v171.945 had 3 crons silently 405'ing this way (case-card-stub-check + shift-reminders + till-open/close-check) before someone noticed. Accepted GET shapes: function-form delegating to POST, or export const GET = POST; assignment. 14 GW cron routes scanned β€” all clean. Memory pin: feedback_vercel_cron_method_get_default. GW gate count: 29 β†’ 30.
v2.97.C9
2026-05-11Production

Added

  • πŸ›‘οΈ **check-no-unsafe-redirect arc-guard β€” cross-stack port from inv v397.485 + scc v22.405 + glw v30.405 + cannagent v6.3025.** Pre-deploy gate locks open-redirect attack vector at baseline 0: catches searchParams.get("returnTo"|"redirect"|"callback"|etc) reads that flow into router.push() / router.replace() / redirect() without a leading-slash guard. Real prod incident reference: inv v396.645 (staff /login) + v397.445 (customer-PWA /account/login) both ran router.push(searchParams.get("returnTo")) β€” attacker phishing link with ?returnTo=https://evil.com performed off-origin navigation after legit auth. Fix pattern: safeRedirectPath() helper that validates leading-slash + rejects //, /\, ://. GW state: 0 offenders today β€” preventive port locks the class before a future agent ships a patient-portal-style flow with the bad shape. Memory pin: feedback_open_redirect_safe_redirect_path. Day-one allowlist trap NOT needed (no fs.readFileSync/process.env mentions in changelog entry). GW gate count: 28 β†’ 29.
v2.97.C8
2026-05-11Production

Fixed

  • πŸ› **check-fs-read-bundled false-positive on its own changelog entry β€” exempted src/lib/changelog.ts.** v2.97.C7 added the gate, but C7's changelog entry quoted the literal text fs.readFileSync(process.cwd()...) to describe what the gate catches β€” and the gate's own regex matched that prose. C7 push blocked at pre-push. Same prose-vs-code class as cannagent v6.3245's check-env-vars-documented fix where the gate matched process.env.INBOUND_SECRET_* inside a release-note explainer. Fix: add src/lib/changelog.ts to ALLOWLIST (release notes are prose, not application code that needs static-trace coverage).
v2.97.C7
2026-05-11Production

Added

  • πŸ›‘οΈ **check-fs-read-bundled arc-guard β€” cross-stack port from inv + scc + glw + sureel + VRG.** Pre-deploy gate flags fs.readFileSync / fs.readdir etc. with dynamic path args (process.cwd(), path.join, ${...}, import.meta.url). Bug class: Next 16 only traces files reachable via static import statements β€” raw fs.readFileSync(process.cwd() + "/foo.md") is opaque to static analysis, so the file isn't bundled into the Vercel function and the runtime read silently catches with no error surfacing. tsc passes CLEAN. GW allowlist: src/lib/migration-drift.ts (prisma/migrations IS bundled) + src/app/admin/launch/page.tsx (fs.readFileSync mention inside a template-literal-rendered shell snippet β€” documentation text, not actual code). Memory pin: feedback_outputFileTracingIncludes_for_fs_reads. Wired into .githooks/pre-push. Baseline 0. GW gate count: 26 β†’ 27.
v2.97.C6
2026-05-11Production

Added

  • πŸ›‘οΈ **check-metadata-exclusive arc-guard β€” cross-stack port from VRG v9.6.91 + cannagent.** Pre-deploy gate scans every src/app/**/page.tsx + src/app/**/layout.tsx for files exporting BOTH const metadata AND generateMetadata (Next.js App Router rejects this combo: 'You are exporting both metadata and generateMetadata from "". Use one or the other'). vitest passes, tsc passes β€” failure only surfaces at Next.js build pass when the deploy is already stuck. VRG had the originating incident at v9.6.27 (~30 min recovery). 101 page/layout files scanned today, 0 collisions. Wired into .githooks/pre-push. GW gate count: 25 β†’ 26.
v2.97.C5
2026-05-11Production

Fixed

  • ⏱️ **src/lib/gbp.ts β€” 6 unprotected Google Business Profile fetch() sites get AbortSignal.timeout(10_000).** Continuation of the v2.97.C3 (cron) + v2.97.C4 (10 API-route blob/RC fetches) timeout audit. The GBP integration was the holdout lib: OAuth token-exchange (2 sites: code + refresh), accounts-list, location-list, reviews-list (paginated), and dailyMetrics performance fetch β€” all 6 ran with no per-request timeout. Pre-fix, a hung Google API endpoint (regional outage, rate-limit penalty, network blip) would block the function until Vercel's maxDuration killed it β€” the pull-gbp-reviews cron would burn its fire window watching a TCP idle, and the OAuth callback during initial setup would hang the operator UI indefinitely. Sister of v2.97.B-N earlier in this version chain that hardened lib/practicefusion.ts (3) + lib/salesforce.ts (2) + lib/email.ts (2) + lib/ringcentral.ts (3) + lib/workflow.ts (1) + lib/ratelimit.ts (1). gbp.ts was the only lib left at 0 timeouts. tsc clean. Past-saturation lane: for f in lib/*.ts; FETCH=$(grep -c 'await fetch(' $f); SIG=$(grep -c 'AbortSignal.timeout' $f); echo $f $FETCH $SIG made the gap visible.
v2.97.C4
2026-05-11Production

Fixed

  • ⏱️ **10 unprotected blob/RC fetch() sites in API routes β€” added AbortSignal.timeout(10_000) to each.** Sister of v2.97.C3 (RC webhook cron) but in patient-facing + provider-facing API routes (not crons). Pre-fix sites: /api/admin/messages/[messageId]/attachments/[attId] (attachment download), /api/admin/messages/[id]/recording (2 β€” RC OAuth + RC recording fetch), /api/admin/appointments/approve (signature for PDF generation), /api/admin/cert/[id] (cert PDF proxy), /api/provider/bulk-approve (signature for bulk-approved PDFs), /api/provider/action (signature for per-row approval), /api/provider/cert-preview/[appointmentId] (signature for preview), /api/provider/documents/[id] (doc PDF proxy), /api/patient/cert/[id] (patient cert download), /api/dispensary/cert/[token] (dispensary cert verification). Pre-fix a hung Vercel Blob endpoint (network blip, regional outage) would freeze the function until Vercel's maxDuration killed it β€” patients/providers stuck at 'still loading'. Under Fluid Compute the hung fetch could also tie up a function instance shared across concurrent requests, multiplying the impact. Same recipe as cannagent v6.3285 (base-usdc.ts) β€” sister-fetches drifted on the timeout field, audit caught the asymmetry. Past-saturation lane discovered via grep "fetch(" --include="*.ts" src/ + filter on external-host calls in API routes (not crons, not /api-relative client fetches).
v2.97.C3
2026-05-11Production

Fixed

  • ⏱️ **rc-webhook-renew cron β€” 5 bare RingCentral fetch() calls gained 10s timeouts.** Cross-stack port from cannagent v6.3285. Pre-fix the cron made 5 unprotected fetch() calls (OAuth token + list-subscriptions + per-sub renew + per-sub delete + per-sub recreate). If any hit a hung RingCentral endpoint, the cron blocked until Vercel's maxDuration killed the function β€” wasting the fire window + leaving subsequent subs un-renewed. Without renewals, RC webhook subscriptions expire after 7 days and stop delivering SMS/voice events silently. Fix: signal: AbortSignal.timeout(10_000) on all 5 sites. Sister of inv's 6 cron fetches which all have AbortSignal coverage (verified during cross-stack audit).
v2.97.C2
2026-05-11Production

Added

  • πŸ“ **Migration-drift detector: orphanedApplied field β€” sister-port from VRG v9.6.82.** Pre-fix checkMigrationDrift() only surfaced pending (in source but not applied) β€” the original Doug-2026-05-07 'forgot to migrate' case. Silently missed the reverse drift class: migrations applied to the DB but NOT in source (deleted file after applied, prisma db push bypass, manual SQL). Real evidence on VRG: prod reported applied=48 / expected=47 / ok:true for unknown duration β€” 1 orphan was invisible. GW is currently in schema-push mode so the gap doesn't surface here today, but the defense is shipped for when GW switches to migration-mode (any future prisma migrate dev + committed migrations folder). Surface-only β€” does NOT flip ok to false because orphan-source is non-fatal at runtime (the DB has the schema; source just lost the audit trail). Cross-stack port queue: inv + cannagent still pending.
v2.97.C1
2026-05-11Production

Fixed

  • πŸ“‘ **/llms-full.txt cache header** β€” sister-port from cannagent v6.3225 + sureel + vrg v0.14.25. Pre-fix the public/ static-file fallback served Cache-Control: public, max-age=0, must-revalidate so every Claude / ChatGPT browse / Perplexity / Bing crawler hit on the 80+ line AI-manifest body was a fresh function invocation. Now: public, max-age=3600, s-maxage=3600 in next.config.ts headers() (matches /llms.txt route-handler's max-age=86400 intent without overriding its longer cache). The Vercel edge cache will now hold the response for 1hr; AI crawlers don't get a free DDOS of the function on every fetch.
v2.97.C0
2026-05-11Production

Added

  • πŸ›‘οΈ **check-imageresponse-cache-pattern pre-push gate** β€” cross-stack arc-guard port from cannagent v6.0605 (referenced memory pin feedback_imageresponse_cache_pattern). Detects ImageResponse routes (from next/og) that set Cache-Control: ...max-age=0... WITHOUT a paired Vercel-CDN-Cache-Control / CDN-Cache-Control carrying s-maxage. Bug class: edge-runtime ImageResponse silently strips s-maxage + stale-while-revalidate from the wire when passed via the headers option β€” share-preview crawlers (Twitter/LinkedIn/Slack) re-render via Satori uncached on every fetch. Cannagent's 2026-05-10 incident: every probed OG endpoint served max-age=0 until the v6.0605 fix wave. GW today (live-probed 2026-05-11): all 5 ImageResponse routes (icon.tsx + apple-icon.tsx + opengraph-image.tsx + icon-192.png/route.tsx + api/og/route.tsx) serve correct s-maxage via Next.js defaults β€” no current violation. **Locked at baseline 0** across 461 scanned files. Gate is GW-adapted: scans ALL files importing next/og (not just opengraph-image.tsx by Next.js file convention) + grants escape hatch when layered Vercel-CDN-Cache-Control / CDN-Cache-Control carries s-maxage (the canonical pattern at src/app/api/og/route.tsx). Wired into .git/hooks/pre-push build-gate umbrella (now 5/5).
v2.97.B9
2026-05-11Production

Added

  • πŸ›‘οΈ **check-conflict-markers pre-push gate** β€” cross-stack arc-guard port from cannagent v6.0785 + inv post-2026-05-08-incident. Asserts no source file under src/ contains an unresolved git merge-conflict marker triple (<<<<<<< / ======= / >>>>>>> or diff3-mode |||||||). Why this matters: Vercel's build pipeline parses each source file BEFORE tsc runs, so unresolved markers fail the deploy at the parser layer with Parsing ecmascript source code failed. β€” pre-push tsc looks CLEAN while the actual deploy fails. Inv's 2026-05-08 incident stalled the deploy queue for 3+ hours / 9+ commits before the markers were diagnosed in CustomerLookup.tsx. GW has lower parallel-session edit density than cannagent but a HIPAA-scoped deploy queue is the worst place to discover this class. **Locked at baseline 0** across 462 scanned files on first run. Wired into .git/hooks/pre-push build-gate umbrella (now 4/4) + package.json (check:conflict-markers + check:all). Skips node_modules/ + .next/ + fixtures/ + markdown (docs quote markers as teaching examples).
v2.97.B8
2026-05-10Production

Added

  • βœ… **Required-field asterisks on the get-started LeadForm** (Mariane QA 2026-05-10 #4). Pre-fix the form had required attributes on First name + Last name but no visible indicator β€” patients submitted without seeing which fields were optional vs not, leading to required browser-validation pops mid-flow. Now both required-field labels carry a rose * glyph with aria-hidden (since the visible asterisk is decorative β€” sr-only (required) text + aria-required="true" carry the semantic). Disclaimer at the bottom of the form now closes the loop with Required fields marked with *.
  • πŸ“¬ **Marketing-consent opt-in checkbox** (Mariane QA 2026-05-10 #11). Separate from the existing clinical-callback consent (which is implicit-on-submit per the disclaimer). HIPAA-adjacent doctrine on marketing comms is explicit opt-in β€” never pre-checked. Captured in marketingConsent boolean on the form payload + sent to /api/leads + persisted in the audit-log LEAD_CAPTURED.detail as marketingConsent=true/false (no dedicated DB column yet β€” followup ship when patient-record table grows a marketing-prefs surface; for now the audit row is the SoT for reconciliation). Marketing comms infrastructure MUST NOT fire to a patient without this flag = true at lead-capture.
v2.97.B7
2026-05-10Production

Fixed

  • πŸ› **Provider Authorization Queue showed empty when dashboard said 1 Appointment Awaiting Provider Authorization** (Mariane QA 2026-05-10). The dashboard card linked to /admin/appointments?status=PENDING_APPROVAL but that page defaulted to a date window of today PT β†’ +30d β€” any past-dated PENDING_APPROVAL row (e.g. an appointment from yesterday that the provider hasn't authorized yet) was filtered out. PENDING_APPROVAL is a queue state, not a time state. **Fix:** when ?status=PENDING_APPROVAL is in the URL and the caller didn't pass explicit from=/to=, widen the default window to -365d β†’ +365d so all rows the dashboard counts are actually visible. Other status filters keep the todayβ†’+30d default (the operational view staff actually uses). No PHI exposure change (admin already sees all appointments β€” this just unblocks the queue UI).
v2.97.B6
2026-05-10Production

Added

  • πŸ›‘οΈ NEW arc-guard scripts/check-cron-auth-no-x-vercel-cron-bypass.mjs β€” sister port (inv + cannagent v6.0465 same evening). Pins memory pin feedback_x_vercel_cron_header_alone_unsafe against re-introduction. Bug class: if (req.headers.get("x-vercel-cron") === "1") return true; β€” Vercel doesn't strip x-vercel-cron from external requests, so any caller can curl with the header and bypass auth. **HIPAA stakes on GW**: every cron touches patient-comms (renewals / intake-reminders / DOH-nudges / no-show follow-up) β€” spoofed firing = unauthenticated PHI-touching writes, possible duplicate sends to patients. GW's lib/cron-auth.ts already correctly enforces Bearer (timingSafeEqual); guard prevents future drift. Skips comments. 14 cron routes scanned, 0 spoofable bypass shapes today. Wired into check:all build-gate suite. Memory-pin β†’ arc-guard mining lane round 2 β€” second cross-stack arc-guard ported this evening (after inline-form-action-tuple-discard v2.97.B5).
v2.97.B5
2026-05-10Production

Added

  • πŸ›‘οΈ NEW arc-guard scripts/check-inline-form-action-tuple-discard.mjs β€” cross-stack sister port (cannagent v6.0365 + inv v397.085 same evening). Pins memory pin feedback_inline_form_action_discards_tuple against re-introduction. Bug class:
    { await someAction(fd); }}> swallows {ok:false, error:'...'} returns from Server Actions β€” user clicks Submit, action rejects, UI shows no change + no error. **HIPAA stakes on GW specifically**: a silent action rejection on a patient appointment / cert update creates a compliance gap (what the chart says happened β‰  what actually wrote). GW historically clean β€” never had this anti-pattern. 235 .tsx/.jsx files scanned, 0 offenders. Wired into check:all build-gate suite. False-positive bypass: // eslint-disable-line on the form-action line. Memory pin β†’ arc-guard mining lane per feedback_memory_pin_to_arcguard_recipe.md.
v2.97.B4
2026-05-10Production

Changed

  • πŸ“ StepPayment.tsx + Step1Qualify.tsx β€” 3 copy fixes flagged by comms-expert audit pass tonight. (1) Payment-down fallback copy was 'Sorry for the friction. Our team has been notified…' β€” apologized for a Stripe-vendor outage (brand-voice violation: never apologize for things that aren't our fault). Now: 'We'll have payment back online soon. The call gets you booked just as fast β€” usually faster.' β€” names the path-forward instead of grovels. (2) Deferred-payment confirm copy buried the load-bearing fact ('Your appointment is held') behind an internal-narrative lead ('We're onboarding our payment system' β€” patient doesn't care). Now leads with what the patient needs to hear first: 'Your appointment is held. Staff will call within 24 hours to confirm and take payment…'. (3) Step1Qualify disqualification copy read like a state statute citation ('Washington State requires patients to be 18 or older…'). Now: 'You need to be 18 to get a medical authorization in Washington β€” that's state law, not our rule. Call us when you're closer to that milestone and we'll get you in.' β€” 'not our rule' displaces the rejection, and the door-left-open ending invites a future call. Same shape for non-WA-resident: 'If you're moving here or visiting long-term, give us a call β€” we can talk through your options.' Auditor: communications-expert subagent. tsc clean.
v2.97.B3
2026-05-10Production

Fixed

  • πŸ› /admin/cron server action β€” x-forwarded-proto header may arrive as a comma-separated chain (https,http) when multiple proxies sit in front of Vercel. Pre-fix the value would interpolate as-is into the URL string yielding https,http://greenwellness.org/api/cron/, which throws on fetch(). Now: .split(",")[0]?.trim() to take the first hop only. Caught by an Explore audit pass after Doug pinged 'bugs' on the v2.97.B1 ship. Latent β€” would only trigger if Vercel's network topology added an upstream proxy, but the cost of the defensive split is zero. tsc clean.

Added

  • πŸ”§ /admin/cron page β€” new 'Run all N skipped' button at the top of the Skipped-today section. Sequentially (not in parallel β€” protects email/db load) fires every cron in the skipped list using the same per-actor server action under the hood; reuses the role gate + whitelist + 25s timeout per cron. Hard cap of 20 actors per batch as a safety floor. Closes the click-friction loop on v2.97.B1 β€” when this evening's deploy flurry skipped 7 daily crons, Doug-action shrinks from 7 clicks to 1. New runCronBatchAction() + RunAllSkippedButton component; returns {attempted, succeeded, failed, results[]} so the UI shows per-actor verdicts post-batch.
v2.97.B2
2026-05-10Production

Added

  • 🧭 AdminNav β€” /admin/cron (Cron Health) added under the Admin group. Pre-this-ship the v2.97.B1 page existed but wasn't reachable from any nav surface β€” Doug or an admin would have had to know the URL. Added with the Wrench icon (signals 'operator tooling' vs the Admin-group's other Activity/Package/Settings icons). Also opened /admin/cron for MANAGER role in ROLE_ALLOWED (matches the server-action's MANAGER/ADMIN gate); SCHEDULER and BOOKKEEPER stay locked out. Closes the v2.97.B1 discoverability gap so the 7-cron backfill is one nav-click away instead of a typed URL.
v2.97.B1
2026-05-10Production

Added

  • πŸ”§ NEW /admin/cron page + one-click backfill β€” closes the operator loop on the v2.97.AX skippedToday detection. Pre-this-page, when Vercel cron skipped a fire window (this evening's deploy flurry skipped 7 daily crons: renewals Β· daily-briefing Β· intake-reminder Β· doh-nudge Β· new-patient-drip Β· review-request Β· rc-webhook-renew), the only backfill paths were (a) curl /api/cron/ with Authorization: Bearer $CRON_SECRET (operator must paste the Sensitive-flagged Vercel env var into terminal β€” exposure risk + tooling friction), or (b) wait for tomorrow's natural fire (1 lost patient-comms day). Now: authed admin opens /admin/cron, sees the inline triage (skippedToday + stale + healthy sections sourced from /api/health), clicks 'Run now' per actor. Server action runCronNowAction() at apps/web/src/app/admin/cron/actions.ts validates the admin session at the action level (re-reads x-admin-id from headers β€” defense in depth alongside the layout guard), looks up the path from a whitelist (14 actors, can't be coerced into arbitrary URL), then fetches the cron URL same-origin with Authorization: Bearer ${process.env.CRON_SECRET} server-side β€” secret never reaches the client. Audit-logged as new ADMIN_CRON_FIRE action (detail = actor= status= durationMs= on success, error message on failure). Page also surfaces the broader readiness snapshot β€” paymentReady + emailReady + emailProvider + smsReady β€” so the operator can see if a backfill is going to succeed before clicking (e.g. running review-request when emailReady=false is wasted load). tsc clean.
v2.97.B0
2026-05-10Production

Changed

  • 🩹 StepPayment.tsx β€” Phase 2 of the v2.97.AV booking-flow incident response. Pre-this-ship: when /api/stripe/intent returns the structured 503 {error:'payment_unavailable'} (Stripe key empty, the actual incident shape), the wizard treated it identically to a generic load failure and showed 'Unable to load the payment form. Please go back and try again' β€” copy that pushes the patient back into the same broken loop. Post-this-ship: parses the structured error body. When error === 'payment_unavailable' (or stripePromise null = NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY missing), renders 'Online payment is temporarily unavailable' with two prominent CTAs β€” direct call AND /get-started lead-form callback β€” instead of the dead-end retry copy. Adds a paymentUnavailable state distinct from loadError. Generic load failures (network, malformed JSON, etc.) still get the existing 'go back and try again' message. Closes the patient-experience loop on the v2.97.AV incident: STRIPE_SECRET_KEY paste is still the load-bearing fix, but until that lands, every patient who hits the wizard now sees a clear path to actually book vs. bouncing. Sister of v2.97.AW WizardErrorBoundary 3-CTA fallback (which catches React render errors; this catches the upstream API 503 the wizard was previously masking). tsc clean.
v2.97.AZ
2026-05-10Production

Added

  • πŸ“‘ /api/health now exports emailReady, emailProvider, and smsReady β€” sister probes to the v2.97.AY paymentReady flag, completing the three-rail patient-comms readiness lens. **emailReady** = activeProvider() !== 'none' (true when any of POSTMARK_API_KEY / AWS_SES envs / RESEND_API_KEY is set). **emailProvider** echoes which provider would actually be used ('postmark' | 'ses' | 'resend' | 'none') so HIPAA-readiness is a one-curl check too β€” 'resend' means transactional-only path is live but BAA-covered Postmark hasn't been wired (gates patient-PHI email at scale). **smsReady** = TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN + TWILIO_PHONE_NUMBER all set (mirrors the gate at lib/twilio.ts:13 client construction). All three flags + their isEmailReady() / isSmsReady() exports are reusable elsewhere (e.g. a future /admin/health widget). None of these failing trips the top-level ok flag β€” Doug-action gated, not infra failure (same precedent as cron staleness + paymentReady). Pre-incident value: silent comms-rail failure today is only discoverable when a patient calls in saying 'I never got a confirmation email'; with these flags it's grep-able from any monitoring tool. tsc clean.
v2.97.AY
2026-05-10Production

Added

  • πŸ’³ /api/health now exports a top-level paymentReady: boolean flag β€” true when STRIPE_SECRET_KEY resolves to a real sk_… value, false when it's unset/empty/placeholder. Direct response to this evening's v2.97.AV incident β€” STRIPE_SECRET_KEY went empty on prod, every booking 500-ed at the payment step, and the only way to discover it was to attempt a real booking. With this flag, /admin/health can render a visible 'Stripe unconfigured' badge inline + the paymentReady=false state is grep-able in any monitoring tool curling /api/health. Logic: resolvedKey !== STRIPE_KEY_PLACEHOLDER && resolvedKey.startsWith('sk_'). Sister of cronActors stale flag β€” false NEVER fails the top-level ok:true infra contract (Doug-action gated, not infra failure). isPaymentReady() exported from lib/stripe.ts so the same probe can be reused elsewhere (e.g. a future homepage banner if the key drops out mid-day). tsc clean.
v2.97.AX
2026-05-10Production

Added

  • 🩺 /api/health cronActors detection now surfaces a per-cron skippedToday: boolean field. True when a daily-cadence cron (staleAfterDays ≀ 3) hasn't fired in >24h. Catches the v2.97.APβ†’AU deploy-flurry-skip class β€” daily crons can be 32h late and still NOT trip the existing stale flag (which uses a multi-day threshold to absorb expected weekly drift). The stale mask hid 7 GW skips during this evening's deploy flurry that an inline 1-day detector would have flagged immediately. Hourly / sub-daily crons (waitlist, reminders) are never skippedToday=true because their staleAfterDays ≀1; the broader stale flag catches them. Sun-only slots is always skippedToday=false (>3d threshold). Sister of the stale field β€” both shipped in same response shape so /admin/health can render two distinct badges (stale for multi-day drift, skippedToday for single-day deploy-flurry). tsc clean.
v2.97.AW
2026-05-10Production

Changed

  • πŸͺœ WizardErrorBoundary fallback now offers 3 CTAs instead of 1: (1) Try Again (existing) β€” recovers when the error was transient. (2) NEW 'Leave us your info β€” we'll call you back' link β†’ /get-started web-to-lead form (already-shipped fallback that captures name/email/phone/reason and routes to admin manual followup). (3) Phone-call link (existing). Pre-fix the modal only showed Try Again + Close + an inline phone link; patients hitting a hard wizard error during the Stripe-key incident (v2.97.AV) had to either retry-into-same-error OR call. Now they have a one-click way to drop their info and get a callback. Closes the lead-loss vector even when the wizard itself is broken. Doug greenlit 2026-05-10 evening: 'unless you can find them and map it all in to a webto lead form for now' β€” /get-started already exists, this just wires it into the wizard fallback.
v2.97.AV
2026-05-10Production

Fixed

  • 🚨 BOOKING FLOW BROKEN β€” /api/stripe/intent was 500-ing on EVERY request because STRIPE_SECRET_KEY is unset/empty on Vercel prod, so lib/stripe.ts falls back to sk_test_placeholder… and Stripe SDK rejects with StripeAuthenticationError. Wizard error boundary catches β†’ 'Something went wrong' modal β†’ patient calls instead OR bounces. Real lead-loss cause caught by Doug's screenshot 2026-05-10 evening. **Two fixes**: (1) lib/stripe.ts now logs a clear [lib/stripe] STRIPE_SECRET_KEY is unset/empty on production warning at module-import time β€” first payment-touching request reveals the root cause in Vercel logs instead of an opaque StripeAuthenticationError. (2) /api/stripe/intent now detects the auth-error shape (StripeAuthenticationError name OR 'Invalid API Key' / 'sk_test_placeholder' message) and returns a structured 503 + { error: 'payment_unavailable', message: 'Payment system temporarily unavailable. Please call us to complete your booking.' } so the wizard can render a clearer fallback CTA pointing to the phone number instead of a generic error modal. **Doug-action TODAY**: Vercel dashboard β†’ green-wellness project β†’ Settings β†’ Environment Variables β†’ STRIPE_SECRET_KEY β†’ paste the live sk_live_… key from Stripe dashboard β†’ save β†’ redeploy. Without that, no patient can complete a booking. tsc clean.
v2.97.AU
2026-05-10Production

Fixed

  • πŸ” Session-secret separation β€” remove ADMIN_SESSION_SECRET fallback from provider / patient / dispensary session signers. Pre-fix all three used XXX_SESSION_SECRET ?? ADMIN_SESSION_SECRET. If the dedicated secret wasn't set on Vercel, sessions were signed with the admin HMAC key β€” anyone with admin secret access could forge valid provider / patient / dispensary cookies and impersonate those roles. HIPAA concern: admin β†’ patient impersonation bypasses audit-trail attribution. Verified all four secrets are already set distinctly on Vercel prod (via vercel env ls), so this is a code-side hardening with no env-var deploy required. Dev-mode retains admin fallback for ergonomics.
v2.97.AT
2026-05-10Production

Added

  • πŸ”’ Defense-in-depth requireAdminFromHeaders guard added to all remaining unguarded admin API routes (62 files across appointments, calendar, cert-requests, daily-briefing, dispensaries, email-preview, email-status, eod-email, feedback, integrations/gbp, leads, locations, mailing, messages, outreach, patients, promo-codes, providers, schedules, settings, slots, smoke-tests, users, waitlist, weekly-digest). Inline requireAdmin() helpers and cookie-based verifyAdminSession calls replaced with the canonical header-based guard. ADMIN-only surfaces enforce requireAdminFromHeaders(["ADMIN"]). tsc clean.
v2.97.AS
2026-05-10Production

Added

  • πŸ”’ requireAdminFromHeaders defense-in-depth guard applied to 4 more PHI-bearing message + patient operations: /api/admin/messages/send (outbound SMS+email composer), /api/admin/messages/call (RingCentral click-to-call), /api/admin/messages/ai-draft (Claude reply composer with PHI context), /api/admin/patients/bulk-remind (bulk reminder fanout). proxy.ts is still primary gate; route-level header check fails closed if middleware were ever bypassed.
v2.97.AR
2026-05-10Production

Fixed

  • πŸ”’ /api/patient/auth/set-password β€” close race-condition replay window. Pre-fix (v2.97.AO) the route checked if (patient.passwordHash) BEFORE the bcrypt+UPDATE; two parallel requests with the same valid portal token both saw passwordHash=null, both computed bcrypt (~100ms each), both reached the UPDATE, last-write-wins overwrote the first patient's password. Now: atomic updateMany({ where: { id, passwordHash: null }, data }) β€” collapses to a single SQL UPDATE WHERE passwordHash IS NULL. First wins (count=1); second sees count=0 and gets 409. Pre-check retained for fast-fail on legitimate retries (saves bcrypt compute).
v2.97.AQ
2026-05-10Production

Added

  • πŸ”’ Defense-in-depth requireAdminFromHeaders() guard added to 6 high-PHI admin routes: /api/admin/audit-log/export (audit trail CSV), /api/admin/reports/export (bulk report + patient export), /api/admin/reports/eod/export (EOD report CSV), /api/admin/mailing/labels (patient mailing address PDF), /api/admin/messages/[messageId]/attachments/[attId] (patient file proxy), /api/admin/import/patients (bulk patient import POST). Middleware proxy.ts remains the primary gate; this layer ensures a future matcher misconfiguration fails closed instead of silently serving PHI.
v2.97.AP
2026-05-10Production

Fixed

  • πŸ”’ lib/cron-auth: remove x-vercel-cron: 1 as sole-sufficient auth path. Vercel does not strip that header from external requests β€” accepting it alone let any caller bypass CRON_SECRET and trigger any of the 14 cron routes (reminders, renewals, drip emails, DOH nudges, etc.) without a secret. Pre-fix the header was Path 1; post-fix only Bearer-token + x-internal-secret are accepted. Vercel-scheduled fires still work: Vercel sends the Authorization-Bearer header alongside x-vercel-cron when CRON_SECRET is set.
v2.97.AO
2026-05-10Production

Fixed

  • πŸ”’ /api/patient/auth/set-password: reject with 409 if patient already has a passwordHash. Pre-fix the HMAC portal token was time-bounded (15 min) but not single-use β€” an attacker who captured the magic-link URL could replay it within the window to override a just-set password. Post-fix the endpoint is a no-op once a password exists; patients are directed to the login page or to request a new link.
v2.97.AN
2026-05-10Production

Fixed

  • 🧾 Patient document uploads β€” orphan-blob cleanup on /api/intake/[token]/documents + /api/my-appointments/[token]/documents. Pre-fix the blob upload was wrapped in try-catch but the subsequent db.medicalDocument.create was not β€” a DB insert failure after a successful blob upload would orphan the file in Vercel Blob (still billing, no metadata to surface or delete from /admin/documents). Now: DB insert is wrapped, on failure the blob is best-effort deleted via del() and the patient sees a 502 prompting retry. PHI-safe error logging throughout.
v2.97.AM
2026-05-10Production

Added

  • πŸ›‘οΈ Defense-in-depth requireAdminFromHeaders guard applied to 5 more PHI-bearing /api/admin/* routes that previously relied solely on proxy.ts middleware: patients/search (GET), patients (PATCH), patients/notes (PATCH), patients/[id]/messages (GET), accounting/export (GET). accounting/export preserves BOOKKEEPER role access via requireAdminFromHeaders(["ADMIN","MANAGER","BOOKKEEPER"]) and its manual headers() role check is removed in favour of the shared helper.
v2.97.AL
2026-05-10Production

Fixed

  • πŸ”’ /api/patient/auth/forgot-password: silent catch block now logs error name on sendEmail failure. Pre-fix: email delivery failures were swallowed entirely β€” no trace in Vercel logs. If Postmark is misconfigured patients see 'check your email' but no email arrives and there is zero observability. Fix: same pattern as admin + provider forgot-password (err.name only β€” PHI redaction: sendEmail errors carry patient.email + reset html body).
v2.97.AK
2026-05-10Production

Added

  • πŸ›‘οΈ NEW src/lib/admin-route-guard.ts β€” shared requireAdminFromHeaders() helper that re-checks x-admin-id + x-admin-role at the route level (defense-in-depth on top of proxy.ts middleware gating). Applied to 5 PHI-bearing /api/admin/* routes that previously relied solely on middleware: documents/[id] (GET + DELETE), patients/export, appointments/export, messages, today. cert/[id] retrofitted to use the helper too β€” single source of truth.
v2.97.AJ
2026-05-10Production

Fixed

  • 🩺 /api/admin/cert/[id] HIPAA defense-in-depth: route now verifies x-admin-id + x-admin-role headers (set by proxy.ts after admin session validation) before serving the patient cert PDF. proxy.ts middleware already gates /api/admin/* in the request lifecycle, but layered checks at the route level fail-closed if middleware were ever misconfigured or bypassed. Audit log already auto-captures the admin actor via x-admin-id header (existing audit.ts behavior).
v2.97.AI
2026-05-10Production

Fixed

  • πŸ” Stripe webhook signature failures now return 401 (auth failure) instead of 400 (bad request). Stripe retries on both, but the status code is the only observability signal that distinguishes signature-verification failures from payload-shape errors in dashboard logs.
v2.97.AH
2026-05-10Production

Fixed

  • πŸ”” Appointment reminder crons: two resilience fixes. (1) Per-appointment try-catch in reminders + reminders-2h β€” a logWorkflowEvent DB failure on one appointment previously crashed the entire batch, leaving all remaining appointments un-reminded for that window. (2) Status re-check before send β€” patient may cancel between the batch query and the email/SMS send; now skip if status changed from SCHEDULED/CONFIRMED.
v2.97.AG
2026-05-10Production

Added

  • πŸ”’ PII console-leak arc-guard: NEW scripts/check-pii-console-leak.mjs β€” catches console.error("msg", err) with raw Error object. HIPAA: Vercel function logs are NOT BAA-covered; Salesforce/RingCentral/Postmark/Resend errors echo patient email/phone/appointment context into err.message. Fixed 1 violation: src/components/scheduling/WizardErrorBoundary.tsx:27 β€” componentDidCatch(error) changed raw error β†’ error instanceof Error ? error.name : String(error). Guard wired into check:all chain. Sister glw v21.305 + scc v13.9005.
v2.97.AF
2026-05-10Production

Fixed

  • πŸ“‹ /admin/launch: added GW_TEST_EMAIL row to Integrations section β€” the env var was documented in .env.example (v2.97.AE) but not on the on-site launch checklist, so it was invisible to operators configuring Vercel.
v2.97.AE
2026-05-10Production

Fixed

  • πŸ“‹ .env.example: added GW_TEST_EMAIL with usage comment (env-var documentation gap β€” the test-mode fallback was wired in v2.97.AB but the variable wasn't discoverable without reading source code).
v2.97.AD
2026-05-10Production

Fixed

  • πŸ”€ HTML entity violations swept from JSX β€” 9 named/numeric entities ( , , , ) replaced with direct Unicode characters per React's JSX non-decoding behavior (non-XML entities render literally). Ports 2 arc-guards from cannagent: check-html-entities-jsx + check-server-actions-async. Guard wired into pre-push chain.
v2.97.AC
2026-05-10Production

Added

  • πŸ€– /llms-full.txt β€” long-form AI-search reference for Green Wellness Medical. Closes Doug-action #23 (GW portion). Comprehensive public-facts-only content: at-a-glance, services+pricing, how the WA medical cannabis program works, all qualifying conditions (RCW 69.51A), all 4 clinic locations with addresses (Lynnwood 4720 200th St SW Β· Spokane Valley 323 E 2nd Ave Suite 201H Β· Olympia 1212 4th Ave E Β· Vancouver Clark County), HIPAA privacy summary, and index links to all 34 /learn articles. All content sourced from public-facing site + publicly documented WA statute β€” no PHI. public/llms.txt updated with Full Reference link pointing to /llms-full.txt.
v2.97.AB
2026-05-10Production

Fixed

  • πŸ§ͺ Test-mode email redirect now works immediately via GW_TEST_EMAIL env var fallback. Prior gap: TEST_EMAIL_REDIRECT_BY_USER_ID is empty until Doug pastes in userId values, so the toggle showed 'TEST MODE' but no redirect actually fired β€” real patients still got emails during QA. Fix: resolveTestRedirect now checks process.env.GW_TEST_EMAIL as a fallback when the per-user map has no match. Per-user map still takes precedence (overrides env var when userId is in the map). Doug-action: set GW_TEST_EMAIL=admin@greenwellness.org (or similar) on Vercel so the toggle is immediately usable; later paste userIds into TEST_EMAIL_REDIRECT_BY_USER_ID for per-admin routing. lookupTestRedirect pure function unchanged β€” existing test coverage unaffected.
v2.97.AA
2026-05-10Production

Changed

  • β™Ώ PWA manifest lang: "en-US" + dir: "ltr" fields β€” accessibility signal for screen readers + Lighthouse PWA audit per W3C web-app-manifest spec. T164 audit caught the gap on the 4 non-vrg sites (vrg already had these). Sister glw v21.005 + scc v13.8705 + sureel same-tick quartet β€” board item #25 (cross-stack PWA manifest field parity). Screenshots field is the remaining piece β€” needs Doug-captured asset work (wide-format 1280Γ—720 + narrow-format 750Γ—1334 captures of /, /apply, /my-appointments), left open for now.
v2.97.Z0
2026-05-10Production

Fixed

  • πŸ›‘οΈ 404 page metadata fix β€” src/app/not-found.tsx now exports metadata = { title: "Page not found", robots: { index: false, follow: false } }. Pre-fix the 404 page inherited the layout's default title template (producing real-page SERP titles on 404 URLs) AND was implicitly indexable β€” soft-404 / thin-content risk. Now: explicit "Page not found" title + robots:noindex closes both gaps. Sister glw v20.905 + scc v13.8605 same-fix triple β€” board item #21 (cross-stack 404 metadata parity, agent-pickable).
v2.97.Y0
2026-05-10Production

Changed

  • πŸ§ͺ Lift pure-function lookupTestRedirect(userId, testModeOn, map?) out of v2.97.W0 resolveTestRedirect β€” testable in isolation without mocking cookies()/verifyAdminSession/dynamic imports. Default map arg = TEST_EMAIL_REDIRECT_BY_USER_ID; tests can pass their own. Returns null on (a) testModeOn=false, (b) missing userId, (c) userId not in map. resolveTestRedirect now delegates to it. No behavior change. Sets up the contract pin: future refactor of the redirect-decision logic must update this function + its (forthcoming) test suite. Sister of the v392.685+v393.085 chain-of-custody writer↔reader pattern from inv β€” same defensive pin shape.
v2.97.X0
2026-05-10Production

Fixed

  • πŸ” Restore GW vercel.json crons[] post-Mariane QA (test-mode set 2026-05-09 23:36 PT, 14h+ ago). All 14 scheduled crons re-armed: reminders / reminders-2h / no-show / renewals / daily-briefing / weekly-digest / intake-reminder / eod-email / doh-nudge / new-patient-drip / review-request / rc-webhook-renew / waitlist / slots. Restored verbatim from git show eab2c3e:vercel.json per the OWNER_ACTION_QUEUE handoff. Patient SMS reminders + appointment renewals + weekly digest emails resume on the next cron firing window.
v2.97.W0
2026-05-10Production

Added

  • πŸͺ› Test-mode email-redirect toggle for QA β€” Doug 2026-05-10: 'have there be a way mariane and i can toggle between test environment and live'. NEW floating chip in the admin top-right (TestModeToggle.tsx). Off (default) = emerald 'Live' label, prod sends. On = amber 'TEST MODE' label, sendEmail (in lib/email.ts) redirects all outbound patient emails to the logged-in admin's mailbox per TEST_EMAIL_REDIRECT_BY_USER_ID map. Subject prefixed [TEST β†’ original@addr] so original recipient is preserved for forensic check. Cookie-based (gw_test_mode=on); cron-fired emails have no request context so they always send to real patients (production-safe by default). Resolver wraps cookies()/session lookups in try/catch β€” outside-request-context (background scripts, edge) falls through to no-redirect. **Doug-action**: paste the AdminUser.id values for Doug + Mariane into TEST_EMAIL_REDIRECT_BY_USER_ID in src/lib/email.ts (currently empty β†’ fail-closed prod-safe). Pull from /admin/users after login.
v2.97.V0
2026-05-10Production

Added

  • πŸ“‘ NEW RSS 2.0 feed at /feed.xml for /learn medical-cannabis education content (34 articles). Pre-fix the site published 34 articles with no machine-readable feed β€” Feedly/NewsBlur/Inoreader subscribers + AI-training crawlers (GPTBot, ClaudeBot, PerplexityBot all probe /feed.xml + /rss.xml) all 404'd. Cross-repo port from glw v8.485 + scc v10.605 (T82 round-3 work). Layout added so feed readers auto-discover when users paste the homepage URL. HIPAA-safe: ARTICLES are general medical-cannabis education content (qualifying conditions, evaluation process, WA State authorization) β€” no PHI, no patient-specific data, no provider identifiers beyond the public clinic-roster page. RSS 2.0 + atom:link self-reference + escapeXml on free-text fields + 30-min edge cache + 1-hour stale-while-revalidate. Closes Doug-action #27 cross-stack RSS parity gap (T171 finding). tsc clean.
v2.97.U0
2026-05-10Production

Fixed

  • πŸ›‘οΈ NEW report-uri /api/csp-report directive added to enforce-mode CSP β€” closes the silent-violation gap. Pre-fix every blocked-request violation vanished without telemetry; Doug had no visibility into what enforce-mode was blocking. Endpoint already existed at src/app/api/csp-report/route.ts (edge runtime, 4KB cap, format-only console.error β†’ Vercel Runtime Logs) but wasn't wired from CSP. Plus NEW arc-guard scripts/check-csp-report-only.mjs (sister of cannagent v5.6925 + sureel + glw + scc) β€” pins the header + report-uri + endpoint + default-src baseline against regression. Wired into .githooks/pre-push build-gates (17/17 β†’ 18/18). Doug greps vercel logs | grep csp-violation to see what's being blocked. tsc clean.
v2.97.T0
2026-05-10Production

Fixed

  • πŸ›‘οΈ NEW X-Robots-Tag noindex, nofollow on /admin/:path* β€” defense-in-depth on top of robots.txt /admin disallow. The 46 patient/clinic admin pages are cookie-gated but URL leaks (Slack/Twitter/email shares) could let Google index admin URLs with 'No description because of robots.txt' even though robots.txt already disallows /admin/. Header-level noindex closes the indexing-without-crawling gap. Especially important for GW since admin surfaces handle PHI flows. Sister cannagent v5.6585 same-day port. tsc clean.
v2.97.S0
2026-05-10Production

Fixed

  • πŸ›‘οΈ Pre-push build-gates chain extended from 10/10 to 17/17 β€” 7 arc-guards that exist as scripts + are wired into pnpm check:all (manual run) but were bypassed by .githooks/pre-push (the actual push gate). Pre-T117 push could land code that violated og-completeness / duplicate-brand-title / og-image-shape / per-route-og-image / title-length-html / description-length-html / button-type rules β€” caught only when someone manually ran pnpm check:all. Now ALL 17 gates run on every push (matching package.json check:all chain). Bonus fix: check-og-completeness.mjs was hitting a false-positive on src/lib/changelog.ts:501 where a changelog entry's PROSE includes the literal substring openGraph: { (describing past T19/T20 fixes) β€” gate's regex couldn't tell metadata-block from prose. Added src/lib/changelog.ts to the EXEMPT set (sister of src/app/layout.tsx which is also exempt for similar reasons). Round-5 2nd ship β€” completes the GW arc-guard discipline from round-2-3-4 (originally 7 marketing-site arc-guards plus 3 GW-specific helpers totaling 10; now all 17 are pre-push gated). tsc clean.
v2.97.R0
2026-05-10Production

Added

  • πŸ›‘οΈ NEW arc-guard scripts/check-article-physician-ld-completeness.mjs β€” pins T87 v2.97.M0 (Article|MedicalWebPage @id + publisher.@id) and T88 v2.97.N0 (Physician @id + worksFor.@id) against regression. Validates 14 required Google-structured-data fields across 2 SoT helpers (buildArticleLd 8 fields + buildPhysicianLd 6 fields) PLUS 2 nested-@id linkage checks (publisher block must contain @id ref to homepage Organization; worksFor block same). Heuristic brace-depth body extraction matches T84 + T103 pattern. Catch-rate verified via injection: stripped publisher.@id line β†’ guard fired with exact missing-field message + T87/T88 changelog reference; reverted clean. Wired into pnpm check:article-physician-ld (manual) AND .githooks/pre-push build-gates chain (now **10/10** β€” was 9/9). Round-5 first ship β€” extends T84 medical-clinic + T103 breadcrumb arc-guard pattern to the remaining round-3 entity-graph @id work. **3 GW-specific JSON-LD arc-guards now**: medical-clinic (T84), breadcrumb (T103), article+physician (T116). All 3 SoT JSON-LD helpers from round-3 entity-graph @id arc are now write-protected against regression. tsc clean.
v2.97.Q0
2026-05-10Production

Fixed

  • πŸ“§ Spam-trigger words removed from 2 patient renewal email subject lines. Pre-fix: (1) Urgent: 7 days left on your authorization β€” "Urgent:" prefix is a known mild spam trigger per Mailchimp + SendGrid spam-filter studies (raises score ~2-3 points). (2) Final notice β€” your authorization expires ${expiryDate} β€” "Final notice" is a stronger spam trigger word (collection-letter language). These are time-sensitive renewal reminders to active patients β€” if Gmail spam-filters them, the 7-day-warning email NEVER reaches the patient and they lose their authorization. Critical deliverability concern especially in HIPAA-aware patient cohort. Fix: (1) Urgent: 7 days left on your authorization β†’ 7 days until your authorization expires (urgency preserved in body, removed from subject). (2) Final notice β€” your authorization expires ${expiryDate} β†’ Your authorization expires ${expiryDate} β€” last week to renew (clearer + non-trigger). Body copy unchanged. Audited all 25 GW subject lines + glw + scc subject lines β€” only these 2 triggered the spam-trigger filter. Round-4 third ship continues the round-3 email-quality arc (T73 a11y β†’ T75/T77 preheaders β†’ T78/T79 List-Unsubscribe + multipart β†’ T104 spam-trigger words). tsc clean.
v2.97.P0
2026-05-10Production

Added

  • πŸ›‘οΈ NEW arc-guard scripts/check-breadcrumb-ld-id.mjs β€” pins T101 v2.97.O0 fix (@id field added to buildBreadcrumbLd() SoT helper) against future regression. Heuristic check: locate the function via brace-depth tracking from return {, scan body for "@id": literal, fail with detailed-context message when missing. Catch-rate verified via injection test: temporarily deleted the @id line β†’ guard fired with exit 1 + clear remediation instructions; reverted cleanly. Wired into BOTH pnpm check:breadcrumb-ld-id (manual) AND .githooks/pre-push build-gates chain (now 9/9 β€” was 8/8). Same pattern as T84 check-medical-clinic-ld-completeness.mjs β€” ship-the-fix β†’ ship-the-arc-guard. Single-function-validator scope (vs T84's 2-function validator) since BreadcrumbList has only one SoT helper on GW. Round-4 second ship (T101 was first); round-3 close pattern of arc-guards-after-arc-fixes continues.
v2.97.O0
2026-05-10Production

Fixed

  • 🌐 BreadcrumbList @id linking on buildBreadcrumbLd() SoT helper β€” closes the entity-graph @id linking arc across THE WHOLE 6-site stack. Pre-fix every BreadcrumbList on GW (telehealth/locations/learn/faq/about/privacy/terms/refer/changelog/dispensaries/conditions etc β€” ~15 caller pages) was a dangling node without entity-level @id. Sibling Article|MedicalWebPage / MedicalClinic / ContactPage couldn't reference the breadcrumb via @id. Sister of glw + scc + cannagent + sureel + vrg T91-T98 same pattern. Single SoT helper edit emits @id: ${SITE_URL}${last.path}#breadcrumb derived from the last crumb's URL β€” all callers inherit automatically. Zero inline-BreadcrumbList emitters on GW, so the helper is the single point of fix. Caught by /loop tick 101 GW probe (deferred from T96 by Vercel WAF challenge β€” file-dump workaround bypassed pipe failure). T100 round-3 close noted GW BreadcrumbList probe was the only outstanding gap; T101 closes it. **First ship of round 4.** tsc clean.
v2.97.N0
2026-05-10Production

Fixed

  • 🌐 WebSite + Physician JSON-LD entity-graph @id linking β€” sister of T87 Article @id pattern. (1) buildWebSiteLd() now emits @id: ${SITE_URL}/#website (sister of glw + scc which both already have this β€” GW was the lone outlier across the stack) + publisher.@id: ${SITE_URL}/#organization linking to homepage MedicalClinic. WebSite is the Google Knowledge Graph anchor entity, so this @id lets every other JSON-LD node on the homepage reference it cleanly. (2) buildPhysicianLd() (currently dead-code: no /providers/[slug] public page exists yet β€” admin UI at /admin/providers only β€” but the helper is staged for future ship per roadmap) gets the same @id pattern preemptively: @id: ${SITE_URL}/providers/${slug}/#physician + worksFor.@id: ${SITE_URL}/#organization. **Methodology lesson re-confirmed**: T85 dead-code-helper class triggered AGAIN β€” buildPhysicianLd has no callers (verified via grep -rn buildPhysicianLd src/app returned zero matches) yet roadmap admin page lists "Per-page JSON-LD β€” Physician" as done. Memory pin candidate: 'helper exists in seo.ts != it's wired into a live page'. T87 (Article) β†’ T88 (WebSite + Physician) closes the @id-on-non-MedicalClinic surface. tsc clean.
v2.97.M0
2026-05-10Production

Fixed

  • 🌐 Article|MedicalWebPage JSON-LD on /learn/[slug] β€” now emits @id + publisher.@id. Pre-fix buildArticleLd() in src/lib/seo.ts had mainEntityOfPage.@id but NO entity-level @id, so the article entity was a dangling node β€” couldn't be cleanly referenced from BreadcrumbList / FAQ / Speakable nodes. publisher was a bare Organization with name+url only (no @id), so Google's structured-data parser couldn't merge it into the homepage MedicalClinic entity graph that already lives at ${SITE_URL}/#organization. Added (1) @id: ${SITE_URL}/learn/${slug}/#article for the article entity itself, (2) publisher.@id: ${SITE_URL}/#organization to merge publisher β†’ homepage MedicalClinic. Same pattern as T85 (per-location MedicalClinic parentOrganization @id linking) and buildConditionWebPageLd() already on /conditions/[slug] (which had this pattern from v2.93.45). buildArticleLd verified live wired into /learn/[slug]/page.tsx (no dead-code-helper class like T85/T86). Round-3 SEO/structured-data hardening continues β€” T83 β†’ T85 β†’ T86 closed MedicalClinic surface; T87 starts closing the Article surface with the same @id/entity-graph methodology. tsc clean.
v2.97.L0
2026-05-10Production

Fixed

  • 🌐 5 more pages emitting inline MedicalClinic JSON-LD now have the same @id + email + priceRange + logo + image fields T83 + T85 added to the SoT helpers. Pre-fix the live pages /telehealth, /telehealth/[city], /telehealth/[city]/[condition], /locations/[city]/[condition], and /pricing were emitting bespoke MedicalClinic schemas that diverged from the SoT β€” every one was missing the same Google-recommended fields. Sweep methodology: grep "@type": "MedicalClinic" across src/app, curl-probe each live URL to confirm missing fields (logo: NO, image: NO, email: NO, @id: NO β€” all 5 affected), then add the 5 fields inline to each page (couldn't refactor to SoT helper without a per-page-customized variant β€” each page has unique name like Green Wellness β€” ${city} Telehealth Β· ${condition} that the helpers don't take). Imported EMAIL from @/lib/constants on each file. Pages NOT affected: /locations (uses different schema shape) + /get-started (no MedicalClinic emitted). T83 β†’ T85 β†’ T86 closes the per-MedicalClinic logo/image gap across the whole GW app surface β€” every live MedicalClinic schema across all routes now has the required Google-structured-data fields. Round-3 SEO/structured-data hardening continues; methodology of curl-probe-after-deploy proven again across 3 ticks. tsc clean.
v2.97.K0
2026-05-10Production

Fixed

  • 🌐 T85 v2.97.J0 SELF-REGRESSION caught + fixed: edits to buildLocationLd() in src/lib/seo.ts had ZERO effect on the live /locations/[city] pages because the page emitted an INLINE localBusinessJsonLd object, never calling the SoT helper. Verified via curl + JSON.parse on https://greenwellness.org/locations/lynnwood post-T85: every logo/image/email/@id field was null despite the SoT helper having them. Same dead-code-helper class as T48-T50 per-route OG images (SoT existed, callers didn't reach for it). Refactored src/app/locations/[city]/page.tsx to call buildLocationLd({ city, state, zip, address, name, phone, hours: parseHoursJson(loc.hours), slug: city }) β€” single SoT call replaces 19 lines of inline object literal. Also deleted the local buildOpeningHoursSpec() function (~10 lines) which was the inline schema's hours helper but is now dead code (the SoT helper has its own buildOpeningHoursSpec internally). Net: -29 lines page code; live MedicalClinic schema now matches what arc-guard validates. Methodology lesson: arc-guard validates the SoT helper, not the rendered HTML β€” also need post-deploy curl to confirm the SoT is actually wired into the page tree. Same lesson as T81 (cascade overwrite caught via prod-headers-first re-curl). tsc clean.
v2.97.J0
2026-05-10Production

Added

  • 🌐 Per-location MedicalClinic JSON-LD now also emits logo + image + email + @id (sister of T83 v2.97.H0 same-fix on the homepage MedicalClinic). Pre-fix buildLocationLd() in src/lib/seo.ts was missing the same Google-recommended structured-data fields that T83 fixed on buildMedicalBusinessLd() β€” every per-location page (/locations/[city]) had identical Knowledge Panel + voice-search + rich-result surfacing degradation. Added (1) @id IRI for per-location entity-graph linking, (2) email (mirror of homepage), (3) logo: ${SITE_URL}/icon (square 180Γ—180), (4) image: ${SITE_URL}/opengraph-image (1200Γ—630). Also upgraded parentOrganization reference to include @id: ${SITE_URL}/#organization so Google can merge the per-location MedicalClinic into the homepage organization entity (entity-graph correctness β€” pre-fix it was a dangling ref-by-name only). Arc-guard EXTENDED: scripts/check-medical-clinic-ld-completeness.mjs now validates BOTH buildMedicalBusinessLd() (12 fields) AND buildLocationLd() (10 fields β€” no email/availableService since those live on the parent org) β€” total 23 fields validated. Catch-rate verified: when arc-guard runs against post-fix source, reports all 23 required fields present across 2 MedicalClinic emitters. Round-3 SEO/structured-data hardening β€” completing the cross-function audit T83 began.
v2.97.I0
2026-05-10Production

Added

  • πŸ›‘οΈ NEW arc-guard scripts/check-medical-clinic-ld-completeness.mjs β€” pins T83 v2.97.H0 fix (logo + image fields on MedicalClinic) against future regression. Heuristic check: locates buildMedicalBusinessLd() in src/lib/seo.ts via brace-depth tracking, scans the function body for 12 required Google-recommended structured-data fields (@id + name + description + url + telephone + email + priceRange + logo + image + medicalSpecialty + areaServed + availableService), fails the build with a per-field missing list when any are absent. Verified catch-rate via injection test: temporarily deleted logo: line β†’ guard fired with 'missing: logo' + exit 1; reverted cleanly. Wired into BOTH pnpm check:medical-clinic-ld (manual) AND .githooks/pre-push build-gates chain (now 8/8 β€” was 7/7) so any regression on the SoT function is caught at push-time before it can reach prod. Sister of glw + scc og-completeness/og-image-shape/title-length-html arc-guards. Pattern: ship the fix β†’ ship the arc-guard. Round-3 SEO/structured-data hardening continues.
v2.97.H0
2026-05-10Production

Added

  • 🌐 MedicalClinic JSON-LD now emits logo + image β€” recommended by Google's structured-data guidelines for medical-business rich results, Knowledge Panel, and voice-search surfacing. Pre-fix the schema in src/lib/seo.ts buildMedicalBusinessLd() had neither field; SERPs fell back to a screenshot of the homepage as the visual representation, and the Knowledge Panel had no logo (worse brand recall on patient searches like 'medical marijuana clinic Seattle' or 'WA telehealth medical card'). Added logo: ${SITE_URL}/icon (180Γ—180 PNG, square ratio β€” ideal logo dimensions per Google) and image: ${SITE_URL}/opengraph-image (1200Γ—630, representative photo for the entity). Both routes already serve correctly + are 24hr edge-cached. sameAs (social-profile disambiguation) deferred β€” GW doesn't have a hardcoded GBP/Facebook URL on file yet; can add later when those URLs are confirmed. Caught 2026-05-10 by /loop tick 83 JSON-LD validation sweep across the 6-site stack β€” GW MedicalClinic was the lone outlier (glw + scc + cannagent + sureel all clean). Especially important for HIPAA-aware patient-facing surface where SERP first-impression directly affects appointment-booking funnel.
v2.97.G0
2026-05-10Production

Added

  • πŸ“§ Plain-text email alternative auto-generated from HTML β€” every outbound email is now multipart/alternative instead of single-part HTML. Pre-fix src/lib/email.ts provider sends only included HtmlBody (Postmark) / html (Resend) β€” single-part HTML emails are a deliverability red flag (Gmail spam filter + Outlook SmartScreen + most enterprise filters all rank multipart higher), and Apple Watch / smart-reply / plain-text-preferring users got nothing readable. Added htmlToText(html) helper that strips style/script/head + display:none preheader divs, preserves URLs next to anchor text in parens (Sign in (https://greenwellness.org/portal/login)), converts block-end tags +
    to newlines, list-items to - prefixes, decodes 7 entities (&/</>/"/'/'/ ), collapses whitespace + caps newlines at 2. All 23 sendEmail callers benefit automatically (no per-template work). Verified output via Node test on a sample bookingConfirmation HTML β€” preserves headline + body + portal link + phone/email contact rows readably. Both Postmark TextBody and Resend text fields populated; SES adapter is a stub so no change there. Especially relevant for HIPAA-aware patient emails where deliverability matters most (renewal reminders + booking confirmations + DOH-nudge cohort). Round-3 email-quality arc continues from T78 (List-Unsubscribe HTTP headers on glw + scc) β€” T79 closes the equivalent gap on GW's transactional + marketing flows. tsc clean.
v2.97.F0
2026-05-10Production

Added

  • πŸ“§ Preheaders wired on 5 more time-sensitive patient emails: (1) noShowEmail β€” Life happens. Telehealth renewal takes 15 minutes β€” let's pick a time… (2) renewalReminderEmail β€” per-stage preheader threaded through the urgencyMap (60d/30d/14d/7d each get a distinct line that surfaces expiry date + lead-time framing). (3) renewalEscalationEmail β€” ${expiryDate} is your last day. After that, you'd restart as a new patient β€” book… (4) reEngagementEmail (90-day) β€” Authorization still active through ${expiry}. Book your renewal whenever you're ready. (5) winBackEmail (post-expiry) β€” Returning patient telehealth β€” \$${PRICING.RETURNING_TELEHEALTH}, ~15 min, same-week. New auth runs through ${newExpiryDate}. Each preheader extends (doesn't echo) the subject line β€” the inbox-preview text becomes a second hook that increases open rate on time-sensitive renewal sequences. T77 continues the round-3 email-quality arc started at T73 (role=presentation), T74 (preheader infra), T75 (3 highest-value), T76 (link underlines). Caught 2026-05-10 by /loop tick 77 β€” false-positive privacy-link probe pivoted into completing the preheader sweep on the renewal cohort that drives the most patient open-rate variance.
v2.97.E0
2026-05-10Production

Fixed

  • β™Ώ Email link underlines (WCAG 1.4.1 β€” Use of Color). Pre-fix all 22 inline text links in src/lib/emails.ts used style="color:#2d6a4f" (sage green) WITHOUT text-decoration:underline. Per WCAG 1.4.1, color cannot be the SOLE means of distinguishing links from regular text β€” low-vision and color-blind patients couldn't easily tell what was clickable. Added text-decoration:underline to every inline text link (regex sweep, skipped buttons which have bg-color + display:inline-block as the cue). Sister glw + scc emails already had this pattern (verified via grep). Especially important for HIPAA-aware GW patient emails where vision-impaired patients managing care need clear interaction affordances. T76 closes the GW email-a11y arc started at T73.
v2.97.D0
2026-05-10Production

Added

  • πŸ“§ Email preheader infrastructure + 3 highest-value templates wired. T75 follow-up to T74 (glw + scc preheader sweep). shell() helper got a new optional 3rd parameter preheader β€” backwards-compat preserved (existing 2-arg callers unchanged). Hidden via display:none;font-size:0;max-height:0;mso-hide:all; so it doesn't render in body but Gmail/Apple Mail/Outlook all surface it as the inbox-preview line. Wired on the 3 highest-value patient-facing templates: (1) bookingConfirmation β†’ "${apptDate} β€” what to bring + check-in link inside." (2) reminderEmail β†’ "Reminder: your Green Wellness appointment ${urgency}. Tap for the join link + checklist." (3) authIssued β†’ "Your authorization is issued. You can shop at any WA dispensary today." Remaining 6 templates (no-show, renewal-30d/14d/7d, cohort-3month, win-back, rescheduled, cancelled, payment-receipt, etc.) keep working unchanged via backwards-compat β€” preheader can be threaded later as content matures. Customer-UX win on highest-visibility inbox surface. Sister of glw v18.605 + scc v13.6305 (3 templates each, simpler structure had / directly). Caught 2026-05-10 by /loop tick 75 round-3 email-quality continuation.
v2.97.C0
2026-05-10Production

Fixed

  • β™Ώ Email a11y β€” role="presentation" added to 8 layout tags in src/lib/emails.ts (patient-facing transactional templates: appointment confirmation, reminder, intake-link, password-reset, etc). Pre-fix every email used
    for layout (correct β€” flexbox/grid don't render reliably across email clients) WITHOUT the role="presentation" ARIA hint. Screen readers (and email-client assistive-tech overlays) announced these layout tables as DATA tables β€” "Table with 1 row, 1 column" β€” confusing the patient's experience. Especially relevant for HIPAA-aware patient surfaces where vision-impaired patients managing their care need clean email-body audio rendering. role="presentation" tells assistive tech "this is layout scaffolding, ignore semantics" so the prose flows naturally. WCAG 1.3.1 (info-and-relationships) hardening. Sister glw v18.505 (7 fixes) + scc v13.6205 (7 fixes) β€” round-3 dimension shift after T67-T72 saturation. Pure additive; no visual change in any email client.
    v2.97.B0
    2026-05-10Production

    Added

    • πŸ›‘οΈ NEW arc-guard scripts/check-button-type.mjs β€” sister glw v18.405 + scc v13.6105 cross-stack port. Pins T69 button-type sweep (313 buttons / 101 files) against future regression. Walks src/app/ + src/components/ for every allowed. 0/0 on GW post-T69. Wired into pnpm check:button-type AND pnpm check:all build-gate. Caught 2026-05-10 by /loop tick 70.
    v2.97.A0
    2026-05-10Production

    Fixed

    • β™Ώ
    v2.97.90
    2026-05-10Production

    Added

    • 🌐 Robots meta β€” added Google SERP-display directives max-snippet:-1, max-image-preview:large, max-video-preview:-1 to root layout robots.googleBot block. Pre-fix only index, follow was declared β€” Google used conservative defaults: shorter snippets (~160c cap), smaller image previews, no video preview. With these directives, Google can render full-length snippets in SERPs (matters for GW patient-condition pages where the snippet is the customer's first impression of our copy on "is X covered" / "qualifying for medical marijuana with Y" searches), large image previews, and full video previews. Pure additive SERP-visibility win β€” no functional change, no risk. Sister glw v18.205 + scc v13.5905 same-fix. Caught 2026-05-10 by /loop tick 65 robots-meta detail audit.
    v2.97.80
    2026-05-10Production

    Added

    • πŸ›‘οΈ NEW arc-guard scripts/check-description-length-html.mjs β€” sister glw v18.105 + scc v13.5805 cross-stack port. Pins T6 + T62 fixes (description-length HTML-rendered cap) against future regression. Walks src/app/, finds every TOP-LEVEL metadata.description: declaration, computes HTML-escaped rendered length (& +4, ' +5, " +5, < +3, > +3), fails if > 160 chars. Same brace + bracket-depth aware reverse-walk pattern as T63 title arc-guard β€” only flags metadata-export descriptions, skips array-of-object data structures + dynamic template literals. Pairs with T63 title-length arc-guard to close the SERP-truncation arc on BOTH title + description sides. 0/0 on GW currently. Wired into pnpm check:description-length-html (manual) AND pnpm check:all (chained build-gate). Caught 2026-05-10 by /loop tick 64.
    v2.97.70
    2026-05-10Production

    Added

    • πŸ›‘οΈ NEW arc-guard scripts/check-title-length-html.mjs β€” sister glw v18.005 + scc v13.5705 cross-stack port. Pins T5 + T24 + T25 + T61 fixes (title-length HTML-rendered cap) against future regression. Walks src/app/, finds every TOP-LEVEL metadata.title: declaration (string literal or { absolute } form), computes HTML-escaped rendered length (& β†’ & +4 chars, ' β†’ ' +5, " β†’ " +5, < β†’ < +3, > β†’ > +3) PLUS layout title.template suffix when applicable, fails build if rendered length > 60 chars. Brace-depth-aware reverse-walk + bracket-depth tracking only flags titles that are immediate children of the exported metadata object β€” skips array-of-object data structures (training step titles, FAQ items, breadcrumb chains, navigation links) which don't render as . Skips template literals with <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">${expr}</code> interpolation (dynamic β€” can't measure statically). 0/0 on GW currently. Wired into <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:title-length-html</code> (manual) AND <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:all</code> (chained build-gate). Cross-stack symmetric. Verified catch-rate via injection test on glw /blog. Caught 2026-05-10 by /loop tick 63.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.97.60</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>β™Ώ Patient-intake form <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">autoComplete="off"</code> on 2 medical-context inputs. Pre-fix /intake/[token] had <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><input type="text"></code> for **allergies** (line 251) and **treating physician** (line 365) with NO autoComplete attr β€” browser autofill could suggest random text from past form contexts (or the patient's own name into the treating-physician slot). Especially important for HIPAA-aware patient surfaces where data integrity matters: an allergies field auto-filled with stale text from a prior browsing session could lead to incorrect medical records. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">autoComplete="off"</code> opts these fields out explicitly. Sister glw v17.805 + scc v13.5405 T58+T59 form-autocomplete sweep applied to GW. Caught 2026-05-10 by /loop tick 60 patient-intake form audit.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.97.50</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>β™Ώ WCAG 2.3.3 β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">@media (prefers-reduced-motion: reduce)</code> global override added to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/globals.css</code>. Pre-fix admin-portal loaders (Loader2 spinners), waitlist skeleton (animate-pulse), previsit-form submitting indicator, and ~dozen other surfaces used <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">animate-pulse</code> / <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">animate-spin</code> / <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">animate-bounce</code> Tailwind classes without a <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">motion-reduce:</code> variant. Browser users with vestibular conditions / migraine triggers / motion sensitivity saw all these animations regardless of their OS-level Reduce Motion preference. Especially relevant for HIPAA-aware patient surfaces β€” chronic-condition patients using assistive tech may need motion-reduce. Global override slows everything to 0.01ms (effectively instant but keeps final keyframe state) when the OS preference is set. Sister glw + scc both already had this pattern in their globals.css; GW was the lone outlier across the 3 web surfaces. Caught 2026-05-10 by /loop tick 57 cross-stack prefers-reduced-motion audit.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.97.40</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ“± <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">applicationName: "Green Wellness Medical"</code> added to root layout metadata. Emits <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><meta name="application-name"></code> β€” used by Microsoft Edge + Windows Start menu when pinning the site as a tile / app shortcut, and by some browser plugins as the canonical app identifier. Pre-fix all 3 sites in the stack (glw + scc + GW) were missing this meta tag (verified via curl audit). Pure additive β€” no existing functionality affected. Sister glw v17.605 + scc v13.5205 same-fix.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.97.30</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>🌐 og:locale = en_US in root layout openGraph block. Facebook/LinkedIn unfurlers + Google use this property for region-aware share-card rendering. GW was a lone outlier (along with vrg) missing this property across the 6-site stack β€” 4/6 had it (glw, scc, cannagent, sureel). Sister vrg v0.14.0 same-class fix. Caught 2026-05-10 by /loop cross-stack og:locale sweep.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.97.30</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ›‘οΈ Permissions-Policy hardening β€” 9 directives added to lock down browser APIs the patient-portal + marketing site never invokes: payment / usb / serial / bluetooth / midi / xr-spatial-tracking / magnetometer / accelerometer / gyroscope. All set to empty allowlist <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">()</code> = disabled for self + iframes. Defense-in-depth β€” blocks any future vendor JS from silently invoking these. Especially important for HIPAA-aware GW where third-party JS surface is already minimized; this closes browser-API attack surface (a malicious script that somehow loads couldn't read device sensors / hardware peripherals / payment-request modal even if it tried). Pure additive β€” no existing functionality uses any of these APIs. interest-cohort=() opt-out (FLoC/Topics) preserved. Sister glw v17.305 + scc v13.5005 same-fix. Caught 2026-05-10 by /loop tick 52 cross-stack Permissions-Policy hardening audit.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.97.20</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ›‘οΈ NEW arc-guard <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">scripts/check-per-route-og-image.mjs</code> β€” sister glw v17.205 + scc v13.4905 cross-stack port. Pins T48 + T49 fixes against future regression. Walks <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/</code>, finds every directory with a co-located <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">opengraph-image.tsx</code> file convention, checks the sibling page.tsx for <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">openGraph.images: [...]</code> arrays containing known dead-code patterns: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">DEFAULT_OG_IMAGE</code> literal, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><x>.logoUrl</code> ref, or <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">"/opengraph-image"</code> string literal (homepage path). The bug class: when present, these patterns OVERRIDE Next 16's per-route file convention, making the co-located <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">opengraph-image.tsx</code> dead code (every share-card on Twitter/Facebook/LinkedIn/iMessage renders the wrong image). 0/0 on GW currently (GW uses <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/api/og?title=…</code> for OG generation, not file convention). Wired into <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:per-route-og-image</code> (manual) AND <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:all</code> (chained build-gate). Cross-stack symmetric β€” identical script ports cleanly to all 3 repos. Caught 2026-05-10 by /loop tick 50.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.97.10</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 Explicit seoTitle on /learn/washington-medical-vs-recreational-dispensary-guide β€” sole remaining over-cap /learn page after v2.96.90 deployed (post-deploy exhaustive sweep showed 1/30+ over). deriveSeoTitle can shorten 'Washington State' β†’ 'WA' but cannot strip the descriptive 'What's the Difference?' question β€” needs explicit author override. New seoTitle: 'Medical vs. Recreational Dispensaries in WA' (50 JS / 55 HTML). H1 keeps the full descriptive title for visitors. Closes the GW SSoT title cap arc β€” 0/30+ sampled /learn pages over cap on next deploy.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.90</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 deriveSeoTitle return-shortened-when-shorter (drop suffix-fit gate). Pre-fix checked <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">s.length + 17 <= 60</code> (whole title-with-suffix budget), which returned null for any shortened title that would still need suffix-drop. With v2.96.10's buildPageMetadata auto-suffix-drop logic, the suffix is dropped when title would overflow β€” so deriveSeoTitle's job is just 'make it shorter than the original', let downstream handle suffix-fit. Caught by /loop GW post-v2.96.70 /learn re-sweep β€” 3 remaining /learn pages (PTSD-veterans-and-civilians, tax-savings, medical-vs-recreational-dispensary-guide) had derived titles 55-66 chars that would render perfectly with title.absolute but deriveSeoTitle was throwing them away. Closes the GW SSoT title arc that started v2.96.10. Sister of v2.96.70.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.80</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸŒ— <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">colorScheme: "light"</code> added to viewport export. Pre-fix <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><meta name="color-scheme"></code> was absent, so browsers' auto-dark-mode logic could re-tint form inputs, scrollbars, and default UA UI to dark on systems where the user prefers dark mode. GW patient-intake forms (and the entire patient-portal experience) are explicitly light-themed; auto-dark coerces input bg toward gray, breaking contrast + readability + accessibility on the patient flow. Declaring the supported scheme explicitly opts out of the auto-adjust. Sister glw v16.505 + scc v13.4205 same-fix triple. Caught 2026-05-10 by /loop tick 43 cross-stack color-scheme audit (curl-confirmed all 3 sites missing the meta).</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.70</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 deriveSeoTitle now matches mid-string 'Washington State' too. Pre-fix only the trailing-<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">$</code> regex worked, so titles like 'Renewing Your Washington State Medical Marijuana Authorization' (62 chars) didn't get shortened β€” fell through to article.title and rendered 62 chars (over Google 60-char SERP cap). Caught by /loop GW post-v2.96.30 /learn re-sweep β€” 7/30 sampled /learn pages still over cap. Now matches with <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">\b</code> word-boundary anywhere in the string, plus order more-specific (' in Washington State' β†’ ' in WA') before bare (' Washington State' β†’ ' WA'). Sister of v2.96.10 SSoT auto-suffix-drop arc.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.60</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ“± <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">formatDetection: { telephone, date, address, email: false }</code> added to root layout metadata. iOS Safari auto-formats numeric strings (zip codes, prices, dates, addresses, dollar amounts, RCW statute numbers like <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">69.51A</code>) as tap-callable / tap-mail / tap-date by default β€” long-form medical content becomes peppered with accidental tap-targets. Especially important for HIPAA-aware GW: a customer reading about Medical Marijuana for Chronic Pain shouldn't accidentally dial '69.51A' or '$190' or '2026-05-15' when trying to highlight content. We already use explicit <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><a href="tel:…"></code> for the 1-888-885-9949 line in 3+ hits per page (verified via curl), so disabling auto-detection sitewide is pure UX cleanup with no loss of functionality. Sister glw v16.405 + scc v13.4105 same-tick triple. Caught 2026-05-10 by /loop tick 42 cross-stack format-detection audit (curl-confirmed all 3 sites missing the meta tag pre-fix). Pure additive.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.50</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 Second-level title trim in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildPageMetadata</code> β€” when title.absolute is STILL over 60-char SERP cap (rare edge case from very long condition + city combos like "MMJ Card for ALS (Lou Gehrig's Disease) Β· Bellingham, WA" = 61 chars HTML), drop parentheticals from the title. The alt-name annotation doesn't add SEO value beyond what the primary term conveys. Caught 2026-05-10 by /loop GW post-v2.96.10 sweep β€” 21/22 telehealth pages were resolved by v2.96.10's auto-suffix-drop, 1 remaining (Bellingham + ALS) needed this second-level handling. Sister of glw v15.805 + scc v13.2105 + scc v13.3805 parenthetical-drop pattern.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.40</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ€– <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/llms.txt</code> Content-Type <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">text/plain</code> β†’ <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">text/markdown</code>. The llms.txt spec (https://llmstxt.org/) requires markdown semantics β€” heading hierarchy, bullet lists, hyperlinks β€” and the body emitted from this route uses all three. AI crawlers (ChatGPT, Claude, Perplexity, Grok) that fetch /llms.txt as authoritative brand context prefer <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">text/markdown</code> for proper structure parsing; under <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">text/plain</code> some crawlers strip the markdown semantics and ingest the body as flat prose, losing the structural hints (e.g. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">## Services</code> stops being a section header). Sister glw + scc both serve <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">text/markdown</code> already; GW was the lone outlier across the 6-site stack. Caught 2026-05-10 by /loop tick 41 cross-stack llms.txt MIME audit (curl-verified differences across the stack). Pure 1-line header fix; body unchanged.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.30</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 /learn/[slug] generateMetadata now entity-aware + auto-title.absolute when too long (sister of v2.96.10 buildPageMetadata SSoT fix). /learn/[slug] bypasses buildPageMetadata for article-specific seoTitle/seoDescription handling, so the same fix needed to ship there too. Pre-fix /loop GW exhaustive sweep round-5 caught 7/15 sampled /learn pages over cap (titles 69-100, descs 163-168 β€” apostrophes in 'Crohn's', 'Parkinson's' inflated <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">'</code> β†’ <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">'</code>; long-title articles fell through deriveSeoTitle to article.title which then hit template suffix). Now: entity-aware desc trim with iterative-shorten + auto-suffix-drop title when escaped length exceeds 60. Memory: feedback_html_escape_inflates_meta_description_length.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.96.10</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 Cross-stack title cap fix in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildPageMetadata</code> β€” auto-switch to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">title.absolute</code> when <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">${title} | Green Wellness</code> rendered HTML would exceed Google ~60-char SERP cap. Pre-fix EVERY page in /telehealth/[city]/[condition] (244 pages, 22/25 sampled were over) + /locations/[city]/[condition] matrix (13/25 over) + many /learn pages had 61-103 char titles because the metaTitle template (e.g. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Telehealth MMJ Card for ALS (Lou Gehrig's Disease) Β· Seattle, WA</code>) plus the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"> | Green Wellness</code> template suffix overshot every time. Caught 2026-05-10 by /loop GW exhaustive title sweep β€” 49 over-cap titles in 75 sampled pages. Single-source-of-truth fix in buildPageMetadata means every caller benefits without per-page changes. Also entity-aware (apostrophe in 'Lou Gehrig's' inflates <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">'</code> β†’ <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">'</code>). Memory: feedback_html_escape_inflates_meta_description_length.</span></li><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 Drop <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Telehealth </code> prefix from <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/telehealth/[city]/[condition]</code> metaTitle template (was <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Telehealth MMJ Card for ${condition} Β· ${city}, WA</code>, now <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">MMJ Card for ${condition} Β· ${city}, WA</code>). Saves ~11 chars + lets the condition.name survive Google's 60-char SERP cap when title.absolute kicks in via buildPageMetadata's auto-suffix-drop. URL path /telehealth/[city]/[condition] already conveys the telehealth context.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.95.80</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ›‘οΈ <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">X-Robots-Tag: noindex, nofollow</code> header on /api/:path* responses. Defense-in-depth on top of robots.txt's existing <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Disallow: /api/</code>. robots.txt blocks crawlers from FETCHING /api URLs, but if an API URL gets shared externally (Slack/Twitter unfurls, accidental copy-paste in tweets, email links), Google may still INDEX the URL without crawling it β€” SERP shows the bare URL with "No description available because of robots.txt." That's worst-of-both-worlds: SERP exposure of internal endpoint name + zero description. Header at response level says "even if you DID get here, don't index this." Especially important for HIPAA-aware GW where /api/intake, /api/admin/calendar, /api/admin/patients/export etc. should never appear in SERPs even by URL alone (despite already being auth-gated for the body, the URL itself can still index). Verified missing across all 6 stack sites via curl β€” none currently set this header. Sister glw v16.205 + scc v13.3605 same-fix; cannagent + sureel + vrg follow-up pending. Pure additive β€” no behavior change, no customer-facing effect, just SERP-hygiene defense. Caught 2026-05-10 by /loop tick 38 prod-headers-first methodology audit (lesson from T36 retraction: probe prod headers FIRST, code-search SECOND).</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.95.70</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-blue-700">Changed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-blue-500"></span><span>πŸ“± PWA <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">display: browser</code> β†’ <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">standalone</code> + added <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">scope</code>/<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">orientation</code>/<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">categories</code>. Pre-fix when patients tapped "Add to Home Screen" + launched, the PWA opened in a regular Safari/Chrome tab WITH the URL bar + tab strip β€” defeating the purpose of the home-screen install. For a HIPAA-aware patient surface (telehealth + intake + appointment management) the app-like fullscreen experience is materially better. Sister glw + scc + cannagent + sureel + vrg all already use <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">standalone</code>; GW was the lone outlier across the 6-site stack. Also added explicit <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">scope: "/"</code> (was implicit), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">orientation: "portrait"</code> (matches mobile-first design), and <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">categories: ["medical", "health", "lifestyle"]</code> (helps app-store + chrome-os categorization). Note: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">display: browser</code> was an intentional original choice when GW was email-deep-link-only (every email link wanted to land in the user's browser context with full back-button history); now that /my-appointments + /referral are real patient destinations the standalone PWA UX is correct. Caught 2026-05-10 by /loop tick 35 cross-stack manifest display-mode audit. Sister glw v15.605 + scc v13.3105 same-tick (both retarget the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Order for Pickup</code> PWA shortcut from /menu broken-target to /order real-cart-target).</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.95.60</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-red-700">Removed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-red-400"></span><span>🩹 Dropped <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">WebSite.potentialAction.SearchAction</code> from JSON-LD β€” pre-fix declared <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">urlTemplate: ${SITE_URL}/?q={search_term_string}</code> claiming GW homepage was a search endpoint, but no page on the GW marketing site reads <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">?q=</code> searchParams (no site-search exists). Google's Sitelinks Searchbox feature would have rendered a search box under the GW SERP listing and sent customer queries to a URL the homepage silently ignores β€” false promise. Sister glw v15.305 + scc v13.2905 RETARGETED their SearchAction at <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/order?q=</code> (real search surface β€” OrderMenu reads the query); GW has no equivalent endpoint so we drop the action entirely. Re-add when a real site-search ships. Caught 2026-05-10 by /loop tick 33 SearchAction-target-actually-works audit.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.95.50</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ“° /learn/[slug] declared <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">og:type=article</code> but emitted ZERO article:* meta tags. Pre-fix every learn-article share card on Facebook + LinkedIn rendered as a generic <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">og:type=article</code> block without the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">article:published_time</code> (date label), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">article:section</code> (category pill above title), or <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">article:tag</code> (taxonomic clustering for re-shares) sibling tags. Per OGP spec these are the highest-consumed article-meta fields by share-card unfurlers. Sister glw v15.205 + scc v13.2805 (which add section + tags to /blog/[slug] β€” those repos already had publishedTime but were missing section + tags). GW was the lone outlier missing publishedTime entirely. Articles already carry <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">publishedAt</code> (ISO 8601 string) + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">category</code> fields so the fix is a 3-line metadata addition. Bonus: also added <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">alt: article.title</code> to the og:image (was previously omitted). Caught 2026-05-10 by /loop tick 32 OGP article-spec completeness sweep.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.95.40</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ›‘οΈ NEW arc-guard <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">scripts/check-og-image-shape.mjs</code> β€” sister glw v15.105 + scc v13.2705 cross-stack port. Pins T29 (og:image shape sweep) + T30 (twitter.images alt via buildPageMetadata) against future regression. Walks <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/lib/</code>, finds every <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">images: [...]</code> array inside an <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">openGraph: {}</code> or <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter: {}</code> block (brace-depth-aware reverse-walk skips arrays in unrelated scopes), fails the build if any element is a literal string or template literal. Bare identifiers (e.g. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">DEFAULT_OG_IMAGE</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">OG_IMAGE_URL</code>) are trusted β€” agents already reach for the SoT const, and identifier-typed-as-URL is rare. Sister of v2.94.55 og-completeness + v2.95.20 duplicate-brand-title arc-guards. Wired into <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:og-image-shape</code> (manual) AND <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:all</code> (chained build-gate). 0/0 across 404 files post-fix. Cross-stack symmetric script β€” same file ported cleanly to all 3 repos. /loop tick 31.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.95.30</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ–ΌοΈ Twitter share-card image alt missing across every page using <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildPageMetadata</code> SoT helper. Pre-fix <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/lib/seo.ts:587</code> set <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter.images: [ogUrl]</code> (string) β€” Next 16 emits ONLY <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><meta name="twitter:image"></code> for string form, OMITTING <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><meta name="twitter:image:alt"></code>. Twitter Cards spec recommends image alt for screen-reader accessibility (LinkedIn's accessible-mode + iMessage VoiceOver also read this attribute). Sister of glw v15.005 + scc v13.2605 og:image shape sweep β€” same string-form bug class on the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter.images</code> field instead of <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">openGraph.images</code>. The OG block already had alt (added in earlier sweep) but Twitter dropped it. Fix: changed to object-form <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">[{ url: ogUrl, alt: input.title }]</code> in the helper (cascades to every consumer page) + same fix on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/get-started/page.tsx:61</code> which doesn't use the helper. /loop tick 30 dimension-shift caught it after T29 closed the openGraph.images shape on glw + scc.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.95.20</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ›‘οΈ NEW arc-guard <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">scripts/check-duplicate-brand-title.mjs</code> β€” sister of v2.94.55 og-completeness arc-guard, ports glw v14.905 + scc v13.2505 cross-stack. Pins T5 + T24 fixes against regression: when a page-level <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">metadata.title</code> body bakes in the brand (<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">title: 'Leads β€” Green Wellness'</code>), the layout's <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">title.template = '%s | Green Wellness'</code> appends the brand AGAIN, producing <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><title>Leads β€” Green Wellness | Green Wellness (brand twice in SERPs). Brace-depth-aware regex skips titles inside openGraph/twitter sub-blocks (where brand-in-title is intentional + harmless). Reads brand from src/lib/seo.ts SITE_NAME. First run caught 3 GW offenders the manual T24 sweep had missed: /admin/leads (admin surface β€” Leads β€” Green Wellness β†’ Leads), /provider/training (Provider Training Guide β€” Green Wellness β†’ Provider Training Guide), and /refer/[code] generateMetadata 404 fallback (Referral Code Β· Green Wellness β†’ Referral Code). All 3 now render with brand exactly once via the template's append. Wired into pnpm check:duplicate-brand-title (manual) AND pnpm check:all (chained build-gate). 0/0 across 350 files post-fix.
    v2.95.10
    2026-05-10Production

    Added

    • πŸ›‘οΈ Cross-Origin-Opener-Policy same-origin header β€” isolates GW's browsing context from cross-origin windows opened via window.open(). Protects against Spectre/Meltdown side-channel attacks on shared memory + against cross-origin window.opener manipulation. Especially valuable for HIPAA-adjacent surfaces where patient browsing context shouldn't leak through opened popups. Safe vs Stripe + RingCentral integrations β€” those use iframes (not popups), and COOP only affects window.open() popup browsing contexts. CORP intentionally NOT set (marketing OG images need to be embeddable on share-card crawlers). Cross-stack port from cannagent SECURITY_HEADERS β€” GW + vrg + sureel were the lone 3 sites missing this header across the 6-site marketing stack. Caught 2026-05-10 by /loop cross-stack Cross-Origin-* header presence sweep. Sister vrg + sureel same-class fix shipping in parallel.
    v2.94.95
    2026-05-10Production

    Fixed

    • 🌐 SEO description trim β€” measure HTML-escaped length, not JS string length. Pre-fix v2.94.80 added auto-truncate to telehealth + locations city/condition lib helpers, but the threshold checked description.length > 160 which counts JS code units. Google measures the rendered HTML β€” long condition names with apostrophes + ampersands (Crohn's Disease & IBD, Parkinson's Disease, etc) inflated 9-12 chars under HTML escaping (' + &) so a 158-char JS-trimmed description came out as 167 chars in . v2.94.80 ship verified post-deploy still showed 7/25 telehealth pages over cap. Real fix: rebuilt the trim in buildPageMetadata (single source-of-truth used by EVERY page) to escape-then-measure, iteratively shortening until the escaped length fits under 160. Most pages converge in 1-2 iterations because entity inflation is bounded by the count of escapable chars. Defense-in-depth iteration cap = 5. Sister truncate in the two lib helpers from v2.94.80 stays as a no-op safety net (will get cleaned up in a follow-up).
    v2.94.80
    2026-05-10Production

    Fixed

    • 🌐 SEO meta-description auto-truncation on /telehealth/[city]/[condition] (244 pages) + /locations/[city]/[condition] (city Γ— condition matrix) β€” long condition names (Crohn's Disease, ALS, Epilepsy & Seizures, Parkinson's Disease, Multiple Sclerosis) pushed the templated description past Google's 160-char SERP cap. /loop telehealth random sample 2026-05-10 caught 8/25 over (Crohn's at 167, ALS at 163, Epilepsy at 162, Parkinson's at 163); locations sample caught 6/15 over (same condition class). Auto-truncate at 157+"…" enforced in both lib/telehealth-condition-content.ts and lib/city-condition-content.ts. The condition + city name (SEO-critical) sit at the front of both templates so they always survive truncation; what gets cut is the trailing price line ($X renewals Β· $Y new patients) which is already on every page in 6+ visible places. Same auto-trunc pattern as v2.94.20 /learn fix.
    v2.94.60
    2026-05-10Production

    Fixed

    • 🌐 Duplicate Green Wellness brand in 2 page titles β€” /about rendered as About Green Wellness | Green Wellness (brand twice, 41 chars) and /get-started rendered as Get Started β€” Green Wellness Medical | Green Wellness (brand twice, 53 chars). Both pages baked the brand into their title: body, then the layout's title.template = '%s | Green Wellness' appended it AGAIN. Sister of T5 v2.93.90 telehealth duplicate-suffix fix + the locations-content.ts class. Caught 2026-05-10 by /loop tick 24 GW duplicate-brand-title sweep β€” probed every sitemap URL's rendered for <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Green Wellness</code> count, flagged any with count>1. Fix: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/about</code> body shortened to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">About Us</code> (lets template append brand once, total 16 chars body + brand = clean); <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/get-started</code> switched to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">title.absolute</code> (bypasses template, body = <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Get Started β€” Free Pre-Qualification | Green Wellness</code>, 53 chars total, action-oriented + brand once). Net SERP-friendliness: both titles now ≀53 chars, brand exactly once.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.94.55</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ›‘οΈ NEW arc-guard <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">scripts/check-og-completeness.mjs</code> β€” pins all 7 required openGraph fields (type, locale, siteName, title, description, url, images) against future regression. Sister of glw v14.405 + scc v13.2005 ports. First run on GW caught 3 silent-drop offenders the manual T19-T21 sweeps had missed: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/get-started/page.tsx</code> (locale), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/learn/[slug]/page.tsx</code> (locale + siteName), and crucially <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/lib/seo.ts</code> line 533 β€” the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildPageMetadata</code> helper itself was missing locale. The helper is the source-of-truth for most page metadata on GW (every page that calls <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildPageMetadata({...})</code> inherits its openGraph shape), so the missing locale silently dropped on dozens of /conditions/[slug], /learn, /telehealth/* etc pages. Fix: add <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">locale: "en_US"</code> to the helper's openGraph block + the 2 page-level overrides. Wired into <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:og-completeness</code> (manual) AND <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pnpm check:all</code> (chained build-gate). Now 0/0 across 404 files.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.94.50</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ“± PWA install meta β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">appleWebApp</code> block added to root layout. Pre-fix GW emitted ZERO <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">apple-mobile-web-app-*</code> / <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">mobile-web-app-capable</code> meta tags. iOS "Add to Home Screen" worked (apple-touch-icon + manifest already in place) but the launched app opened with full Safari chrome instead of standalone fullscreen, and the home-screen tile name fell back to the page title (often truncated mid-word on the springboard) instead of the short brand name. New block emits <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">mobile-web-app-capable=yes</code> (the modern platform-neutral form), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">apple-mobile-web-app-status-bar-style=default</code>, and <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">apple-mobile-web-app-title=GreenWellness</code>. 4/6 sites in the stack already had this; GW + vrg were missing. Sister vrg same-class fix shipping separately.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.94.40</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🐦 SEO+a11y β€” added <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">alt</code> export to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/opengraph-image.tsx</code> so og:image:alt is emitted on the homepage. Pre-fix the homepage rendered og:image / og:image:type / og:image:width / og:image:height but NOT og:image:alt β€” Twitter/Facebook crawlers fell back to the filename for screen-reader text. Per Next 16 file-convention docs, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">alt</code> exported from opengraph-image.tsx becomes the og:image:alt meta tag (overriding any layout.tsx openGraph.images[].alt). 5/6 sites in the stack already emit alt; GW alone was missing it because the file convention takes precedence over layout.tsx openGraph.images. Caught by /loop cross-stack OG image dimensions+alt audit 2026-05-10.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.94.30</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>β™Ώ WCAG 2.4.1 β€” added skip-to-content link to root layout. Pre-fix keyboard users had no way to jump past nav + announcement banner; tabbing through the entire header chrome to reach page content. sr-only by default; focus reveals an emerald pill at top-left. Target #main-content matches a wrapper div added around <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">{children}</code>. Sister cannagent v3.297 same WCAG fix. Caught by /loop cross-stack skip-link audit 2026-05-10.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.94.20</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 SEO meta-description auto-truncation on /learn/[slug] β€” sister of v2.94.05 title trim. /learn page bypasses buildPageMetadata which would auto-truncate at 157+"…". 10+ articles serving 162-192 char descriptions (over Google ~160 mobile SERP cap). Patient SERP queries truncated mid-sentence with "…". Added inline trunc: when description >160, slice to 157 + "…". Closes the meta arc started in v2.94.05.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.94.10</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🐦 Twitter/X share-card title bug. /loop tick 8 cross-stack og:title vs twitter:title comparison flagged GW /learn/[slug] (and other pages overriding <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">openGraph.title</code> via <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildPageMetadata</code>) all showing the homepage TITLE constant on Twitter share cards instead of the page-specific title. Pre-fix layout hard-coded <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter.title = TITLE</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter.description = DESCRIPTION</code> and child pages overrode <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">openGraph</code> but never <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter</code>. Example: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/learn/medical-marijuana-chronic-pain-washington-state</code> rendered og:title="Medical Marijuana for Chronic Pain in Washington State | Green Wellness" but twitter:title="Green Wellness β€” Washington Medical Marijuana Evaluations". Fix: drop <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter.title</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">twitter.description</code> from layout. Per Twitter Cards spec + Next 16 metadata cascade, Twitter's crawler falls back to og:title + og:description when twitter:title isn't emitted. Now every page's Twitter card matches its OG card. Sister glw v12.905 + scc v13.605 same-class fix.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.94.05</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 SEO title-length sweep on /learn/[slug] β€” sister of v2.93.95 /conditions fix. ~30 of 34 articles had titles 65-90 chars after <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"> | Green Wellness</code> template suffix appended (e.g. "Can Washington State Medical Marijuana Patients Grow Their Own Cannabis? | Green Wellness" = 89 chars). Patient queries truncated mid-word in SERPs. Added optional <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">seoTitle</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">seoDescription</code> fields to Article type + NEW <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">deriveSeoTitle()</code> helper that auto-shortens common patterns (Medical Marijuana β†’ MMJ, Washington State β†’ WA, drop trailing parens). <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/learn/[slug]</code> page resolves: explicit seoTitle ?? derived ?? raw title. og:title still uses long-form (social cards have more room). One-helper ship fixes ALL 34 articles automatically β€” no per-article seoTitle population required, just the smart derive. Sister cannagent v3.304-v3.306 + glw v11.905 + scc v13.005 + GW v2.93.95 absolute/short-title pattern arc.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.95</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 SEO title-length sweep on /conditions β€” pre-fix 11 condition pages had titles 73-90 chars (e.g. "Medical Marijuana Evaluation for Multiple Sclerosis in Washington State | Green Wellness" = 88 chars), well over Google ~60-char SERP cap. Patient queries truncated mid-word in SERPs. Added optional <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">seoTitle</code> field to ConditionContent type; populated for all 11 conditions with shorter ≀45-char body ("MMJ Card for Chronic Pain in WA" pattern). <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions/[slug]</code> falls back to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">headline</code> when <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">seoTitle</code> unset. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">headline</code> (long-form) kept for h1 + ogTitle (social cards have more room). Index page <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions</code> title also trimmed 61 β†’ 38 chars. Sister of cannagent v3.304-v3.306 + glw v11.905 + scc v13.005.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.90</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 Telehealth city Γ— condition pages had <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">| Green Wellness</code> duplicated in title β€” sister of v2.93.x locations-content.ts fix that swept 5 /locations/* + city-condition templates but missed <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">lib/telehealth-condition-content.ts</code> (a SECOND template with the same baked-in suffix pattern). Pre-fix every page like <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/telehealth/olympia-telehealth/als</code> rendered <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Telehealth MMJ Card for ALS (Lou Gehrig's Disease) Β· Olympia Area, WA | Green Wellness | Green Wellness</code> (108+ chars, brand twice, blown WAY past Google's 60-char SERP cap). Caught 2026-05-10 by /loop tick 5 cross-stack title-length re-audit (10 GW telehealth pages flagged at 100+ chars). Fix: dropped <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"> | Green Wellness</code> from the metaTitle template β€” root layout's <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">title.template = '%s | Green Wellness'</code> already appends it. Net: drops ~17 chars per page across ~60+ city Γ— condition combinations, and Google's mid-string truncation behavior now starts mid-suffix instead of mid-condition-name. Sister discipline of glw v11.105 + scc v12.105 + cannagent v3.304+ title.absolute / template-suffix sweeps. tsc clean.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.85</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ”— Three dead links on the homepage qualifying-conditions section. /loop tick 3 cross-stack internal-link audit (homepage <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><a href></code> β†’ expect 200) caught <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions/sleep-disorders</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions/nausea</code>, and <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions/tbi</code> 404'ing β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Conditions.tsx</code> was rendering ALL 13 entries from the master <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">CONDITIONS</code> list (booking-flow checkbox source-of-truth in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/lib/constants.ts</code>) as <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><Link></code> cards, but only 11 of those IDs have corresponding detail pages under <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions/[slug]</code>. Customers AND Google saw dead links from the highest-crawl-priority page on the site. Fix: introduced <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">IDS_WITH_DETAIL_PAGE</code> allowlist that's source-of-truth-aligned with the slugs in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">conditions-content.ts</code>. Conditions in that set render as <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><Link></code> (clickable, navigates to detail page); conditions outside it render as plain <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><span></code> badges (visually identical, not clickable, no 404). The marketing message β€” "WA recognizes these qualifying conditions" β€” stays complete without 404'ing the click. When detail pages get added for the missing 3 (sister of the v2.93.45 MedicalWebPage wrapper added across the existing 11), graduate the ID from badge β†’ link by adding the slug to the allowlist. Files: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/components/sections/Conditions.tsx</code>. tsc clean.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.80</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🌐 SEO duplicate-content bug on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">flow.greenwellness.org</code>. /loop tick 2 cross-stack canonical/og:url audit found that customer routes (/, /about, /faq, /conditions/*, /learn/*, etc.) all served 200 on BOTH <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">flow.greenwellness.org</code> AND <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">greenwellness.org</code> with <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><link rel=canonical href=greenwellness.org/...></code> pointing at apex. That's a confusing signal β€” Google's canonical-respecting indexing dedupes correctly, but non-canonical-respecting crawlers (some social unfurlers, AI search bots, lower-tier search engines) still index flow.* as duplicate content. Pre-existing <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">FLOW_REDIRECT_TO_APEX=true</code> env var only redirected the bare <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/</code> path on flow.* β€” left every other customer path serving directly. Expanded proxy.ts flow-host logic to 308 ALL non-staff paths to apex (preserving path + query). New <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">isStaffPrefix(p)</code> helper uses <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">=== p || startsWith(p + "/")</code> (NOT bare startsWith) so <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/dispensaries</code> (customer plural) doesn't get accidentally classified as <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/dispensary</code> (staff). Wildcard matcher added so proxy runs on customer routes β€” staff paths use slash-bounded negative lookaheads (<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">admin/</code>, not <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">admin</code>) so plural customer routes match the wildcard. Still gated behind FLOW_REDIRECT_TO_APEX env β€” dormant on prod until Doug flips it. Pre-commit Explore review: identified the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/dispensaries</code> collision (already fixed in this push) + open-redirect concern (verified safe β€” Next normalizes <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">pathname</code> before middleware). tsc clean.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.75</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-blue-700">Changed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-blue-500"></span><span>πŸ§ͺ Test mode β€” Vercel auto-crons disabled (<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">vercel.json.crons = []</code>) for Mariane's QA pass. Manual fires still work (curl with <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Authorization: Bearer $CRON_SECRET</code> or <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">x-vercel-cron: 1</code> header). Dr. Ari (ND) can log in and fill provider availability manually since the weekly <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">slots</code> cron is paused. To restore the 14 cron schedules later, re-add the array to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">vercel.json</code> β€” full snippet preserved in this commit's parent (eab2c3e) <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">git show eab2c3e:vercel.json</code>. Crons paused: reminders, renewals, slots, weekly-digest, no-show, review-request, waitlist, reminders-2h, intake-reminder, daily-briefing, new-patient-drip, doh-nudge, eod-email, rc-webhook-renew. Doug-directed 2026-05-09 PT.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.70</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸš€ Sister of v2.93.60 + v2.93.65 β€” added 3 cache pin entries I missed first pass: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/icon-192.png</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/icon-512.png</code> (route handlers ignore force-static alone for edge cache β€” sister vrg v0.13.2 same pattern) + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/manifest.webmanifest</code> (Next file convention same <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">revalidate</code> no-op as sitemap.ts). Pre-fix all 3 served <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">cache-control: public, max-age=0, must-revalidate</code> despite v2.93.60 cache pin + v2.93.65 force-static export. Now: icon-192/512 24hr, manifest 1hr β€” matches the rest of the PWA cache surface.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.65</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ“± PWA install infrastructure β€” 5 new files: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">app/icon.tsx</code> (32x32), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">app/apple-icon.tsx</code> (180x180), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">app/icon-192.png/route.tsx</code> (192x192 Android Chrome A2HS), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">app/icon-512.png/route.tsx</code> (512x512 Android splash + maskable), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">app/manifest.ts</code> (Web App Manifest). Pre-fix /icon /apple-icon /icon-192.png /icon-512.png /manifest.webmanifest all returned 404 β€” broke PWA install on iOS + Android, fell back to globe icon on Slack/Discord/RSS-reader previews + missing Add-to-Home-Screen branded shortcut for repeat-visit patients hitting /my-appointments / /reschedule / /referral surfaces. Brand palette mirrors OG image: dark navy #0f2744 background + #2d6a4f accent + Georgia serif. force-static export so Vercel emits with proper edge cache (manifest gets next.config.ts pin from v2.93.60). HIPAA-safe: manifest contains zero PHI, just brand identity. Cross-stack port from cannagent + vrg PWA pattern.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.60</div><span class="text-sm text-[#5a7a68]">2026-05-10</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸš€ Edge-cache pin for crawler-facing files. Cross-stack port from cannagent v4.685+v4.705+v4.725 + glw v11.605 + scc v12.605 + sureel + vrg. Next 16 ignores <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">export const revalidate</code> for MetadataRoute file conventions (sitemap.ts, robots.ts) β€” every Googlebot / Bingbot / GPTBot / ClaudeBot crawl + every favicon fetch was hitting Vercel function instead of CDN edge. Pinned <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Cache-Control</code> headers in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">next.config.ts</code> <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">headers()</code> function: sitemap 30min, robots/og 1hr, favicon/icon family 24hr. None of these paths contain PHI β€” sitemap/robots/icon are fully public-marketing surfaces. /llms.txt intentionally excluded from this list β€” it already serves max-age=86400 via in-route headers.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.55</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>🩺 <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">CollectionPage</code> JSON-LD wrapper on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/learn</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions</code> index pages. Sister of <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions/[slug]</code>'s <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">MedicalWebPage</code> wrapper from v2.93.45. Pre-fix both index pages shipped only <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">ItemList</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">BreadcrumbList</code> β€” Google could see the article/condition list but couldn't tell what KIND of page is rendering it. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">CollectionPage</code> is the schema.org standard for index/listing pages; <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">mainEntity</code> references the existing <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">ItemList</code> (added <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">@id</code> to ItemList so the reference resolves) so the two nodes form one connected graph instead of two orphans. Both wrappers include <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">about: MedicalSpecialty=Medical Cannabis Evaluation</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">audience: MedicalAudience=Patient</code> for medical-YMYL signal + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">publisher: #organization</code> IRI link to the layout's MedicalClinic node. Files: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/learn/page.tsx</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/conditions/page.tsx</code>.</span></li></ul></div><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ”— <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">MedicalClinic</code> JSON-LD now emits stable <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">@id: /#organization</code>. Pre-fix the site's other structured-data nodes (MedicalCondition.provider, MedicalWebPage.publisher, CollectionPage.publisher, Article.publisher) all referenced <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">${SITE_URL}/#organization</code> via IRI, but the MedicalClinic node returned by <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildMedicalBusinessLd()</code> had no <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">@id</code> β€” every reference was dangling. Google couldn't merge the publisher/provider nodes into the MedicalClinic entity, so the site's entire structured-data graph rendered as N orphans instead of one connected business entity. Critical for medical-content rich-result eligibility. Found while wiring CollectionPage in this same push. File: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/lib/seo.ts</code>.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.50</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🩹 PascalCase schema.org enum on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">MedicalWebPage.aspect</code>. v2.93.45 shipped the buggy camelCase (<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">symptomsHealthAspect</code> / <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">treatmentsHealthAspect</code>) β€” Explore review caught it pre-commit, but my staged version snapshotted the buggy line and the in-working-dir fix never made it into the commit. Now correct: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">SymptomsHealthAspect</code> / <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">TreatmentsHealthAspect</code>. Lesson: reviewer-flagged fixes need explicit re-stage before commit; <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">git add</code> then edit then <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">git commit</code> captures the staged version, not the working-dir version.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.45</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>🩺 <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">MedicalWebPage</code> JSON-LD wrapper on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions/[slug]</code> β€” sister of <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/learn/[slug]</code>'s existing <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">["Article", "MedicalWebPage"]</code> compound type. Pre-fix the 11 condition pages shipped only the bare <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">MedicalCondition</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">FAQPage</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">BreadcrumbList</code> nodes β€” Google can SEE the condition info but can't tell what KIND of page is rendering it. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">MedicalWebPage</code> is the schema.org type Google's medical-YMYL ranking weighs explicitly (added to schema.org in 2014 for exactly this case). New <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">buildConditionWebPageLd()</code> helper in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/lib/seo.ts</code> returns a node with <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">@type=MedicalWebPage</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">aspect=[symptomsHealthAspect, treatmentsHealthAspect]</code> (per the WebpageAudienceType enumeration), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">about</code> referencing the existing <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">#condition</code> IRI, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">mainContentOfPage</code> linking the same node, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">audience=Patient</code>, and <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">publisher</code> linking the org IRI β€” so the four nodes (MedicalWebPage / MedicalCondition / FAQPage / BreadcrumbList) form one connected graph instead of four orphans. Wired into <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/conditions/[slug]/page.tsx</code>. Should bump rich-result eligibility for symptom + treatment query intents on the qualifying-condition pages.</span></li><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/.well-known/security.txt</code> (RFC 9116) β€” sister-port of glw + scc. HIPAA-aware: contact field with PHI-routing note (<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">mark "PHI" in subject</code> so the 60-day breach-clock per 45 CFR Β§ 164.404 starts triaged), Expires 2027-05-10, out-of-scope list calls out third-party platforms (Practice Fusion, Salesforce, RingCentral, Resend, doxy.me, Stripe, Clerk, Vercel) so reporters know to disclose to the vendor. File at <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">public/.well-known/security.txt</code>.</span></li><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/.well-known/change-password</code> (W3C webappsec-change-password-url) β€” sister-port of glw + scc. Modern browsers (Safari Keychain, Chrome) probe this URL to offer "Change weak password" suggestions; pre-fix it 404'd β†’ broken UX. GW has 3 separate auth surfaces (patient/provider/admin) so the redirect target needs picking β€” landed on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/patient/login</code> since patient is the largest cohort and the only group reachable from public credential-stuffing leaks. Forgot-password is an in-page mode toggle on the login form, not a standalone route. File at <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/.well-known/change-password/route.ts</code>.</span></li></ul></div><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ“ Meta description length β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions</code> trimmed 195 β†’ 142 chars + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/faq</code> trimmed 200 β†’ 148 chars. Both were truncating mid-list on Pixel-class viewport SERPs. Sister of v2.93.25's title-tag length sweep (3 pages under 60-char SERP cap) but for description tags. Files: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/conditions/page.tsx</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/faq/page.tsx</code>.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.40</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸšͺ /providers redirect actually fires now. v2.93.30 deleted the public pages + added a 308 redirect to / inside proxy.ts, but the export const config.matcher didn'''t include /providers, so proxy never ran on those paths and Next fell through to a 404. Verified live: curl https://greenwellness.org/providers returned 404 instead of 308. Fix: add /providers + /providers/:path* to matcher list. Sister-of: any future redirect added to proxy.ts must also add the corresponding matcher entry β€” proxy doesn'''t auto-match every path. Memory recipe candidate.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.35</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>πŸ“₯ <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/admin/leads</code> β€” work-the-queue surface for inbound web leads. Reads <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">LEAD_CAPTURED</code> + new <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">LEAD_CONTACTED</code> rows from <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">AuditLog</code> (last 30 days) in one query, parses the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/api/leads</code>-written detail string (<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">sf=… contact=… reason_len=N firstName=… lastName=… email=… phone=…</code>) and renders a table with All / New / Contacted filter chips, today-count + 30d-total, and a <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Mark contacted</code> button per row. Mark-contacted writes a <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">LEAD_CONTACTED</code> audit row whose <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">resourceId</code> points at the original capture row, so the page can pair them up cheaply. **No outbound notification** β€” Salesforce already runs auto-responses on every lead and Doug 2026-05-09 explicitly does NOT want Flow side double-touching the lead while SF stays primary CRM this week. Pure queue-management surface so customer service can work leads in Flow + so the EOD email's <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">LEAD_CAPTURED</code> count graduates from "happens in SF" to a number Doug can drill into. ADMIN/MANAGER gated; HIPAA-adjacent fine-print + audit-log link in the page footer. Files: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/admin/leads/page.tsx</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/admin/leads/MarkContactedButton.tsx</code> (client component, idempotent POST + router.refresh on success), <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/api/admin/leads/[leadAuditId]/mark-contacted/route.ts</code> (verifies admin session, blocks duplicate <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">LEAD_CONTACTED</code> rows with 409, audits with the staff member's name from the session). New <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">LEAD_CONTACTED</code> action added to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">AuditAction</code> union in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/lib/audit.ts</code>. AdminNav gets a "Leads" entry under the Patients link with a <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Inbox</code> icon; MANAGER allow-list updated.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.30</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-red-700">Removed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-red-400"></span><span>πŸšͺ <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/providers</code> index + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/providers/[slug]</code> detail pages removed per Doug 2026-05-09 directive ("take the providers page off greenwellness"). Files deleted: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/providers/page.tsx</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/providers/loading.tsx</code>, <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/app/providers/[slug]/page.tsx</code>. Reference sweep: sitemap.ts drops both /providers static entry + providerEntries (per-provider URLs) + the unused <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">db.provider.findMany</code> fetch + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">toProviderSlug</code> import. SiteNav.tsx drops the "Providers" nav link. HomeContent.tsx drops the <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><Physicians /></code> section render + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">Physicians</code> import + the "Our Providers" link in the row of homepage anchors. proxy.ts now 308-redirects <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/providers</code> + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/providers/[slug]</code> to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/</code> so existing SERP entries + bookmarks land on the homepage instead of 404. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/admin/providers</code> (the internal admin tool for managing providers) is unaffected β€” it's a separate surface, never customer-facing. **Note for the next agent:** <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/components/sections/Physicians.tsx</code> still exists in the repo but is now unused; left in place rather than deleted in case Doug wants to repurpose it later. Also retained: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">src/api/public/providers/route.ts</code> (still callable; safe to remove if no internal consumer; defer to Doug). Tsc clean against source (Next.js .next/types/validator cache flags the deleted routes β€” regenerates on next build, ignored).</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.25</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-blue-700">Changed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-blue-500"></span><span>🌐 Title-tag length sweep β€” 3 page titles trimmed under Google's ~60-char SERP cap (auto-appends <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"> | Green Wellness</code> ~17 chars). <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/pricing</code> (63β†’47): "Medical Marijuana Card Cost β€” Washington State" β†’ "Medical Marijuana Card Cost β€” WA". <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/conditions</code> (80β†’47): "Qualifying Conditions for Medical Marijuana in Washington State" β†’ "Qualifying Conditions β€” WA Medical Marijuana". <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/locations</code> (89β†’44): "Green Wellness Locations β€” Medical Marijuana Clinics in Washington State" β†’ "Locations β€” WA Medical Marijuana Clinics". Sister of glw v11.105 + scc v12.105 cross-stack title sweep. Caught by /loop saturation grind 2026-05-09 cross-stack title-length audit.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.20</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-blue-700">Changed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-blue-500"></span><span>🩺 Cron-schedule jitter β€” spread 4-cron herd at <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">0 16 * * *</code> to :03 renewals + :09 intake-reminder + :17 new-patient-drip + :24 doh-nudge. Pre-fix all 4 fired at exact same minute against the same Neon DB + Resend (renewals + intake-reminder both send patient SMS, doh-nudge + new-patient-drip both send email). Sister of inv v375.805 cross-repo cron-jitter sweep (which spread 21 crons across 4 herds). Per CronCreate doc: pick a minute NOT 0 or 30. No semantic change β€” daily cadence preserved, all stale-actor math still holds.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.15</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-blue-700">Changed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-blue-500"></span><span>πŸ”’ Permissions-Policy adds <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">interest-cohort=()</code> (FLoC/Topics opt-out) β€” HIPAA-relevant since patient browsing on telehealth pages shouldn't be aggregated into ad-cohort signals. Pre-fix: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">camera=(), microphone=(), geolocation=()</code> only. Post-fix: adds <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">interest-cohort=()</code> for parity with Sureel + cannagent + glw + scc + (now) VRG cross-stack pattern. GW was the lone outlier across the 6-site stack curl audit (caught by /loop saturation grind 2026-05-09 cross-stack security-headers comparison).</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.10</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ›‘οΈ Invalid-date RangeError guard on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/admin/promo-codes</code> Add Code form β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><input type="date"></code> field could produce a truthy non-empty string that <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">new Date(x)</code> parses to Invalid Date, and <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">.toISOString()</code> on Invalid Date throws RangeError. Pre-fix throw bubbled out of click handler β†’ React error boundary β†’ admin Add-Code panel tanked mid-create. Post-fix: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">isNaN(parsed.getTime())</code> guard before serializing, with inline error 'Pick a valid expiry date (or leave blank for never).' Sister of inv v365.005 client-side date-guard fix. GW lone outlier from cross-repo audit (glw + scc + Sureel + cannagent all clean on this pattern).</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.93.05</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-blue-700">Changed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-blue-500"></span><span>πŸš€ sitemap.xml + robots.txt CDN cache (cross-repo port of inv v342.605 + v342.405 OG cache). Pre-fix both endpoints served <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">cache-control: public, max-age=0, must-revalidate</code> (Next.js default for metadata routes) β†’ every Google/Bing/AI-bot crawl re-rendered. Sitemap pulls from DB (providers, articles via publishedAt) + multiple constants β€” 30-min revalidate balances freshness vs cache benefit. Robots is fully static β€” 1-hour revalidate. Added <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">export const revalidate = 1800</code> (sitemap) + <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">revalidate = 3600</code> (robots). Sister of inv v342.605 + glw v8.165 + scc v9.385 cross-repo CDN-cache sweep.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.92.25</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>πŸ›‘οΈ HIPAA-bearing canonical-URL allow-list defense β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">lib/app-url.ts</code> <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">CANONICAL_APP_URL</code> was using deny-list-only <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">.vercel.app</code> rejection. Same vulnerability that broke STAFF_APP_URL on inv prod for 24h: env was set to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">app.{store}</code> (404'd subdomain), passed the deny-list check, every canonical/sitemap/email-deeplink URL pointed at a 404. Worse blast radius here: HIPAA-bearing email deep-links (cancel/reschedule/referral) landing patients on a wrong/dead URL is a privacy concern. Now allow-list requires <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">hostname ∈ {flow.greenwellness.org, greenwellness.org, www.greenwellness.org}</code>. Defense-in-depth β€” even if Doug-action env-var fix is missed, code falls through to the canonical fallback. Sister of inv v337.005 + v338.005 + v340.605 + v340.805 + glw v8.145 + scc v9.365 cross-repo allow-list defense sweep. tsc clean.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.92.05</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-blue-700">Changed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-blue-500"></span><span>πŸ“Š EOD email reframed for soft-launch posture (Doug 2026-05-09: 'send me a email at end of day, how many new leads, how many calls, etc'). <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/api/cron/eod-email</code> now leads with **lead-pipeline + booking + call counts** instead of staff-productivity. (1) Added <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">LEAD_CAPTURED</code> audit-log count + Appointment count for the day. (2) Header subline now: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">N new leads Β· M bookings Β· X↓ Y↑ calls Β· Z voicemail Β· …staff (when present)</code>. (3) 3-tile header reworked: New leads / Bookings / Calls (replaces Total actions / Active staff / Top performer). Top-performer card moves below as a smaller surface, only renders when staff actions exist. (4) Skip-gate now checks ALL signals (logs + leads + bookings + calls); pre-fix the cron skipped on 'no staff activity' which meant Doug got NO email on soft-launch days with leads but zero clinic activity. (5) Subject line leads with leads + bookings counts; falls back to staff productivity language only when zero leads/bookings. (6) Heartbeat now records <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">leads=N bookings=M calls=X actions=Y</code> for cross-day trend. <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">ADMIN_NOTIFY_EMAIL</code> set to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">doug@greenwellness.org</code> on Vercel β€” Doug gets the daily 5pm PT recap directly. tsc clean.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.91.65</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🚨 v2.91.45 theme-color was a no-op β€” Next.js 14+ moved <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">themeColor</code> out of <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">metadata</code> to a separate <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">viewport</code> export, so setting <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">metadata.themeColor</code> silently did nothing. Caught in post-deploy verification: <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><meta name="theme-color"></code> was empty in rendered HTML despite v2.91.45 declaring <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">themeColor: "#0f2744"</code> in the metadata object. Fixed: moved to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">export const viewport: Viewport = { themeColor: "#0f2744" }</code>. Comment doc updated. Mobile Chrome / Safari now paint the address bar with GW brand navy. tsc clean.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.91.55</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>🚨 **UX gap caught: homepage had ZERO links to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/get-started</code>.** The new soft-launch lead-capture page shipped in v2.88.05 + verified live at apex post-cutover, but the homepage hero only exposed 'Book my appointment' (opens full booking wizard) + the phone number. No path for visitors who prefer 'request a callback within 1 business day' over committing to a 5-step wizard. Soft-launch traffic landing on <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/</code> would never discover the gentler entry. **Fix:** added a 'Request a callback' secondary CTA to the homepage Hero next to 'Book my appointment', linking to <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/get-started</code>. Equal-weight visual treatment (white-outline button, same height + spacing as the phone-number link). Comment doc explains why the lane matters pre-BAA. Caught while auditing internal-link discovery as part of the SEO cleanup pass β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">grep -oE 'href="/get-started'</code> on rendered homepage HTML returned zero matches before this fix. tsc clean.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.91.45</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-[#2d6a4f]">Added</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#2d6a4f]"></span><span>🎨 Mobile chrome theme-color β€” <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><meta name="theme-color" content="#0f2744"></code> (GW brand navy) renders site-wide via root layout's <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">metadata.themeColor</code>. Mobile Chrome + Safari paint the browser address bar with this color when the site loads, giving a polished branded chrome on phones. Sister pattern to VRG + Sureel landing pages. Same value for light + dark mode (no dark-mode UI to swap to). Trivial-but-noticeable mobile-first polish for soft-launch traffic.</span></li></ul></div></div></div><div><div class="flex items-center gap-3 mb-4"><div class="flex items-center gap-2 px-3 py-1.5 rounded-full border font-mono text-sm font-semibold bg-white border-[#dde6e0] text-[#0f2744]">v<!-- -->2.91.35</div><span class="text-sm text-[#5a7a68]">2026-05-09</span><span class="text-[10px] uppercase tracking-wide font-semibold text-[#7fa98f] bg-[#eef5f1] px-2 py-0.5 rounded-full">Production</span></div><div class="bg-white rounded-2xl border border-[#dde6e0] divide-y divide-[#f0f0ec]"><div class="px-5 py-4"><p class="text-xs font-semibold uppercase tracking-wide mb-2.5 text-amber-700">Fixed</p><ul class="space-y-1.5"><li class="flex items-start gap-2.5 text-sm text-[#3a3a3a]"><span class="mt-1.5 h-1.5 w-1.5 rounded-full flex-shrink-0 bg-amber-500"></span><span>🚨 **Real bug: 5 page titles had 'Green Wellness' duplicated** because the per-page <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">metaTitle</code> baked in <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">| Green Wellness</code> and the root layout's <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">title.template</code> (<code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">%s | Green Wellness</code>) appends it again. Pre-fix <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono">/locations/spokane</code> rendered <code class="text-[11px] bg-[#f0f0ec] px-1.5 py-0.5 rounded font-mono"><title>Medical Marijuana Card Spokane, WA | Green Wellness β€” Same-Day Authorization | Green Wellness (93 chars, brand twice). Sister bug on /locations/lynnwood, /locations/olympia, /locations/vancouver, plus the city-condition-content.ts template that's used for ~43 city Γ— condition pages. **Fix:** stripped | Green Wellness from per-page metaTitle in lib/locations-content.ts (4 fields) + the city-condition template (1 line). Layout template now appends the brand once, no duplication. Net char savings: 17 chars per affected title (e.g. Spokane goes from 93 β†’ 76). Still some titles over Google's 60-char SERP truncation budget on long cities Γ— long conditions β€” that's a separate tightening pass deferred for now since the headline keywords still appear in the leftmost portion. tsc clean.
    v2.91.25
    2026-05-09Production

    Added

    • 🩺 SEO post-launch β€” MedicalCondition JSON-LD on all 11 /conditions/[slug] pages. Pre-fix the pages rendered only FAQPage + BreadcrumbList schema β€” Google had no signal that the page was ABOUT a specific medical condition. Now ships a MedicalCondition node per slug (chronic-pain / ptsd / anxiety / cancer / multiple-sclerosis / epilepsy / crohns-disease / glaucoma / parkinsons-disease / hiv-aids / als) with: name, description, audience: MedicalAudience{Patient}, relevantSpecialty: MedicalSpecialty{Medical Cannabis Evaluation}, possibleTreatment: MedicalTherapy (the WA medical-cannabis evaluation linked to /get-started, with provider @id-linked to homepage MedicalClinic β€” no duplicate node). YMYL signal Google weighs for medical-content rich-result eligibility. NEW buildMedicalConditionLd() helper in lib/seo.ts (sister of buildArticleLd / buildPhysicianLd / buildLocationLd) β€” comment doc explains why possibleTreatment is honest-by-design (an evaluation IS the therapy in our model; we never claim cannabis cures the condition). tsc clean.
    v2.91.15
    2026-05-09Production

    Added

    • 🌐 SEO post-launch β€” sitemap submitted across platforms. Both Google's /ping?sitemap= (deprecated 2023) and Bing's equivalent (returned 410 today) are dead, so anonymous one-shot submission isn't an option anymore. Switched to the IndexNow protocol β€” one POST fans out to Bing + Yandex + Naver + Seznam + Mojeek (the search engines that participate). Setup: (1) generated 32-char hex key, (2) wrote public/.txt containing the key (search engines fetch this to verify domain ownership before honoring submissions), (3) shipped scripts/ping-indexnow.mjs reading the sitemap + POSTing to api.indexnow.org/indexnow with the URL list, with a key-file reachability pre-check that fails fast if the file 404s. Re-runnable anytime β€” pass URLs as args to submit specific pages, or omit args to submit the full sitemap (currently 356 URLs). Run via node scripts/ping-indexnow.mjs. Doug-action remaining for the holdouts: Google Search Console + Bing Webmaster Tools verification (both require dashboard auth).
    • πŸ›‘οΈ Search engine verification meta-tag scaffold in root layout β€” metadata.verification.google for Search Console, msvalidate.01 for Bing Webmaster Tools, yandex-verification for Yandex. All env-gated (GOOGLE_SITE_VERIFICATION / BING_SITE_VERIFICATION / YANDEX_VERIFICATION) β€” empty env renders no tag (Next.js handles this cleanly via undefined-skip). When Doug pastes a verification token from any of those dashboards, the meta tag appears site-wide on next deploy. .env.example updated with each var + the dashboard-URL recipe.
    v2.91.05
    2026-05-09Production

    Added

    • πŸš€ **APEX DNS CUTOVER COMPLETE β€” https://greenwellness.org is LIVE on Vercel.** The 8-year WordPress era ends; soft-launch posture begins. **Sequence executed:** (1) Backed up pre-cutover GoDaddy DNS state to /tmp/gw_dns_backup_20260509_084745.json (rollback insurance). (2) Verified MX records intact (Proofpoint β†’ M365 chain) β€” must NOT touch since GW email delivery rides on them. (3) Set 3 soft-launch env vars on Vercel production: NEXT_PUBLIC_MANUAL_CALLBACK_MODE=true (booking-confirmation copy says 'we'll call you back' instead of automated-email), PAYMENT_DEFERRED=true (server-side β€” /api/appointments accepts bookings without Stripe), NEXT_PUBLIC_PAYMENT_DEFERRED=true (client-side β€” StepPayment skips Stripe Elements). (4) Triggered production redeploy. (5) Added greenwellness.org + www.greenwellness.org as custom domains on the green-wellness Vercel project (only flow.* was attached pre-cutover). (6) Flipped apex A record at GoDaddy via API: PUT /v1/domains/greenwellness.org/records/A/@ body [{"data":"76.76.21.21","ttl":600}] (Vercel's apex IP, 10-min TTL for fast rollback). (7) DNS propagated to public resolvers within ~5min. (8) Vercel auto-issued apex + www SSL certs via DNS-01 (no manual vercel certs issue needed β€” clean issuance). (9) Updated NEXT_PUBLIC_APP_URL from https://flow.greenwellness.org to https://greenwellness.org so emails / OG images / sitemaps / internal links all use the new canonical. (10) Verified all critical paths: /api/health 200 + sha matches, /get-started 200, /sitemap.xml 356 entries, /llms.txt references apex everywhere, /pricing 200, homepage 200. **What's deferred for later (not launch-blocking):** FLOW_REDIRECT_TO_APEX=true flip to make flow.* staff-only entry β€” kept open as alternate access during validation period. Postmark/SES BAA β†’ flips off MANUAL_CALLBACK_MODE. Stripe live keys + webhook β†’ flips off PAYMENT_DEFERRED. Twilio HIPAA BAA + A2P 10DLC β†’ enables SMS reminders. Anthropic Enterprise BAA β†’ enables AI Drafts. **Rollback recipe in LIVE.md** if anything goes red. **9-day arc**: 2026-05-01 first GW commit shipped β†’ 2026-05-09 apex live.
    v2.90.15
    2026-05-09Production

    Changed

    • ✍️ Soft-launch copy polish across the lead-capture + booking-confirmation surfaces patients see during the manual-callback window. get-started/page.tsx β€” reassurance tile #2 reframed ("Same-week appointments" β†’ "Usually within the week", lede leads with speed not the callback mechanic) and tile #3 sharpened ("No automated approvals" β†’ "Every evaluation is done by a person, not a form"). get-started/LeadForm.tsx β€” disclaimer dropped the weasel-hedge "We'll never share" for the more accurate "Your information stays with Green Wellness"; success-state body tightened ("A member of the Green Wellness team will reach out" β†’ "Someone from our team will call") and "need us sooner?" reads warmer than "if you need us sooner". Reason placeholder went from "with our doctor" (singular, presumes assignment) to "with the provider" (matches our actual provider language). StepConfirmation.tsx MANUAL_CALLBACK_MODE branch β€” "A staff member will call ... within 24 hours" β†’ "We'll call ... within 1 business day" (matches /get-started copy + matches what staff actually commits to); telehealth-link line untangled from the awkward "when staff calls to confirm" subordinate clause; share-via-text body de-dispensary-fied ("Check it out" β†’ patient-voice). TrustBar.tsx β€” privacy footnote restructured so the link label leads ("What we collect and how it's protected: HIPAA Notice...") rather than burying the value behind "See our...". faq-data.ts β€” 6 answers tightened: Q1 hedge-fix ("we encourage you to book" β†’ "book a visit"), Q4 mealiness-fix ("anyone who believes they could benefit" β†’ "anyone who thinks medical cannabis might help"), Q12 "potentially" hedge cut (the tax exemption IS real per HB 1453), Q13 added the actual RCW citation (69.51A) where there was just "RCW laws", Q15 bureaucratic verb cut ("is designed to issue" β†’ "ends with your written authorization in hand"), Q16 lede inverted ("We believe in transparent pricing" β†’ "Because you should know what something costs before you book it"). HowItWorks.tsx β€” step 4 description rewritten so the optional-but-useful framing leads with the patient benefit ("raises your possession limits and adds legal protections") rather than the registry mechanic. No regulatory citations, pricing, schema.org strings, or hardcoded contact constants changed; tsc clean.
    v2.90.05
    2026-05-09Production

    Added

    • 🌐 SEO β€” /get-started Service JSON-LD for SERP rich-result eligibility. Pre-fix the page only had BreadcrumbList (single schema, no service description) β€” Google would treat it as a generic page rather than a medical-evaluation lead-capture surface. Sister-pattern to /telehealth (MedicalTherapy + MedicalClinic) and /pricing (OfferCatalog). Added Service schema with: name='Washington State Medical Marijuana Evaluation', areaServed=Washington, provider=existing MedicalClinic node (@id-linked, no duplication), potentialAction=ReserveAction with urlTemplate + result=Reservation. Tells Google + AI crawlers this page IS the canonical 'request a medical evaluation' surface for the service. Audit baseline: homepage = 12 schemas, /telehealth = 10, /pricing = 10, /get-started now = 5 (Breadcrumb + Service nodes). tsc clean.
    v2.89.95
    2026-05-09Production

    Fixed

    • 🌐 SEO go-live audit round 2 β€” 2 real fixes from h1 + canonical sweep across sitemap. **Fix #1 β€” /dispensaries had ZERO h1**: only an h2 'Partner Dispensary Directory' inside section component. Page-with-zero-h1 = soft-404 signal to Google + screen-reader landmark gap. Directory is single-consumer (only src/app/dispensaries/page.tsx per grep), safely bumped h2 β†’ h1. **Fix #2 β€” /my-appointments was in sitemap BUT layout sets noindex AND robots.txt Disallows it** β€” three-layer signal mismatch. Sitemap-vs-noindex inconsistency signals 'thin content' to Google + wastes crawl budget. Page is PHI-adjacent (patient magic-link login + token portal) so noindex is correct; sitemap entry was the bug. Removed from sitemap, kept noindex + Disallow as canonical signals. tsc clean.
    v2.89.75
    2026-05-09Production

    Fixed

    • 🩺 /api/og route now honors &kicker= query param. Pre-fix: the parallel session that shipped v2.88.05's /get-started page added &kicker=Washington Medical Marijuana Evaluations to its OG URL builder, expecting that to replace the hardcoded 'Washington State' label next to the brand mark. The route never read the param β€” it was silently ignored on every social-share render. So the new lead-capture page's social-preview card showed the generic 'Washington State' kicker instead of the contextual 'Washington Medical Marijuana Evaluations' message. Now reads searchParams.get("kicker") with MAX_KICKER = 60 cap (DOS defense) and a fallback to the original 'Washington State' string. tsc clean.
    v2.89.65
    2026-05-09Production

    Added

    • 🚦 Post-cutover host redirect β€” flow.greenwellness.org/ β†’ https://greenwellness.org/admin/login 308 (Doug 2026-05-09: 'flow should go to the admin login page'). Env-gated behind FLOW_REDIRECT_TO_APEX=true so it stays OFF pre-DNS-cutover (apex still on Sucuri/WordPress, would 404). Once Doug flips DNS apex β†’ Vercel + sets the env var, flow.* root collapses to apex/admin/login. Internal /admin/* paths on flow.* still pass through unchanged so deep-linked admin pages keep working without a hop. **Doug-action sequence post-cutover:** (1) flip apex DNS A record β†’ Vercel 76.76.21.21; (2) verify SSL auto-issue + apex serves the new app; (3) set Vercel env FLOW_REDIRECT_TO_APEX=true + redeploy; (4) verify curl -I https://flow.greenwellness.org/ returns 308 β†’ https://greenwellness.org/admin/login.
    v2.89.25
    2026-05-09Production

    Fixed

    • 🚨 **REAL BUG: legacy WordPress traffic landed on homepage but booking wizard never opened.** BookingParamHandler (the homepage's auto-open-wizard hook) checked only searchParams.get("book") === "true", but the 8 next.config.ts legacy redirects (/book-now, /intake-form, /renewal-patients, /new-patient, /new-patient/intake, /new-patient/intake2, /new-patient/renewal-patients, and previously /get-started) all 308 β†’ /?book=1 (with 1, not true). So someone clicking a Google-indexed /book-now link landed on / with the booking wizard CLOSED β€” patients had to find the CTA themselves after the 308. The legacy redirects shipped in v2.73.20 (sitemap preservation) but the handler was never extended to accept the normalized =1 value. Fix: BookingParamHandler now accepts BOTH =true (SiteNav-emitted canonical) AND =1 (legacy redirect target). Comment documents the gotcha. tsc clean.
    v2.89.15
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ PHI-leak hardening β€” lib/practicefusion.ts createPatient() + createFhirAppointment() boundaries (sister of v2.89.05 SF lift). Both functions threw new Error(\... ${await res.text()}\) embedding the raw PF API response body in the thrown error's message. PF (FHIR R4 EMR) error envelopes echo patient demographics back ('Patient.name.family must be set: "Doe"'). Same threat model as the SF fix β€” any caller that logs err.message or err.stack leaks PHI. Now both throw with status=${res.status} only and drain the response. Closes the await res.text() in throw-message pattern across the entire repo (zero remaining sites). tsc clean.
    v2.89.05
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ PHI-leak hardening β€” lib/salesforce.ts createLead() boundary. Pre-fix: when the SF API returned non-OK, the helper threw new Error(\Salesforce Lead creation failed: ${await res.text()}\) β€” embedding the raw SF response body in the thrown error's message. SF error envelopes routinely echo input field values back ('FirstName must not be empty: Jane Doe'), so any caller that logs err.message or err.stack would leak Lead PII. Both current callers (api/leads, api/integrations/salesforce) safely use err.name only, but defending at the boundary closes the class for any future caller. Now drains the response (releases connection) and throws with status=${res.status} only. Sister of the v2.83.75 round 4+5 + v2.86.35 round 7 PHI-leak hardening sweeps; same shape β€” keep raw vendor-error bodies out of any object that might get logged. tsc clean.
    v2.88.95
    2026-05-09Production

    Fixed

    • 🩺 /admin/audit-log label + color maps β€” added LEAD_CAPTURED (emerald, 'Captured web lead') so the new v2.88.45 audit rows render with a label instead of the raw enum string. Sister-fixed 3 pre-existing gaps caught while there: CRON_HEARTBEAT (neutral stone β€” lots of these fire so shouldn't compete with PHI-rows), GBP_OAUTH_CONNECTED / GBP_OAUTH_DISCONNECTED (sky-blue β€” low-frequency operational event). Each entry has a documenting one-line comment. The audit-log filter dropdown also picks up these actions automatically (it reads from Object.keys(ACTION_LABELS) at line 332). tsc clean.
    v2.88.85
    2026-05-09Production

    Added

    • πŸ₯‡ SEO completeness sweep on /get-started (sister of v2.86.25 BreadcrumbList sweep): (1) BreadcrumbList JSON-LD added β€” was the one public marketing page without it (the v2.86.25 sweep ran 2 days before /get-started landed in v2.88.05). 2-level breadcrumb (Home β€Ί Get Started). (2) Sitemap entry added β€” was unlisted, so Google wouldn't have discovered it organically; now priority: 0.95 (just under the homepage's 1.0, since /get-started is the soft-launch entry CTA). (3) llms.txt extended with a 'request a callback (no booking commitment)' line pointing AI search engines (ChatGPT/Claude/Perplexity) at /get-started as the gentler entry path alongside /?book=true. tsc clean.
    v2.88.75
    2026-05-09Production

    Fixed

    • 🚨 **Real customer-facing copy bug: /get-started claimed in-person appointments are available in Wenatchee.** Green Wellness has NO Wenatchee clinic β€” the in-person locations are Lynnwood, Spokane Valley, Olympia, and Vancouver, WA. Wenatchee IS a TELEHEALTH service area (cross-Cascades patients can do video visits from anywhere in WA) AND a dispensary-directory entry (where patients can buy after getting their card), but is NOT a GW clinic. The parallel session that shipped v2.88.05's /get-started Web-to-Lead landing crossed wires with the GreenLife Cannabis project (Wenatchee-based β€” sibling repo under /CODE/). A patient driving from Wenatchee for an in-person visit would have arrived to no clinic. Now reads 'In-person also available at our Lynnwood, Spokane Valley, Olympia, and Vancouver clinics.' Cross-checked all other Wenatchee references in src/ β€” telehealth-cities.ts and dispensaries.ts entries are correct in their context; only the /get-started copy was the bug. tsc clean.
    v2.88.65
    2026-05-09Production

    Fixed

    • 🌐 SEO go-live audit β€” caught 1 unported WordPress URL: /intake (bare, no token) returned 404 on the new site. WordPress had /intake/ as an entry-point CTA per the Rank Math sitemap; on the new app src/app/intake/ exists but only as a layout + [token] dynamic route (post-booking patient intake form) β€” bare /intake had no page.tsx. WP-era inbound links + Google-indexed entries were dead-ending in 404 instead of converting. Added /intake β†’ /get-started redirect (the soft-launch lead-capture page is the right next step for 'I want to start an evaluation' intent). Once the booking platform graduates from test, retarget to /?book=1. Audit recipe: for path in $(curl -fsS https://greenwellness.org/page-sitemap.xml | grep -oE '[^<]+' | sed 's|||;s|https://greenwellness.org||'); do clean=$(echo $path | sed 's|/$||'); status=$(curl -sI https://flow.greenwellness.org$clean | head -1 | awk '{print $2}'); [ "$status" = "404" ] && echo "404 $clean"; done β€” flag any WP URL that 404s on the new site. With this fix all 30 WP-sitemap URLs port cleanly to flow + will work post-DNS-cutover. Sister checks confirmed clean: 79/80 redirect sources resolve (1 false-positive was a /(.*) regex), 356/356 sitemap URLs return 200/308. tsc clean.
    v2.88.45
    2026-05-09Production

    Fixed

    • 🚨 **REAL BUG (latent in v2.88.05): /api/leads would silently LOSE leads if Salesforce was down.** Pre-fix: SF-push success path only console.log'd the sfId; SF-failure path only console.error'd the error class β€” neither persisted the lead anywhere queryable by staff. Effect: if Salesforce was down for an hour during soft-launch traffic, every lead in that window would be unrecoverable (Vercel logs aren't a reconciliation surface). Now every successful POST writes an AuditLog row with action LEAD_CAPTURED REGARDLESS of SF outcome β€” sf=ok|down|skipped, plus name/email/phone/contact-preference/reason-length, with resourceId=sfId deeplinking to the SF Lead when push succeeded. Lead-data is in BAA-covered Postgres so Doug can reconcile from /admin/audit-log if SF goes down. Fail-open user-facing semantics preserved (returns {ok:true} even on SF outage, so the patient-side form doesn't break). Added LEAD_CAPTURED to AuditAction union with documenting comment block. tsc clean.
    v2.88.35
    2026-05-09Production

    Added

    • πŸ›‘οΈ NEW build-gate scripts/check-redirect-shadow.mjs β€” pins the v2.88.25 fix against regression. Fails the build when any redirects() source: path in next.config.ts matches a real src/app//page.tsx. The bug class: Next.js applies redirects() BEFORE routing to pages, so a legacy redirect entry can silently shadow a brand-new page (the v2.88.05 /get-started lead-capture landing was unreachable for ~6 hours because the v2.73.20 WordPress sitemap-preservation entry was still in the redirect list). Gate parses 80 redirect sources and cross-references each against src/app/. Wired into package.json check:all umbrella + .githooks/pre-push (7/7 gates). Pre-push reports βœ“ check-redirect-shadow: 0 redirects shadow existing pages (80 sources scanned). Sister of the 6 existing structural gates β€” same shape, same EXEMPT-with-rationale convention.
    v2.88.25
    2026-05-09Production

    Fixed

    • 🚨 **REAL BUG: /get-started Web-to-Lead landing page was 308-redirecting to /?book=1 β€” page unreachable in prod.** v2.88.05 shipped a brand-new lead-capture landing at src/app/get-started/page.tsx for soft launch, but next.config.ts:85 had a legacy { source: "/get-started", destination: "/?book=1", permanent: true } redirect entry from the v2.73.20 WordPress Rank Math sitemap-preservation ship. Next.js applies redirects() BEFORE routing to pages, so every flow.greenwellness.org/get-started request returned 308 β†’ /?book=1 instead of serving the new page. Caught by post-deploy verification β€” curl -I .../get-started returned 308. Removed the entry from redirects(). The new landing page is a much better destination for legacy traffic anyway (a soft lead form is gentler than a 5-step booking wizard for someone following a 2-year-old WordPress link). Added a comment documenting the gotcha so future redirect-list adds verify they don't shadow a real src/app//page.tsx. Audited all 80 other redirect entries β€” none shadow existing pages. tsc clean.
    v2.88.15
    2026-05-09Production

    Fixed

    • 🩺 RC_SERVER SSoT consolidation β€” 3 identical process.env.RC_SERVER_URL || "https://platform.ringcentral.com" declarations across lib/ringcentral.ts + api/cron/rc-webhook-renew/route.ts + api/admin/messages/[id]/recording/route.ts consolidated to a single RC_SERVER export from lib/ringcentral.ts. The 2 sister callers now import { RC_SERVER } instead of reimplementing the env+fallback. If RC ever changes their endpoint OR if Doug needs a sandbox URL, it's a one-constant edit. Same shape as the v2.81.95 CANONICAL_APP_URL SSoT consolidation. tsc clean.
    v2.88.05
    2026-05-09Production

    Added

    • πŸš€ **/get-started interim landing page β€” Web-to-Lead bridge for soft launch.** Doug 2026-05-09: ship the new site live TODAY with a lead-capture form posting to Salesforce, while the full booking platform stays in test until Postmark BAA + Stripe live + Neon prod blockers clear. Two new files: src/app/get-started/page.tsx (server component β€” branded landing with 3-tile reassurance + sign-in / call CTAs) + src/app/get-started/LeadForm.tsx (client component β€” name/email/phone/optional 'what brings you here?' + contact-preference pill picker, honeypot field, 3/min rate-limit, fail-open on SF push errors so we never lose a lead). New API route src/app/api/leads/route.ts POST: anonymous, IP-rate-limited 3/min, validates input, calls existing createLead() helper from lib/salesforce.ts (synthesizes appointmentType: 'WEB_LEAD_INTERIM' so SF reports can split web-leads from booked-patients), fail-open if Salesforce push errors (logs class name + ipPrefix only β€” no PHI). Phone CTA pulls from PHONE constants SSoT. Once BAAs land + booking platform graduates from test, /get-started stays as the softer 'not ready to book yet' lead-capture path alongside the full funnel. tsc clean. **Doug-action:** confirm SF_CLIENT_ID / SF_CLIENT_SECRET / SF_INSTANCE_URL env vars are set on the Vercel project β€” without them createLead() early-returns null + the form silently drops the lead (only Vercel log entry remains). Verify with curl -X POST https://greenwellness.org/api/leads -H 'Content-Type: application/json' -d '{"firstName":"Test","lastName":"Lead","email":"test+launch@example.com"}' after deploy β€” should return {ok:true} AND a Lead row should appear in SF within 30s.
    v2.87.35
    2026-05-09Production

    Fixed

    • 🚨 **Real bug: copy-paste migration recipe on /admin/launch referenced wrong env var.** src/app/admin/launch/page.tsx:627 rendered node -e "... process.env.DB_URL ..." as the one-liner Doug copies to apply un-applied migrations. The actual env var is DATABASE_URL β€” DB_URL is undefined. If Doug ever copy-pasted the recipe to apply a migration, postgres(undefined, ...) would throw before the SQL ever ran. Caught while comparing process.env.X references vs .env.example documented vars (sweep flagged 6 undocumented env-var lookups; only this one was a real typo, the rest were Vercel-injected or intentional kill-switches). Now references DATABASE_URL. tsc clean.
    • πŸ“ .env.example expanded β€” added 18 previously-undocumented env vars: EMAIL_FROM / EMAIL_REPLY_TO (vendor-agnostic email config) Β· POSTMARK_API_KEY + POSTMARK_INBOUND_AUTH + POSTMARK_STREAM (HIPAA-safe path) Β· AWS_SES_REGION + AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (alt HIPAA-safe path) Β· AI_DRAFTS_ENABLED Β· EMAIL_AUTO_ACK_ENABLED Β· NEXT_PUBLIC_MANUAL_CALLBACK_MODE Β· NEXT_PUBLIC_PAYMENT_DEFERRED + PAYMENT_DEFERRED Β· HIPAA_COMPLIANT (attestation flag) Β· RC_WEBHOOK_VERIFICATION_TOKEN Β· RC_SERVER_URL Β· RC_FROM_NUMBER (kill-switch β€” DO NOT SET in prod). Each entry has a comment block explaining what flipping it does + which BAA/vendor work it gates. Closes the documentation drift between process.env.X lookups in code and .env.example.
    v2.87.25
    2026-05-09Production

    Fixed

    • 🚨 **REAL BUG: /api/integrations/email route bypassed the HIPAA-vendor-abstraction layer.** The booking-confirmation email path used by /api/appointments (real customer bookings) and /api/admin/appointments/manual hit https://api.resend.com/emails directly via fetch with hardcoded from: "Green Wellness " and RESEND_API_KEY check β€” bypassing lib/email.ts + lib/workflow.ts's vendor tier ordering (Postmark > AWS SES > Resend). Effect: when Doug signs the Postmark or SES BAA and sets POSTMARK_API_KEY / SES creds, this route would STILL send via Resend (no BAA) β€” silently breaking the HIPAA-safe email path for booking confirmations on Day 1 of HIPAA-vendor activation. Now uses sendEmail() from lib/workflow.ts which respects the vendor abstraction + EMAIL_FROM env var. Removed the stale RESEND_API_KEY skip-check (sendEmail returns false β†’ 500 if no vendor configured, with structural failure audited to /admin/launch).
    • 🩺 NEW getFromAddr() exported from lib/email.ts β€” returns the resolved EMAIL_FROM env var value (or canonical DEFAULT_FROM "Green Wellness "). admin/messages/send/route.ts:214 was independently reimplementing process.env.EMAIL_FROM || "no-reply@greenwellness.org" for its patientMessage.fromAddr column β€” but with a different fallback (no "Green Wellness <>" name prefix). Result: when EMAIL_FROM is unset, the audit row stored a different sender string than what the vendor actually saw. Now both flow through getFromAddr(). tsc clean.
    v2.87.15
    2026-05-09Production

    Fixed

    • 🚨 **REAL BUG: patient password-reset emails pointed at WordPress, not the Next.js app.** src/app/api/patient/auth/forgot-password/route.ts read NEXT_PUBLIC_BASE_URL (does not exist as a configured env var on the GW Vercel project β€” the configured one is NEXT_PUBLIC_APP_URL) with fallback https://greenwellness.org β€” the apex, still on WordPress + Sucuri WAF until DNS cutover. So resetUrl = ${BASE_URL}/patient/reset-password?token=... resolved to https://greenwellness.org/patient/reset-password?token=... which 404'd at WordPress. Every patient who clicked 'Forgot password' got a dead link. Sister routes (provider/forgot-password + admin/forgot-password) already used CANONICAL_APP_URL from lib/app-url.ts correctly β€” only the patient route had the divergent env-var-name + wrong-domain combo. Now reads CANONICAL_APP_URL (which has *.vercel.app drift defense + canonical apex fallback). Caught while sweeping process.env.X || "" patterns. tsc clean.
    v2.87.05
    2026-05-09Production

    Fixed

    • 🩺 PHONE SSoT lift β€” prisma/seed.ts 4 location seed records had hardcoded phone: "1-888-885-9949" outside the check-contact-ssot.mjs gate scope (gate was scanning src/ only). Now phone: PHONE from lib/constants.ts. The 4 location seeds (Spokane / Lynnwood / Olympia / Vancouver) all flow through the SSoT β€” when the public phone changes, one constant edit propagates to fresh-DB seeds + every public surface.
    • πŸ›‘οΈ check-contact-ssot.mjs extended to scan prisma/*.ts too β€” was scanning src/ only, missing the seed file. Now scans 447 files (was 446). Pre-push reports βœ“ check-contact-ssot: 0 hardcoded PHONE/EMAIL across 447 src files. Sister of the v2.86.85 contact-SSoT gate; same shape with broader scan-root. tsc clean.
    v2.86.95
    2026-05-09Production

    Fixed

    • 🚨 **Real drift bug caught: TaxSavings calculator used a separate hardcoded CARD_COST = 175** instead of the PRICING SSoT. Component already imported PRICING (added during v2.86.65 sweep) but the breakeven-weeks math used a parallel constant. Effect: if PRICING.NEW_IN_PERSON ever changes, the calculator silently lies about the breakeven point shown to customers (e.g., bumping the price to $185 would still tell users they break even after 1.X weeks based on the stale $175 β€” wrong by ~6%). Now const CARD_COST = PRICING.NEW_IN_PERSON;. Comment added documenting the fix. tsc clean.
    v2.86.85
    2026-05-09Production

    Fixed

    • 🩺 PHONE + EMAIL SSoT lift β€” sister of v2.86.55β†’v2.86.75 PRICING arc. Pre-sweep state had ~17 hardcoded 1-888-885-9949 and admin@greenwellness.org sites across lib/locations-content.ts (4 location phone fields + 3 inline narrative mentions), lib/articles.ts (2 inline mentions in Spokane + Vancouver location articles), app/leave-a-review/page.tsx (mailto: link), and 6 admin/cron route files using the env-fallback pattern process.env.ADMIN_NOTIFY_EMAIL || "admin@greenwellness.org" (drift hazard same as the v2.81.95 NEXT_PUBLIC_APP_URL sweep). All now flow through PHONE and EMAIL SSoT in lib/constants.ts. Result: 0 hardcoded PHONE/EMAIL across src/ (only lib/seo.ts:61 remains as a documented // comment reference). tsc clean.
    • πŸ›‘οΈ NEW build-gate scripts/check-contact-ssot.mjs β€” pins the PHONE/EMAIL sweep against regression. Sister of check-pricing-ssot (v2.86.75) β€” same shape, same EXEMPT-with-rationale convention. Allowlist: lib/constants.ts (the SSoT), lib/changelog.ts (history), lib/seo.ts (comment-only mention documenting E.164 conversion). Wired into package.json check:all umbrella + .githooks/pre-push (6/6 gates). Pre-push reports βœ“ check-contact-ssot: 0 hardcoded PHONE/EMAIL across 446 src files. Why: if the public phone or email ever change, hardcoded sites silently lie β€” same drift class as PRICING.
    v2.86.75
    2026-05-09Production

    Added

    • πŸ›‘οΈ NEW build-gate scripts/check-pricing-ssot.mjs β€” pins the v2.86.55β†’v2.86.65 PRICING SSoT sweep against regression. Scans src/**/*.{ts,tsx} for hardcoded $175 or $140 and exits 1 on offenders. Allowlist: lib/constants.ts (the SSoT) + lib/changelog.ts (historical narrative). Wired into package.json check:all umbrella + .githooks/pre-push (5/5 gates). Sister of the 4 existing structural gates (check-app-url-ssot, check-vercel-cron-dedup, check-cron-heartbeat, check-env-fallback-pattern) β€” same shape, same EXEMPT-with-rationale convention. Pre-push reports βœ“ check-pricing-ssot: 0 hardcoded $175/$140 across 446 src files. Why: the v2.86.55 sweep caught a real bug (AI prompt said returning $130 β€” $10 off β€” for many versions). Without an SSoT we couldn't grep for it. Gate ensures the next price change is a single constant edit + a green build.
    v2.86.65
    2026-05-09Production

    Fixed

    • 🩺 PRICING SSoT lift β€” phase 2 closeout. 17 files swept across the public-facing surface: 4 lib content files (conditions-content.ts, locations-content.ts, city-condition-content.ts, telehealth-condition-content.ts β€” metaDescription / intro / whatToExpect / FAQ-answer fields with embedded prices) + 13 page/component files (telehealth/page.tsx + [city]/page.tsx + [city]/[condition]/page.tsx, learn/[slug]/page.tsx, faq/page.tsx, terms/page.tsx, locations/page.tsx + [city]/page.tsx + [city]/[condition]/page.tsx, conditions/page.tsx + [slug]/page.tsx, pricing/page.tsx, components/sections/TaxSavings.tsx). Three transformation passes via Python scripts: (1) backtick template-literal regions β†’ $${PRICING.NEW_IN_PERSON} / $${PRICING.RETURNING_TELEHEALTH} interpolation, (2) double-quoted JS strings β†’ backtick conversion + interpolation, (3) JSX text nodes >$175< β†’ JSX-expression wrap >{\$${PRICING.NEW_IN_PERSON}\}<. Every changed file got the PRICING import at top. **Result: 0 hardcoded $175/$140 across src/ (was ~50+ sites pre-arc).** Closes the price-drift class β€” next price change is one constant edit. Pre-commit Explore review verified all 89 insertions / 72 deletions. tsc clean.
    v2.86.55
    2026-05-09Production

    Fixed

    • 🚨 **REAL BUG (dormant): AI patient-comm prompt quoted wrong renewal price.** src/app/api/admin/messages/ai-draft/route.ts SMS + email system prompts told the model 'returning $130' β€” actual price is $140. Bug was dormant because AI_DRAFTS_ENABLED is gated on the Anthropic BAA which hasn't landed. But once Doug flipped that env var, every AI-drafted reply quoting renewal pricing would have been $10 off. Now reads through PRICING.RETURNING_TELEHEALTH SSoT β€” drift class closed at the source. Caught while sweeping for hardcoded prices across the codebase.
    • 🩺 PRICING SSoT lift β€” phase 1 (high-leverage files). Hardcoded $175 / $140 across 6 files now flow through PRICING.NEW_IN_PERSON / PRICING.RETURNING_TELEHEALTH constants in src/lib/constants.ts: lib/faq-data.ts (transparent-pricing FAQ), lib/articles.ts (~20 sites across the article corpus including how-to-get-card / cost-of-evaluation / per-clinic location articles + 5 description-string conversions), api/admin/messages/ai-draft/route.ts (SMS + email AI prompts β€” the real-bug site), opengraph-image.tsx (default OG stat tile), llms.txt/route.ts (AI-citation surface β€” keeps ChatGPT/Claude/Perplexity quoting the right price), api/og/route.tsx (OG-image badge default). Phase 2 follow-up will sweep telehealth/conditions/locations pages (~30 more sites). tsc clean.
    v2.86.45
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ HIPAA β€” noindex layouts on 6 PHI-adjacent token surfaces. /checkin/[token], /cancel/[token], /reschedule/[token], /confirm/[token], /intake/[token], and /my-appointments (+ /[token]) now ship via layout-level metadata. robots.txt's Disallow: // blocks crawl but does NOT block indexing if Google sees the URL via inbound links β€” and these tokens travel in confirmation/intake/reminder emails routinely. Patient-portal layout already had this since v2.x; provider + dispensary too. This sweep closes the matching pattern across the 6 patient-side token routes. Sister of the 'privacy noindex' bug class from the 2026-05-08 grind-past-tapped scoreboard. Defense-in-depth β€” every PHI-adjacent surface is now covered by BOTH robots.txt Disallow + page-level noindex meta. tsc clean.
    v2.86.35
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ PHI-leak hardening β€” round 7: 2 stragglers caught in cross-repo audit. (1) api/integrations/email/route.ts:86 was logging await res.text() raw β€” Resend error bodies commonly echo the recipient email ('InvalidEmailRequest: To: doug@example.com is not a valid email'). Now drains the response (releases connection) and logs status= only. Sister of v2.83.75 round 4+5 sweep across lib/email.ts Postmark + Resend non-OK branches. (2) api/cron/review-request/route.ts:70 logged the full appt.patient.id UUID β€” convention across workflow.ts / audit.ts / patient-message-backfill.ts is id.slice(0, 8) prefix. Per Β§164.514(b)(2)(i)(R) Safe Harbor, a unique patient UUID is a 'unique identifying code' disqualifier. Now prefix-only, comment updated. tsc clean.
    v2.86.25
    2026-05-09Production

    Added

    • πŸ₯‡ BreadcrumbList LD on /leave-a-review β€” last remaining public page without breadcrumb path rendering eligibility. Closes the BreadcrumbList sweep arc 15/15: every public marketing + content page now ships Home β€Ί … schema. The LIVE.md note about /resources was stale (/resources is a 308 β†’ /learn since v2.83.75). 2-level path (Home β€Ί Leave a Review). tsc clean.
    v2.86.15
    2026-05-09Production

    Added

    • πŸ₯‡ BreadcrumbList LD on /locations/[city] dynamic route β€” Lynnwood, Spokane, Olympia, Vancouver per-city pages now have 3-level breadcrumb (Home β€Ί Locations β€Ί City). SERP path rendering eligibility for city-specific MMJ-clinic searches. Sister of /locations index v2.86.05. tsc clean.
    v2.86.05
    2026-05-09Production

    Added

    • πŸ₯‡ BreadcrumbList JSON-LD on /locations β€” was the last public marketing page without breadcrumb path rendering eligibility. /about, /faq, /learn, /pricing, /providers, /telehealth, /dispensaries already had it. Earns SERP path rendering (Home β€Ί Locations) under search results instead of raw URL. Single-line buildBreadcrumbLd addition. Sister of the 2026-05-09 BreadcrumbList sweep arc. tsc clean.
    v2.85.95
    2026-05-09Production

    Fixed

    • 🚨 check-env-fallback-pattern gate was DEAD β€” existed at scripts/check-env-fallback-pattern.mjs but never wired into pre-push hook OR check:all OR any npm script. Discovered while grinding past v2.85.55. Cumulative drift exposure: any post-v2.76.9 multi-line nullish-coalesce regression would have been silent. Fix: (a) added check:env-fallback script + appended to check:all umbrella; (b) added scripts/check-env-fallback-pattern.mjs to the .githooks/pre-push gate-loop list (now 4/4 instead of 3/3); (c) updated scripts/setup-hooks.sh header comment to reflect the 4-gate structure. Verified: gate runs clean (439 files, 0 offenders). Sister of inv feedback_cron_get_handler_silent_stale class β€” gate-not-gating β†’ silent-stale class.
    v2.85.75
    2026-05-09Production

    Fixed

    • 🩹 /locations/spokane-valley 308 redirect β€” caught pre-launch via patient-facing surface 200 sweep. The clinic NAME on the GW Location row says "GreenWellness Spokane Valley" but the city field is just "Spokane", so toSlug() yields spokane and the sitemap canonicalizes to /locations/spokane (which 200s). Direct-typed /locations/spokane-valley 404'd until this commit β€” patients who type the full city name into the URL bar (or an SEO tool that auto-derives slugs from the location name) would dead-end. Fix: 308 redirect in next.config.ts from /locations/spokane-valley β†’ /locations/spokane. Preserves SEO juice on the canonical slug + serves the alt-typed traffic. **Doug-action option**: if you'd rather have the alt slug be canonical (more accurate to the actual clinic city), update the DB row's city field to "Spokane Valley" and swap source ↔ destination on this redirect line. Same /locations/everett 404 also surfaced in the sweep but is correct (Everett isn't a clinic city). Caught alongside data-integrity probe of all 4 active locations: GreenWellness Spokane Valley (city=Spokane), Lynnwood, Olympia, Vancouver. Plus prod-migrations 20 + 21 applied this session (idempotent backfill: IssuingDoctorHistory unique-open partial index + GbpConnection table β€” both confirmed present in Neon prod). tsc clean.
    v2.85.65
    2026-05-09Production

    Added

    • πŸ“° FAQPage JSON-LD on /pricing β€” earns Google 'People also ask' rich-result eligibility on the 4 pricing FAQs (no-qualify-no-fee guarantee, what's-included, other-fees, insurance-coverage). Pre-fix /pricing had MedicalClinic + OfferCatalog + BreadcrumbList LD but the FAQ section was unstructured. Single-line buildFaqPageLd(FAQS, { speakableSelector: ['#pricing-faq'] }) addition + id='pricing-faq' on the section element. Pricing FAQs are exactly what Google promotes as PAA boxes for cost-related cannabis-card searches. Sister of /faq + /telehealth FAQPage LD. tsc clean.
    v2.85.55
    2026-05-09Production

    Changed

    • πŸ›‘οΈ check-env-fallback-pattern gate tightened with multi-line nullish-coalesce scan β€” sister of inv v314.005. Pre-fix: gate's 5 single-line regexes (URL/NUM/EMAIL/PLAIN/CONST) couldn't see process.env.X ?? patterns where the fallback string sat on the next line. Inv had 4 latent sites of this class (api/health Β· api/health/ping Β· api/cron/quiz-nurture Β· vmi/assets); GW has 0 currently, but the gate now guards future regressions. Implementation: second pass detects process.env.X ?? at line-end + collapses up-to-8 continuation lines + re-runs the 5 regexes against the collapsed form. Skips comment-only continuation lines + dedupes via Set so per-line + multi-line passes don't double-record. Run post-tighten: 439 files scanned, 0 offenders.
    v2.85.35
    2026-05-09Production

    Added

    • πŸ€– robots.txt β€” explicit AI-bot allowlist (18 user-agents) with PHI-safe Disallow rules. Each AI-bot UA (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, Applebot-Extended, etc.) gets the same phiDisallow rules so PHI surfaces (admin/patient/provider/intake/cancel/checkin/dispensary/my-appointments/reschedule) stay locked even with explicit allow on public marketing pages. Patients researching 'WA telehealth MMJ card' via ChatGPT/Claude/Perplexity now get GW cited from /telehealth, /faq, /providers, /about, /learn, /dispensaries. Sister of glw/scc/Sureel allowlist pattern (~19 UAs each). PHI never reaches model-training corpora β€” Disallow is respected by every AI bot per robots.txt spec. Single-file edit. tsc clean.
    v2.85.25
    2026-05-09Production

    Changed

    • πŸ›‘οΈ **Pre-launch site copy sweep β€” no over-promises on services that need vendor BAAs not yet signed.** Doug 2026-05-08 evening: get the website ready to launch THIS WEEK as a Salesforce-feeder bridge, with the warning to confirm we don't promise services gated on Anthropic / Postmark / SES / Twilio / RC voice BAAs. Two specific patterns swept across the 4 public telehealth pages + /about + /locations: (1) **"authorization is emailed the same day"** (6 sites) β†’ **"is issued the same day"** β€” until Postmark or SES BAA lands, GW emails ride on Resend (no BAA). The same-day authorization claim is real (provider issues it during the visit); the *email-delivery* commitment crosses into PHI-on-non-BAA-vendor territory. Softening to "issued" preserves the real value-prop without binding us to email-delivery method while the vendor BAA is pending. (2) **"HIPAA-protected"** (6 sites: 3 trust pills, 2 marketing-card bodies, 1 locations bullet) β†’ **"Confidential"** / **"HIPAA-aware"** β€” matches the conservative TrustBar pattern (which already flips to "HIPAA-compliant design" only when HIPAA_COMPLIANT=true env-var attestation is set, gated on all 4 BAAs signed). Pre-fix risk: marketing-truthfulness liability if a patient or regulator interprets "HIPAA-protected" as full BAA-tier coverage on every PHI-touching vendor in the stack today. Internal provider/training/page.tsx left as-is (not patient-facing). **Files**: telehealth/page.tsx, telehealth/[city]/page.tsx, telehealth/[city]/[condition]/page.tsx, about/page.tsx, locations/page.tsx. tsc clean.
    v2.85.15
    2026-05-09Production

    Added

    • πŸ›‘οΈ AI cost-amplification defenses on /api/admin/messages/ai-draft. Endpoint is currently 503'd via AI_DRAFTS_ENABLED=false (gates on Anthropic BAA), but when Doug flips it post-BAA, these caps prevent unbounded Anthropic billing from a click-spam (or compromised admin session). **Three new caps**: (1) per-staff rate limit 30 drafts/hour/staff via checkRateLimit("ai-draft:${x-admin-id}"), falls back to IP if header absent β€” limits cost-amp blast radius; (2) MAX_INBOUND_BODY_BYTES=4000 per message β€” pathological 100KB email body or MMS payload gets sliced + truncated marker before joining the prompt; (3) MAX_THREAD_TOTAL_BYTES=16000 cap on assembled thread (last 8 messages, oldest-truncated-first if over budget) plus MAX_PROMPT_BYTES=32000 belt-and-suspenders cap on the final context. Plus patientId validation (string + length<100) β€” defensive against payload shape attacks. Sister of inv defense-class arc (memory pin project_defense_arc_2026_05_08.md) β€” same pattern as inv /api/admin/training/draft (max_tokens=4096) + /api/admin/products/generate-description. **GW AI endpoint coverage now complete**: /api/chat (already had MAX_MESSAGES=50, MAX_MESSAGE_BYTES=4KB, MAX_TOTAL_BYTES=100KB, 30/hour/IP rate limit since v2.80.10) + /api/admin/messages/ai-draft (this ship). tsc clean.
    v2.85.05
    2026-05-09Production

    Fixed

    • 🩺 /admin/launch cron section β€” fixed permanent false-negative. Pre-fix EXPECTED_CRONS hardcoded action names like CRON_REMINDERS, CRON_NO_SHOW, CRON_REVIEW_REQUEST etc. **ZERO code in the repo writes any of those action strings** β€” verified via grep. Every cron row was rendering as caveat ("no firing in last 7 days") forever, regardless of whether crons were actually running. Operator-side false signal: looked like every cron was broken when in fact the audit-action-name convention had drifted (or never landed). Post-fix: query selects action='CRON_HEARTBEAT' (the actual rows written by writeCronHeartbeat() v2.84.15+), parses actor= from detail, maps to per-actor cronByActor map. EXPECTED_CRONS now lists all 14 GW Vercel crons (was 8) with cadence-aware staleness budgets matching EXPECTED_CRON_ACTORS on /api/health: reminders 36h, reminders-2h 12h, no-show 5h, daily 72h, weekly 336h, waitlist 24h. **Acceptance**: rows now show Last fire: (Xh ago β€” healthy/past cadence). Caveat rows only fire if actor genuinely hasn't fired (cron disabled / scheduling broken). tsc clean.
    v2.84.95
    2026-05-09Production

    Added

    • πŸ›‘οΈ Pre-push hook portable + 3 build-gates wired in. NEW .githooks/pre-push (committed to repo) + NEW scripts/setup-hooks.sh (one-command install via git config core.hooksPath .githooks). Mirrors inv scripts/setup-hooks.sh pattern (memory pin feedback_pre_push_typecheck_gate.md). Hook now runs **5 gates** (was 4 β€” local-only): (1) changelog guard, (2) footer version-badge guard, (3) SSR-false-in-server-component guard, (4) tsc --noEmit, (5) NEW build-gate umbrella running all 3 SSoT/arc-guard scripts (check-app-url-ssot.mjs + check-vercel-cron-dedup.mjs + check-cron-heartbeat.mjs). **Why portable matters**: pre-push hooks live in .git/hooks/ which isn't tracked by git β€” without a checked-in template + setup script, every fresh clone (Doug's other laptop, future Codespace, parallel sessions) had only the 4-gate hook. Now any clone can run bash scripts/setup-hooks.sh once and inherit the same 5-gate enforcement. **Build-gate umbrella catches**: inline process.env.NEXT_PUBLIC_APP_URL || "fallback" regressions (vercel.app drift, localhost-in-prod-email), duplicate cron paths in vercel.json (silent rejection), cron route added without heartbeat / actor-name drift. NEW pnpm check:all script chains all 3 for manual verification. **Bypass**: git push --no-verify for emergency-only. tsc clean. Verified end-to-end: hook runs in ~16s (15s tsc + 1s gates).
    v2.84.85
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ HIPAA: PHI-leak hardening round 7 β€” 4 sites where err.message was being templated into log lines despite the patient-ID redaction. **Sites:** lib/workflow.ts:132 (workflow-log-failed; Prisma errors echo SQL params for the workflow_events insert row, which carries staffUserId + patient context), lib/audit.ts:236 (audit-write-failed; Prisma errors echo audit_log row payload), api/admin/patients/[id]/send-renewal/route.ts:60 (workflow-log-failed via .catch handler), api/cron/rc-webhook-renew/route.ts:126 (DELETE fetch error; can echo request URL + response body, which carries the RC webhook destination address). **Pre-fix** all four extracted err.message (or used String(err) fallback) into a ${msg} template, then logged it alongside the redacted patientIdPrefix / sub.id / resourceId. The structured field redactions were correct, but the err.message itself can leak the rest of the row. **Post-fix:** log err.name (Prisma class identifier β€” PrismaClientKnownRequestError, PrismaClientValidationError) plus .code (Prisma error code β€” P2002, P2003) when present. Forensic trail still flows through the existing audit infrastructure. Sister of v2.84.05 patient-message-backfill + the cross-repo PHI-leak hardening pattern. tsc clean.
    v2.84.75
    2026-05-09Production

    Added

    • πŸ›‘οΈ NEW arc-guard scripts/check-cron-heartbeat.mjs β€” closes the v2.84.15 β†’ v2.84.65 cron-observability arc against regression. Three-way cross-check: (1) every vercel.json crons[] path has a writeCronHeartbeat() call in its route.ts (β‰₯2 calls β€” canary + completion); (2) every heartbeat actor literal is in EXPECTED_CRON_ACTORS on /api/health/route.ts (else the row is invisible to the staleness probe); (3) every EXPECTED_CRON_ACTORS entry has a real cron route writing it (else the probe permanently reports lastFiredAt: null + stale: true). Also flags actor-name-vs-directory drift (heartbeat actor must match the route directory + the vercel.json path). Exposed as pnpm check:cron-heartbeat. Sister of inv arc-guard pattern (memory pin feedback_arc_guard_regression_test_pattern.md). GW has no test runner installed; build-gate is the load-bearing pin shape. **Verified**: 14 vercel.json crons / 14 EXPECTED_CRON_ACTORS entries / 14 cron routes with heartbeat β€” all aligned. Future agent (or future-Doug) attempting to add a 15th cron without wiring the heartbeat will fail this gate. tsc clean.
    v2.84.65
    2026-05-09Production

    Added

    • πŸ›‘οΈ NEW build-gate scripts/check-vercel-cron-dedup.mjs (ported from inv v313.405) β€” pins the v2.84.45 dedup fix against regression. Parses vercel.json and fails (exits 1) if any cron path appears more than once. Exposed as pnpm check:vercel-cron-dedup for manual verification. Sister of inv v313.405 same-class gate. Why we need this on GW: appointment-reminder cron /api/cron/reminders was duplicated β†’ Vercel silently rejected the daily cron block β†’ patient-facing reminders silently un-scheduled. Without this gate a future agent can re-introduce the pattern by pasting two entries with same path + different schedules instead of using the comma-list form (0 16,21 * * *). **Verified**: 14 cron entries, 0 dupes. Memory pin: feedback_vercel_cron_path_dedup.md. tsc clean.
    v2.84.55
    2026-05-09Production

    Added

    • 🩺 Cron-observability arc β€” **CLOSED 14/14**. EXPECTED_CRON_ACTORS on /api/health extended from 7 to 14 (parallel session wired heartbeat into the remaining 7: reminders-2h, doh-nudge, new-patient-drip, rc-webhook-renew, review-request, waitlist, slots β€” including 3 early-return paths in eod-email + 2 in rc-webhook-renew). All 14 GW Vercel crons (per vercel.json crons[]) now write writeCronHeartbeat() canary-after-auth + completion-with-result. Per-actor staleness budgets calibrated to expected cadence Γ— ~3-misses (or 2-misses for the weekly Mon-only / Sun-only schedules). /api/health.cronActors now reports total: 14, stale: , details: [...]. **Acceptance**: lastFiredAt populates as each cron fires under v2.84.15+ code (within 24h for daily, 4h for waitlist, 2h for reminders-2h, 1h for no-show, 7d for weekly-digest/slots). Initial post-deploy state will show all stale=true with lastFiredAt=null β€” that's expected (no fires yet under heartbeat code). Sister of inv arc closed 2026-05-07. Closes the cross-repo cron-observability port. tsc clean.
    v2.84.45
    2026-05-09Production

    Fixed

    • 🩺 Cron-config dedup: /api/cron/reminders was listed TWICE in vercel.json (lines 6 + 10 with schedules 0 16 * * * and 0 21 * * *). Sister of inv v313.205 same-class fix discovered cross-repo same session. Vercel cron config requires unique paths β€” duplicates cause undefined behavior (silent rejection of one or both, or rejection of the entire cron block depending on validator version). For GW the impact is patient-facing: appointment reminders fire /api/cron/reminders, and a Vercel-side rejection would mean appointment reminder SMS/email don't fire on the affected schedule. Fix: combined into single entry with 0 16,21 * * * (cron's comma-separated hour list β€” fires at both 16:00 and 21:00 UTC daily). 15 β†’ 14 unique. **Investigation recipe** (paste-ready): python3 -c "import json; cfg = json.load(open('vercel.json')); paths = [c['path'] for c in cfg['crons']]; print([p for p in set(paths) if paths.count(p) > 1])". Run before every push that touches vercel.json. tsc clean.
    v2.84.25
    2026-05-09Production

    Added

    • 🩺 Cron-observability arc β€” phase 2+3-partial. Phase 2: /api/health now exposes cronActors: { total, stale, details: [{actor, lastFiredAt, staleDays, stale}] }. Per-actor staleness budget = expected-cadence Γ— ~3-misses (reminders 1.5d, no-show 0.2d, renewals/daily-briefing/intake-reminder/eod-email 3d, weekly-digest 14d). stale: true flips when lastFiredAt exceeds the budget β€” Doug-action signal (env vars unset / cron disabled / scheduling broken), NOT infrastructure failure (so doesn't 503 the endpoint). Pattern lifted from inv staleActorDetails shape. Phase 3-partial: extended writeCronHeartbeat() from 2 crons (v2.84.15) to 7 β€” added renewals (daily 9 AM PT renewal sequence), daily-briefing (daily 7 AM PT admin summary email), weekly-digest (Mon 8 AM PT practice-health digest), intake-reminder (daily 9 AM PT pre-visit form nudge), eod-email (daily 6 PM PT staff-productivity rollup). Each gets a canary heartbeat after auth + a completion heartbeat with result summary at end. **Remaining for phase 3 (next ship)**: doh-nudge, new-patient-drip, rc-webhook-renew, reminders-2h, review-request, slots, waitlist (7 crons). PHI-safe: result summaries are aggregate counts only. tsc clean. Once phase 3 closes the loop, /admin/launch can render a 'Cron staleness' tile against cronActors.stale > 0.
    v2.84.15
    2026-05-09Production

    Added

    • 🩺 Cron-observability arc β€” phase 1 of porting from inv (sister of inv arc closed 2026-05-07). NEW lib/cron-heartbeat.ts exports writeCronHeartbeat(actor, result?) β€” writes an AuditLog row with action: "CRON_HEARTBEAT", staffUserName: "cron", detail: actor= result=. Reuses the existing AuditLog table so no schema change needed; CRON_HEARTBEAT extended onto the AuditAction union type. **Wired into 2 starter crons this ship**: /api/cron/reminders (twice-daily appointment reminders) + /api/cron/no-show (hourly stale-appointment marker). Pattern: **canary heartbeat fires immediately after verifyCronAuth()** (so silent compute failures still surface β€” the existing 'log when sent>0' pattern can't see failures where compute throws or returns early); **completion heartbeat at the end** with full result summary (overrides the canary). PHI: result summary is aggregate counts only (sent=12 marked=3 skipped=4), never patient identifiers β€” AuditLog isn't BAA-covered for vendor-side errors. **Phase 2 (next ship)**: extend /api/health with staleActorDetails: [{actor, lastFiredAt, staleDays}] reading from AuditLog where action='CRON_HEARTBEAT' group-by detail-actor-prefix. **Phase 3**: extend heartbeat to remaining 12 crons (renewals, daily-briefing, weekly-digest, intake-reminder, eod-email, doh-nudge, new-patient-drip, rc-webhook-renew, reminders-2h, review-request, slots, waitlist). tsc clean.
    v2.84.05
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ HIPAA: lib/patient-message-backfill.ts:61 PHI-leak. Pre-fix logged (err as Error).message β€” Prisma errors echo SQL params, which on a patient-message linkage query include patient names + identifiers from the linkage row. Pre-fix kept the patientId redacted (8-char UUID prefix per Β§164.514(b)(2)(i)(R)) but the err.message still leaked sibling PHI from the same row. Post-fix: format-only via err.name. Sister of v2.79.10 + v2.82.40 + v2.82.85 PHI-leak hardening pattern. tsc clean.
    v2.83.95
    2026-05-09Production

    Changed

    • 🧹 DRY consolidation β€” lib/seo.ts + lib/articles.ts no longer duplicate the canonicalBase() helper; both now import CANONICAL_APP_URL from lib/app-url.ts (the SSoT introduced in v2.81.95). Pre-fix this file pair declared private canonicalBase() helpers with subtly different bodies (seo.ts stripped trailing slash, articles.ts didn't), each implementing the same vercel.app-defense pattern. Bug-class risk: any future drift defense (e.g. add localhost rejection, or harden against *.preview.app.vercel.com) had to be applied in 5 places (seo.ts + articles.ts + sitemap.ts + robots.ts + llms.txt/route.ts) and was guaranteed to be applied in 0–4. Now: trailing-slash strip hoisted into CANONICAL_APP_URL itself; all 5 consumers reduced to a single-line import. pnpm check:app-url-ssot still passes (439 files, 0 offenders). tsc clean. Closes the duplicate-helper class entirely.
    v2.83.85
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ PHI-leak hardening β€” round 6: 6 sites where .catch(console.error) shorthand passed the raw err object straight to console (it stringifies + logs whatever's attached, including request body / response body / Postmark+Resend error envelopes). Sites: api/admin/appointments/manual/route.ts (Γ—4 internal-fetch sites β€” email/sms/salesforce/practicefusion β€” request body carries patient.email/firstName/id), api/admin/forgot-password/route.ts (sendEmail catch β€” user.name + reset-link token), api/provider/forgot-password/route.ts (same β€” provider.name + token), api/appointments/route.ts admin-notify (patient name + email + phone in scope), api/my-appointments/route.ts magic-link send (patient.email + portal token), api/webhooks/stripe/route.ts admin-orphan-notify (email body references PHI via Stripe Dashboard deeplink). Manual-route fetches now share a logFetchErr(label) helper so all 4 sites use the same redaction. Pattern: name-only log. tsc clean. Sister of v2.83.65 round 4+5; closes the .catch(console.error) shorthand class entirely (0 remaining outside changelog narrative).
    v2.83.75
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ /resources soft-redirect β†’ 308 permanent. Pre-fix redirect('/learn') returned HTTP 307 (temporary) β€” wrong SEO signal for an intentional rename, since 307 tells Google 'short-lived, keep both indexed'. Now uses permanentRedirect('/learn') + force-dynamic (Next 16 quirk: without force-dynamic the redirect prerenders as static 200 + router-push payload). Sister of glw /brands 308 fix + scc legacy-URL 308 sweep. Single-file change. tsc clean.
    v2.83.65
    2026-05-09Production

    Fixed

    • πŸ›‘οΈ PHI-leak hardening β€” round 4+5: 26 catch-block + non-OK-response sites across 16 files now redact raw err to name/status only. Sister sweep continuing the v2.79.10 β†’ v2.79.30 β†’ v2.79.50 β†’ v2.79.70 β†’ v2.79.90 β†’ v2.80.10 β†’ v2.80.30 β†’ v2.80.50 β†’ v2.80.70 β†’ v2.80.90 β†’ v2.81.85 β†’ v2.81.90 β†’ v2.82.40 β†’ v2.82.60 β†’ v2.82.85 arc. **Round 4 (route handlers + cron + webhooks):** api/appointments/route.ts (booking + integration paths β€” Zod-validated patient name/email/phone/DOB + intake answers in scope) Β· api/appointments/reschedule/route.ts Β· api/admin/appointments/{cancel,manual,reschedule}/route.ts (PF FHIR + waitlist notify err sites β€” patient name + appointment payload) Β· api/admin/messages/{ai-draft,send}/route.ts (ai-draft prompt context + outbound attachment persist) Β· api/admin/outreach/route.ts (patient list + send-results) Β· api/integrations/{email,practicefusion,salesforce}/route.ts (PF FHIR demographics + SF Lead payload) Β· api/my-appointments/route.ts (portal-link requester email) Β· api/availability/route.ts (public β€” redacted for hygiene) Β· api/webhooks/postmark/inbound-email/route.ts (Γ—2 β€” attachment upload + auto-ack send; rawName can reveal medical context like 'lab-results.pdf') Β· api/webhooks/ringcentral/{calls,sms}/route.ts (persist err carries phone + recording URL + body text) Β· api/cron/review-request/route.ts (Γ—2 β€” siteSettings lookup + per-patient SMS send) Β· api/provider/bulk-approve/route.ts (cert + appointment payload β€” id stays loggable, payload doesn't) Β· api/intake/[token]/documents/route.ts (Blob + filename) Β· api/admin/documents/[id]/route.ts (Blob delete URL). **Round 5 (lib non-OK-response branches):** lib/email.ts Postmark + Resend !res.ok branches were logging await res.text() raw β€” vendor error response bodies commonly echo recipient email + sender details ('InvalidEmailRequest: To: doug@example.com is not a valid email'); now status-only. lib/ringcentral.ts token-exchange + SMS-send !res.ok branches same pattern; SMS error envelopes echo from/to E.164 + body text. **Pattern**: const name = err instanceof Error ? err.name : "unknown"; console.error([] : ${name}). For HTTP non-OK branches: void res.text().catch(() => ""); console.error([] error: status=${res.status}) (drains the response so the connection releases, but doesn't log the body). Vercel function logs aren't BAA-covered; raw err / response body in logs = HIPAA breach risk in patient-care context. tsc clean across all 23 file edits.
    v2.83.55
    2026-05-08Production

    Added

    • πŸ₯‡ BreadcrumbList JSON-LD on 4 missing pages: /telehealth, /changelog, /privacy, /terms. Pre-fix the seo helper buildBreadcrumbLd was wired on /about, /faq, /learn, /providers, /dispensaries, /refer but missed these 4 surfaces. Earns SERP path rendering (Home β€Ί Telehealth, Home β€Ί HIPAA Privacy Notice, etc.) β€” 1-2% CTR lift per Search Console A/Bs. /resources still has zero JSON-LD; separate ship to add the full WebPage shape there. Sister of glw v7.385/v7.445 + scc v8.525/v8.585 BreadcrumbList sweeps. tsc clean.
    v2.83.45
    2026-05-08Production

    Changed

    • 🎯 Softphone polish β€” Bundle B2 closes the SOFTPHONE_POLISH.md punch list. Three additions, all desktop-targeted (mobile uses bottom-sheet from B1 where these don't apply): **drag** β€” header is now a pointer-captured drag handle. pointerdown records start position + base offset, pointermove updates {dx, dy}, pointerup releases capture. Iframe doesn't steal events because the parent has pointer capture for the duration. Skipped when click target is one of the chrome buttons (minimize/close) so they don't double-fire as drag-start. Cursor changes to grab / grabbing. **Position memory** β€” {state, dx, dy} persists to localStorage["rc-softphone-pos"] on change; hydrated post-mount in a useEffect (SSR-safe), with shape validation so a corrupted entry can't crash the dialer. The next page-load reopens the widget where staff left it. **⌘\ keyboard toggle** β€” Cmd/Ctrl + \ toggles open ↔ hidden globally. Skipped when an input/textarea/contenteditable has focus so staff typing in a patient note can use \ literally. Avoids ⌘K (AdminCmdK) and the VS-Code-conditioned ⌘P/⌘./⌘B. Punch list closed: 5/5 items shipped (A1–A3 v2.83.05, B1 v2.83.35, B2 v2.83.45). tsc clean.
    v2.83.35
    2026-05-08Production

    Changed

    • πŸ“± Softphone polish β€” Bundle B1 (iPad / small-laptop responsiveness from SOFTPHONE_POLISH.md). Pre-fix: hardcoded h-[600px] w-[360px] overflowed ~768px viewports, ignored safe-area-inset-bottom (iPad home-bar overlapped the dialer keypad), and used vh which clipped on iOS Safari URL-bar collapse. **Fix:** below sm: (≀640px, phones), open state is now a full-width bottom sheet (inset-x-0 bottom-0 h-[85dvh] rounded-t-2xl) with a tap-to-dismiss backdrop β€” same modal pattern AdminCmdK uses. sm: and up keeps the floating panel: w-[min(360px,calc(100vw-2rem))] h-[min(600px,calc(100dvh-6rem))] so the widget never clips on small laptops. dvh (dynamic viewport height) accounts for iOS Safari URL-bar expansion; vh would overflow when the URL bar collapses. Bottom offset uses bottom-[max(1rem,env(safe-area-inset-bottom))] so iPad home-bar doesn't sit on the dialer. Minimized state remains a docked pill at bottom-right on every viewport. B2 (drag + position memory + ⌘\ toggle) ships next.
    v2.83.25
    2026-05-08Production

    Added

    • πŸ“ˆ GBP performance dashboard at /admin/marketing/gbp-performance β€” port from inv. Live read of Google's Business Profile Performance API for the practice's GBP listing. 4 headline tiles (impressions / call clicks / direction requests / website clicks) with 30-day totals + WoW % trend chips, server-rendered SVG sparklines (no JS / no chart deps), per-source impressions split (desktop/mobile Γ— search/maps) with progress bars. Single Performance API call covers all 8 metrics via fetchMultiDailyMetricsTimeSeries β€” no fan-out. Server Component with force-dynamic + revalidate=0 so each visit pulls fresh. Admin/Manager gated via verifyAdminSession; lower roles redirect. Inline not-connected fallback (env vars / no OAuth / no location resource) shows the actual gap + deep-links to /admin/integrations/gbp. **Lib helpers** added to src/lib/gbp.ts: sumMetric() + splitForTrend() (the perf fetcher itself shipped with v2.81.50). **HIPAA scope:** GBP metrics are aggregate counts (impressions / clicks / direction requests) β€” NOT PHI. Reviews CAN contain patient-identifying detail and don't surface here; separate /admin/marketing/reviews surface deferred. **Palette adapted** to GW's #0f2744 navy + #2d6a4f green (vs inv's zinc dark). tsc clean.
    v2.83.05
    2026-05-08Production

    Changed

    • 🎨 Softphone polish β€” Bundle A (3 fixes from SOFTPHONE_POLISH.md UX-expert review). **A1 Header chrome matches admin language**: white header with border-b border-[#f0f0ec], navy #0f2744 title, rounded-xl shadow-lg ring-1 ring-foreground/10 chrome β€” replaces the third-party-widget-pasted-in look (solid emerald-700 header + amber/rose pills). Pills now use shadcn Badge primitives (destructive for Incoming, outline for Sign-in) β€” same components AdminCmdK uses. RcPresenceDot reused inline as the at-a-glance status indicator (same component AdminNav uses) so signed-in/ringing/on-call state is consistent across the shell. **A2 Always-mount iframe (kills cold-boot on inbound ring)**: pre-fix state === "open" &&