Skip to content

Green Wellness

Changelog

What’s new in each release of the scheduling platform

Show:
v2.97.TURNERPREP2current
2026-07-12Production
For everyone

The online booking flow can now run per-state — it shows each state's own questions and condition list — while Washington booking stays exactly as it is today.

What this means for you

Second groundwork release for the multi-state expansion (Doug: one flow that knows the difference in the states). The public booking wizard is now state-keyed: a patient arriving from a state's page will see that state's residency question, that state's official condition list, and that state's rules — while every existing entry point still runs the Washington flow unchanged. Also: honest-expectations FAQs on the new state pages, and a small states-menu registry built but deliberately not wired into the site menu yet.

Show technical details

Added

  • 🧭 **State-keyed booking flow (state-booking-flow.ts + wizard threading, dark).** One wizard, per-state config: residency question, under-18/non-resident copy, and the condition selector now come from stateBookingFlow(jurisdictionState). WA config is the verbatim legacy funnel (same strings, same CONDITIONS array object — pin-tested) and every existing entry point defaults to WA, so live booking is byte-for-byte unchanged. PA/OH flows render their own 24/26 official condition lists (ids = state-qualifying-conditions slugs, loss-free normalization downstream) and attest patientPhysicalState to their own state. A live state page's booking CTA passes &state=XX (shape-validated); unknown states fall back to the WA UI while the fail-closed write gate stays the real enforcement. MD/VA/MO/MN flow configs are a REQUIRED pre-flip step (noted in-module). 14 pin tests. (multi-state)(booking)(dark)
  • 🗂️ **States menu registry (states-nav.ts, NOT wired).** Tiny client-safe list of every state front page (WA + 6 expansion states) for the future SiteNav "States" dropdown — built now, deliberately unwired per Doug ("menu wiring later"), so wiring it is a one-import change. (multi-state)
  • 🛑 **Cert-issuance jurisdiction gate (cert-pdf-issue.ts).** Every cert artifact the pipeline can produce is a WASHINGTON legal document (legacy cert PDF + DOH 630-123, both RCW 69.51A) — an expansion-state visit could previously have fallen through and had a WA form printed for it. issueCertForAppointmentUnified now refuses (reason: jurisdiction-cert-artifact-missing) when Appointment.patientPhysicalState is set to any non-WA state, before the idempotency branch — same shape as the dob/email gates. null/WA = legacy funnel, unchanged. (multi-state)(hipaa-clean)(fail-closed)
  • 📋 **Expansion cockpit: platform-artifacts panel (/admin/expansion).** The launch-readiness card now shows the ENGINEERING half of each state's flip picture — public page, condition set, PDMP reference, booking flow, cert artifact, consent copy — derived live from the modules that implement each artifact (state-platform-readiness.ts), so the checklist can never disagree with the code. Decision-support only; the runtime gates are unchanged. (admin)(multi-state)

Changed

  • 🤝 **Honest-expectations FAQ on the PA + OH pages.** Both new state pages now carry the claim-class guard from the PA content plan verbatim in spirit: certification/recommendation is the physician's independent determination — you pay for the visit, never a guaranteed result. (content)(compliance-voice)
v2.97.TURNERPREP1
2026-07-12Production
For everyone

Groundwork for offering telehealth visits in Pennsylvania and Ohio is now in place behind the scenes — nothing changes for Washington patients or for how you work today.

What this means for you

We're preparing to offer medical-cannabis certification visits in more states, starting with Pennsylvania and Ohio, with a new physician who is licensed there. This release lays the technical groundwork: each state's official qualifying-condition list, its prescription-database rules, and a public 'coming soon' page where future patients can join a waitlist. None of it is switched on — every new state stays fully dark until the legal review is complete and the physician's licenses are verified — and nothing about Washington visits changes at all.

Show technical details

Added

  • 🗺️ **Per-state qualifying-condition sets (state-qualifying-conditions.ts, dark).** PA's 24 Serious Medical Conditions (verified against pa.gov 2026-07-12; the 2 research-only conditions — TBI, Type II diabetes — are an explicit deny-list that can never certify) + OH's 26 OMMCP conditions (with the Medical Board's arthritis/migraines/CRPS-under-chronic-pain determinations encoded as normalizer variants). WA delegates to the untouched RCW module — same list object, same normalizer output, pin-tested byte-for-byte. Unknown states fail closed (null, no cross-state fallback — wrong-state labels invalidate certs). 18 pin tests. Rebuilds + supersedes the lost feat/pa-state-aware-cert branch. (multi-state)(dark)
  • 💊 **Per-state PDMP reference (state-pdmp.ts) + state-keyed PdmpQuerySection.** Closes open-item D in each state's launch predicate: PA = PDMP review per 35 P.S. §10231.403(b) (pennsylvania.pmpaware.net), OH = MANDATORY OARRS query covering ≥12 months per OAC 4731-32-03 (ohio.pmpaware.net, lookback floor rendered in the UI). The encounter PDMP section now takes an optional jurisdictionState: absent = WA rendering verbatim-unchanged (pin-tested against the exact legacy strings); an unknown state fails closed with a manual-verify notice instead of deep-linking the wrong portal. New pin tests; all legacy WMC tier-1 pins still green. (multi-state)(provider)(dark)
  • 🌐 **Pennsylvania + Ohio public state pages (states-content.ts, waitlist mode).** /telehealth/pennsylvania and /telehealth/ohio join the coming-soon registry with verified state facts (PA: $50/yr card, MMAP $0 fee reductions, padohmmp registry, Act 44 telehealth-by-statute; OH: state fee eliminated 5/15/2024, CTR physician, digital card) + per-state city lists and FAQs. comingSoon governs teaser copy only — bookability stays owned by the DB release gate, so both pages render waitlist CTAs until counsel + credentialing flip the state live. (seo)(multi-state)

Changed

  • 🕶️ **Turner expansion seed now creates the provider DARK (isActive=false).** /api/public/providers lists active providers — the seed as written would have published 'Dr. Turner' on the public site before the NDA/IC are signed. Flip isActive by hand when he's papered. Also applied to prod: migration 87 (ProviderOnboardingAgreement e-sign table + per-state legal-reference columns — was never applied), canonical migration 88 (legalPredicateReviewedAt, backfilled from the divergent legalPredicateSetAt variant), and the seed itself — Dr. Turner provider row + 14 pending state-license shells + 14 state rules, every enforcementActive=false, zero live-behavior change. (multi-state)(dark)
v2.97.HIPAAGATE1
2026-07-12Production
For everyone

Four new automated safety nets now block the kinds of code mistakes that could leak patient information — nothing changes in how you work; the protection is behind the scenes.

What this means for you

We ran a full security review of the system (two independent deep audits) and the good news is everything live came back clean. What the review DID find is that a few of our automated pre-flight checks had blind spots — kinds of future mistakes they wouldn't have caught. So we closed them: the system now automatically blocks any new code that could show one partner dispensary another dispensary's patients, that could write patient details into server logs, that could put a patient's name into one of Doug's summary emails, or that could add an admin page without a login check. None of this changes anything at the desk — it just means whole categories of privacy mistakes now can't reach the live site at all.

Show technical details

Added

  • 🛡️ **check-dispensary-scoped-reads gate (HIPAAGATE1 — the AUDITFIX1-class permanent fix).** The 2026-07-07 cross-tenant leak was an UNSCOPED READ (db.appointment.findMany missing dispensaryId in the dispensary cert route) found by MANUAL audit; the existing check-dispensary-scoped-writes gate scans create/upsert only, so that exact class could recur with every gate green. New READ-side sister gate: in any dispensary-context file (under /api/dispensary/ OR reading the x-dispensary-id header — capability-derived, new surfaces covered by construction), every read op (findMany/findFirst/findUnique/+OrThrow/count/aggregate/groupBy) on any model NOT on the two-entry NON_PHI_MODELS allowlist (dispensary/dispensaryUser) must carry dispensaryId in the query window. Fail-closed on new files AND new models — exempting is the reviewed act. Negative-tested: synthetic unscoped findMany fails the gate; live tree passes (4 context files, 2 read sites, AUDITFIX1 fix confirmed still scoped). Registered in gates.manifest.mjs (HIPAA section) + package.json check:dispensary-scoped-reads. [hipaa][gates]
  • 🔦 **check-phi-in-logs multi-line blind spot CLOSED.** The gate's own header admitted 'Multi-line calls slip through (accepted false-negative)' — a console.error spanning lines (e.g. a template literal interpolating JSON.stringify(patient) on line 2) evaded the single-line 800-char capture and shipped PHI to the non-BAA Vercel logging plane with all gates green. Replaced with a paren-balanced multi-line extractor (string/template literals skipped opaquely, 4000-char cap bounds pathological input); all 5 existing violation classes now run over the FULL arg body. All exports/exemptions unchanged — 10/10 anti-divergence pin tests green; live tree stays at 0 violations; negative-tested with a multi-line stringify offender. [hipaa][gates]
  • 📬 **check-safe-harbor-digest-content: hardcoded allowlist → capability-derived discovery.** The non-BAA digest gate scanned a hand-maintained 13-file list — FAIL-OPEN on new files: a brand-new digest renderer to the owner's gmail was invisible until someone remembered to add it, so a patient.firstName interpolation in a new digest would have shipped full names to a non-BAA inbox uncaught (§164.514(b)(2)(i) breach vector). The gate now auto-discovers any src/lib or src/app/api file referencing the non-BAA recipient rail (OWNER_ALERT_EMAIL / the gmail literal) — scope grew 13→30 files, all clean. Also added full-row egress detection (JSON.stringify of a patient-shaped receiver / object spread / bare-object interpolation — never maskable, flagged regardless of mask helpers on the line). One LINE_EXEMPT: workflow.ts waitlist greeting (that email goes TO the patient over the BAA M365 rail, not to the non-BAA inbox). Negative-tested: a synthetic NEW digest file with an unmasked firstName is caught with zero list maintenance. [hipaa][gates]
  • 🚪 **check-admin-api-routes-self-auth gate — last un-gated route tier closed (2026-07-12 security-auditor recommendation).** Cron (60 routes) + portal tiers already had structural self-auth CI gates; /api/admin/* (213 routes) relied on the proxy edge gate alone — a proxy matcher regression or over-broad ADMIN_BEARER_ALLOW entry would leave a new admin route with NO auth. New gate (ported from the portal pattern): every admin route must call requireAdminFromHeaders / verifyAdminSession / verifyCronAuth, OR check the proxy-verified x-admin-id/x-admin-role headers inline, OR sit on a 4-entry justified PUBLIC_ALLOWLIST (login/forgot/reset pre-auth flows + the dedicated-bearer retell-sync trigger). 213/213 pass today; negative-tested with a synthetic bare route. [security][gates]

Fixed

  • 🪝 **Working-clone hook wiring.** The gwfix clone had .githooks/pre-push on disk but core.hooksPath unset — local pushes from this clone ran ZERO of the 69 gates (CI still ran them; the first line of defense was silently absent). core.hooksPath now points at .githooks. Full suite verified green post-wiring: 69/69 in --env=ci including both new gates. [gates]
v2.97.ROIFEE1
2026-07-11Production
For front desk

Records requests we run for a patient can now carry a $15 processing fee that shows up in the invoice queue automatically — switched OFF until Doug flips it on. Patient self-uploads stay free, always.

What this means for you

When you create a records-request form (us pulling records FROM a patient's old provider), a $15 processing fee row can now appear in the invoice queue on its own — same queue, same Poynt invoice steps, same Mark-paid button you already use, nothing new to learn. It won't double-charge a patient who already has one waiting, and if a patient uploads their own records that stays completely free. The whole thing ships switched OFF until Doug flips the switch, so nothing changes at the desk today. We also drafted (but are NOT yet sending) a kinder email for patients whose authorization is ready but held on an unpaid balance — it tells them plainly that one quick payment releases it, with a pay button and a no-worries reply option.

Show technical details

Added

  • 🗂️ **Records-request processing fee, DARK (ROIFEE1 / Q42, Doug accepted recs 7/11).** New RECORDS_REQUEST_PROCESSING CertServiceRequest type + RECORDS_REQUEST_FEE_CENTS ride the EXISTING invoice-queue rails — createPatientForm drops a PENDING fee row (best-effort, err.name-only logging, idempotent per patient while one is open) only when formType=RECORDS_REQUEST and ROI_PROCESSING_FEE_ENABLED=true (default OFF, .env.example documented). Fee note renders on /admin/forms/new from the shared constant; type/label support added to /admin/invoice-queue, /admin/mailing, and the patient portal requests page. String column — no migration; patient self-upload (PatientUploadedRecord) untouched and free; PENDING→PAID stays the existing type-agnostic PATCH. [payments][forms]
  • 💌 **Gated-authorization honesty email, DRAFT ONLY (Q41).** certGatedUnpaidEmail template in emails.ts — first-name-only (PHI-minimal), optional pay button + exact remaining balance, warm no-blame copy with phone + already-paid escape hatches. Deliberately NOT wired to any send path (the gate lives in auth-payment-gate.ts; the release path already emails the cert on payment) — pin test asserts it stays unwired until a deliberate, reviewed step. [emails]
v2.97.DEADCHAN1
2026-07-11Production
For everyone

A new automated safety check now blocks anyone from accidentally pointing patients at a fax or text line that isn't actually staffed.

What this means for you

You know how our fax line is in a transition and texting isn't switched on yet? A new automated check now makes sure no one can accidentally add new patient instructions — in emails, the chat helper, or Isabella's phone script — that point patients at a channel where nothing would actually arrive. The handful of existing fax mentions are catalogued and will be cleaned up together once Doug settles the new fax setup. There's also a new quarterly checklist so we regularly confirm every phone number, email, and fax we give patients really reaches a person.

Show technical details

Added

  • 📠 **Dead-channel patient-copy gate (DEADCHAN1, Q31).** New scripts/check-dead-channel.mjs (sister of check-contact-ssot, wired into package.json + gates.manifest for pre-push + CI) reads a new reviewed-code designation registry, scripts/channel-designations.mjs — fax=DARK (legacy AT&T/RC line; inbound gated on Notifyre DID + BAA, DOUG_GATES Q38) and sms-patient-phi=DARK (Telnyx BAA pending, no public DID). Any NEW reference to a DARK channel in patient-facing code (src/ minus admin surfaces + tests) fails the build — including Isabella's spelled-out fax digits ('eight eight eight, five oh four, six one two nine') and copy that renders the handle via the FAX constant import. Designation flips are reviewed PRs: the guard can't read prod env, so the registry IS the designation SSoT. [hipaa][gates]
  • 🧮 **Baseline ratchet for the Q32 fax copy.** The 13 existing fax references in patient copy (voice-prompt spelled digits + comment literal, voice/booking tools, chat + SMS + email AI prompts, booking-confirmation + records-reminder + records-upload-invite email templates, booking UI ×2, records-request PDF) are baselined pending the DOUG_GATES Q32 fax-copy strip/update. The gate passes on baseline entries, fails on any NEW reference, and fails when a baseline entry stops matching (stale-baseline cleanup signal) — the baseline shrinks to zero when Q32 lands and never grows. Pin tests in src/lib/__tests__/check-dead-channel.test.ts. [hipaa][gates]
  • 🗓️ **Quarterly dead-channel + designation checklist.** New docs/GW_DEAD_CHANNEL_AND_DESIGNATION_CHECKLIST.md runbook — PASS = the 4 hipaa-architect 2026-07-07 invariants (Privacy Officer named per §164.530(a) + breach contacts live/staffed/BAA-covered; every PHI transport has a signed unexpired BAA or stays flag-OFF; no dead channel wired to any patient path, handles enumerated + probed, spelled-digits trap called out with the Retell prompt-sync requirement; no PHI at the cross-tenant boundary). Documents how designations flip (reviewed PR; a new provider usually means a NEW number — update patterns too) and the baseline-ratchet rule. [hipaa][docs]
v2.97.LICEXP1
2026-07-11Production
For front desk

Provider license expiry dates now live on the Providers page — with a gentle heads-up 90 days before a license lapses, so renewals never blindside anyone.

What this means for you

Each provider's profile now has a 'WA license expires' date field, and once a date is entered you'll see it right on the provider list. The admin warning banner gives you an amber nudge when a license is within 90 days of lapsing and a red flag if one has already expired — plenty of runway to get the renewal filed. Entering dates is completely optional: a provider without a date entered is simply left alone, and nothing blocks visits or authorizations because of an expiry date until Doug says so.

Show technical details

Added

  • 🪪 **Provider.licenseExpiresAt (Q10 LICEXP1, Doug 'yes to all recs' 7/11).** New nullable expiry-date column (prod-migration-110-provider-license-expires-at.sql, expand-only, STAGED not applied — Doug runs it by hand against Neon before/with merge). NULL = inert. ADVISORY ONLY — deliberately not wired into cert-pdf-issue.ts; no hard-gate on cert issuance. [providers]
  • 📅 **/admin/providers 'WA license expires' date field.** Edit-form date input next to the WA License #; the provider list shows the date with a red EXPIRED / amber expires-soon (≤90d) hint. Sent through the existing PATCH (zod-validated ISO date string; empty string clears to NULL). [providers]
  • ⚠️ **Preflight banner license-expiry warnings.** Expired licenses = high ('update the expiry date or deactivate the provider'); expiring within 90 days = med; both link to /admin/providers. Providers without a date entered are never flagged. [providers]
  • 🚦 **/admin/launch 'Provider license expiry' readiness tile.** Blocker if any active provider's license is expired, caveat if any expire within 90d, else ready — an advisory readiness signal, not an issuance gate. [providers][launch]
v2.97.Q40PREP1
2026-07-11Production
For everyone

Groundwork for patient self-scheduling: the online booking wizard now points at our real payment rails, and telehealth bookings are double-checked against the patient's original clinic.

What this means for you

Two readiness fixes for the day Doug turns on patient self-scheduling. First, the wizard's payment step used to fall back to a card form wired to a test-only processor — a dead end where patients could never actually pay. It now uses the same rails staff bookings use: the appointment books, and the confirmation email carries a secure pay link. Second, telehealth renewals have always been scheduled by the clinic the patient was previously seen at — Isabella asks this on the phone, but the online wizard and the booking system itself never checked. The wizard now asks the same question, and the booking system verifies the picked time is actually on that clinic's covering schedule. Nothing changes for patients today — the self-scheduling wizard itself stays off until Doug flips it.

Show technical details

Changed

  • 💳 **Wizard payment branch repointed off the Stripe test-key path (Q40a).** StepPayment's un-flagged default is now the DEFERRED panel (books via /api/appointments with no payment intent; the booking-confirmation email attaches the signed /pay HMAC pay-link — the exact rail staff-manual bookings use). The Poynt pay-to-confirm branch is unchanged and still takes precedence when SELF_SCHED_PAY_TO_CONFIRM is on; its server-flag-disagree fallback also lands on deferred now instead of Stripe. Stripe Elements survives ONLY behind the new explicit NEXT_PUBLIC_STRIPE_WIZARD_ENABLED legacy opt-in. [booking][payments]

Added

  • 🗺️ **Telehealth prior-clinic rule enforced server-side (Q40b).** POST /api/appointments (the chokepoint every booking source flows through) now accepts priorClinic (lynnwood|olympia|spokane) and rejects a TELEHEALTH booking whose slot provider isn't in getRenewalProvidersForPriorClinic coverage for the named clinic (409, refund-safe, before the slot claim) — previously TELELOC1 lived only in the Isabella/chat tool layer and a direct POST bypassed it. Missing-priorClinic hard-require is behind TELEHEALTH_PRIOR_CLINIC_REQUIRED (default OFF; flip with the wizard flags). [booking][telehealth]
  • 🧭 **Wizard + proposal threading for the prior clinic.** Step 3 asks a returning telehealth patient which clinic they were previously seen at (same question Isabella asks) and the answer rides the booking POST; chat/email proposals (BookingProposalPayload) now carry priorClinic from proposeBooking through confirmBooking so the tool lane satisfies the chokepoint gate too. Older signed proposals without the field keep working. [booking]
v2.97.DOUGGATE1
2026-07-11Production
For front desk

Three things you asked for: patients on state assistance now show a $15-off badge everywhere, partial payments can finally be topped up instead of dead-ending, and full phone numbers on the Isabella call log are built and waiting on Doug's switch.

What this means for you

Kat's three requests all landed. Patients who say they're on Social Security or low income can be marked on their profile with one button (no paperwork — Doug's honor-the-patient ruling); a green 'State assist · $15 off' chip then follows them on the Today board and the payment popup pre-checks the discounted rate, so the patient never has to correct you about their own discount. Partial payments no longer dead-end: a partially-paid appointment keeps a 'Record top-up' button that pre-fills the balance due and adds to the running total, and the certificate still waits until the visit is fully paid. And the Isabella call log can now show full, tappable phone numbers for callbacks — that one ships switched OFF until Doug flips it on.

Show technical details

Added

  • 🟢 **State-assistance flag (STATEASSIST1, cmrfa7m5).** New Patient.stateAssistanceEligible (prod-migration-109, expand-only) + front-desk toggle on the patient profile (self-attest per Doug 7/10 ruling, no proof docs — heroesEligible posture) + emerald chip on /admin/today + mark-paid modal pre-checks paidInFull with the $15 note. Set/cleared via POST /api/admin/patients/state-assistance (ADMIN/MANAGER/SCHEDULER, UPDATE_PATIENT audit). [patients][payments]
  • 💵 **Partial-payment top-up (TOPUP1, cmrf6o1b).** mark-paid's 409 guard relaxed from any-sentinel to genuinely-fully-paid; a partial now accepts a top-up that ACCUMULATES amountCollectedCents (amount required — can't accumulate null; original sentinel kept for downstream prefix parsers; top-up method/ref/amount in the MARK_PAID audit row). 'Record top-up' button on Today board + appointment detail, balance-due banner, pre-filled amount. Cert auto-release unchanged — releaseGatedAuthForAppointment still requires isAppointmentFullyPaid. [payments]
  • 📞 **Isabella full-contact widening, DARK (ISAFONE1, cmrfabln pt 2).** ISABELLA_FULL_CONTACT=true (env, default OFF) adds unmasked tel: links to the voice call log — same minimum-necessary rationale + flag shape as DEMI_FULL_CONTACT; VIEW_ISABELLA_COCKPIT audit rows carry fullContact=on while active. Doug flips the env (needs a real commit for pickup). [isabella][hipaa]

Fixed

  • 🔒 **Patient-ID oracle closed (AVAILORACLE1).** Public /api/availability no longer accepts or looks up ?patientId= (a valid id changed the telehealth provider pool → attacker could confirm ids exist by diffing responses). The only real caller (/admin/appointments/new, authed) now derives the routing client-side and passes non-PHI ?telehealthClinic=olympia|general; stray legacy patientId params are ignored without lookup. [security]
  • 🛡️ **Cron self-auth CI gate (CRONGATE1).** New scripts/check-cron-routes-self-auth.mjs (registered in gates.manifest + check:cron-self-auth) walks src/app/api/cron/**/route.ts and fails the push if any route skips verifyCronAuth — 60/60 green at introduction; empty allowlist by design. Closes the 'new cron route ships publicly reachable' gap. [security][cron]

Changed

  • 🧹 **Salesforce W2L readiness retired (SFRETIRE1, Doug ruling).** The rail was decommissioned 2026-05-24 (no BAA; postToWebToLead hardwired skip) but its hardwired-false probe kept ONE permanently-red issue in /api/health readinessHint + a stale SF_W2L_OID preflight banner on every render — noise that buried real rail failures. Probe + banner removed; decommission stub + pinned tests remain as the re-enable-only-if-BAA record. [health]
  • 📝 **RC softphone secret documented (RCRESTRICT1).** NEXT_PUBLIC_RC_CLIENT_SECRET added to .env.example with the exposed-by-design warning + rotate-on-offboarding rule; console-side scope/redirect restriction checklist + PKCE support-email draft handed to Doug (Desktop docx). [docs]
v2.97.DEPREMIND2
2026-07-10Production
For front desk

Deposit reminders are ON — and patients will only ever get them between 8am and 8pm.

What this means for you

Doug armed the unpaid-deposit reminder lane tonight. Before flipping it on we added quiet hours: the automation checks every hour, but patient emails/texts only send between 8am and 8pm Pacific — nobody gets a payment nudge at 3am. The 10-hours-before staff heads-up is deliberately NOT quiet-hours-gated (it goes to the front-desk mailbox, not a patient — an early-morning appointment's cutoff lands overnight and shows up when you open email). Reminders start flowing with the first eligible unpaid-deposit booking.

Show technical details

Changed

  • 🌙 **Patient quiet hours 8am–8pm PT on the deposit-reminder lane (DEPREMIND2), then armed LIVE.** isWithinPatientSendWindow() (DST-safe PT wall clock) holds the once-ever ladder outside the window; the T-10h DEPOSIT_STAFF_ALERT is exempt by design (front-desk mailbox, overnight cutoffs must not be lost — structural pin covers the ordering). DEPOSIT_REMINDER_ENABLED=true set in prod; this deploy is the env pickup. [payments][cron][deposit]
v2.97.DEPREMIND1
2026-07-10Production
For front desk

Unpaid booking deposits now chase themselves — patients get automatic reminders with their pay link, and the front desk gets ONE heads-up email when a deposit is still unpaid 10 hours before the visit. Nothing auto-cancels.

What this means for you

Mariane asked for this on 7/03 and Doug approved the shape on 7/04: appointments booked with a $50 deposit that never gets paid now get automatic reminder emails (and texts, when the patient consented) with the secure pay link — one about a day after booking, another 3 days out, and a final one the day before. If the deposit is STILL unpaid 10 hours before the appointment, the front-desk mailbox gets a single heads-up so you can call the patient or release the slot — per Doug's ruling nothing is ever cancelled automatically. Each reminder sends at most once, shows up in the patient's message log like any other outreach, and the whole lane stays off until Doug arms it.

Show technical details

Added

  • 💰 **Pre-visit unpaid-deposit reminder ladder + T-10h staff notify (DEPREMIND1 — closes reviewer-feedback cmr5rnw0i).** New hourly cron /api/cron/deposit-reminder: r1 at booking+20h, r2 at T-72h, r3 at T-26h (each once ever, 6h min gap, PatientMessage.externalId dedup — payment-chase substrate), then a one-time front-desk email at the T-10h cutoff with an /admin deep link. NO auto-cancel — Doug §E ruling 2026-07-07, pinned in tests (route never writes Appointment.status). Eligibility mirrors the /pay deposit-button condition exactly (isDepositPayOptionEligible + bookingDepositInScope + fail-closed pricing), so a reminder is never sent that the pay page won't honor. Ships DARK behind DEPOSIT_REMINDER_ENABLED (+ goes quiet if BOOKING_DEPOSIT_ENABLED is off); heartbeat fires every authed run so the actor never reads stale while dark. New audit actions DEPOSIT_REMINDER_SENT / DEPOSIT_STAFF_ALERT (PHI-free detail). [payments][cron][deposit]
v2.97.CHARTCONTACT1
2026-07-10Production
For providers

The patient identity card on the chart now shows phone and email alongside name, DOB, and address.

What this means for you

Providers asked for patient contact information on the chart ('I can't see any patient address or contact information' — Dr. Ari's 7/1 email, item #2a). The identity card on the encounter chart now includes Phone and Email next to the existing name / DOB / address. Empty fields read '— not on file —' so a blank means the data was never captured at intake, not a broken screen.

Show technical details

Added

  • 📇 **Chart identity card gains Phone + Email (CHARTCONTACT1).** Identity card on /provider/portal/encounters/[id] retitled 'Patient identity & contact' and now renders phone + email (with '— not on file —' empties). Recovered from draft PR #8 (#2a, the display-only piece) after Dr. Ari's 7/8 deactivation unblocked the safe subset. [provider][chart]
v2.97.KATPAID1
2026-07-10Production
For front desk

Mark paid now immediately refreshes the Today board — the green 'Paid' badge appears right away instead of waiting for the next 30-second poll.

What this means for you

When you mark an appointment as paid on /admin/today, the payment badge now flips to 'Paid' instantly (same session, no wait). Previously the board didn't know about the change until its next auto-refresh (up to 30 s), so the card still showed 'Unpaid' momentarily.

Show technical details

Fixed

  • ✅ **Today board: immediate paid-status refresh after mark-paid (cmrf6mc5 — Kat).** MarkPaidButton now fires the Today client's load() callback on success so the PaymentBadge flips from 'Unpaid' → 'Paid (method)' without waiting for the 30 s poll. [admin-today][payments]
v2.97.KAT1
2026-07-10Production
For front desk

The voice call log on the Isabella cockpit now shows up to 500 calls — use the Rows dropdown to pick 250 or 500.

What this means for you

Kat's feedback: the voice log was capped at 100 rows, which only covered about the last hour and a half of calls. The Rows dropdown on the Isabella cockpit (admin/isabella) now offers 250 and 500 options so you can scroll back through a full day of calls without switching pages.

Show technical details

Changed

  • 📋 **Isabella cockpit voice log row cap raised to 500 (KAT1).** Rows dropdown now offers 20 / 50 / 100 / 250 / 500; query hard-cap raised from 100 → 500. Kat feedback: 'this is only the last hour and a half.' [isabella][voice-log]
v2.97.KOKPIT1
2026-07-10Production
For front desk

Isabella cockpit: voice call log now appears above the email log (calls first).

What this means for you

On the /admin/isabella cockpit, the Voice Call Log section now appears before the Sent Email Log — phone calls are the higher-priority channel and should be easier to spot at a glance.

Show technical details

Changed

  • 📞 **Isabella cockpit: voice call log promoted above email log (KOKPIT1).** Reordered Zone G (VoiceCallLog) to render before Zone E (SentEmailLog) on /admin/isabella. Closes reviewer-feedback cmrf59to8000404jok57wnppi. [cockpit][isabella][polish]
v2.97.HARDSHIP3
2026-07-10Production
For front desk

A patient who pays exactly the $130 assistance rate now automatically counts as PAID IN FULL — no more false '$15 balance due' on people who owe nothing, no matter how the payment came in.

What this means for you

Doug's call tonight: 'remove the $15 for them.' Until now, only two paths recorded the Social-Security/low-income/veteran discount — the patient checking the box on the pay page, or staff ticking 'paid in full' on Mark-paid. A $130 payment arriving any other way (staff pay-link, terminal) read as a partial with a phantom $15 balance, and the authorization held as unpaid. Now: any payment that lands exactly $15 short of the standard fee is recognized as the discount paid in full, on every payment path. Today's affected patient was corrected retroactively. Anything that isn't exactly the $15 discount still shows its real balance — genuine partials don't get hidden.

Show technical details

Fixed

  • 💚 **Hardship-rate auto-stamp (HARDSHIP3, cmrfb90h pts 3-4).** releaseGatedAuthForAppointment (runs via after() on every payment path: /pay Collect, Poynt webhook paid+additional, Stripe webhook, mark-paid) stamps discountCents=RENEWAL_HARDSHIP_DISCOUNT when collected == undiscounted expectedAppointmentFeeCents − $15 EXACTLY and no discount recorded; guarded updateMany (discountCents:null) = once-only under webhook races; audited actor=system. One-row prod backfill applied (cmrbiekj, audited actor=backfill). Exact-match only — arbitrary shortfalls stay partial. [payments][auth-release]
v2.97.GRIND2
2026-07-10Production
For front desk

Payment buttons on Today now know the visit type — renewal vs new patient with the right amount pre-filled — plus honest partial-payment labels, call-log summaries on every row, and expired patient links now offer a fresh one instead of a dead page.

What this means for you

Evening sweep, five fixes. (1) The Bill-via-Poynt and Mark-paid buttons on Today show a renewal-vs-new-patient chip and pre-fill the CORRECT fee (renewal $145, new $175, discounted $130) instead of always $175. (2) The morning payments strip no longer shows phantom balances on renewals — it now uses the same fee math as everything else. (3) A partial payment only says 'Deposit paid' when it's actually the $50 booking deposit; anything else reads '$X paid · balance due $Y'. (4) The Isabella call log shows the call summary on every row instead of '(no transcript summary)'. (5) When a patient clicks an emailed link after it expired, they get a one-click 'email me a fresh link' page instead of a dead 404 — this also un-breaks the new records-nudge emails.

Show technical details

Fixed

  • 💳 **Visit-fee context at point of payment (cmrf7q1n).** BillViaPoyntButton/MarkPaidButton accept apptType/isNew/discountCents; Today board passes them + renders a renewal-vs-new chip; modals show an expected-fee banner and pre-fill from expectedAppointmentFeeCents instead of hardcoded $175. [payments][today]
  • 🧮 **today-payments strip fee math (cmrfb90h).** expectedDollars now derives from expectedAppointmentFeeCents (type+isNew+discountCents) — the local ternary ignored both, so in-person renewals showed phantom $30-due and $130-discounted renewals phantom $15-due. [payments]
  • 🏷️ **Honest partial labels (cmrfb90h).** PaymentBadge only says 'Deposit paid' when collected ≈ the $50 booking deposit; other shortfalls render '$X paid · balance due $Y'. [payments]
  • 🗣️ **Voice-log summaries (cmrfabln pt 1).** Zone G rows prefer persisted aiCallSummary (scrub-then-trim, 120c) — only ~half of CALL rows have a subject, so most showed '(no transcript summary)'. [isabella]
  • 🔗 **Expired-link refresh (LINKFRESH1).** Expired-but-authentic portal magic links render a one-click 'email me a fresh link' page (signature re-verified server-side; forged tokens still 404). Fixes the records-nudge lane emailing 15-min links from a daily cron, and every stale staff-sent link. [portal]
  • 📪 **Records-nudge hardening.** Lane now honors contactFlag + emailBouncedAt, and writes its 14-day dedup row fail-closed BEFORE sending (a silent log failure could re-nudge the same patient daily). [records][cron]
  • 🛡️ **Security sweep smalls.** Lead-document viewer gets the same CSP ('script-src none' + nosniff) as the patient-document route (stored-SVG XSS); SVG upload sanitizer now rejects script-less XSS vectors (event handlers, javascript:/data: hrefs, foreignObject); admin bearer-allow proxy branch strips spoofable x-admin-* headers. [security]
v2.97.GATEFIX1
2026-07-10Production
For everyone

Tonight's queued improvements (calendar colors, call-log badges, renewals cockpit, records nudge) are now actually live — a deploy-gate conflict had been holding all of them back.

What this means for you

No behavior change in the app itself. The PHI-in-logs deploy gate was blocking every production build because the renewals cron logs a truncated error snippet — a deliberate fix from the July silent-outage post-mortem (error-name-only logging hid a 9-day failure). The renewals cron is now a documented exemption (same treatment as the two sister crons), so the six queued releases from tonight ship with this one.

Show technical details

Fixed

  • 🚦 **Deploy unblocked (GATEFIX1).** check-phi-in-logs vs RENEWHARD1 collision: cron/renewals message-HEAD logging (160-char slice, justification in source) added to PHI_LOG_EXEMPT (8/10 cap) instead of reverting to the err.name-only blindness the 7/02→7/10 outage post-mortem removed. Six blocked releases (KATFIX1-4, RENEWCOCKPIT1, RECNUDGE1) ride this deploy. [infra][compliance-gate]
v2.97.RECNUDGE1
2026-07-10Production
For front desk

Booked patients with NO records on file now get an automatic secure-upload nudge before their visit — the gap behind today's 'records in PF but not Flow' morning.

What this means for you

From the patient-flow audit: the records-reminder emails only ever chased LEADS (48 leads, zero patients in 60 days) — a patient could book, confirm, and arrive with an empty chart and nothing automated ever asked them for records. The patient self-upload rail had literally never been used because nothing sent its link. New lane on the same daily cron: any patient with an appointment in the next 7 days, a real email, and ZERO documents on file gets the same secure-upload invite the staff button sends (magic link to their portal upload page). One nudge per patient per 14 days, 25 per day max, logged in the message history, BAA-safe send. The lead lane is unchanged.

Show technical details

Added

  • 📎 **Booked-patient records nudge (RECNUDGE1).** records-reminder cron gains a pre-lead lane: upcoming ≤7d SCHEDULED/CONFIRMED × real email × 0 MedicalDocument/PatientUploadedRecord → recordsUploadInviteEmail w/ minted portal token; dedupe via CommunicationLog templateType=RECORDS_NUDGE (14d); cap 25/fire; heartbeat carries patientNudges. [records][cron]
v2.97.KATFIX4
2026-07-10Production
For front desk

Isabella's call log now flags callers who have an appointment TODAY — with the time and which clinic (or Telehealth) right on the row.

What this means for you

Kat's rows cmrf5efv ('when one of these phone calls is an appointment today it should be flagged') and cmrf5fe9 ('we should also be marking these calls with what location') in one: every call in the voice log whose matched patient has a scheduled/confirmed appointment today now carries an amber '📅 today 2:30 PM · Lynnwood' badge (or '· Telehealth'). One batched lookup per page — no per-row queries.

Show technical details

Fixed

  • 📅 **Same-day + location badges on the voice log (KATFIX-cmrf5efv + cmrf5fe9).** getVoiceCallLog enriches rows with the matched patient's TODAY appointment (PT-day bounds, SCHEDULED/CONFIRMED, batched in-query); VoiceCallLog renders the amber badge with time + clinic/Telehealth. [isabella][calls]
v2.97.KATFIX3
2026-07-10Production
For front desk

The admin calendar now color-codes telehealth (blue) vs in-person (green) and says which is which on every block — with a legend.

What this means for you

Kat's row cmrexvu6: calendar blocks were colored by status only, so you couldn't tell telehealth from in-person at a glance. Now: telehealth = blue, in-person = green, cancelled/no-show wash out gray with a strikethrough; every block says 'tele' or 'in-person' next to the patient name, the hover tooltip spells it out, and a legend sits above the grid.

Show technical details

Fixed

  • 🗓️ **Calendar type color-coding + labels + legend (KATFIX-cmrexvu6).** Visit TYPE drives block color (sky/emerald), status stays in tooltip; cancelled/no-show gray + line-through. [admin][calendar]
v2.97.KATFIX2
2026-07-10Production
For front desk

You can now read what Isabella actually sends patients — her outbound recap emails store their full text in the message timeline, not a 2-line stub.

What this means for you

Kat's row cmrf36n1 ('Why can't we read what Isabella is sending out?'): outbound post-call recap emails were logged with only a summary stub ('Post-call confirmation recap sent. Appointment type: …') by an over-cautious early design — while inbound emails and staff-composed messages already store full content. The ledger row now leads with the rendered plain text of the email the patient actually received (up to 2000 characters), followed by the structured summary lines. Applies to recaps sent from now on; historical rows keep their stubs (the full sent copies remain in the M365 Sent folder).

Show technical details

Fixed

  • 📧 **Outbound recap emails store their sent text (KATFIX-cmrf36n1).** sendVoiceCallSummaryEmail returns the tag-stripped rendered body (≤2000c); the Retell webhook's PatientMessage OUT row leads with it. Ledger parity with inbound/staff-composed rows. [isabella][messages]
v2.97.KATFIX1B
2026-07-10Production
For front desk

Two of Kat's morning asks: Isabella message threads now read in date order, and Bill-via-Poynt shows a lasting 'Invoice sent ✓' instead of the button just sitting there.

What this means for you

(1) cmrextfi: the patient-chart conversation thread mixed its order — the server sent newest-first while new sends appended at the bottom, so mid-thread jumps were everywhere. The thread now always renders oldest → newest like any conversation, no matter how the data arrives. (2) cmrf9lgu: after billing via Poynt, the only confirmation was a toast that faded — the button now flips to '✓ Invoice sent — bill again' with a 'payment request sent' note that stays put.

Show technical details

Fixed

  • 💬 **Chart conversation thread chronological (KATFIX-cmrextfi).** CommunicationPanel sorts by occurredAt ascending at render — prop order and append order no longer matter. [admin][messages]
  • 🧾 **Bill-via-Poynt persistent confirmation (KATFIX-cmrf9lgu).** sentConfirmed state flips the trigger to '✓ Invoice sent — bill again' + inline note after either success path. [admin][payments]
v2.97.RENEWCOCKPIT1
2026-07-10Production
For front desk

The renewals cockpit is live: a worklist of EVERYONE expiring (−60 to +90 days) — and click any patient for their full outreach story: every email and what it said, replies, calls with Isabella's summaries, bookings.

What this means for you

Doug's afternoon ask. /admin/renewals-pipeline now leads with the renewal WORKLIST — every patient whose authorization expires from 60 days ago to 90 days out (red = overdue), with their stage (booked · outreached · NO OUTREACH YET), last touch, a 💬 when they replied after our last touch, and their next appointment. Click a patient for the full timeline: every renewal reminder (from tonight onward, the exact text sent), every email/SMS either direction, every call with Isabella's summary, bookings — newest first, with a jump to the chart. Morning grind: work the red 'no outreach yet' rows top-down and chase the 💬 replies first. Also: the renewal sender now counts and names its failures in the daily heartbeat — the silent-death mode that hid the July outage is structurally gone.

Show technical details

Added

  • 📊 **Renewal worklist + per-patient timeline (RENEWCOCKPIT1).** List drives off Patient.certExpiryDate (window −60d…+90d, cap 400) with LEFT-joined pipeline stage, derived booked-from-appointment, last-touch = max(PatientMessage OUT, WorkflowEvent RENEWAL_*), replied detection; NEW drill-in /admin/renewals-pipeline/[patientId] merges WorkflowEvent + CommunicationLog(RENEWAL) + PatientMessage(email/SMS/CALL w/ AI summaries) + Appointments + pipeline stamps. RBAC ADMIN/MANAGER/SCHEDULER + render re-check; new audit action VIEW_RENEWAL_PATIENT_TIMELINE (counts-only detail). Follow-up queued: claim/snooze/note work-lane (expand-only migration). [renewals][cockpit]
  • 🩺 **Renewal sender hardening (RENEWHARD1).** Per-touch catch logs the error-message head (160c, PHI-safe) not just err.name; blockFailures counted into the heartbeat + response — sent=0-with-failures can never read healthy again (the exact blindness that hid the 7/02→7/10 outage). Sends also log subject/body-head via logCommunication so timelines show WHAT was sent. [renewals][observability]
v2.97.AUTHUP2
2026-07-10Production
For front desk

Bulk authorization upload for providers (drop all of today's scans at once — files match to patients by name, provider confirms each) — upload rail LIVE-TESTED end to end. Plus: renewal emails now mention the $130 Social Security / low-income rate.

What this means for you

Two Doug asks. (1) BULK UPLOAD: on the provider Today page, 'Bulk upload signed authorizations' — pick every scanned file at once; each filename is matched to one of today's patients (e.g. smith-jane.pdf → Jane S.), the provider SEES and confirms every match before anything uploads (a guess is never silently committed into the wrong chart), then all files post through the same secure rail. (2) VERIFIED FOR REAL: a disposable test provider + appointment in production, a real login, a real PDF uploaded, downloaded back byte-exact, an upload against ANOTHER provider's appointment correctly REJECTED (chart scoping enforced), every test fixture deleted. (3) Renewal emails now tell SSDI/SSI/state-assistance/veteran patients their renewal is $130 with the check-the-box instruction.

Show technical details

Added

  • 📤 **Bulk signed-authorization upload w/ fuzzy filename→patient matching (AUTHUP2).** _BulkAuthUpload on /provider/portal/today; last-name-weighted token scoring on a non-rendered matchKey (display stays list-redacted per the PHI pin — buildAuthUploadMatchKey helper keeps the page clean); confident matches (score ≥2, no tie) pre-fill, everything else demands an explicit pick; sequential posts to the existing provider-scoped rail. LIVE E2E: bridge 307→cookie, POST ok, GET round-trip 200/209B, foreign-appointment 404, fixtures deleted. [provider-portal][records]
  • 🏷️ **$130 hardship rate named in renewal emails (HARDSHIP2, Doug 'yes add it').** Shared HARDSHIP_RATE_LINE_HTML at the 3 renewal price mentions — 'On Social Security (SSDI/SSI), state assistance, or a veteran? Your renewal is $130 — just check the box on the payment page.' Ties to the /pay self-attest checkbox. [renewals][emails][copy]
v2.97.AUTHUP1
2026-07-10Production
For front desk

Providers can now upload each patient's signed authorization straight from their Today page — built for Dr. Morical's Friday clinic.

What this means for you

Doug's ask: Dr. Morical needs a secure place to upload today's signed certifications, per patient. The secure upload rail already existed server-side (provider-login-gated, each upload locked to that provider's own appointment, stored in PRIVATE blob storage with virus-safe file checks and a PHI-free audit trail) — it just had no button. Now every appointment row on /provider/portal/today carries 'Upload signed authorization' (PDF or phone photo; multiple files per patient supported). Login flow for Dr. Morical: her provider profile needs an email on file, then the portal's set-password link goes out and she signs in at greenwellness.org/provider/login → Today.

Show technical details

Added

  • 📤 **Per-appointment signed-authorization upload on the provider Today page (AUTHUP1).** New _AuthUploadCard client component → existing POST /api/provider/documents (provider-scoped appointment check, private blob, compression + MIME allowlist, PROVIDER_DOCUMENT_UPLOADED audit). Sits outside the chart Link so row navigation is untouched; accepts PDF/JPG/PNG/HEIC; multi-upload per patient. [provider-portal][records]
v2.97.RENEWFLOW1
2026-07-10Production
For front desk

Patient-flow gap fixes from the CX audit: the one-click renewal email link works now, the online calendar can no longer show already-booked times, /get-started stops promising same-day visits we can't give, the closed Spokane clinic is off the site, and every email tells the same story about the $125 balance.

What this means for you

From the patient-flow audit Doug ordered: (1) the personalized 'Book my renewal' email link used to drop patients on a bare homepage — it now opens the booking form pre-set to returning. (2) The online booking calendar now filters out times a provider already has booked — the double-booking class Kat caught this morning, closed on its last surface. (3) /get-started promised 'often same day' while new patients are Fridays-only through late July — the copy now tells the truth for the window and reverts on its own. (4) Spokane (closed) is no longer offered; locations are Lynnwood, Olympia, Seattle. (5) The balance is described ONE way everywhere: collected at your visit, authorization issued once paid in full. (6) The cert email links straight to the patient portal.

Show technical details

Fixed

  • 🔗 **/renew one-click link un-dead-ended (RENEWFLOW1).** BookingParamHandler consumes renewAuthId → opens the modal type=returning + prefill (until the wizard flips on and takes over the params natively). [renewals][booking]
  • 🧑‍⚕️ **SLOTSAFE2 — provider-collision filter on /api/availability** (month + day modes): the wizard/reschedule pickers share the same offer-time truth voice/chat got this morning. [slots][booking]
  • 📄 **/get-started truth pass:** window-aware scheduling tile (Fridays-only copy self-reverts after 7/24 via isFridayOnlyCoverageWindow) + closed Spokane clinic removed from the in-person list (Lynnwood/Olympia/Seattle). [copy][truth]
  • 💵 **One balance story:** 3 conflicting framings unified — 'collected at your visit; authorization issued once you're paid in full.' Cert email's portal mention now links the portal. [emails][copy]
v2.97.INBOXQUIET1
2026-07-10Production
For front desk

Unsigned consent forms now chase YOU — a new list on the Today page shows every patient with an upcoming visit who hasn't signed, with a one-click resend.

What this means for you

The Today page has a new 'Consents to chase' list: every patient with a visit in the next 7 days who hasn't signed their informed consent, showing how long the form has been sitting unsigned and a Send button right there — so it's signed before they walk in. Each patient's profile shows the same unsigned-consent banner with days outstanding and a resend button. The form-signed and new-lead notification emails that were cluttering inboxes are off; everything lives on these admin surfaces now (failed fax/email deliveries still alert).

Show technical details

Added

  • 📝 **Consent-chase panel on /admin/today (INBOXQUIET1).** lib/consent-chase.ts (landed a commit early via the RENEWFIX1 sweep): appointments in the next 7d (SCHEDULED/CONFIRMED/PENDING_APPROVAL) whose patient has no SIGNED INFORMED_CONSENT or NEW_PATIENT_PACKET PatientForm — deduped to earliest visit per patient, soonest first. Rows show visit date (bold ≤2d, TODAY/tomorrow callouts), consent state (never sent / sent Nd ago / opened but unsigned) + inline SendConsentFormButton reuse. Metadata-only VIEW_CONSENT_CHASE_PANEL audit row (actor + count), AdminTodayTiles-style degrade-to-null resilience. [consent][today]
  • 📝 **Unsigned-consent banner on the patient profile.** Renders when the patient lacks a signed consent AND has an upcoming visit or a stale outstanding send — days outstanding + next-visit countdown + inline resend. Escalates amber→rose when the visit is ≤2 days out. Derived from the already-loaded patientForms + upcomingAppts (zero new queries on the high-traffic chart page). [consent][patient-profile]

Changed

  • 📪 **Per-event staff-alert emails are now opt-in (Doug: 'those should all just go through flow').** With OWNER_ALERT_EMAIL/ADMIN_NOTIFY_EMAIL empty in prod, resolveStaffAlertRecipients() bottomed out at 'all active ADMIN users' — every new lead AND every signed form (the informed-consent pings) emailed Doug's personal inbox. sendLeadStaffAlert now gates on LEAD_STAFF_ALERT_EMAIL=true; sendFormStaffAlert gates signed/delivered on FORM_STAFF_ALERT_EMAIL=true. delivery_failed is NEVER gated (terminal fax/email failure to a provider office still alerts). recipientsOverride bypasses (test surfaces). Weekly stale-lead-escalation cron untouched — still the rot safety net. Gate helpers are pure fns in the shared modules with 10 pin tests. (Gate code itself rode into prod inside 5e6ae7e via the twin-session sweep; this entry documents it.) [email][staff-alerts]
v2.97.RENEWFIX1
2026-07-10Production
For front desk

RENEWALS ARE BACK ON — the automated renewal reminders had been silently dead since July 2 (a database drift); fixed, and the first wave of 58 reminders went out this afternoon. Booking links in those emails now open the booking form directly.

What this means for you

Doug asked why the schedule is mostly new patients. Answer: renewal reminders have been silently failing for EVERY patient since the July 1 cadence update — the production database was missing two event types the new code checks, every send failed quietly, and the health ping read sent=0 as 'nothing due'. ~137 patients near expiry got almost no outreach. Fixed today: the missing values were added and the reminders re-fired — 58 went out immediately; expect ~380 this month plus win-backs. Also: renewal emails now deep-link straight into the booking form pre-set to 'returning' instead of the bare homepage. Coming next: the renewals cockpit (click a patient → their full outreach timeline) and fixing the one-click renewal link dead-end.

Show technical details

Fixed

  • 🔁 **Renewal outreach engine revived (RENEWFIX1).** prod-migration-108: ALTER TYPE WorkflowEventType ADD VALUE IF NOT EXISTS RENEWAL_21D / RENEWAL_0D (drift documented 6/05, never remediated; the 7/01 cadence rewrite queries CADENCE_EVENT_TYPES incl. both → every per-touch block threw invalid-enum, swallowed by the err.name-only catch, heartbeat sent=0 skipped=0 read healthy 7/02→7/10). Applied to prod + cron re-fired: sent=58 skipped=0. Follow-up hardening queued: catch should log the message class + heartbeat should carry failure counts so sent=0-with-errors can never look healthy. [renewals][incident]
  • 🔗 **Email booking CTAs deep-link the booking modal.** BOOKING_URL → /?book=1 (type-neutral, no-show/reschedule lanes) + new RENEWAL_BOOKING_URL → /?book=1&type=returning for the 4 renewal templates + renewal SMS — patients stop hunting the homepage for Book Now. BookingParamHandler already reads both params. [renewals][emails]
v2.97.RCENV1
2026-07-10Production
For front desk

Softphone devtest mystery SOLVED — a hidden developer 'Environment' override inside the widget was enabled and pointing at a dead sandbox; the app now switches that override off automatically on every machine.

What this means for you

Doug found the smoking gun: the RingCentral widget has a hidden Environment panel, and on his browser it was ENABLED with the dead devtest sandbox address — set during May setup and persisted in the iframe's own partitioned browser storage, where none of our deploys or storage clears could reach it. It silently overrode the correct AT&T server on every sign-in (the 'site can't be reached' popups). Fix: on load, the app now commands the widget to disable any environment override, so it always uses the correct server — no one ever needs to find that panel. Combined with today's auto-sign-in retry ladder, the softphone should connect itself on every admin machine after one refresh.

Show technical details

Fixed

  • 📞 **Auto-disable the widget's stale Environment override (RCENV1).** postMessage rc-adapter-set-environment {enabled:false, server: RC_APP_SERVER} at 0.8s/3s unless already signed-in — kills the partitioned-storage devtest hijack from the parent frame (iframe storage is partitioned per top-level site; top-level clears can't touch it). [softphone][rc]
v2.97.PAYVERIFY1
2026-07-10Production
For front desk

Online patient payments work again — the same 'Pay' link from a patient's confirmation email now goes through.

What this means for you

Since June 27 every online card payment failed with an error after the patient typed their card (the card form looked fine; the charge behind it was refused). Cause: a stray second store appeared inside our Poynt merchant account and charges were routing to it — it has no payment terminal. Charges are pinned to the real store (its terminal was never deactivated) and the payment pre-flight was verified end-to-end today. Patients who hit the error can RE-CLICK the same payment link (links stay valid 14 days); the appointments that errored today are on /admin/payments for callbacks.

Show technical details

Fixed

  • 💳 **/pay charge path verified after the PAYFIX1 store pin (PAYVERIFY1).** Diag round-trip green: token mints, POYNT_STORE_ID resolves to Green Health Solutions (terminal 'Green Wellness' ACTIVATED — never deactivated), charge route entitled. Rogue radhealth.ai store holds zero devices; its deletion is refused by Poynt's public API (dashboard/support-only) → GoDaddy support ask filed with Doug; harmless meanwhile — nothing routes to it. Temporary CRON_SECRET_PREVIOUS diagnostic window opened + closed same-session (env deleted; this deploy flushes the runtime). [payments][poynt][incident]
v2.97.NOCLOCK1
2026-07-10Production
For front desk

Isabella's 'we got your message' emails/texts no longer promise a specific clock time — a patient just watched 'by Friday 2:25 PM' come and go with no call.

What this means for you

Doug caught it live: the stale-message acknowledgment promised follow-up 'by Friday, July 10 at 2:25 PM' (a computed next-business-time), the time passed, and nobody had reached out — the ack itself became the broken promise. New copy apologizes for the wait and promises 'as soon as possible' — honest, keeps the 988 crisis line, no deadline nothing tracks. IMPORTANT for staff: these acks fire because messages ARE sitting past SLA — the people owed callbacks are on /admin/isabella-today (stale warm-transfers band). The ack buys grace; only the callback clears the debt.

Show technical details

Fixed

  • ⏰ **No computed deadlines in patient acks (NOCLOCK1).** getStaleTransferAckCopy drops the nextBusinessTimeLabel interpolation for 'as soon as possible' + apology; pins inverted to FORBID clock times in ack copy. 988 line retained. [isabella][copy][sla]
v2.97.RCRETRY1
2026-07-10Production
For front desk

The softphone now signs itself in reliably — no more 'Sign in to dial' dead-ends (and never click that Sign In button; it's a broken RingCentral popup).

What this means for you

The admin softphone signs in silently, but that sign-in fired exactly once, a quarter-second after load — if that single shot missed, staff saw 'Sign in to dial' for 50 minutes, and clicking it opens the broken RingCentral popup. Now a retry ladder keeps re-trying the silent sign-in (half a second out to 30 seconds, plus the existing hourly re-mint) until the panel reports signed-in, and any failure leaves a status-only breadcrumb. Hard-refresh once after this deploys; the panel should connect on its own within seconds.

Show technical details

Fixed

  • 📞 **Softphone auto-sign-in retry ladder (RCRETRY1).** loginStatusRef-guarded retries at 0.5/2/5/12/30s stop as soon as rc-login-status-notify reports signed-in; single-shot 250ms injection was the fragile bit. PII-safe console breadcrumbs on fetch/network failure. Server JWT path verified live (webhook-renew renewed 2 subscriptions). [softphone][rc]
v2.97.HARDSHIP1
2026-07-10Production
For front desk

The $15 Social Security / low-income discount is now self-serve at checkout (patient attests, you verify) — and the staff mark-paid checkbox uses the same defined $15, not a made-up discount.

What this means for you

Doug's policy, wired end to end: GW has exactly ONE discount — $15 off for Social Security (SSDI/SSI), state low-income assistance, or veterans. (1) The patient payment page now has a checkbox: 'I receive Social Security or state low-income assistance — apply the $15 discount. I understand Green Wellness may ask for my state paperwork to verify.' Checking it drops the charge by $15 on the spot and records the attestation so staff can verify paperwork afterward — self-attest now, verify later, exactly as Doug set it. (2) The staff mark-paid checkbox now reads '$15 off applied (Social Security / low income / veteran)' and records exactly $15 — any other odd amount stays an honest partial payment with a balance due.

Show technical details

Added

  • 🏷️ **Hardship self-attestation at checkout (HARDSHIP1 — Doug 2026-07-10).** CollectPaymentForm checkbox (hidden on admin custom charges) → live price drop to expected−$15; chargeViaCollect accepts hardshipAttest, floors the discount at PRICING.RENEWAL_HARDSHIP_DISCOUNT (never reduces a larger existing discount), persists discountCents on success, and stamps hardshipAttest=1 in the PAY_COLLECT_CHARGED audit row = the staff verification trail. Server recomputes everything; client amount is never trusted. [payments][pay]
  • 💵 **Mark-paid checkbox refit to the defined discount.** '$15 off applied (Social Security / low income / veteran)' → stores exactly $15 as discountCents (was: computed expected−collected gap, which could mint arbitrary discounts). [payments]
v2.97.KATPAY1
2026-07-10Production
For front desk

Kat's live feedback, shipped: appointments now come FIRST on Today (AI tiles moved below), discounted payments can be marked 'paid in full' so they stop showing a phantom balance, and the new payments strip understands deposits vs partial payments.

What this means for you

Three fixes from Kat's feedback this morning. (1) /admin/today reorder: the appointment worklist renders at the top; the AI pulse + ops tiles moved below it — same tiles, same data, order only. (2) 'These deposit paid are not accurate': the two odd rows were discounted payments-in-full ($80, $130) being read as deposits with a balance due. Mark-paid now has a 'payment in full (discounted rate)' checkbox that records the discount so the badge reads Paid, the authorization releases, and no phantom balance shows. (3) The payments strip now separates partial payments (like the $50 new-patient deposit) from paid-in-full — a deposit no longer hides the remaining balance; partial rows stay listed with the actual balance due.

Show technical details

Fixed

  • 🗂️ **/admin/today: appointments first (cmrf9jkv, Kat-approved).** TodayClient renders above the AI pulse/ops tiles. [admin-today]
  • 💵 **Mark-paid 'payment in full (discounted rate)' checkbox (cmrfb90h + state-assistance ask).** POST mark-paid accepts paidInFull; server computes discountCents = expected − collected so PaymentBadge/auth-release/today-payments all read PAID. [payments]
  • 🧾 **Today's-payments strip: partial-state (deposit) awareness.** paid = collected ≥ expected (sentinel-only legacy rows still count paid); partial rows render with the real balance due instead of vanishing as 'paid'. [admin-today][payments]
  • 🔐 **Proxy bearer-allow for the one-shot rogue-store removal diag** (hardcoded to the radhealth.ai store id, confirm-gated). [diag]
v2.97.PAYFIX1
2026-07-10Production
For front desk

Online payments are FIXED — the outage's real cause was a stray store inside our Poynt account routing charges to the wrong place; charges are now pinned to the right store.

What this means for you

Root cause of the two-week /pay outage: a second store ('radhealth.ai', from unrelated product exploration) appeared inside Green Wellness's Poynt merchant account. Our charge code auto-selected the account's FIRST store when none was pinned — which became the stray store with no activated card terminal — so Poynt rejected every charge with a 'store device not activated' error. Nothing was ever deactivated: the real store's terminal has been active the whole time. Fix: the correct store is now pinned and this deploy bakes it in. After a confirmed live payment, the paused payment-reminder emails get re-armed. Doug follow-up: have GoDaddy remove the stray store from the GW Poynt account; Rad products get their own merchant accounts.

Show technical details

Fixed

  • 💳 **/pay outage root-caused + fixed (PAYFIX1).** listStoreDevices() diag revealed 2 stores in the GW Poynt business — rogue 'radhealth.ai' (no devices) listed ahead of 'Green Health Solutions' (terminal 'Green Wellness' = ACTIVATED). resolveStoreId's stores[0] fallback had been charging the rogue store since ~6/25-7/2 → http-403 STORE_DEVICE_NOT_ACTIVATED on all 26 attempts. POYNT_STORE_ID env now pins the correct store (the fallback's escape hatch, used as designed). Chase cron stays paused until a live charge confirms. [payments][poynt][incident]
v2.97.TODAYPAY1
2026-07-10Production
For front desk

New on Admin Today: a 'Today's payments' list showing exactly which of today's visits haven't been paid yet — collect before the provider takes the patient.

What this means for you

Doug 7/10 (after Kat hand-marked every visit paid during the Poynt outage): the morning receptionist needs one clear list of today's unpaid visits. Admin Today now shows a 'Today's payments' strip — every scheduled/confirmed visit today that has no recorded payment, with time, patient, visit type, provider, and the expected fee; click a row to open the appointment (that page has Resend confirmation, Send pay link, and Bill via Poynt). Paid visits collapse to a count; all-paid shows a green all-clear. Also: the Poynt diagnostics endpoint now enumerates the store's terminal devices with their statuses so the deactivated-terminal outage can be pinpointed by device name from production.

Show technical details

Added

  • 🧾 **Admin Today 'Today's payments' strip (TODAYPAY1).** New lib today-payments.ts — paid = payment sentinel present OR amountCollectedCents>0 (same canon as /admin/reports/payments); PT-day window; fail-soft (loader fault hides only this strip). Rows link to the appointment detail page where the collection buttons live. RBAC unchanged (ADMIN/MANAGER/SCHEDULER). [admin-today][payments]
  • 🔎 **Poynt store-device enumeration in the collect-verify diag.** listStoreDevices() (read-only, PHI-free: names/types/statuses/ids) surfaced in /api/admin/diag/poynt-collect-verify so the STORE_DEVICE_NOT_ACTIVATED root cause is pinpointed to a named device — evidence for the GoDaddy reactivation ask. [payments][diag]
v2.97.PAYNAME1
2026-07-10Production
For front desk

Poynt transactions will show the patient's name instead of 'Card Customer' — and for matching the old ones, use the Payments report.

What this means for you

Doug 7/10: transactions in the Poynt/GoDaddy dashboard need names so we can track them. Forward fix: the /pay card form now asks for the name on the card (first + last) inside Poynt's own secure card area — the name goes straight to Poynt with the card details (it never passes through our system), so new dashboard rows show the real cardholder. For EXISTING 'Card Customer' rows: open /admin/reports/payments — every card-form charge is listed with the patient, amount, date, and the Poynt reference; match dashboard rows by reference (or date + amount) and click through to the appointment. Note this supersedes the earlier design choice of sending Poynt no identity at all — a cardholder name on a card payment is standard processor data (HIPAA payment-activity disclosure, §164.506).

Show technical details

Changed

  • 💳 **Cardholder name fields on the Poynt Collect form (PAYNAME1).** displayComponents gains firstName/lastName — patient-typed, browser→Poynt direct, SAQ-A posture unchanged; the Poynt dashboard shows the real name instead of 'Card Customer'. Reconcile historic rows via /admin/reports/payments (MANUAL:POYNT: sentinels already carry the Poynt transaction id per appointment). [payments][poynt]
v2.97.GREET1
2026-07-10Production
For front desk

Isabella no longer opens calls by offering a transfer — she leads with helping, and the human option appears the moment a caller asks for it.

What this means for you

Doug's live listen caught the greeting making TWO person-offers before the caller said a word ('any time you'd rather talk to a person, just say so' in the disclosure, then 'or would you rather I have someone give you a call?' in the invite). Leading with the exit invites callers out of the conversation before Isabella has helped. The greeting now discloses she's an automated assistant, then leads straight into booking as the easy default. Nothing about actual transfers changed: the moment a caller ASKS for a person (or sounds stuck or frustrated), the live-transfer rule still checks availability and connects them — that whole lane is untouched.

Show technical details

Changed

  • 🗣️ **Greeting stops leading with the human option (GREET1, Doug 2026-07-10 live listen).** Removed the disclosure-breath 'talk to a person, just say so' insert (deleted voiceGreetingHumanOption(), both armed/unarmed variants — supersedes the FORCEXFER4 'one ask away' greeting line below) and the 'or would you rather I have someone give you a call?' invite branch; added an explicit 'do NOT offer a transfer/agent in the greeting — human option is REACTIVE only' instruction pointing at the unchanged live-transfer rule + reach-for-a-person guidance. FORCEXFER4's escalation choice + single-interim-line rules kept as-is. Synced live via sync-retell-prompt.mjs. [voice][isabella]
v2.97.FORCEXFER4
2026-07-10Production
For front desk

Transfers CONFIRMED WORKING (9:51am live call bridged to the covering cell) — plus Isabella now opens with what she can help with, offers a live person when she can't, stops stacking hold lines, and the payment-reminder emails are paused until the Poynt terminal is reactivated.

What this means for you

Doug's 9:51am call proved the transfer chain end to end (ask for a person → availability check → connected). Polish from that call: (1) the greeting now leads with what Isabella CAN do — book, renew, reschedule, answer pricing questions — with 'want a live person? just say so' one ask away. (2) When she can't answer something she offers a choice — get a team member on the line or take a message — instead of going straight to message-taking. (3) The stacked hold lines are collapsed to one interim line. (4) Spoken copy says 'a member of our team' since coverage rotates. (5) The payment-chase emails are paused so no more patients get pointed at the broken payment page before the terminal is fixed. (6) The automation credential was rotated.

Show technical details

Changed

  • 🗣️ **Help-first greeting + transfer-or-message choice + single interim line (FORCEXFER4 — Doug live-call feedback 9:51am).** Armed-variant greeting names her capabilities and keeps the live-person offer one ask away; can't-answer escalation offers team-member-on-the-line OR detailed message; live-transfer rule caps interim chatter at one line (the 9:51 call stacked three hold sentences). flagForHuman 'other' copy: 'our office manager' → 'a member of our team'. Prompt re-synced through the abstention gate. [voice][transfer][isabella]
  • ⏸️ **Payment-chase cron PAUSED** — PAYMENT_CHASE_ENABLED removed from prod (the 9:00am fire emailed 11 patients at the Poynt-dead /pay page; the 2:00pm fire will not send). Re-arm = re-add the env + a real-commit deploy, after the Poynt store terminal is reactivated (STORE_DEVICE_NOT_ACTIVATED since ~6/25-7/2). [payments][cron]
v2.97.FORCEXFER3
2026-07-10Production
For front desk

Second fix to live transfers: Isabella now actually checks for a person the moment a caller asks, instead of falling back to the old take-a-message script.

What this means for you

Doug's 9:20am re-test still got the message-taking script. Root cause: the earlier fix appended one 'transfer update' sentence after several strong 'do NOT transfer, take a message' passages — and the phone model follows the dominant repeated instruction. The prompt is restructured: when transfers are armed, the no-transfer passages are replaced by a dedicated live-transfer rule (caller asks for a person → check availability first, connect when cleared, take a message when not). Scoped to person-requests only — booking flows, clinical redirects, and other escalations are unchanged; the narrowed version passes all 19 safety probes. Live on the phone line as of ~9:33am.

Show technical details

Fixed

  • 🗣️ **Transfer-first prompt restructure (FORCEXFER3).** voice-prompt.ts gains env-gated variants (greeting human-option, escalation paragraph, office-contact line) that REPLACE the no-transfer doctrine when DEMI_TRANSFER_NUMBER is set, instead of contradicting it in a trailing clause. Env unset → byte-identical legacy prompt (verified). Abstention eval: first broad draft FAILED AB03/SH01 (tool-first energy leaked into booking/clinical lanes) → narrowed to person-requests-only → 19/19 PASS, PATCHed live via sync-retell-prompt.mjs. [voice][transfer][isabella]
v2.97.SLOTSAFE1
2026-07-10Production
For front desk

Isabella no longer offers same-day appointment times, and she can no longer offer a time another patient already holds.

What this means for you

From Kat's 7/10 review: Isabella offered a caller a same-day 3:30 PM opening that was already booked for another patient. Two fixes. (1) Same-day offers are OFF for the AI phone/chat channels: the earliest time Isabella quotes is tomorrow — same-day availability changes too fast to trust an automated offer, so same-day requests become a staff callback (or a live transfer during the coverage window). Staff surfaces and the patient booking wizard are unchanged. (2) The already-booked bug: telehealth and in-person openings exist as twin rows at the same clock time, so a booking only used up one twin — the other kept being offered. Isabella now runs the same double-booking check BEFORE offering a time that the booking system runs at confirmation.

Show technical details

Fixed

  • 📅 **AI channels never offer same-day slots (SLOTSAFE1 — Kat 2026-07-10).** dateRangeWindow (consumed ONLY by the voice + chat listOpenSlots handlers) floors from at the start of tomorrow PT (new pure startOfNextPtDay, 3 pins incl. the 8:04am incident shape). Same-day asks fall through to the existing capture-preference/callback framing. Staff + wizard surfaces untouched. [isabella][slots]
  • 🧑‍⚕️ **Offer-time provider-collision filter.** New slot-offer-collision.ts reuses the providerOverlapWhere truth from the /api/appointments chokepoint (Dr. Ari 6/17 class: twin telehealth/in-person slot rows at one clock time — a booked appointment owns one row, its twin stayed offerable). Both listOpenSlots handlers now drop any slot whose provider has an ACTIVE appointment overlapping it, so Isabella never offers what booking would 409. One predicate, offer + write parity. [isabella][slots][double-book]
v2.97.FORCEXFER2
2026-07-10Production
For front desk

Fixed: callers who asked for a person this morning were still getting the take-a-message script — live transfers to the covering cell now actually happen, starting at the top of the window (8am), not 9am.

What this means for you

Doug's 8:34am test call ('transfer to a person') hit three stacked gaps and got the message-taking script instead of Kat's cell. (1) The instructions the phone assistant actually runs from were an older copy that forbade promising a transfer — the transfer-aware version is now synced, through the sanctioned rail with the eval gate. (2) The master transfer destination number was never set on the production server, so even the right prompt would have said 'take a message' — it is now set. (3) The 8am-5pm window was also gated on business hours that start at 9am, so the 8-to-9 hour Kat covers was dead by construction; the window now only requires an open weekday and carries its own hours. Weekends and holidays still fail closed to message-taking.

Show technical details

Fixed

  • 📞 **Live transfers were never reachable (FORCEXFER2 — root-caused from Doug's 8:34am 7/10 test call).** Three independent breaks, each alone fatal: the live Retell prompt predated the transfer feature and hard-instructed 'do NOT promise to transfer' (drift flagged 7/9 but misread as benign — sync-retell-prompt.mjs had never been run since the clause shipped 6/04); DEMI_TRANSFER_NUMBER was absent from Vercel prod so flagForHuman's transferEnabled gate was false; and the forced-window check required !isAfterHours (9am–5pm) which killed the 8–9am slice of the 08:00-17:00 window. Fixes: prompt synced (abstention-eval gate PASS), env set via REST + real-commit deploy, and the window now gates on new business-hours.isOpenDay() (weekday + non-holiday, clock-agnostic) instead of the desk-shift clock. [voice][transfer][isabella]
v2.97.FORCEXFER1
2026-07-10Production
For front desk

Today until 2pm, callers who ask for a person are connected straight to the covering team member's cell phone — no admin tab needed for the transfer to work.

What this means for you

Doug's call for Friday 7/10: Kat covers the receptionist line on her cell until 2pm, then calls route to Doug's cell for the rest of the day — the handoff swaps the destination automatically at 2pm. Until now Isabella only offered a live transfer when someone had an admin tab open in the last 15 minutes — right for a desk line, wrong for a cell. New: inside the configured window, during an open day, Isabella connects callers who need a person straight through, no admin-tab check. When the window closes she goes back to taking detailed messages — nothing to remember to turn off mid-day. Her spoken lines say 'a member of our team' instead of naming Demi, since coverage rotates. Outside the window everything behaves as before.

Show technical details

Changed

  • 📞 **Forced-transfer window for cell-phone coverage (FORCEXFER1 — Doug 2026-07-10 'it really should be pushed to her cell').** New pure voice-transfer-window-shared.ts (VOICE_TRANSFER_FORCE_WINDOW="HH:MM-HH:MM", clinic/PT wall clock via Intl so a UTC server can't shift the cutoff; 5 pin tests). Inside the window + business hours, flagForHuman treats staff as present WITHOUT the admin-heartbeat check (the heartbeat gate was built for Demi's DESK — a cell answerer has no /admin tab). Outside the window / env unset / malformed → byte-identical heartbeat-gated behavior (fail-closed to message-taking, 2pm cutoff EXCLUSIVE + self-expiring). Env set to 08:00-14:00 for Fri 7/10 Kat coverage; unset it (+ redeploy) to retire. Retell tool destination separately points at Kat's cell (swapped 7/9 via add-retell-transfer-tool.mjs). [voice][transfer][isabella]
  • 🗣️ **Isabella's transfer copy is now staff-name-FREE.** 'I am bringing Demi on the line' / 'Let me get Demi' → 'a member of our team' — Demi's account has been deactivated since 6/16 and the line's coverage now rotates (Kat today), so promising a named person was a stale promise. The transfer_call tool description in add-retell-transfer-tool.mjs matches ('our on-duty team member'). Pin updated from asserting the name to BANNING any staff name in the spoken copy. [voice][copy]
v2.97.CFDRAIN1
2026-07-09Production
For everyone

New patients now get ONE combined intake + consent packet link (not two separate forms), and the website tells the truth that medical records are required before an authorization.

What this means for you

Two fixes from the couldn't-fix feedback pile. FORMS: new patients were emailed BOTH a health-intake link AND the new-patient packet — but the packet already contains the intake plus every consent and the signature, so it read as two overlapping forms. New patients now get exactly one link (the packet); the portal checklist and day-before reminder point at it too. WEBSITE TRUTH: the site said records are 'helpful but not required' in ~20 places — our real process requires records of a WA qualifying condition. Every page now says records are required and that we help request them. SCHEDULING: Dr. Frisch's Mon/Wed days had been swept into Dr. Morical's Friday-only window — her blocks were removed, July 13 telehealth is bookable again, and Dr. Morical's video room now resolves a link.

Show technical details

Fixed

  • 📅 **Dr. Frisch's non-Friday availability un-blocked (data fix — closes reviewer-feedback cmra10ub6).** Root cause was two-layered: (1) the original report (filed 7/6) hit the PROVIDER_SLUGS dead-id bug (prov-marnie never existed on prod) that FRIWIN1 fixed on 7/8; (2) FRIWIN1's window blocks were then applied to EVERY active provider — including Marnie Frisch (prov-olympia), whose separate Mon/Wed Olympia + telehealth practice isn't part of Dr. Morical's Friday-only coverage lane. Fix = deleted the three friwin1-agent ProviderDateBlock rows for prov-olympia ONLY (her own out-of-office blocks and every other provider's window blocks untouched). Live-verified: /api/availability month view now offers 2026-07-13 (5 telehealth slots) plus her 7/15/7/22/7/27/7/29 cadence; Morical's lane still Friday-only. Data-only — no code change. [scheduling][date-blocks][feedback-close] Closes reviewer-feedback cmra00klk. Closes reviewer-feedback cmra1a57b. Closes reviewer-feedback cmra10ub6.
  • 🎥 **Dr. Morical's default doxy.me room set (data fix, provider row).** Her doxyMeUrl was NULL, so 6 of the 9 booked 7/10 telehealth visits had NO video link (staff had hand-set links on only 3). Set to the clinic's standard https://doxy.me/greenwellness room — the same value staff were already hand-entering and every other provider row uses — so effectiveVideoLink() now resolves a link for all 9 confirmed 7/10 visits (verified: 3 own links + 6 provider-fallback), and every future Morical telehealth visit inherits it. [telehealth][doxy][provider-data]

Changed

  • 📋 **One form link for new patients (FORMMERGE1 — closes reviewer-feedback cmra00klk).** The NEW_PATIENT_PACKET is already the merged form the feedback asks for (pages 1-3 = health intake writing the same appointment-linked IntakeForm row; pages 4-5 = informed consent + 7-initial acknowledgement + e-signatures, in the requested order: history → medications → allergies → cannabis history → consents → signature; document upload stays on the portal). The duplication was in DELIVERY: booking confirmations sent new patients the standalone /intake card NEXT TO the packet card. Now: confirmation email sends new patients the packet card only (intakeUrl omitted when isNew + packet link exists; returning patients + packet-mint-failure fallback unchanged) · portal checklist collapses intake+consent into one 'New patient packet — intake + consent in one' step while the packet is pending (deriveAppointmentChecklist grew an optional consentIsPacket input — legacy callers unchanged) · the amber portal nudge + T-24h intake reminder (email + SMS) point at the still-signable packet instead of re-splitting the flow (fallback to /intake preserved). ZERO consent-language changes — every clause the packet carries is byte-identical to before; this is routing/presentation only. [forms][intake][consent][feedback-close]
  • 🩺 **Website records-requirement truth sweep (QUALREC redo — closes reviewer-feedback cmra1a57b; the prior agent's v2.97.QUALREC1 fix was stranded by branch protection and never landed).** ~20 claims of 'medical records are helpful but optional' / 'we can assess you from reported history alone' corrected across: conditions/[slug] What-to-bring · locations/[city]/[condition] bullets · homepage Services card · main FAQ (3 answers) · per-condition FAQs + whatToExpect (chronic pain, cancer, MS, Parkinson's, HIV/AIDS, Crohn's, PTSD ×2) · telehealth per-condition FAQs (chronic pain, Parkinson's) · Spokane/Lynnwood location whatToExpect · 4 articles — and the 'Do You Need Medical Records?' article REWRITTEN from answering 'no' to the honest 'yes': WA authorization requires records documenting a qualifying condition; providers review existing records to confirm eligibility (they can't mint a diagnosis at the visit); GW's team helps patients request records before the appointment. Consistent one-message rule across every surface. [website][copy][records][feedback-close]
v2.97.RECONCILE2
2026-07-08Production
For everyone

The automatic feedback closer shipped earlier today is now actually able to run on its hourly schedule.

What this means for you

Follow-up fix (RECONCILE2): the hourly feedback finalizer from RECONCILE1 was wired so that the platform's scheduler could never authenticate to it — it would have silently never run. The schedule can now start it, and it's registered with the system health monitor so we'd be alerted if it ever stops running. No patient data is touched.

Show technical details

Fixed

  • 🔌 **Feedback reconciler cron actually actuates on the :23 schedule (RECONCILE2).** RECONCILE1's outer gate accepted ONLY the dedicated FEEDBACK_FINALIZER_SECRET bearer — but Vercel's scheduler presents Bearer CRON_SECRET (GW has no cron dispatcher to construct a custom bearer the way the VRG twin's dispatcher does via bearerEnv), so every scheduled tick would 401 and the reconciler would stay inert forever, invisible to the watchdog. Outer gate now accepts EITHER the dedicated secret OR CRON_SECRET via verifyCronAuth — capability-neutral (any CRON_SECRET holder can already close rows directly via the [id]/agent PATCH this route self-calls); both-unset still 401 fail-closed. Also: canary heartbeat after auth + actor feedback-reconciler registered in cron-actors-shared + EXPECTED_CRON_ACTORS (staleAfterDays 1) so /api/health + cron-watchdog surface it. hipaa-architect delta review PASS 2026-07-08. Caught pre-live by the same review pass, shipped same-day. [feedback][reconciler][cron-auth]
v2.97.RECONCILE1
2026-07-08Production
For everyone

Feedback you submit now closes itself out automatically once its fix is confirmed live — no more items stuck showing 'being worked on' after they're actually done.

What this means for you

Background safeguard (RECONCILE1): when the team ships a fix for a feedback item, the item sometimes stayed in a 'being worked on' state even though the fix was already live, because the automated closer couldn't authenticate itself from a laptop. A new in-app hourly check now finds those items, verifies the fix is actually deployed in production right now, and closes them out with the real version + a note — so you get your confirmation email and the item leaves the queue. It only ever closes items whose fix it can prove is live; anything unverified stays open. No patient data is touched. Off by default until its dedicated key is set.

Show technical details

Added

  • 🧹 **Feedback finalizer/reconciler cron (RECONCILE1 — GW lane of the FINALIZER_RECONCILER spec; ported from the FedRAMP-reviewed VRG twin).** New /api/cron/feedback-reconciler (hourly :23) scans the changelog compiled into the running bundle for Closes reviewer-feedback markers, HARD-GATES on being the live production deployment with a known build sha, and PATCHes each matching STRANDED row (approved-autofix/agent-working) to done via the app's own /api/admin/reviewer-feedback/[id]/agent endpoint — carrying the real doneSha + version, so the close rides the audited path (close-gate + submitter-confirm email + thread entry), never a hollow close, never a raw DB write. Pure core in lib/feedback-reconciler.ts (PHI-blind by construction: reads row {id,status} + changelog text only), 11 pins. Outer gate = dedicated FEEDBACK_FINALIZER_SECRET bearer (constant-time; NOT CRON_SECRET) → 401 inert-by-default until the secret is set. Retires the feedback-stuck-in-loop watchdog red. [feedback][reconciler][watchdog]
v2.97.FRIWIN2
2026-07-08Production
For everyone

Follow-up to the Friday-only pivot: the homepage 'next available' teaser and Isabella's email-draft availability list now skip blocked (non-Friday) days too.

What this means for you

Completes FRIWIN1's enforcement parity: three read surfaces still listed slots without checking provider date-blocks — the public /api/next-slot teaser, the homepage server-rendered teaser, and the availability block Isabella's email drafts quote from. During the Friday-only window those could have advertised a Monday/Wednesday time the booking system would then refuse. All three now run the same block filter as the booking wizard.

Show technical details

Fixed

  • 🧩 **Date-block parity on the last three slot-read surfaces (FRIWIN2).** /api/public/next-slot, home-server-data.ts getNextSlotDisplay (SSR homepage teaser), and the ai-draft BILLAVAIL1 availability block each read AvailabilitySlot through bookableSlotFilter but never filterBlockedSlots — during the FRIWIN1 window they could advertise/quote a blocked non-Friday day the /api/appointments chokepoint would 409. Each now filters through the same predicate (no-op when PROVIDER_DATE_BLOCKING_ENABLED is off); the two findFirst teasers became findMany(50)+filter+[0] since a block can cover a multi-day span of head slots. [date-blocks][parity][teaser]
v2.97.FRIWIN1
2026-07-08Production
For everyone

Scheduling pivot (Doug, 7/8): through Friday 7/24 we book FRIDAYS ONLY — new patients in person at Dr. Morical's Seattle office, renewals by doxy.me telehealth. Dr. Ari is no longer with GW.

What this means for you

Doug's coverage-window order for the next three Fridays (7/10, 7/17, 7/24): Dr. Ari no longer works with GW and Lisa is not joining; Dr. Lesley Morical covers. Online booking, Isabella (voice/chat/SMS/email), and the wizard now only offer Friday times through 7/24 — non-Friday days are blocked for every provider. New patients see 'in person at Dr. Morical's office in Seattle'; renewals see 'telehealth video visit via doxy.me.' Everything is window-scoped and reverts by itself after 7/24 — nothing here is the permanent schedule. Four already-booked non-Friday appointments (Mon 7/13 ×2, Wed 7/15 ×1, Thu 7/23 ×1) need staff reschedule calls — the date-block admin surface lists them.

Show technical details

Changed

  • 📅 **Friday-only coverage window through 2026-07-24 (FRIWIN1 — Doug 2026-07-08).** Mechanism = the EXISTING ProviderDateBlock rail, finally armed: 15 ProviderDateBlock rows inserted (every active provider × the three non-Friday ranges 7/8-7/9, 7/11-7/16, 7/18-7/23; Fridays 7/10, 7/17, 7/24 deliberately uncovered) + PROVIDER_DATE_BLOCKING_ENABLED=true flipped in Vercel prod (was staged-dark since migration 80). Blocks self-expire as the dates pass — no cleanup deploy. Applied via scripts/friwin1-friday-only-window-2026-07-08.sql (data-only, reversible, no schema change). Booking chokepoint (/api/appointments 409), availability route, admin manual/reschedule already enforced blocks; this ship adds the missing parity in BOTH Isabella listOpenSlots paths (chat booking-tools.ts + voice voice-tools.ts now run filterBlockedSlots so she can't QUOTE a blocked slot the booking POST would refuse). [scheduling][coverage-window][date-blocks]
  • 🚪 **Dr. Ari (Dawn Reardon, prov-reardon) deactivated — she no longer works with GW (Doug 2026-07-08).** Provider.isActive=false removes her open future slots from every surface via the shared bookableSlotFilter; her (and Ruth's) ProviderSchedule rows also deactivated so the Sunday slot cron + Isabella's standing-windows describer stop using them. Her existing booked appointments were NOT touched (2 fall on blocked days → staff reschedule list). [roster][provider-sunset]
  • 🩺 **Dr. Morical wired into the rules layer (window-scoped).** PROVIDER_SLUGS.morical added (prod cuid); getRenewalProvidersForPriorClinic includes her for EVERY prior clinic while isFridayOnlyCoverageWindow() is true, so telehealth-renewal scoping (TELELOC1) resolves to her real Friday slots; normalizeLocationId("Seattle") now maps to the prod Seattle Location id so voice IN_PERSON lookups reach her office's slots; PROVIDER_HOME_CLINIC.morical=lynnwood (renewal catch-all clinic — Seattle is deliberately NOT a new LocationSlug to avoid rippling the persona tool enums). New dep-free src/lib/coverage-window-shared.ts holds the dated gate (start 7/8 00:00 PT, end 7/24 23:59 PT) — every copy/routing consumer self-reverts after the window. [rules][teleloc1][window-scoped]
  • 🗣️ **Patient-facing copy states the new reality (self-reverting).** Wizard Step 3: new patients read 'First visits with us are in person at Dr. Morical's office in Seattle — we're currently scheduling Fridays'; renewals read 'Renewals are currently done as telehealth video visits via doxy.me — we're scheduling Fridays'; the telehealth info box now says the doxy.me link is EMAILED BEFORE the appointment (honest while Dr. Morical's doxy room URL is not yet on file — the confirmation email already falls back to 'a secure video link will be emailed to you' when Provider.doxyMeUrl is null). Isabella's location list (all 5 persona formats) leads with a window line — PROVIDER-NAME-FREE per the standing Doug 2026-06-19 rule ('Fridays only… new patients in person at our Seattle office… renewals by telehealth video'). Voice static prompt grew ~190 chars → VOICE_PROMPT_SOFT_CAP_CHARS consciously bumped 33500→34000 with history note (assembled-playbook ceiling untouched at 42000). [copy][isabella][wizard]

Fixed

  • 🪤 **PROVIDER_SLUGS pointed at dev-seed provider ids that never existed on prod.** dawn: "prov-dawn" / marnie: "prov-marnie" vs prod's prov-reardon / prov-olympia (verified read-only against the prod Provider table) — every TELELOC1 telehealth-renewal providerId scope resolved to nonexistent rows → zero dated slots → silent standing-windows fallback on chat + voice since 7/4. Slugs corrected + the two stale pin tests re-pinned to prod truth. [silent-failure][teleloc1]
  • 🧪 **Baseline-red repaired: emails↔/pay deposit-mirror pin.** AUDITFIX1 (7/7) rewrote /pay's DEPOSIT_CENTS literal to derive from the PRICING SSoT and left the source-regex pin red on clean HEAD. Pin now asserts the SSoT-derived form AND that PAY_DEPOSIT_CENTS equals PRICING.NEW_PATIENT_DEPOSIT * 100. Suite 11331/11331 green. [tests][baseline-red]
v2.97.AUDITFIX1
2026-07-07Production
For front desk

When you use 'suggest a reply' on a billing email, the draft now quotes our current prices ($175 new in-person / $145 returning telehealth) — it had been quoting old, lower ones.

What this means for you

The AI reply-suggester was pulling outdated, too-low visit prices ($149 / $99) into drafts for billing questions, which could have led to undercharging a patient. It now reads the same live price list the rest of the site uses, so a suggested draft always matches what we actually charge.

Show technical details

Fixed

  • 💵 **Isabella billing draft-suggest quoted stale prices (AUDITFIX1).** patient-email-draft-suggest.ts CATEGORY_TONE_HINTS.billing hardcoded $149 new in-person / $99 returning telehealth — the SSoT (PRICING in constants.ts) is $175 / $145. Reachable in prod today via the admin/manager/scheduler messages/[id]/draft-suggestion route (not flag-gated). Now interpolates PRICING.NEW_IN_PERSON / PRICING.RETURNING_TELEHEALTH. [pricing][ssot][isabella]
  • 🔒 **Cross-tenant PHI isolation on dispensary-partner cert routes (AUDITFIX1).** api/dispensary/certs (list) and api/dispensary/cert/[token] (PDF download) authenticated the partner via x-dispensary-id but filtered the Appointment query only on dispensaryConsent / share-token — never on dispensaryId. A second BAA-signed partner would have seen every tenant's consenting patients (name/DOB/conditions) and could pull any cert PDF by token. Added the dispensaryId tenant scope to both where clauses, restoring the PLAN_TENANT_ISOLATION invariant. Latent today (single active partner); this closes it before partner #2. [phi][tenant-isolation][dispensary]
  • 📮 **HIPAA §164.526 amendment path pointed at an unprovisioned mailbox (AUDITFIX1).** amendment-request.ts staffAlertEmail() fell back to the literal alerts@greenwellness.org (never provisioned) when MARIANE_EMAIL/ALERTS_TO are unset — used both for the internal 60-day-clock staff alert AND the patient-facing denial letter that tells a patient where to send a Statement of Disagreement. Fallback now resolves to the provisioned EMAIL (admin@). [hipaa][mailbox][ssot]
  • 🧹 **SSoT hygiene (AUDITFIX1).** pay/[appointmentId] deposit now derives from PRICING.NEW_PATIENT_DEPOSIT instead of a hardcoded 5000; Dr. Ari provider-welcome front-desk contact now uses the provisioned EMAIL instead of unprovisioned info@greenwellness.org. [ssot][cleanup]
v2.97.CSCLEAN1
2026-07-07Production
For front desk

The CS Command Center no longer lets ancient, abandoned items pile up at the top as permanent 'hard breaches' burying today's real work — items nobody has touched in 6+ weeks now show as muted 'stale' at the bottom.

What this means for you

Doug flagged a 'HARD BREACH · 269 bh' item — a lead follow-up promised back on May 17 that nobody ever made. The queue had no way to age items out, so anything unworked stayed a bright-red hard breach forever, sorted to the very top, and buried the breaches that actually need attention today. Now: any obligation nobody has worked in 6+ weeks (that isn't on a legal clock) is marked STALE — muted grey, sorted to the bottom, dropped from the breach headline and the watchdog alert. It's never hidden; a person still reviews it to close it or re-engage. Records requests are exempt — they stay red on the Washington 15-working-day legal clock no matter how old, because a late records request is a real legal miss, not clutter.

Show technical details

Changed

  • 🧹 **CS Command Center ages out abandoned obligations into a 'stale' tier (CSCLEAN1).** Root cause of Doug's 'HARD BREACH · 269 bh' screenshot: computeCsObligations had ZERO aging-out logic, so an item nobody worked (e.g. a lead_followup 'promised for 2026-05-17', ~50 days old) composted as a live hard breach forever — sorted oldest-first to the TOP and burying today's real breaches, and inflating the headline breach count. New slaStateFor reclassifies a NON-LEGAL-lane obligation past STALE_AGE_BUSINESS_HOURS (240bh ≈ 6 working weeks) from hard_breachstale: muted slate tone (not red), sorts below fresh breaches (SEVERITY_RANK), dropped from the fresh-breach headline + the per-lane breachCount + the patient-slip-watchdog escalation, surfaced instead as a separate '+ N stale (abandoned 6wk+)' count. NEVER hidden — a human still triages via the existing desk overlay. records_export is a LEGAL lane (WA RCW 70.02.080 15-working-day clock) and is EXPLICITLY EXEMPT (isLegalSlaLane) — it stays hard_breach forever. Nothing goes stale without first being a hard breach (240bh >> every non-legal lane's hard threshold). Pins cover the aging-out boundary + the records-legal exemption. [cs-command-center][sla][cleanup]
v2.97.RIGHTS1
2026-07-07Production
For everyone

HIPAA Notice of Privacy Practices now includes the four required elements it was missing, and the Terms page carries a Poynt (not Stripe) payment fix plus a prominent 911/988 emergency notice.

What this means for you

Filled the remaining required pieces of our published notices, using the exact language the regulations call for (researched against the federal rules, reviewed by the HIPAA specialist). The HIPAA Notice of Privacy Practices now carries the verbatim required header, a statement of our duty to notify you after a breach, a section on which uses need your written authorization, and the right to restrict disclosures for services you pay for out of pocket. The Terms page now correctly names Poynt as the payment processor (it said Stripe) and leads with a clear 'not for emergencies — call 911 or 988' notice. No patient data involved. Section 1557 (a nondiscrimination notice) was researched and is not required for a cash-pay practice, so nothing was added there.

Show technical details

Added

  • 🩺 **HIPAA Notice of Privacy Practices — 4 required §164.520(b) elements added (RIGHTS1).** Researched against eCFR/Cornell LII, drafted in regulation-standard language, hipaa-architect-verified. (1) The prescribed header (§164.520(b)(1)(i)) is now the exact verbatim mandated string. (2) NEW 'Our Legal Duties' section carries the breach-notification duty (§164.520(b)(1)(v)(A), a 2013 Omnibus hard requirement that was absent). (3) NEW 'Uses That Require Your Written Authorization' section (§164.520(b)(1)(ii)(E) — psychotherapy notes, marketing, sale of PHI, other-uses-only-with-authorization, revocable). (4) NEW cash-pay restriction right (§164.522(a)(1)(vi)) — directly relevant since GW is 100% cash-pay. Effective date bumped to 2026-07-07. Privacy Officer stays a title (publication doesn't require a named individual). Counsel does a final read + supplies the internal §164.530(a) designation. [privacy][hipaa][npp]

Fixed

  • 💳 **Terms names the correct payment processor — Poynt, not Stripe (RIGHTS1).** §3 said fees are collected 'via Stripe'; the live rail is Poynt. Factual correction to a legal document. [terms][accuracy]
  • 🚨 **Terms leads with a 'not for medical emergencies — 911 / 988' notice (RIGHTS1).** Standard telehealth-ToS safety disclaimer, placed as a prominent alert at the top of the Terms (911, the 988 Suicide & Crisis Lifeline, and text HOME to 741741 — mirroring our live footer + Isabella crisis copy). The site had this in the footer but not the Terms body. Effective date bumped to 2026-07-07. [terms][safety]
v2.97.MHMDA1
2026-07-07Production
For everyone

Washington's My Health My Data Act now has its own standalone Consumer Health Data Privacy Policy page, with a distinct homepage link — the structure the law requires.

What this means for you

Washington's My Health My Data Act requires a SEPARATE consumer-health-data privacy policy with its own prominent homepage link — having it as a section inside the HIPAA privacy notice doesn't satisfy the law, and the law lets patients sue. The exact same content that was already published now lives on its own page at /consumer-health-data-privacy, linked distinctly from the homepage footer and every inner-page footer. The HIPAA notice keeps a short pointer to it. This is a structural move of already-published wording, not new legal language — counsel still does a final review of the standalone text, tracked on Doug's decision sheet.

Show technical details

Added

  • 🔒 **Standalone Consumer Health Data Privacy Policy at /consumer-health-data-privacy (MHMDA1).** WA My Health My Data Act (RCW 19.373.020) requires a SEPARATE consumer-health-data policy with its own prominent, distinct homepage link — a subsection embedded in the HIPAA Notice does not satisfy the statute, which carries a private right of action (highest website legal exposure per the 2026-07-07 rights audit). The MHMDA content that has been live at /privacy since v2.97.AE3025 is relocated VERBATIM to the new standalone page (only edit: 'above/below' cross-references fixed to explicit /privacy links now that it stands alone). /privacy keeps a short pointer section. Distinct homepage link added to the HomeContent footer + the shared SiteFooter. Structural relocation of already-published text — NOT new legal wording; counsel confirms the standalone text carries nothing beyond the statute (filed as a Doug/counsel gate). [privacy][mhmda][wa-law]
v2.97.A11Y1
2026-07-07Production
For everyone

Accessibility pass: a new Accessibility statement page, Terms + Accessibility links added to the footer, a keyboard-friendly confirmation dialog, and a keyboard-operable document uploader.

What this means for you

First round of an accessibility (ADA / WCAG) review of the public site. Added a plain-language Accessibility page (what we do, honest limitations, and how a patient can report a barrier or ask staff to complete a task for them by phone) linked from the footer, alongside a now-linked Terms of Service. The confirmation pop-up used across the site now announces itself properly to screen readers and puts the keyboard cursor on a button. The patient document uploader can now be opened with the keyboard (Enter/Space), not just a mouse click, and is labeled for screen readers. No change to any medical, privacy, or legal wording — those items are a separate list going to Doug and counsel.

Show technical details

Added

  • ♿ **New /accessibility statement page (A11Y1).** Closes the missing-accessibility-statement gap the ADA + rights audits flagged. Honest + factual: aspirational WCAG 2.1 AA conformance target (never a guarantee), a plain-language summary of what the site does (skip link, keyboard operability, labels, contrast checks, reduced-motion, resizable text), honest known-limitations (third-party Doxy.me video, some documents), and a real barrier-report + accommodation path (email/phone, 2-business-day aim, staff completes the task by phone so nobody is turned away from care over a website barrier). Prose in body-string literals (JSX-entity-gate-safe, same pattern as /terms). Linked from SiteFooter. [accessibility][ada]

Fixed

  • 🔗 **Footer now links Terms of Service + Accessibility (A11Y1).** The inner-page SiteFooter previously linked only the HIPAA Privacy Notice + Cookie preferences; /terms existed but was reachable only from the homepage footer, and /accessibility is new. Both are now in the shared footer on every inner page. [footer][links]
  • ⌨️ **Confirmation dialog is keyboard- and screen-reader-accessible (A11Y1, WCAG 2.1.1 / 4.1.2).** The shared ConfirmDialog (used across patient + admin confirm flows) now carries role=dialog + aria-modal + aria-labelledby/describedby, the click-to-dismiss scrim is aria-hidden (was a phantom focus stop), and the keyboard cursor lands on the Cancel button when it opens. [a11y][dialog]
  • 📄 **Patient document uploader is keyboard-operable (A11Y1, WCAG 2.1.1 / 1.3.1).** The my-appointments upload dropzone was a click-only div — a keyboard-only patient couldn't open the file picker. It now has role=button + tabIndex + Enter/Space activation + a visible focus ring, and the file input carries an accessible label. [a11y][patient-portal]
v2.97.ADMINEFF3
2026-07-07Production
For front desk

The 'Send reminder to N' button on Appointments is now available to the front desk, not just managers.

What this means for you

Yesterday's bulk 'Send reminder to N' button on the Appointments page started out manager-only while we confirmed the policy. It's now available to the front desk too — the same people who already send these reminders one at a time all day. Nothing else about it changed: it still asks you to confirm before sending, still skips anyone unsubscribed, without SMS consent, or already reminded in the last 12 hours, and still records each send the same way. Bulk 'Confirm N' was already available to everyone.

Show technical details

Changed

  • 📣 **Bulk 'Send reminder to N' widened to the front-desk tier (ADMINEFF3).** It shipped MANAGER_RESTRICTED in ADMINEFF2 because a bulk patient-facing outbound fan-out is a Doug-gate tier (autonomy playbook §5); the SCHEDULER-widening was filed as a default-with-override gate and Doug approved it 2026-07-07 ("I don't object; it's admin now"). The bulk-remind route role array now includes SCHEDULER (matching the per-item [id]/remind route the front desk uses 10-20×/day), with a paired SCHEDULER_ELIGIBLE EXCEPTIONS entry in the scheduler-coverage gate, and the server-side canBulkRemind gate now shows the button for exactly the roles the route accepts (ADMIN/MANAGER/SCHEDULER) so a role that would 401 never sees it. Zero change to the send itself — consent gates, confirm modal, 50 cap, and 12h dedupe are untouched. [appointments][bulk][rbac]
v2.97.ADMINEFF2
2026-07-06Production
For front desk

Bulk actions on Appointments (confirm many at once; managers can send reminders in a batch), keyboard shortcuts + bulk-dismiss on Isabella's drafts, and a faster isabella-today load.

What this means for you

Three time-savers for repeated work. (1) The Appointments page now has checkboxes: tick the ones you want and Confirm several at once instead of one at a time. Managers also get a 'Send reminder to N' button that emails/texts a batch of patients (it always asks first, and it automatically skips anyone unsubscribed, without SMS consent, or already reminded in the last 12 hours). (2) On Isabella's draft replies you can now tick several and Dismiss them together, and use keyboard shortcuts — j/k to move between drafts, u to use, d to dismiss, o to open the conversation. Every dismiss still records exactly the same way as clicking the button. (3) The isabella-today page loads a little faster on busy days.

Show technical details

Added

  • ☑️ **Bulk confirm + bulk reminder on /admin/appointments (BULKAPPT1).** Row checkboxes + select-all-on-page + a bulk bar. **Confirm N** (front-desk tier, SCHEDULER) mirrors the per-item confirm route EXACTLY per appointment — only SCHEDULED→CONFIRMED, one CONFIRM_APPOINTMENT audit row each, race-safe conditional updateMany, skip+report non-eligible ids, cap 100 (new POST /api/admin/appointments/bulk-confirm). **Send reminder to N** (MANAGER+ only — a patient-comm fan-out is a Doug-gate tier per the autonomy playbook; SCHEDULER-widening filed as a default-with-override gate) calls the SAME extracted send per appointment (new src/lib/appointment-reminder-send.ts, lifted VERBATIM from the [id]/remind route so consent gates + pay-link threading + vendor-error capture can't drift), cap 50, behind a required confirm modal, with a bulk-only 12h REMINDER_24H dedupe so a double-submit can't double-text patients. Sends stay SEQUENTIAL (the vendor-error caches are module-global). Pure eligibility/partition/summary logic in import-free src/lib/appointment-bulk-actions.ts (14 pins). No new content, no migration, no PHI in audit/logs. [appointments][bulk][hipaa-clean]
  • ⌨️ **Keyboard nav + bulk-dismiss on /admin/isabella-drafts (BULKDRAFT1).** Per-card checkboxes + select-all + 'Dismiss N selected' — the bulk path makes N SEQUENTIAL calls to the EXISTING per-draft /api/admin/messages/[id]/draft-suggestion endpoint (one aiSuggestedReplyDismissedAt stamp + one audit row each, byte-identical to a single dismiss; DELIBERATELY no new bulk server route so the SCHEDULER-worked queue stays SCHEDULER-eligible). Keyboard: j/k (or ↓/↑) move a focus ring, u=use, d=dismiss, o=open, x=select; ignored while typing, on modifier combos, and (action keys) on auto-repeat. u/d route through each card's own DraftActions so the hotkey path IS the click path. Pure key logic in import-free src/lib/isabella-drafts-hotkeys.ts (19 pins). [isabella-drafts][keyboard][bulk]

Changed

  • ⚡ **isabella-today loads its 'From Mariane' + schedule-availability lanes inside the main Promise.all batch (BULKAPPT1 sibling).** They previously ran as two sequential DB round-trips AFTER the main 13-query batch; they never depended on any other lane's result, so folding them into the batch removes serial latency on high-volume days. Behavior-identical (same missing-table fallback, same swallow-all availability catch); structural pin guards against a sequential-await regression. [isabella-today][perf]
v2.97.ADMINEFF1
2026-07-06Production
For front desk

Faster daily flow: promised lead callbacks now show on the morning page, the CS Command Center auto-refreshes, patient search finds any phone format you paste, and printing mailing labels tees up 'Mark as mailed'.

What this means for you

Five small speed-ups for the daily flow. (1) The morning admin-today page now shows a 'Lead callbacks due' tile when a promised follow-up call is due today or overdue — same count as the leads page's Due-today view, so a promised callback can't hide. (2) The CS Command Center got the same Refresh button + 60-second auto-refresh as admin-today, so new escalations appear without reloading. (3) Patient search now finds phone numbers pasted in any format — a caller ID copied straight from the phone (+1…) or a dashed number used to return nothing even when the patient was on file. (4) On the Mailing page, printing labels now pre-selects those rows so 'Mark as mailed' is one click, not a re-select. (5) A provider missing a WA license number now warns on every admin page.

Show technical details

Added

  • 📞 **'Lead callbacks due' tile on /admin/admin-today (ADMINEFF1).** The front-desk morning anchor surfaced ZERO lead work — a promised follow-up call was only visible if someone remembered to open /admin/leads (or was Doug/Mariane, whose pages both have the tile). New count-only getDueTodayFollowupLeadCount() in src/lib/leads.ts uses the SAME derivation as the /admin/today tile + the /admin/leads due_today view (FUPDUE1 invariant: tile == destination; deliberately no LEAD_MARKED_READ release — read ≠ contacted), 1000-row bounded like the badge cohort. Fail-soft loader, no PHI, hidden at zero, deep-links to /admin/leads?status=due_today. [admin-today][leads]
  • 🔄 **Refresh affordance on /admin/cs-command-center (ADMINEFF1).** The noon/close sweep anchor was the only daily surface without RefreshShell (admin-today + isabella-today both have it) — a crisis flagged after the page was opened waited on a manual reload. Same 60s auto-refresh + button, same component. [cs-command-center]
  • 🩺 **PreflightWarnings now checks the WA-license hard-block (ADMINEFF1, OPSVIS1 follow-up).** The every-admin-page checklist banner checked npi/email/doxyMe but NOT licenseNumber — the one field that actually hard-blocks cert issuance (cert-pdf-issue.ts license-required gate). Now flags active providers missing it: high severity when a CERT_DOH_* form path is enabled (issuance blocks today), med otherwise. [preflight][cert-readiness]

Fixed

  • ☎️ **Phone search now matches phone-SHAPED queries, not just bare digits (ADMINEFF1).** phoneSearchClauses only expanded to format variants when the query was 100% digits — so an E.164 caller-ID paste ('+12065550100') or a dashed '206-555-0100' against the parens-stored '(206) 555-0100' returned ZERO results for a patient who was on file. Any query that is only digits + phone punctuation (10-11 digits) now expands to the same variants. Pin test deliberately FLIPPED (the old pin asserted the miss); 3 new pins cover E.164/dashed/formatted. Benefits /admin/patients search + every other phoneSearchClauses caller. [patients][phone-search][pin-flip]
  • 🖨️ **Mailing: printing labels arms the mark-mailed step (ADMINEFF1).** 'Print all' and per-row print now pre-select the printed rows, so the existing 'Mark N as mailed' bulk button is immediately ready instead of requiring a manual re-select of everything just printed. Selection is inert until staff explicitly confirm the bulk modal — no write-path change. [mailing]
v2.97.OPSVIS1
2026-07-06Production
For front desk

The morning admin-today page now shows what's waiting on the office (certs to mail, $50 fees to invoice, Isabella drafts), the Providers page warns about incomplete setups that block authorizations, and an email-failure warning no longer disappears on you.

What this means for you

Four visibility fixes so nothing slips silently. (1) /admin/admin-today gains a 'Waiting on the office' strip: a red 'Certs to mail' tile when patients are waiting on their authorization by mail, plus 'Fees to invoice' ($50 service requests) and 'Drafts to review' (Isabella replies) — each hidden at zero, tap to jump to the full queue. (2) /admin/providers now shows an amber banner listing any active provider missing a WA license number (which hard-blocks issuing their patients' certs), NPI, or signature, with a one-click jump to fix it. (3) When you authorize a visit but the patient's email fails, that warning now stays put instead of vanishing when the page refreshes — so you actually see you need to send it manually.

Show technical details

Added

  • 📋 **'Waiting on the office' obligation strip on /admin/admin-today (OPSVIS1).** Three count-only, deep-linked tiles surfacing standing daily obligations that were invisible on the front-desk anchor page (each only alarmed elsewhere or nowhere): unmailed completed certs (was Doug's 7am briefing email only — patients legally waiting on their mailed authorization), PENDING $50 service-request fees (was /admin/invoice-queue only, no chase anywhere), and pending Isabella email drafts (was /admin/isabella-drafts only). New src/lib/mailing-queue.ts provides getUnmailedCertCount() (status COMPLETED + certPdfUrl + mailedAt null) as the single source of truth — the AdminNav mailing badge + the 7am daily-briefing now call it too (3 inline copies consolidated, behavior-identical) — plus getPendingServiceRequestCount(). Each tile loads via its own fail-soft .catch(()=>null) AFTER the page's main Promise.all, so a count failure hides only that tile, never the page. Count-only, no PHI, hidden at zero. [admin-today][obligations][hipaa-clean]
  • 🩺 **Provider setup-incomplete banner on /admin/providers (OPSVIS1).** An active provider with no WA practitioner license number HARD-BLOCKS every cert they'd issue (cert-pdf-issue.ts license gate) — but that only surfaced reactively, per-cert, at issuance time. The page now shows an amber banner naming each active provider missing a license (rose 'hard-blocks issuance' chip), NPI, or signature, each with a 'Complete setup' jump to that provider's edit form. Derived entirely client-side from the already-loaded provider list — no new query, admin-facing config visibility only (not the cert logic). [providers][cert-readiness]

Fixed

  • ⚠️ **The 'patient was NOT emailed' authorization warning no longer vanishes on refresh (OPSVIS1, regression from today's FBGRIND1).** FBGRIND1 added router.refresh() after Authorize to flip the status pill — but the Actions block hosting the button is gated on status ∈ {SCHEDULED,CONFIRMED,PENDING_APPROVAL}, so the refresh flipped status→COMPLETED and UNMOUNTED the whole block, silently discarding the email-failure warning before staff could act (a success hiding a partial failure — the exact class PROVEYES1 fixed). Now the refresh fires ONLY when the patient was actually notified; on an email failure the warning persists in place so staff know to send the cert manually. [authorize][cert-delivery][regression]
v2.97.CALLBACKADMIN1
2026-07-06Production
For front desk

The overnight callbacks digest now goes to the shared admin mailbox (admin@greenwellness.org), not a personal inbox — and its subject line reads 'Front desk,' not a name.

What this means for you

With Demi gone, the 9am weekday 'callbacks owed from overnight' digest is repointed to the shared front-desk mailbox admin@greenwellness.org — which Mariane and Lisa both watch — so it can never land in a departed person's dead inbox again. The email's subject now reads 'Front desk — N callbacks owed from overnight' instead of naming a person. If the recipient setting is ever blank, the digest now defaults to that shared mailbox by design rather than scattering to every admin.

Show technical details

Changed

  • 📧 **Callbacks-owed digest routes to the shared admin mailbox (CALLBACKADMIN1, Doug 2026-07-02: 'demi no longer works for GW, change it to admin').** Set CALLBACKS_OWED_DIGEST_RECIPIENTS=admin@greenwellness.org (BAA-covered M365) in prod AND hardened the code default: resolveRecipients() now falls back to a named DEFAULT_DIGEST_RECIPIENTS=['admin@greenwellness.org'] when the env is unset/empty, instead of the previous 'all active ADMIN users' scatter — deterministic, survives staffing changes, cannot rot to a departed inbox (the exact dead-mailbox failure the workflow-map audit flagged: the env was set 39d ago, pre-offboarding). Subject line 'Demi —' → 'Front desk —' (buildDigestSubject + pin test updated). No PHI/schema change; the digest stays count+deep-link only. [callbacks][digest][staffing][hipaa-clean]
v2.97.WORKFLOW1
2026-07-06Production
For everyone

New 'Your Day' page: the step-by-step daily runbook for staff — exactly what to do, on which page, for everything Isabella and the automations can't finish themselves.

What this means for you

There's a new page under Help called 'Your Day' (/admin/workflow) — the plain-English runbook for a shift. The rule: Isabella and the automations handle the routine (calls, confirmations, deposit chasing, pay links, reminders, the portal checklist); you handle exactly what they can't. It gives you the morning routine in order, a 'when X happens, do Y' section for every point Isabella hands to a human (warm transfers, flagged emails, records callbacks, the honest 'fax line is down' script, $50 lost-cert/change requests, the 'License required' fix, crisis calls), a before-close checklist, a weekly sweep, and a 'who does what' split. Crisis steps carry the safety script: call 911 for immediate danger, otherwise re-share 988 / Crisis Text Line 741741 / the DV hotline, never counsel.

Show technical details

Added

  • ☀️ **'Your Day' daily-workflow runbook (WORKFLOW1) at /admin/workflow.** Static staff-facing guide built to the /admin/training idiom (admin-session gated, ALL_ADMIN roles, sticky table-of-contents deep-links). Designed from a 4-lane operational map (Isabella handoff boundary, patient-journey crons, inbound-comms queues, staff-surface inventory) and hipaa-architect-reviewed: the two crisis blocks (morning Band 0 + the during-day crisis-text row) carry the built machinery's safety language verbatim — 911 for immediate danger, warm re-share of 988 / Crisis Text Line 741741 / DV hotline 1-800-799-7233, an explicit no-counsel/assess/diagnose ban, and 'resolved is a queue action, not a clinical all-clear.' Every page reference is a real (22 routes, all verified present). Registered in the nav SSoT (nav-config.ts, Help group, 'Your Day', Sunrise icon) so it feeds both the sidebar and the ⌘K palette; /admin/training gains a cross-link card. Documents only what's live today — fax dark, reminder calls + returning-patient SMS not-yet — so staff stop expecting them. [training][staff-ops][hipaa]
v2.97.CHECKLIST1
2026-07-06Production
For everyone

Patients now see a 'Before your visit' checklist on each upcoming appointment, and reminder emails include a pay button when money is still owed.

What this means for you

Two Doug-approved follow-ups from today's feedback round. (1) The patient portal now shows a compact 'Before your visit · N of 4 done' checklist on every upcoming appointment: deposit, health intake, consent, and ID upload — green checks for finished steps, tappable links for the rest. Nothing is locked; it's a progress map, not a gate. (2) The 48-hour and 24-hour reminder emails (and the manual 'send reminder' button) now include a secure pay button whenever the patient still owes money — the $50 deposit option when nothing has been paid, or 'pay remaining balance' after a deposit. Patients who already paid in full see no change.

Show technical details

Added

  • ✅ **Non-blocking 4-step appointment checklist on the patient portal (CHECKLIST1, row cmr5row2i — Doug picked option ii).** New pure derivation seam deriveAppointmentChecklist() (pin-tested 15/15) + thin AppointmentChecklist component on each upcoming-appointment card, ordered deposit → intake → consent → ID. False-pending guarded: any payment signal marks deposit done, ID pending_review renders done ('WA ID in review'), consent satisfied done, consent create (no signable row) omits the step rather than dead-linking. Links reuse the exact surfaces the confirmation email resolves (signed /pay via buildPayUrl, /intake/, /patient/forms/ via resolveConsentEnsureAction, /patient/portal/id). NextStepsBanner untouched; no new data exposure — all signals derive from the patient's own rows the page already loads. [patient-portal][forms]
  • 💳 **Pay link in visit reminders (REMPAY1, residual of row cmr5rnw0i — Doug-approved).** New shared deriveAppointmentPayLink() helper (byte-equivalent to the booking-confirmation route's inline derivation; that route can adopt it later) threads an HMAC-tokened /pay URL into reminderEmail from the 48h/24h cron + the admin manual-remind route. Amber pay card mirrors the DEPCOPY1 confirmation branching: deposit-eligible → '$50 deposit holds your appointment; remaining balance due after your visit' with both buttons; partial-paid → 'Pay remaining balance'. Fail-quiet: a derive failure never blocks the reminder. Forward-safe pins: only the $50 figure may appear, never the fee/balance amount; every /pay href carries the token; fully-paid renders zero pay hrefs (12 new pin tests). reminders-2h is SMS-only — unchanged. [emails][payments]
v2.97.DEPCOPY1
2026-07-04Production
For everyone

Booking-confirmation email now names the deposit workflow directly: '$50 deposit today, balance due after your visit,' instead of the fuzzy 'pay ahead of your visit.'

What this means for you

Mariane flagged that the booking-confirmation email's 'Pay ahead of your visit' heading read as 'pay the whole thing now,' which isn't the policy. When the appointment is deposit-eligible, the section now leads with 'Confirm your appointment with a $50 deposit' and explains the remaining balance is due after the visit, before authorization is issued. Both buttons ('Reserve with a $50 deposit' and 'Pay in full') stay clickable — patients who prefer to pay in full still can — but the primary framing now matches how the office actually runs the flow.

Show technical details

Changed

  • 💳 **Booking-confirmation payment section names the deposit workflow directly** — when deposit is offered (hosted-paylink mode, nothing collected, fee > $50), the heading is now 'Confirm your appointment with a $50 deposit' and the body reads 'A $50 deposit today holds your appointment. The remaining balance is due after your visit, before your authorization is issued.' Both existing buttons stay clickable and both still link to the standard HMAC-tokened /pay URL — pinned by two new tests (deposit-first branch + fallback branch). Non-eligible fallback (due==deposit, in-portal Collect, or partial-paid) keeps the classic 'Pay ahead of your visit' heading since deposit isn't offered there. Mariane reviewer-feedback cmr5rds28. [admin][payments]
v2.97.FBGRIND1
2026-07-06Production
For front desk

Feedback round: sent email replies now show in the conversation, the Today follow-ups count matches its list, authorization errors say what's actually missing, and booking confirmations ask for the Washington ID upload.

What this means for you

Four fixes from your feedback in one round. (1) Opening an email conversation now shows the replies that were sent — including Isabella's auto-replies — instead of just a 'reply sent' marker. (2) The 'Follow-ups due' tile on Today and the list it opens now use the same math, so everyone counted appears in the list, and the count is exact. (3) Authorization screens now say exactly what's missing (for example, the provider's WA license number — enter it on the Providers page) instead of a cryptic error, retried visits that already have a cert flip to Completed instead of sticking at Awaiting Authorization, and cert PDF downloads work again for staff and patients. (4) Booking-confirmation emails and the patient portal now prompt patients to upload their Washington ID before the visit.

Show technical details

Fixed

  • ✉️ **Email thread view shows sent replies (row cmr9ho8ru).** The thread page now canonicalizes its URL key (message-id links resolve to the row's threadId and the query matches both), and all four AI-reply create sites in email-ai.ts fall back to threadId ?? inboundMessageId so future auto-replies land in the conversation their inbound message anchors — previously a NULL threadId left every AI reply body invisible (only the audit marker showed). [messages][email]
  • 📊 **Today 'Follow-ups due' tile == destination list (FUPDUE1, row cmr98bdw1).** Tile universe was a 365d follow-up scan; the /admin/leads due-today view only pulled 30d, so older promised callbacks were counted but never listed. The page now supplements the due_today view (only) with the same 365d derivation the tile uses — shared LEAD_BADGE_FOLLOWUP_LOOKBACK_DAYS constant, same helpers, deduped by id — while the 30d array stays the sole universe for every chip, the header, and the Mark-all-read write cohort (invariant preserved). Tile also returns a PRE-slice totalDueTodayFollowups so the count is exact, with '(showing 10)' when the preview is trimmed. [leads][today]
  • 🩺 **Cert-issuance errors, stuck status, and PDF downloads (CERTUX1, row cmr0313ul).** Admin approve + authorize-override routes return friendly issue reasons (raw code preserved in reasonCode); the cert-pdf fast path self-heals appointment status to COMPLETED on both idempotent branches (was sticking at Awaiting Authorization after a successful reissue); admin + patient cert download routes stream the private blob correctly; AuthorizeButton guards a null certExpiry; provider portal copy no longer dead-ends at 'portal profile'. Fastest real-world unblocker is data entry: the provider's WA license number on /admin/providers. [certs][authorization]
  • 🪪 **WA ID upload in the booking checklist (IDUPLOAD1, row cmq61ukdl).** bookingConfirmationEmail gained an optional 'Upload your Washington ID' card (link-only, mirrors the intake/consent cards) gated by the patient's derived ID status; both production senders wire it, and the portal NextStepsBanner prompts for it too — including asking for a fresh photo after a rejected upload. Hard-blocking confirmation on ID is a separate policy call (gate row filed). [emails][patient-portal]
  • 📝 **Training quiz fee/mailing answers aligned (rides MAILCOPY1).** The 'Check your understanding' quiz taught the stale $25 resend fee and dispensary-pickup framing as correct answers on the same screen the copy fix corrected — quiz strings now derive from RESEND_FEE_CENTS and teach the mail-everything workflow. Also trimmed the ORACLECLOSE1 staffSummary to satisfy the ≤800-char changelog pin test (pre-existing red). [training][copy]
v2.97.PKTLINK1
2026-07-06Production
For front desk

Signing the new-patient packet now checks off the intake — patients stop getting intake reminders for a form they already finished.

What this means for you

When a new patient signs their new-patient packet, the intake inside it now counts everywhere: the appointment checklist, the portal banner, and the day-before intake reminder all mark it done instead of nagging the patient again. The confirmation email for new patients also now says 'Complete your new patient packet' instead of listing what looked like two separate forms.

Show technical details

Fixed

  • 📋 **Signed new-patient packet now creates the appointment-linked intake record (PKTLINK1, Mariane intake-cluster cmq6203a7).** The packet e-sign extracted intake answers into the patient chart but passed appointmentId: null, so the per-visit IntakeForm row never existed — and all three nag surfaces (the /my-appointments checklist, the portal NextStepsBanner Priority-1 card, and the day-before intake-reminder cron) key on exactly that row, so packet-signers kept being told to 'complete your intake'. The sign route now resolves the patient's SOONEST upcoming SCHEDULED/CONFIRMED appointment without an IntakeForm (pure rule in packet-intake-appointment-shared.ts, pin-tested; mirrors the cron's status cohort exactly — COMPLETED/NO_SHOW/CANCELLED/PENDING_APPROVAL never receive a late intake link) and upserts the IntakeForm against it (idempotent — appointmentId is @unique). Zero-candidate → null → prior behavior (chart baseline still updates, sign never fails; resolution sits inside the existing swallow-catch). Cron cohort predicate extracted to intake-reminder-shared.ts + pin test proving a packet-created IntakeForm drops the appointment out of the reminder cohort. [forms][intake][cron-dedup]
  • ✉️ **Booking-confirmation consent card is packet-aware.** For new patients the consent link points at the NEW_PATIENT_PACKET (intake + consent in ONE e-sign), but the email rendered it as a second standalone 'Complete your consent form' card next to the intake card — reading as two separate forms. New optional consentIsPacket on bookingConfirmationEmail (default false = unchanged copy) retitles the card 'Complete your new patient packet'; wired from BOTH senders that run ensureConsentFormForBooking (/api/integrations/email and the staff resend-confirmation route — lane parity) off appointment.isNew, the same input the form-type pick keys on. Email-preview route stays on the default. Suppressing the separate intake card when a packet is pending is deliberately deferred. [emails][copy]
v2.97.MAILCOPY1
2026-07-06Production
For front desk

Training and help text for Mailing now show the correct $50 resend fee and the real mail-everything workflow.

What this means for you

The Mailing & Resends training section and the Mailing page help now match how mailing actually works: every completed authorization is mailed to the patient's mailing address within 3-5 business days — the old 'picked up at a dispensary' wording is gone. The resend fee also now reads $50 everywhere, matching what the request form actually charges, and the badge tip explains what 'Shared' really means (72-hour dispensary partner visibility) while making clear every queued row still gets mailed.

Show technical details

Fixed

  • 📬 **Stale mailing/training copy corrected (MAILCOPY1, copy-only).** The admin training 'Mailing & Resends' section and the /admin/mailing PageHelp still described the pre-5/19 dispensary-pickup workflow ('most authorizations are picked up at a dispensary' / 'didn't pick up at dispensaries') and the pre-6/1 $25 resend fee — contradicting both the public How-It-Works copy (fixed 5/19 per Mariane) and the live request form, which auto-fills $50 from feeForCertRequest. Reality: the To-mail queue holds ALL completed unmailed authorizations regardless of dispensary consent, and both resend + change fees are $50 (RESEND_FEE_CENTS/CHANGE_FEE_CENTS, Doug-raised 2026-06-01). Training fee strings now derive from those shared constants (imported from src/lib/cert-service-request.ts) so the copy can't drift again; the badge tip now describes the coded behavior ('Shared' certs are visible 72h post-issuance in the BAA-gated dispensary partner portal, but every queued row is mailed either way); the mailing FAQ's 'Doug to set the price' placeholder is replaced with the derived $50 fee. No logic, schema, auth, or payment-path changes; 'Mail only'/'Shared' badge UI untouched. [copy][training][mailing][fee-drift]
v2.97.ORACLECLOSE1
2026-07-04Production
For everyone

Privacy fix: Isabella now always asks which clinic for a telehealth renewal instead of silently looking it up — closes a way to probe whether an email belongs to one of our patients.

What this means for you

For a telehealth renewal, Isabella used to quietly look up the patient's prior clinic from the email or phone in the conversation, and only ask 'which clinic were you seen at?' if she couldn't find a record. That made her behave differently for a real patient's email vs a stranger's — someone could type a target's email and tell from her response whether that person is a Green Wellness patient, which is protected health information. Now she always asks which clinic first and scopes the schedule only after the patient answers — her response is the same for everyone, so the probe no longer works. Patients notice one extra question on a telehealth renewal; nothing else changes. Reverses the 7/4 'fewer questions' tweak on purpose, at Doug's go-ahead.

Show technical details

Fixed

  • 🔒 **Telehealth patient-membership oracle CLOSED (ORACLECLOSE1).** listOpenSlots (both the chat booking-tools.ts and voice voice-tools.ts lanes) resolved a returning patient's prior clinic from a self-asserted email/phone (voice also from caller ID) and returned scoped slots on a match vs the which-clinic question on a miss — a distinguishable response that leaked whether an asserted identity belongs to a GW patient (PHI). Scoping now comes ONLY from an explicitly-named priorClinic: always ask when it isn't provided, scope after the patient answers — response is identical regardless of record match. Removed the patientEmail/patientPhone tool params + the resolvePriorClinicForIdentity lookup from this path; prompt copy + tool descriptions updated to ask-first; pin test added (asserted identity → still asks). Reverses the TELELOC1 'less questions' follow-up (Doug greenlit). Found by the 2026-07-04 security pass (P3). NOTE: live Retell agent updated out-of-band via the tools+prompt sync. [security][hipaa][membership-oracle][voice][booking]
v2.97.CANCELURL1
2026-07-04Production
For front desk

Fixed two broken 'cancel your appointment' links that sent patients to a dead page.

What this means for you

Patients who tried to cancel from the payment page — or from the cancel link Isabella gives out on voice/chat bookings — hit a 'page not found' error instead of the cancellation screen. The cancel page expects the appointment's cancel code in the web address itself, but both spots were building the link the old way (with a question-mark parameter), which no longer matches. Both now build the correct link. If an older appointment happens to have no cancel code on file, the payment page now shows a 'call us to cancel' phone link instead of a dead link.

Show technical details

Fixed

  • 🔗 **Two broken cancel links → 404 (CANCELURL1).** The cancel route is /cancel/[token] (path segment), but src/app/pay/[appointmentId]/page.tsx linked /cancel?appointmentId=… and src/lib/booking-tools.ts (the voice/chat booking-confirmation cancel URL) built /cancel?token=… — both query-string forms that don't match the route → Next.js 404. Every patient trying to self-cancel from the pay page or the Isabella booking link hit a dead page. Both now build /cancel/; the pay page reads appt.cancelToken (added to the select) and falls back to a tel: call-us link when it's null. [bug][patient-facing][cancel-flow][404]
v2.97.SECAUTHFIX1
2026-07-04Production
For everyone

Security fix: closed a gap that could have let an outsider mark provider bug-reports as resolved without logging in.

What this means for you

A back-end fix — nothing changes in how you work. One internal endpoint that closes out provider (doctor) feedback trusted a piece of information a web request can fake, which in theory let someone who wasn't logged in close those items and forge who did it. It now requires a real staff login or the system's own secure key, exactly like its sibling endpoints. We also added an automatic check that blocks this whole class of mistake from shipping again, and tightened one payment-diagnostic tool so it only ever returns a fixed, safe set of fields.

Show technical details

Fixed

  • 🔒 **Auth bypass on provider-feedback resolve (SECAUTHFIX1, P2).** /api/admin/feedback/[id]/resolve is on the proxy's bearer-allow list, so the middleware passes the request through WITHOUT stripping client-supplied x-admin-role — but the route authenticated via requireAdminFromHeaders, which trusts that header. An unauthenticated POST … -H 'x-admin-role: ADMIN' bypassed the CRON_SECRET check and could resolve arbitrary ProviderFeedback rows + forge resolvedBy. Now authenticates like its sibling reviewer-feedback/[id]/agent: Bearer CRON_SECRET (agent lane, note-mandatory) OR a verified AdminSession cookie (staff lane, roles ADMIN/MANAGER/SCHEDULER, real session.userId attribution) — never a raw header. Also restores the staff lane, which the header model had silently broken (the proxy never sets x-admin-role on bearer-allow paths). [security][auth-bypass][hipaa]

Added

  • 🛡️ **check-bearer-allow-no-header-trust pre-push gate.** Reverse of check-bearer-routes-allowlisted: fails any route in proxy.ts ADMIN_BEARER_ALLOW that derives authorization from the proxy-set x-admin-role/x-admin-id headers (attacker-controlled on that path) or calls requireAdminFromHeaders. This is the net that was missing when SECAUTHFIX1's bug slipped in — wired into gates.manifest.mjs. [security][ci-gate][defense-in-depth]

Changed

  • 🔬 **Poynt ?deliveries=1 diag now projects named fields.** listWebhookDeliveriesForDiag returned Poynt's /deliveries objects verbatim as unknown[] while the docstring claimed a PHI-light projection; it now maps to a fixed {id,eventType,resourceId,deliveryStatus,statusCode,attempts,createdAt} allow-list (mirrors listRecentTransactionsForDiag), so a future Poynt API shape-change can't silently widen a CRON_SECRET-gated response. [security][diag][hygiene]
v2.97.POYNTTEST1
2026-07-04Production
For front desk

Payment-sync test tooling: we can now see Poynt's own delivery log and replay a real payment through the webhook to prove the sync end-to-end.

What this means for you

Small observability add to finish the Poynt work: the webhook diagnostic can now list recent Poynt transactions (ids and status only — no cardholder details) and read Poynt's own log of webhook deliveries, which shows exactly what Poynt sent us and how our system answered. Used right away to run a live sync test by replaying a real captured payment through the webhook.

Show technical details

Added

  • 🔬 **Poynt diag lenses (POYNTTEST1)** — ?recentTransactions=1 (PHI-light txn projection: id/status/amount/source/reference — no cardholder fields) + ?deliveries=1 (Poynt's ~30d delivery log = the canonical did-it-fire/what-did-we-answer check) on /api/admin/diag/poynt-webhook-registration. [payments][diag]
v2.97.DEPOSIT1
2026-07-04Production
For front desk

Booking deposits are ON for telehealth: patients now pay the deposit up front when booking, and Poynt payments record themselves — no more hand-marking paid.

What this means for you

Two switches flipped together, in the order Doug required. First, the Poynt webhook is registered (our system did it directly with Poynt — no portal needed), so card payments — at the terminal and through emailed pay links — now mark the appointment paid automatically, and refunds/voids sync too. Second, with that safety net live, the booking deposit is enabled for telehealth: a patient booking a telehealth renewal pays the deposit during booking instead of promising to pay later. If a deposit doesn't auto-record, the daily payment-alignment check flags it at /admin/payments/alignment for a one-click fix. Auto-cancel of unpaid bookings is NOT on — staff still decide.

Show technical details

Changed

  • 💵 **BOOKING_DEPOSIT_ENABLED=true (telehealth scope)** — the §E flip Doug pre-authorized 'the moment the webhook is registered.' Deposit-then-balance model goes live on the self-sched wizard + voice pay links; the Poynt webhook (registered this hour, hook d9a1586b) auto-records each payment with server-to-server verification before anything releases. Auto-cancel-if-unpaid stays unbuilt (staff-notify only) pending the cmr5rnw0i sub-decision. [payments][booking]
v2.97.POYNTHOOK1
2026-07-04Production
For front desk

Poynt payments are about to sync themselves — the webhook can now be registered from our own system (no portal needed), and real Poynt notifications are correctly understood when they arrive.

What this means for you

Two halves of finishing the Poynt auto-sync. First, registering the webhook no longer needs anyone in the Poynt portal: our system registers it directly with Poynt using the credentials it already holds, and can list what's registered any time. Second — found while doing it — Poynt doesn't send payment details in its notifications, just a pointer; our receiver used to shrug at those pointers, so a real card payment would never have marked the appointment paid. The receiver now follows the pointer back to Poynt, verifies the payment server-to-server, and then marks the appointment paid (or refunded) exactly like before. Once the registration is confirmed, front desk stops hand-marking Poynt payments.

Show technical details

Added

  • 💳 **Poynt webhook self-registration (POYNTHOOK1)** — new diag /api/admin/diag/poynt-webhook-registration (Bearer CRON_SECRET): GET lists the business's hooks + whether our deliveryUrl is registered; POST idempotently registers ONE hook carrying the six documented TRANSACTION_* eventTypes (docs.poynt.com re-verified — no TRANSACTION_COMPLETED exists). Runs INSIDE prod because the Poynt creds are Vercel-Sensitive (empty everywhere else); the hook secret is the same env the receiver verifies, so the two sides can't drift. registerPoyntWebhook (built 5/20, never wired) finally gets a caller; new listPoyntWebhooks + POYNT_TRANSACTION_EVENT_TYPES. [payments][diag]

Fixed

  • 📦 **Webhook receiver understands Poynt's real envelope deliveries** — Poynt sends {eventType, resource:'/transactions', resourceId} with NO amount/reference inline; the old parse read the delivery uuid as an invoice id and 0 cents, so every real capture would have acked 'no-match' and nothing flipped paid. Now isTransactionEnvelope() detects the shape and fetchPoyntTransactionForWebhook() resolves the transaction server-to-server (status/amount/references — our appointment cuid rides references[].customType=externalReferenceId, same extraction listRecentOrders uses); resolve-failure returns 503 so Poynt redelivers, absorbed by the existing idempotency + ledger dedup. Pure projection pin-tested (poynt-webhook-envelope-shared, 10 pins). [payments][webhook]
v2.97.TELELOC2
2026-07-04Production
For everyone

Isabella's phone line is now running the whole 7/4 batch — telehealth-by-prior-clinic, the admin@ records address, honest renewal wording — after passing all 19 safety probes.

What this means for you

The voice sync is done: Isabella's live phone script now carries everything that was staged — records go to admin@ (records@ bounced), renewal wording no longer implies pre-approval, and telehealth scheduling quietly matches the patient to the clinic where they were previously seen. Before going live the script passed the full 19-probe behavioral safety gate (crisis, dosage, records-release, provider-name, injection attempts). One hardening from that gate: when Isabella declines a clinical, diagnostic, or dosage question she now always offers the next step — book the visit or take a message — instead of leaving the caller hanging.

Show technical details

Changed

  • 📞 **Voice sync executed (TELELOC2)** — Retell tools reconciled (listOpenSlots gains priorClinic/patientEmail/patientPhone; description trimmed to Retell's 1,024-char cap) + prompt PATCHed after a 19/19 abstention-eval pass on GW's BAA-covered Bedrock (GW AWS creds now provisioned in the ops keychain — the eval gate that blocked B/C/F is unblocked permanently). Decline-then-route hardening added from the eval's one initial miss: a dosage/clinical/diagnostic refusal always offers booking or a message in the same breath. [voice][isabella][safety-eval]
v2.97.TELELOC1
2026-07-04Production
For everyone

Telehealth renewals are now matched to the clinic where the patient was previously seen — looked up automatically from the patient's record (caller ID / email), with the question asked only when no record matches, per Doug's ruling.

What this means for you

Doug ruled: a renewal patient's telehealth options come from the clinic they were previously seen at — plus any clinic a provider is cross-covering (Spokane's prior patients are picked up by Lynnwood's providers). Per his follow-up ('just look it up — less questions'), the system finds the prior clinic from the patient's own record (caller ID, or the phone/email already collected) and quietly scopes the schedule; Isabella only asks 'which clinic were you previously seen at?' when no record matches, and never says what the record shows. Patients can still join the video call from anywhere in Washington — what changed is whose schedule they book into. Also per Mariane: the records message now says the provider's review decides the renewal. Script half rides the voice sync.

Show technical details

Changed

  • 🩺 **Telehealth-by-prior-clinic, lookup-first (Doug ruling + follow-up 2026-07-04, rows cmqell76a + cmr03k7i2)** — new RENEWAL_CROSS_COVERAGE map + getRenewalProvidersForPriorClinic() in provider-location-rules.ts (prior clinic's own renewal providers ∪ cross-coverers, departed filtered); new prior-clinic-lookup.ts resolves the prior clinic from the patient's OWN record (email exact / unambiguous phone via fail-closed phoneOrWhere → newest located in-person appointment, else issuing provider's home clinic); listOpenSlots on BOTH lanes (voice: caller ID + patientEmail/patientPhone params · chat/email: patientEmail/patientPhone) looks up first and only hands back the which-clinic question when no record matches — telehealth times are never quoted unscoped, and the looked-up clinic is never spoken back to an unverified caller (disclosure rule); proposeBooking re-validates the picked slot's provider server-side (telehealth_prior_clinic_mismatch). Provider-NAME ban unchanged; the fallback ask-the-prior-CLINIC is the one permitted reversal. [voice][booking][isabella]
  • 🗣️ **Prompt sweep across all four channels** — voice/chat/email/SMS renewal-routing prose (single SoT in getLocationListForPrompt) now carries the lookup-first rule (pass the conversation's phone/email; ask only when the tool asks; never echo the record); 'telehealth anywhere in Washington State' replaced with join-from-anywhere-but-schedule-by-prior-clinic framing; voice soft-cap 31.5K→33.5K + assembled ceiling 39.5K→42K (documented history). Goes live on the phone at the next Retell sync. [voice][prompts]

Fixed

  • 📋 **Records message no longer implies auto-acceptance** (Mariane cmr03kio2) — the renewal reassurance now says 'our provider reviews them at your appointment, and that review is what decides the renewal' instead of wording that sounded like the appointment was guaranteed once records were in hand. [voice][isabella]
v2.97.LEADUP1
2026-07-04Production
For front desk

A lead who clicks their records-upload link can now actually upload — the files land on their lead record for in-app review and carry forward automatically when you convert them to a patient.

What this means for you

Mariane's portal-attachment ask, root-caused. Isabella emails a secure records-upload link to anyone who asks — but if that person was still a LEAD (not yet a patient), their upload hit 'patient not found' and the records landed nowhere. Now the upload recognizes the lead by the exact email the link was sent to (possession-verified — we never guess a fuzzy match, so records can't attach to the wrong chart) and stages the files on their lead record, exactly like the ones you upload from the lead page yourself. You review them in-app without downloading, and when the lead converts to a patient the documents carry forward automatically, same as before.

Show technical details

Fixed

  • 📎 **Portal records-upload now works for LEADS** (Mariane cmr5rfahi + cmr5rfho7, §G of the 7/4 greenlight) — /api/my-appointments/[token]/documents falls back from Patient to the newest exact-email LEAD_CAPTURED match (findLeadAuditIdByEmail, re-parsed exact compare — doe@x.co can never claim doe@x.com's upload) and stages onto the same PendingIntakeUpload substrate as the staff lead-documents lane, so the existing lead→patient carry-forward + in-app staff review just work; lead-token + blob-path conventions extracted to shared lead-document-shared.ts so both writers can't diverge; uploads list back with synthesized display names (original file names are never stored on lead rows — PHI). Audit: LEAD_DOCUMENT_UPLOADED channel=portal-token, PHI-free detail. [portal][leads][hipaa]
v2.97.FBPROV1
2026-07-04Production
For everyone

Provider-filed issues can no longer sit unseen — the autonomous fix loop now reads open provider feedback and can close it with evidence, alongside the existing staff resolve button.

What this means for you

Structural fix from the hidden-feedback sweep (Dr. Ari's payment-visibility note sat OPEN for 10 days because provider feedback lives in a separate table the agent loop never read). The feedback queue endpoint can now include open provider-filed issues on request, and the resolve endpoint accepts an agent closure — but ONLY with a note naming the fix (an agent close with no evidence is rejected outright, per the done-without-evidence doctrine). Staff resolution from /admin/feedback is unchanged.

Show technical details

Added

  • 🔁 **ProviderFeedback joins the drain loop (FBPROV1)** — reviewer-feedback/queue gains opt-in ?includeProvider=1 (OPEN provider rows, same CRON_SECRET trust tier as reviewer bodies — the metadata-only agent token stays rejected; ephemeral-read rule applies); /api/admin/feedback/[id]/resolve gains a Bearer-CRON_SECRET agent lane that REQUIRES a resolvedNote naming the fix (sha/version) and stamps resolvedBy=agent — hollow closes 400 instead of landing. Watchdog's provider-feedback-open-age probe (already live) is the alarm half. [feedback][agent-lane]
v2.97.DRAFTSTALE1
2026-07-04Production
For everyone

The 'Emails without a draft' list no longer says a draft is coming 'in a few minutes' for an email that's actually hours old — those now tell you to just reply directly.

What this means for you

Follow-on to the draft-visibility work. An email Isabella is eligible to draft for, but that never got a draft (it hit the daily AI limit, a conversation throttle, or was routed to the team), used to show 'Draft pending — within a few minutes' forever, so you'd keep waiting for a draft that was never coming. Now, once such an email is past the normal drafting window (30 minutes), it flips to 'No draft generated — open it and reply directly instead of waiting,' so you're not left watching a spinner that never resolves. Fresh emails still correctly show as pending.

Show technical details

Fixed

  • ✉️ **'Draft pending' stops lying about hours-old emails** (Mariane cmqq18zth follow-on) — an eligible email past the 30-min drafting window with no draft now reads 'No draft generated — reply directly' instead of promising one 'within a few minutes'; these are the ones that hit a daily AI cap / conversation throttle / handoff and will never draft. Pure age-aware diagnosis, PHI-safe (no email content, just timing + category). [admin][isabella]
v2.97.LEADXREF1
2026-07-04Production
For everyone

Searching Patients for someone who's actually still a Lead no longer dead-ends — the patient search now points you to matching leads so you don't miss them and they don't keep getting lead emails.

What this means for you

Mariane's search trap, fixed. When you searched a name on the Patients page and it wasn't found, you'd assume the person didn't exist — but they were often still in Leads (un-converted), so they kept getting automated lead emails. Now, when a Patients search turns up no patient (or even when it finds one but a lead also matches), the page shows an amber 'Found N matching lead(s) — View in Leads' pointer that jumps straight to the Leads list pre-filtered to your search. No more silent dead-ends between the two lists.

Show technical details

Fixed

  • 🔎 **Patients search now surfaces matching Leads** (Mariane cmr1flpwc) — a Patients search that finds no patient shows how many leads match the same query with a 'View in Leads' link (pre-filtered); when patients DO match but a lead also matches, a slim banner keeps the lead from hiding behind the patient hit. New bounded, PHI-safe count helper (getLeadNotificationCohort's neighbor countLeadMatchesForQuery). [admin][leads]
v2.97.ISAADMIN1
2026-07-03Production
For everyone

Isabella's phone script now sends patient records to admin@ (the records@ address bounced) and no longer implies a renewal is approved before the provider reviews the records — staged, goes live on the next voice sync.

What this means for you

Two of Mariane's Isabella corrections, vetted for WSLCB-safe wording. (1) Isabella was still reading 'records at greenwellness dot org' for patients to email records — that mailbox bounced, so all three spoken records rails now say 'admin at greenwellness dot org' (matches where emails already point). (2) On a renewal, Isabella implied the patient was set once we had records; it now confirms the time slot but says the provider reviews records and decides at the visit — no implied approval. These change the script in code; they reach the live phone only after the separate voice sync (a Doug step). Provider names were already kept out; the telehealth-by-location question awaits a business-rule decision.

Show technical details

Changed

  • 📨 **Isabella records email → admin@** (Mariane cmr5ret01) — all three spoken records-submission rails (renewal confirm, new-patient wrap, end-of-call wrap-up) now say 'admin at greenwellness dot org'; the records@ mailbox was never provisioned and bounced. Matches RECORDS_EMAIL in code. Staged — goes live on the next Retell voice sync. [isabella][voice]
  • ⚖️ **Isabella no longer implies a renewal is pre-approved** (Mariane cmr5rebqb / cmr03kio2) — the renewal confirmation used to suggest the patient was set once records were in; it now confirms the time slot but states the provider reviews the records and decides at the visit (WSLCB-safe: no guaranteed eligibility). Staged for the next voice sync. [isabella][voice][compliance]
v2.97.PROVNAV1
2026-07-03Production
For everyone

Provider portal menu is now four plain tabs (Schedule · Today's Patients · Patient Records · Charts) with the extras tucked under More, the Patient Portal button is back on the website, and the Leads 'Mark all read' now actually clears the badge to zero.

What this means for you

Mariane's workflow round. The provider menu was six flat tabs that read as overwhelming — it's now the four daily destinations in plain words (Schedule, Today's Patients, Patient Records, Charts) with Templates and My reports moved into a More menu; no page moved, just the labels and grouping. The Patient Portal link is back in the website's top-right now that the portal is real (login, appointments, records upload). And the Leads notification bug is fixed at the root: 'Mark all read' said it marked 15 but the badge stayed at 2 because it counted overdue follow-ups it never cleared — the count and the button now share one definition of the cohort, so the badge reliably drops to zero (overdue leads still show in the Due Today / Needs Callback filters — only the red notification clears).

Show technical details

Fixed

  • 🔔 **Leads 'Mark all read' now zeroes the badge** (Mariane cmr5rh8f2) — the badge counted uncontacted + overdue-follow-up leads, but the button only cleared the uncontacted ones, so it stuck at the overdue count ('15 marked, still shows 2'). The count endpoint and the mark-all-read button now derive from one shared cohort helper (getLeadNotificationCohort) so they can't drift; marking an overdue lead read releases only the notification — the lead still appears in the Due Today / Needs Callback queue filters. [admin][leads]

Changed

  • 🧭 **Provider menu simplified to four tabs** (Mariane cmr030cm8) — Schedule · Today's Patients · Patient Records · Charts, with Templates + My reports under a More ▾ menu. Same routes, clearer labels; the old 'Worklist' is now 'Today's Patients'. [provider]
  • 🔗 **Patient Portal link restored on the website** (Mariane cmr5rf1ji) — the upper-right entry point (hidden in May while the portal was half-built) is back now that login, appointments, and records upload are live; also in the mobile menu. [website]
v2.97.PROVEYES1
2026-07-03Production
For providers

The provider portal now tells the truth after Sign & Issue: real confirmation when the authorization goes out, a clear warning when the patient email doesn't send, and an honest reason when issuance fails — plus a Report-issue button on every patient's chart and schedule row.

What this means for you

Provider-side reliability round. The big one: the schedule page's Sign & Issue used to show 'Authorized ✓' even when the authorization never issued — it now says exactly what's missing (DOB, qualifying condition, license number) in plain language. Certs can no longer issue without your signature image embedded — if it can't load, issuance blocks with 'try again in a minute' instead of mailing an invalid document. When a cert issues but the patient email fails, you now see that (single and bulk signing). Also: report-an-issue now sits on each patient's chart and schedule row, the Schedule and Worklist pages say what they're for, an all-caught-up note replaces the vanishing signature queue, and the Worklist warns if check-in alerts lose connection.

Show technical details

Fixed

  • 🚨 **Sign & Issue on the schedule no longer reports success when issuance failed** — the approve action returned success while the audit log quietly recorded cert=skipped (the exact class Dr. Ari hit in June); it now returns the real, actionable reason (missing DOB / qualifying condition / license number / patient email) and the row shows it. [provider][reliability]
  • 🖋️ **A cert can no longer issue unsigned** — if your signature image is on file but fails to load (transient storage error), issuance now BLOCKS with 'try again in a minute' instead of silently generating an authorization without the practitioner signature (invalid per RCW 69.51A.030). Applies to every issuance path: schedule approve, bulk sign, encounter Sign+Lock, and admin approve. [provider][compliance]
  • 📧 **'Signed' no longer implies 'patient notified'** — single Sign & Issue and bulk sign both now report when the cert issued but the patient email didn't send (amber row + 'front desk can resend'), and the post-sign chart banner no longer claims the email failed when it had already gone out on an earlier issuance. [provider][reliability]
  • ⏳ **Confirmation you can actually read** — the schedule's Sign & Issue outcome now stays on screen (4s success / 8s warning) instead of a 1.5-second checkmark, and a timeout says 'the cert may still be generating — refresh before retrying' instead of a generic failure. [provider]

Added

  • 🚩 **Report an issue — per patient** (Mariane 6/30) — the Report-issue button now sits on every encounter chart and every awaiting-signature schedule row, carrying that patient + visit automatically, so 'missing records / incomplete intake / wrong info' reports land with staff already knowing who needs attention. The portal-wide card stays for general problems. [provider]
  • 🧭 **Schedule vs Worklist, explained** (Mariane 6/30) — each page header now says what the page is for ('Your visits by day — sign & issue from here…' / 'Today's working view — check-ins, open charts, what's ready to sign'). [provider]
  • ✅ **All-caught-up state** — when nothing awaits your signature, the schedule now says so instead of silently hiding the section. [provider]
  • 📶 **Check-in alert health** — the Worklist shows a small 'Check-in alerts paused — reconnecting…' badge after 3 straight failed polls, so a dead connection can't silently eat arrival notifications. Records-review buttons now flip to 'Saved ✓' while the queue updates. [provider][reliability]
v2.97.FETCHTMO1
2026-07-03Production
For everyone

Buttons can no longer hang forever: every remaining in-app action now gives up cleanly if the network stalls, instead of leaving a spinner stuck until you reload.

What this means for you

Housekeeping ship. Ten places in the app (document uploads on charts/appointments/leads, Isabella draft use/dismiss, catch-up queue actions, the cash-deposit toggle, and provider records-review actions incl. the autosave flush) sent their request with no time limit — a stalled connection meant a spinner that never resolved until you refreshed. Each now has a sensible time limit (2 minutes for file uploads, 10 seconds for everything else) and shows the normal error state when it trips, so you can just retry. No behavior change on a healthy network.

Show technical details

Fixed

  • ⏱️ **All 10 remaining unbounded network calls now time out cleanly** — AbortSignal.timeout on: chart/appointment/lead document uploads (120s), Isabella draft actions, lead catch-up queue actions, cash-deposit toggle, provider records-review decisions + form, and the SOAP autosave unload flush (30s, keepalive preserved). Closes the fleet fetch-abort-signal-discipline finding for GW (10 → 0). [admin][provider][reliability]
  • 🧯 **Document delete can't strand the row in 'deleting…' anymore** — the appointment-chart document delete already had a time limit but a timeout/network failure threw past the state reset (button stuck until reload); it now shows the normal error toast and re-enables. Found by the pre-ship review of this batch. [admin][reliability]
v2.97.KATFIX1
2026-07-03Production
For everyone

Kat's front-desk bug list, all 16 items: My feedback no longer logs you out, the calendars actually load, Isabella chats open, the CS command center is fast, and denied pages now explain themselves instead of bouncing you.

What this means for you

Every item from Kat's morning walkthrough. The big one: clicking My feedback (or the feedback button) silently bounced you between the two site addresses and dropped your session — that whole class is fixed, including locations and appointment slots failing to load for the same hidden reason. The appointment calendar's reload loop (appointments flashing then vanishing) is fixed at the root. Isabella's chats open into a real conversation view, the CS command center loads in seconds, and pages you can't access now explain why instead of bouncing you to Today. Plus: email replies show in the thread, caller pop-ups link to the matching chart, What's New is current, palette search knows 'password reset', and training gained real quiz questions.

Show technical details

Fixed

  • 🔐 **My feedback / feedback button no longer log you out** — staff surfaces living outside /admin (/me/feedback, /api/feedback, /api/upload) were being 308-redirected from flow.* to the apex host, where the host-only session cookie doesn't exist; the redirect classifier is now a pure, pin-tested predicate (flow-staff-path.ts) with those paths staff-side, and login round-trips carry ?next=/me/feedback. [admin][auth]
  • 📍 **Locations + appointment slots load on the staff host** — /api/locations, /api/availability, /api/appointments joined the staff-host allowlist (same 308 class as the feedback bug); the booking page also gained honest, distinguishable error states with Retry for network-vs-genuinely-empty. [admin][scheduling]
  • 📅 **Appointment calendar reload loop fixed at the root** — a render-scope date object made the loader refire after every render (appointments flashing then vanishing, audit spam); appointments now stay rendered during refresh and the promised once-a-minute auto-refresh actually exists. [admin][scheduling]
  • 💬 **Isabella chats open** — chat rows link to a real conversation view (/admin/messages/chat/[threadId], session-gated, audit-logged, opaque ids). [admin][isabella]
  • ⚡ **CS command center loads in seconds** — three root causes fixed (per-call date-formatter construction, an O(buckets) business-hours walk now O(days) with an exact-parity test oracle incl. DST seams, and a 50-query N+1 now one batched query); per-lane timeouts fail VISIBLE with a red could-not-check strip instead of hanging the page. [admin][cs]
  • 🚧 **Denied pages explain instead of bouncing** — /admin/no-access shows your role + the page you hit (Payments for front desk now lands here rather than silently returning to Today; Dashboard nav is admin-only so it stops masquerading as a second Today). [admin][nav]
  • 📧 **Email threads show your replies** — sent replies render in the same timeline as the inbound message; AI draft-reply cards show what they're replying to; the operational email composer no longer lives only under Marketing. [admin][email]
  • 🩹 **Banner close buttons clear the live/test chip** (right-padding on What's-New/announcement/task banners); **caller pop-ups link to the matching patient chart** (exactly-one-match rule, masked digits); **What's New is current**; **palette search finds 'password reset'**; **training got real quiz questions** (client-side; HIPAA-basics module drafted, compliance-review-gated, provably unpublished). [admin][polish]
v2.97.PROVFLOW1
2026-07-03Production
For providers

The provider portal now flows: after signing a chart you're offered the next patient and your next draft, today's tiles link to prior charts, renewals show who hasn't been reminded, and your reported issues have a status page.

What this means for you

Five clinical-day flow improvements. (1) After signing an encounter, the page now offers 'Next patient — start chart' (your next unstarted appointment today) and 'Resume next draft' instead of dead-ending. (2) Each appointment on Today has a 'Prior charts' link, so you can check history before opening the visit. (3) The authorizations queue gained an 'Expiring ≤30 days, no reminder sent' chip — the renewals worth a personal nudge. (4) When records-review is enabled and items are waiting, Today shows a 'Records awaiting review' tile so nothing is discovered mid-visit. (5) New 'My reports' page: every issue you've filed with its status, who resolved it, and the resolution note — the loop is closed.

Show technical details

Added

  • 🩺 **After-signing next steps** on the encounter page (next unstarted appointment today + oldest other draft, PT day-bounds + redacted-name labels matching the Today board; metadata-only VIEW_SIGNED_ENCOUNTER_NEXT_STEPS audit) · **Prior charts** link per Today tile (encounters list already accepted patientId) · **Renewal-due chip** on authorizations (expiring ≤30d AND all four reminder stamps NULL — the cron stamps windows independently, so any-stamp-present means already reminded) · **Records-awaiting-review tile** on Today (flag-gated, count-only, degrades silently) · **My reports** feedback log (provider's own rows + resolver name + note; session guard copied from records-review; VIEW_PROVIDER_FEEDBACK_LOG audit; linked from the nav and the report-issue confirmation). [providers][workflow]
v2.97.PTPORTAL1
2026-07-03Production
For everyone

Patient portal self-serve: every upcoming appointment has an 'Add to calendar' button, the telehealth join time is stated explicitly, and uploading records now says what happens next.

What this means for you

Three fewer reasons to call the office. (1) Each upcoming appointment in the portal has 'Add to calendar' — it downloads a standard calendar file that works with Apple, Google, and Outlook. The calendar entry is deliberately generic ('Green Wellness appointment', no visit details) since family calendars are often shared. (2) Instead of 'the join button activates 30 minutes before', the portal and visit page now say exactly when: 'Join opens at 1:30 PM PT'. (3) After uploading medical records, the page now says 'We'll have these ready for your visit on <date>' — or offers the booking link if no visit is scheduled — instead of leaving the patient wondering if anything happened.

Show technical details

Added

  • 📅 **Add-to-calendar (.ics)** per upcoming appointment — hand-rolled RFC 5545 (no new dependency), UTC times, PHI-minimal by design (fixed generic SUMMARY pinned by test; LOCATION only for in-person; description links the portal). Route mirrors the patient download siblings exactly: rate-limited fail-closed, session + ownership gate with unified 404, audited before bytes (PATIENT_DOWNLOAD_APPOINTMENT_ICS, ids-only), private/no-store. 15 new pins. [patients][portal]
  • 🕐 **Explicit join-opens time** — portal card + JoinVisitCard say 'Join opens at h:mm a PT' (fmtPT; the visit card's three window checks now share one named constant so copy can't drift from the button gate). [patients][telehealth]
  • 📤 **Records-upload next-step nudge** — success state names the upcoming visit date (server-formatted, PHI-free prop) or links the booking entry when none. [patients][records]
v2.97.STAFFFLOW1
2026-07-03Production
For front desk

Front-desk speed round: copy buttons on lead contacts, a 'Convert & book' button that lands you straight in scheduling, patient context on returning leads, and ⌘K can now create things.

What this means for you

Four workflow accelerators for the leads-and-booking day. (1) Every email and phone in the leads queue has a tiny copy button — no more careful text-selecting. (2) Lead conversion now offers 'Convert & book' alongside the existing convert: one click converts the lead AND lands you on the new-appointment form with the patient pre-filled (the on-a-call flow keeps its deliberate no-auto-navigation behavior). (3) When a lead matches an existing patient, the detail page now shows their appointment count and next visit at a glance, linked to the chart. (4) The ⌘K palette gained creation actions: New appointment, New patient, Compose email — searchable by 'book', 'create', 'schedule'.

Show technical details

Added

  • ⚡ **Leads copy-contact buttons** (silent-degrade clipboard, inline 'Copied' flash — no toast spam across queue rows) · **Convert & book** second CTA (intent via ref so both the relink fast-path and DOB-modal path honor it; existing button untouched) · **Prior-patient context on lead detail** ('N appointments · next: ' under the already-a-patient pill, try/catch-degraded) · **⌘K creation actions** role-gated through the nav-config SSoT rather than hardcoded roles. One survey item rejected on verify: patient-detail appointment rows were ALREADY linked (shipped d26accc7) — no churn. [leads][front-desk][cmdk]
v2.97.OPSPOLISH1
2026-07-03Production
For everyone

Dashboard truth round: the morning pages' 'loaded at' times were showing UTC next to a 'PT' label, staff-session date filters were an hour off all summer, and task-board buttons now show they're working.

What this means for you

Time-honesty sweep on the ops dashboards plus single-source fixes. (1) Admin/Isabella/Mariane Today stamped 'loaded at … PT' with the SERVER's clock — UTC in production, seven hours off. All now render real clinic time. (2) Staff-sessions date filters were hardcoded to winter time, so all summer a filter for 'July 2' started and ended an hour early; now daylight-saving-correct. (3) Every task-board button (Send task, Done, Send back, Reopen, Close it out) now disables and shows progress while saving. (4) The pricing page's search-engine data still advertised the old renewal fee — it now reads the same pricing constants as the visible page; two hardcoded phone-number copies now pull from the contact constant; get-started stopped naming a clinic we don't operate.

Show technical details

Fixed

  • 🕐 **'Loaded at' stamps on Admin/Isabella/Mariane Today render clinic time** — toLocaleTimeString with no timeZone renders the SERVER locale (UTC on Vercel) and every one of these badges appends 'PT' to it; now fmtPT / explicit America/Los_Angeles (incl. the Isabella auto-refresh stamp). [admin][dashboards]
  • 🌗 **Staff-sessions date filters are DST-correct** — from/to were parsed with a hardcoded -08:00 (PST) offset, off by one hour the ~8 months a year the clinic runs on PDT; now fromZonedTime(…, CLINIC_TZ). [admin][sessions]
  • ⏳ **Task-board pending states** — new shared ActionSubmitButton (useFormStatus) wired into all 5 server-action buttons; sister of the provider portal's FormPendingSubmit (which covers plain POST forms where useFormStatus has no context). [admin][tasks]
  • 🧾 **SSOT pulls**: pricing-page JSON-LD offers now read PRICING.* (the renewal offer still advertised the pre-June-10 figure to crawlers/LLMs); forms-delivery email + records-request PDF phone strings now import PHONE; get-started copy stopped listing a Vancouver clinic (3 locations exist). Stale scheduler-name comment cleaned. [public][ssot]
v2.97.PROVUX1
2026-07-03Production
For providers

Provider portal polish: the Sign + reissue button locks after one click, the eligibility shield reads properly to screen readers, and a few queue messages got clearer for the front desk too.

What this means for you

Papercut round on the provider portal and this week's newest features. (1) The 'Sign + reissue' button now disables itself and shows 'Signing…' once clicked — a double-click on a signed clinical action can no longer submit twice. (2) The compassionate-care shield icon in the authorizations table now announces itself correctly to screen readers. (3) On the leads queue, when a status and an action filter together match nothing, the empty message now names the action filter narrowing the view and offers one click to clear it — before it looked like the status had no leads at all. (4) When emailing a payment link fails, the error now points at the 'Text pay link' alternative. (5) The feedback widget's receipt now says where replies actually land: under My feedback.

Show technical details

Fixed

  • 🖊 **Reissue sign button double-submit guard** — new FormPendingSubmit client button for plain method=post forms (disables on the form's submit event; the in-flight POST completes, re-clicks are blocked), wired into the authorization reissue form. Reusable for any other plain-form submit in the portal. [providers][clinical-ux]
  • 🛡 **Compassionate-care icon a11y** in the authorizations table (role="img" + aria-label on the wrapping span, icon aria-hidden — was an orphaned label on the icon element). Also a stale staff-name reference in a code comment generalized. [providers][a11y]
  • 🎯 **Leads empty state is action-filter-aware** — zero-result status+action intersections now say which action filter is active ('…that need an email follow-up') with a 'Clear the action filter' link preserving the status view; previously read as if the status itself were empty. [leads][front-desk]
  • 💬 **Copy honesty pair**: Bill-via-Poynt email-failure message now points at the 'Text pay link' lane instead of the vague 'send another way'; feedback-widget receipt says 'Replies and fix notices land under My feedback' (the actual reply channel). [admin][copy]
v2.97.PORTALFIX2
2026-07-03Production
For everyone

Patient-page fixes: reschedule times always show clinic time (a patient's device on Mountain Time saw the wrong hour), and expired form links now show a phone number instead of a dead end.

What this means for you

Three patient-facing fixes. (1) The self-serve reschedule page rendered available slot times in the DEVICE's timezone while the rest of the page showed clinic time — a patient on a Mountain Time device (Idaho border, travelers) could pick '5:00 PM' and actually book 4:00 PM clinic time; slot times and the success message now always render clinic time like every other patient page. (2) When a form link is expired, revoked, or invalid, the error page said 'Contact Green Wellness' with no way to do so — it now shows a tappable phone number, and an already-signed form links to the patient portal. (3) Submit buttons on five patient forms now show a not-allowed cursor while disabled, matching the rest.

Show technical details

Fixed

  • 🕐 **Reschedule slot times render clinic time (PT)** (RescheduleForm used browser-local format() for the slot grid and the success message while the server page used fmtPT — same page, two timezones; a Mountain-Time device showed each slot one hour later than reality). Both renders now use fmtPT. [patients][scheduling]
  • 📞 **Form-link error screens get a way forward** (/patient/forms/[token]): NOT_FOUND/EXPIRED/REVOKED now show a tappable call link (same pattern the intake page already had); ALREADY_SIGNED links to the patient portal. Previously all four said 'Contact Green Wellness' with no contact info. [patients][forms]
  • 🖱 **disabled:cursor-not-allowed on 5 patient submit buttons** (reschedule, intake, pre-visit, cancel, forgot-password) — the disabled state no longer shows a clickable pointer. [patients][a11y]
v2.97.MATRIXTIER1
2026-07-03Production
For everyone

Behind the scenes: a switch now controls how many of the 286 auto-generated city pages search engines are told about — nothing changes today, but the SEO team can now dial it without a code change.

What this means for you

Infrastructure only — zero visible change. The site auto-generates 286 city-and-condition pages (33 location pages + 253 telehealth pages). The May SEO audit recommended a control lever for how many of these are exposed to search engines, because most share template text and can dilute how Google spends its attention on the site. That lever now exists as a single setting with three positions: full (today's behavior, the default), substantive (only the 27 pages with genuinely unique writing), and off. The setting also trims the sitemap and cross-links consistently so search engines never get told about a page the switch has turned off. Flipping it is a deliberate SEO decision that stays with Doug — nothing was flipped today.

Show technical details

Added

  • 🎚 **MATRIX_TIER crawl-budget lever, built dark** (src/lib/matrix-tier.ts SSOT; GW_SEO_AUDIT_2026_05_15 Phase C, unbuilt for 49 days). Gates sitemap entries, generateStaticParams (with dynamicParams=false, pruned pairs 404 at deploy = intended semantics), and every on-site cross-link enumeration (city condition-chips, condition-page telehealth upsells, matrix cross-links) from one helper. full (default, byte-identical to today — deep-equal pinned) | substantive (mechanical criterion: pair-level FAQ + city intro + condition note all present → 27 location pairs; telehealth matrix has zero pair-level copy today so substantive=0 there, flip point marked) | disabled. Unset/junk env values fail OPEN to full. 19 unit tests + structural pins that sitemap and both routes keep referencing the helper. llms.txt/areas-we-serve verified matrix-free (no gating needed). [seo][infra]
v2.97.PAYUX1
2026-07-03Production
For everyone

Clearer payment page: honest wording when a connection drops mid-payment, a proper 'already paid — no new charge' message, and a note confirming the deposit was received.

What this means for you

Three payment-page clarity fixes. (1) If our processor can't be reached, the patient now sees 'Connection problem — no payment was recorded, please try again' instead of a generic error that read like a card problem; a declined card keeps its own message. (2) If the visit is already fully paid at the moment of submitting (say a family member paid seconds earlier), the page now says plainly it was already paid and no new charge was made — before, it showed a confusing 'Payment received'. The system was already safe against double-charging; this fixes only what the patient is told. (3) After a deposit is paid, the page says 'Deposit received — the remaining balance is below' so the missing deposit button isn't a mystery.

Show technical details

Fixed

  • 💬 **Honest connection-failure copy on /pay** (CollectPaymentForm + charge action): network/5xx/pre-charge setup failures now classify as 'connection problem' with the provably-true 'no payment was recorded' (GW only records on confirmed success — deliberately NOT 'your card was not charged', which is unknowable on a timeout); declined-card copy unchanged; the browser-side catch now says to check for a confirmation email before retrying. [payments][patients]
  • ✅ **Already-paid preempt renders its own card** — the fully-paid re-check that already ran before any charge call now returns alreadyPaid and the patient sees 'Already paid — no charge was made' instead of a misleading fresh 'Payment received'. Check→charge ordering + copy pinned by new pay-collect-payux1-pin.test.ts. [payments][patients]
  • 🧾 **Deposit-received explainer** in the fee summary whenever a partial payment exists — covers both the hosted two-button flow and in-portal balance mode. Copy only; eligibility and amounts untouched. [payments][patients]
v2.97.ADMINPOLISH3
2026-07-03Production
For front desk

Stale-name cleanup and safer save buttons: no more Salesforce/Stripe mentions in help text and error messages, and Save buttons on the appointments table can't double-fire anymore.

What this means for you

Follow-up papercut round. The appointments 'Unpaid filter' help said Stripe (we bill through Poynt); the patient-import page told you to export from Salesforce (any CSV works — the Salesforce column auto-mapping remains for legacy archive imports and is now labeled that way); a consent-form send failure told you to 'use Salesforce' as the fallback (it now points to the Patient Forms card); and a retired staff name was still a hidden search keyword. Separately, the notes and video-link Save buttons on the appointments table now disable and show 'Saving…' while a save is in flight — matching every other action button in that row — so a double-click can't fire twice.

Show technical details

Fixed

  • 🧹 **Retired-system copy sweep on live admin surfaces**: appointments Unpaid-filter help (Stripe→'recorded payment'), import-page intro + legacy-labeled Salesforce mapping tip, consent-form error fallbacks (2 strings), Demi search keyword removed from Admin Today. [admin][copy]
  • ⏳ **saveNotes/saveVideoLink double-submit guards** on the appointments table — pending state + disabled + 'Saving…' feedback, same pattern as Confirm/Complete/No-Show/Cancel already had. [admin][appointments]
v2.97.DOCSYNC1
2026-07-03Production
For everyone

Housekeeping: eight old planning documents now carry a dated 'status pass' banner so nobody acts on plans that already shipped or were cancelled.

What this means for you

Documentation-only. Several planning files in the repo still described the world as it was in May — the launch tracker still 'gating' a launch that happened two months ago, a cron runbook listing 15 scheduled jobs when production runs 58, a plan to build a Salesforce bridge for a system we retired, and an improvement plan whose top item (the inbound-fax safety gate) shipped weeks ago. Each now opens with a dated verification banner saying what's still true, what shipped, and where the live source of truth is. No app behavior changed.

Show technical details

Changed

  • 📚 **STATUS PASS 2026-07-03 headers on 8 stale planning docs** (verified vs src/ + vercel.json + git log): CRON_RUNBOOK (15→58 crons + 4 schedule-drift examples; live SSOT = vercel.json + /api/health cronActors), LAUNCH + ROADMAP + LAUNCH_DECISIONS closed as historical (launch happened; Salesforce bridge superseded by native leads), TODO (M365 EMAIL_FROM blocker long-resolved), LEAD_CRM_NEXT_5 (promotion + queue upgrades shipped), Isabella dashboard plan (superseded), expert improvement plan (P0 fax BAA gate CLOSED — shipped fail-closed behind INBOUND_FAX_BAA_OK). [docs]
v2.97.PAYOPT1
2026-07-03Production
For everyone

The payment page now remembers which button the patient clicked in their email — click 'Reserve with a deposit' and that option arrives highlighted with a 'Your selection from the email' tag.

What this means for you

Follow-up to the two clickable pay buttons that shipped in the booking-confirmation email. Until now, a patient who clicked 'Reserve with a deposit' landed on the payment page and saw the same two neutral buttons again — no sign of what they'd picked. Now the page reads the hint carried in the email link and visually preselects the matching option: the chosen button gets the filled style and a small 'Your selection from the email' label. It is a visual hint only — the page's own rules still decide which options genuinely exist (nothing collected yet, sufficient balance, and so on), and an out-of-date hint is silently ignored rather than shown as an error. Amounts, eligibility, and charging logic are untouched.

Show technical details

Added

  • 💡 **/pay honors the email's ?opt= hint as a visual preselection** (completes the forward-compat lane PAYREQ1 minted; src/app/pay/[appointmentId]/page.tsx). opt=deposit emphasizes the deposit button (filled style + 'Your selection from the email' tag) and drops pay-in-full to the outline style; opt=full tags the already-primary full button; any other value — or any state where both options wouldn't render anyway (partial payment on file, in-portal Collect, fully paid) — is silently ignored. HARD RULE pinned by a new structural test (pay-page-opt-hint-pin.test.ts, 10 pins): the hint may only ever appear in styling ternaries and the tag conditional, never inside an eligibility gate, form amount, or if-statement — the three render gates are byte-identical to before. [payments][patients]
v2.97.ADMINPOLISH2
2026-07-03Production
For front desk

Admin papercut round: the calendar's color help matches the real colors, provider-feedback shows WHO resolved each item, and the patients Clear button appears for every filter — not just some.

What this means for you

Three small truthfulness-and-polish fixes. (1) The calendar's 'Color coding' help described colors that don't exist — it now matches the real scheme (green confirmed, blue scheduled, sage completed, amber no-show, purple pending approval) and the legend under the grid. (2) The provider-feedback Resolved tab now shows who resolved each item, its help text stopped promising a screenshot the form never captures, and the Open/Resolved tabs are properly announced to screen readers. (3) On the patients list, filtering by Dormant, Missing doctor, or No records now shows the Clear button and the correct 'no match' empty state — previously those three filters got a misleading 'No patients yet' with no way out.

Show technical details

Fixed

  • 🗓 **Calendar 'Color coding' help text matched to the actual STATUS_COLOR scheme** (was describing an amber 'needs attention' and a red 'no-show or cancelled' that don't exist; amber is no-show, purple is pending approval, sage is completed). [admin][calendar]
  • 🧹 **Patients list: Clear button + filtered empty state now cover ALL filter chips** — Dormant, Missing doctor, and No records were missing from both conditions, so a zero-result filtered view showed the 'No patients yet' onboarding copy with no visible way out. Empty-state copy also stopped naming the retired CRM. [admin][patients]
  • 👤 **Provider feedback: resolved items show the resolver's name** (resolvedBy was stored since the beginning but never displayed; now looked up against the staff roster and rendered as 'by '), Open/Resolved tabs carry tablist/tab + aria-selected roles, and the page help no longer claims feedback includes a screenshot (the provider form captures category + message only). [admin][feedback][a11y]
v2.97.PROMPTSYNC1
2026-07-03Production
For everyone

Behind the scenes: the test harness that checks Isabella's email and text drafts now always uses her REAL current instructions — a stale duplicate copy (which had already drifted) is gone for good.

What this means for you

Quality-infrastructure fix with no visible behavior change. The dry-run test routes used to carry their own pasted copy of Isabella's email and SMS drafting instructions, trusting humans to keep the copies in sync. They drifted — the copies were missing recent rules including this week's 'never say yes to an unverified appointment time' fix, so the harness was testing instructions production no longer runs. The routes now import the one real prompt directly, and a new automated test permanently forbids reintroducing a local copy. This class of bug can't recur.

Show technical details

Fixed

  • 🧪 **Email/SMS dry-run harness prompts de-duplicated** (src/app/api/admin/test/email-ai-dry-run + sms-ai-dry-run now import the exported EMAIL_AI_SYSTEM_PROMPT/SMS_AI_SYSTEM_PROMPT from src/lib/email-ai.ts/sms-ai.ts instead of embedding truncated snapshots). The stale copies were missing DRAFTTIME1's Unavailable-requested-time rule, the Demi-offboarding update, and the live location list — the 'update the copy on every prompt change' comment convention had already failed silently. New dryrun-prompt-anti-divergence.test.ts (10 structural pins) forbids a local prompt const in either route, requires the imports, and asserts the real prompts still contain the load-bearing rule text. [isabella][testing][debt]
v2.97.PAYREQ1
2026-07-03Production
For front desk

Billing a patient no longer ends in copy-paste: Bill via Poynt can now EMAIL the payment link straight to the patient, and the booking-confirmation email shows two working buttons — reserve with the $50 deposit or pay in full.

What this means for you

Two payment upgrades from Mariane's feedback. (1) Inside an appointment, after Bill via Poynt creates a payment link, a new 'Email this link to the patient' button sends it from the app — no more copying the link into a separate email. It refuses safely when the patient has no usable email or the visit is already paid, and every send is logged. (2) The booking-confirmation email's payment section now shows the two options as real clickable buttons — 'Reserve with a $50 deposit' and 'Pay in full' — both landing on our own secure card page, which already knew how to take either amount. Deposits record as partial payments; the visit only shows paid when the full fee is in.

Show technical details

Added

  • 📧 **'Email this link to the patient' inside Bill via Poynt** (closes Mariane cmr4dhj4u 'generate and send a payment request... without having to leave the system'; new POST /api/admin/appointments/[id]/email-pay-link, email sister of the Z272 'Text pay link' lane). Server re-mints the link itself — a client-supplied URL is never accepted — then sends the same template both lanes share (src/lib/pay-link-email.ts) via the BAA-covered mail rail. Fail-closed on missing/unsubscribed/bouncing email, already-paid (409), cancelled/no-show; 5/hr per-admin-per-appointment rate limit + 60s duplicate guard; audited as PAY_LINK_EMAIL_SENT (recipient hashed, no card data). [payments][front-desk]
  • 💳 **Two clickable pay options in the booking-confirmation email** (closes Mariane cmq8x3wut 'have this option back but make sure it is clickable'). 'Reserve with a $50 deposit' + 'Pay in full' buttons render when the deposit is genuinely offerable, mirroring the /pay page's own rule (nothing collected yet, balance above the deposit, in-portal Collect off) — pinned cross-file so the email can never promise a button /pay won't show. Both buttons carry the standard signed token; NO new charge surface (the /pay page has offered both amounts since ICB0009, and partial payments already record correctly). Falls back to the single 'Pay online' button whenever ineligible. Lead-stage (pre-booking) email now says the clickable links arrive with the booking confirmation — it has no appointment to link to. [payments][email]

Changed

  • 🧱 **Bill-via-Poynt tier chain extracted to one shared resolver** (src/lib/bill-poynt-tier-shared.ts — auto-invoice → exact-remaining Collect link → any-amount Collect link → honest manual instructions) so the billing modal and the new email lane can never drift apart (the new-patient-fee fixed-paylink error cmq8x1llj was exactly this class of two-copies drift; 11 unit tests pin the tier order byte-for-byte). Behavior unchanged. [payments][refactor]
v2.97.CONSENTCHK1
2026-07-03Production
For everyone

The consent form is now part of the appointment checklist: every confirmed appointment (telehealth and in-person) sends the existing consent form alongside the intake form, in the portal checklist and the confirmation email.

What this means for you

Mariane asked twice for this and it's in: when an appointment is confirmed, the patient now gets the consent form as a required checklist item — the same existing form from Patient Forms, not a new copy. New patients keep getting the New Patient Packet (which already contains the informed consent inside it); returning patients without a consent on file get the standalone Informed Consent. The 'Forms to review & sign' card in the patient's appointment page picks it up automatically, and the confirmation email shows a 'Complete your consent form' card next to the intake one. Nobody gets duplicate forms — if a consent is already pending or on file, the system links the existing one or stays quiet.

Show technical details

Added

  • 📋 **Consent form auto-ensured at booking confirmation** (closes Mariane cmq6203a7 'the Consent Form is still missing... use that existing form' + the consent half of cmq61ukdl; both telehealth and in-person). New ensureConsentFormForBooking (src/lib/consent-form-ensure.ts + pure decision logic in consent-form-ensure-shared.ts): new patient → NEW_PATIENT_PACKET (intake pages + informed consent, the canon Doug set on the G7 arc), returning → INFORMED_CONSENT, exactly the same create shape as the existing admin Patient Forms send (status SENT, 7-day magic link, PHI-free FORM_CREATED audit). IDEMPOTENT: an existing non-revoked/non-expired packet or consent suppresses creation; a pending one with a live token gets ITS link reused in the email — never a duplicate, never a dead link. [forms][patients]
  • 📨 **'Complete your consent form' card in the booking-confirmation email** (both the canonical send and the staff resend lane), mirroring the intake card. The patient portal's 'Forms to review & sign' checklist needed ZERO changes — its pending-forms query is form-type-agnostic, so the new row appears automatically (pinned). [forms][email]
v2.97.LEADSWORK1
2026-07-03Production
For front desk

Three leads-queue upgrades: an 'action needed' filter inside every status view, Mark-as-read buttons that finally clear the red badge, and the sidebar search now finds leads too.

What this means for you

All three from Mariane's follow-ups. (1) Inside any leads view (All, New, a specific status...) you can now narrow by what you actually owe the lead: Email follow-up, Call/text, or No action needed — stacked on top of the main filter, exactly the 'inside filter' she described. (2) Every unworked lead row has a one-click 'Mark read', and the filter bar has 'Mark all read (N)' — these clear the red sidebar notification WITHOUT pretending anyone was contacted: the lead stays in the queue with its aging badge, and scheduled follow-ups are never silenced. (3) The sidebar search bar now searches leads alongside patients and pages, with a 'Leads (not converted)' section — so a person who's still a lead no longer looks like they don't exist while lead automations keep emailing them.

Show technical details

Added

  • 🎯 **Action-needed second filter dimension on /admin/leads** (closes Mariane cmq61cwgo reject 'there is already a main filter, but I also need an inside filter within each status'). New ?action= chips — ✉ Email follow-up / 📞 Call·text / ✓ No action needed — compose WITH the status filter; counts are computed within the current view so the number always matches what the click shows. Derived live from contacted-state + status + the lead's contact preference ('either' appears under both channels; legacy no-preference leads default to the phone bucket rather than vanishing). The lead form has no separate SMS bucket, so Phone covers call+text. [leads][front-desk]
  • 👁 **Mark-as-read for lead notifications** (closes Mariane cmq7g6ldn reject 'add a Mark as Read button... and a Mark All Leads as Read'). New LEAD_MARKED_READ audit action + per-row 'Mark read' + bulk 'Mark all read (N)' targeting EXACTLY the badge's own cohort, so one click zeroes the red number. Read ≠ contacted by design: the one-click only asserts 'I saw it' (Mark-contacted keeps its forced log-notes flow per Mariane's 2026-05-15 #5), the row stays in the queue, and overdue follow-up commitments are deliberately never cleared by read. [leads][front-desk]
  • 🔎 **Sidebar search now surfaces leads** (closes Mariane cmr1flpwc reject — she searched, assumed the person didn't exist, and found them later still in Leads getting automated emails). The sidebar bar queries the same role-gated leads-search API the Cmd+K palette has used since CRM #2, in parallel with the patient search, and renders a 'Leads (not converted)' section below Patients. Placeholder now reads 'Search patients, leads, or pages…'. [leads][search]
v2.97.DRAFTTIME1
2026-07-03Production
For everyone

Isabella's reply drafts will no longer say yes to an appointment time she hasn't verified — she only offers real open slots, and a picked slot gets 'request received, confirmation email to follow', never 'you're booked'.

What this means for you

Mariane caught Isabella's email/SMS drafts agreeing to whatever time a patient proposed ('yes, we can do that') even when that exact time wasn't verified open. Both draft prompts now carry an explicit rule: a patient-named time is never affirmed unless it exactly matches a slot the live calendar lookup returned in that conversation; otherwise the draft acknowledges the preference and offers the nearest real openings. And when the patient picks a listed slot, the draft uses request-received framing — a separate confirmation email follows once the office reviews — extending the existing tentative-appointment language rules. Drafts only; the phone line was not changed.

Show technical details

Fixed

  • 🕐 **Unavailable-requested-time rule added to both draft prompts** (closes Mariane cmr2wnxm6 reject 'she should not say yes... unless the slot is actually checked and available'; src/lib/email-ai.ts + src/lib/sms-ai.ts). Never affirm a patient-named time unless it EXACTLY matches a listOpenSlots result from this conversation; no match → acknowledge preference + offer the tool's nearest real openings; picked slot → request-received + separate-confirmation framing (defers to the existing Tentative-appointment sections rather than duplicating them). 14 new pins including rule ordering and SHAFT-safety; no voice-prompt change, so no Retell sync needed. [isabella][email][sms]
v2.97.ISAHELP1
2026-07-02Production
For everyone

Isabella got more helpful on chat and email: renewal patients now hear real open times up front (like phone callers already do), 'no slots' now relays our real weekly windows instead of handing off, and she can answer the veteran/SSDI discount question honestly when asked.

What this means for you

Three upgrades across Isabella's lanes. (1) Chat and email now match the phone line: a returning patient asking about renewing hears the one or two nearest REAL open times instead of being asked to guess a day. (2) When no dated slots are open, Isabella relays our real standing weekly windows instead of escalating to a human for a normal availability question. (3) All three lanes can answer the veteran/SSDI discount question when asked — $15 off a renewal ($130) — answer-only, never advertised unprompted, eligibility verified at the visit. Also: phone renewal callers now hear the patient-portal upload option first, and the booking-confirmation email's 'what to bring' list no longer has a stray reassurance line inside it.

Show technical details

Changed

  • 💬 **ISASCHED1 renewal proactive-availability ported to chat + email (parity gap closed).** Voice shipped 'lead with real availability' 7/2 AM; chat (src/app/api/chat/route.ts Initiative block) and email (src/lib/email-ai.ts) still told renewals 'offer to book it' with no times. Both now call listOpenSlots and offer the nearest 1-2 returned windows, with the anti-hallucination rule restated inline (offer ONLY tool-returned times). [isabella][chat][email]
  • 🪟 **Empty-slot guidance updated to relay the tool's standing-windows message (chat + email).** booking-tools' listOpenSlots has returned a helpful describeStandingWindows message on empty since PAYSLOTS1, but both prompts still forced a human handoff (flagForHuman reason=slot-lookup-empty) on a normal availability question. Now: relay the real weekly windows, ask which works, and only hand off when the patient wants a specific time locked or a callback. Fewer spurious escalations. [isabella][chat][email]
  • 🎖️ **Veteran/SSDI renewal discount now answerable in all three lanes (answer-if-asked only).** The $15-off renewal rate ($130, PRICING.RENEWAL_HARDSHIP_DISCOUNT/RENEWAL_DISCOUNTED — real fee-structure SoT; voice-tools already carried TTS spellings for both amounts) was in no lane's prompt, so price-sensitive veterans got a message-take instead of an answer. All three lanes now answer when the patient asks about discounts or self-identifies veteran/SSDI — explicitly instructed NOT to advertise it unprompted, team verifies eligibility at the visit. PATIENT_COMMS_REVIEW P2 'accept or close' → accepted. [isabella][voice][chat][email][pricing]
  • 🗂️ **Voice: renewal records rail now leads with the patient-portal upload** (sign in at greenwellness dot org → Your records) before fax/email — matching what chat + email already tell renewals; portal auth is the patient-OTP flow returning patients already have. New-patient rail unchanged (no pre-visit portal account). Plus: the DOB-confirmation style example no longer models a five-oh-nine (Spokane) caller — Spokane closed 2026-06-13 (closure-cutoffs.ts); example now uses two-oh-six. REQUIRES node scripts/sync-retell-prompt.mjs post-deploy. [isabella][voice]
  • 📧 **Booking-confirmation email: 'If you qualify, your authorization is issued the same day' moved OUT of the 'What to bring' list** (src/lib/emails.ts) — it's a reassurance, not an item to bring; the list stays scannable pre-visit. [email][polish]
v2.97.ADMINPOLISH1
2026-07-02Production
For everyone

Admin polish round: the last 'Demi' mentions are gone, Admin Today gets the same Refresh button as Isabella Today, feedback buttons tell you when they fail, and a few pages got counts and Pacific-pinned timestamps.

What this means for you

A cleanup pass over the admin screens. Every remaining place that said 'Demi' now says 'front desk' (Isabella dashboard, Isabella Today's queue, the AI-receptionist report, one error state). Admin Today gets the same Refresh button and 60-second auto-refresh as Isabella Today, so fresh callbacks show without a full reload. The feedback 'Mark resolved' button now says when it couldn't save instead of silently doing nothing. Dashboard sections show item counts, Today-page timestamps are Pacific-pinned with a PT label, the Leads title matches the rest of the admin, and the feedback-triage empty state no longer names only Mariane.

Show technical details

Fixed

  • 🧹 **Five stale user-visible 'Demi' strings replaced with 'front desk'** — admin/isabella ('{n} to Demi' — the string the 7/2 PAYSLOTS1 changelog claimed was already swept), admin/isabella-today ('Demi's queue' heading + 'Nothing waiting on Demi' empty state — two sisters missed when :802/:1057 were fixed), admin/reports/ai-receptionist ('caller needed Demi'), and admin/admin-today's load-failure state ('demi today' → 'admin today'). Sidebar search keywords keep 'demi' as an invisible muscle-memory alias (matches only, never displayed). [admin][copy]
  • 🔁 **Feedback 'Mark resolved' no longer fails silently** (admin/feedback ResolveButton): non-ok response or 10s timeout now shows 'Couldn't mark this resolved — check your connection and try again' + a 'Try again' button label, mirroring ReconcileOrphansButton's failure affordance. Previously an exception also escaped the handler (try/finally with no catch). [admin][reliability]
  • 🕐 **/admin/today header timestamps Pacific-pinned** (_TodayClient.tsx): the date line + 'last updated' stamp used browser-local date-fns format() — wrong hour on any non-PT machine (same class as the Mariane 4:10→2:10 bug already fixed for slot times) — now fmtPT + explicit 'PT' label; dormant-patient 'no visit since' date too. [admin][tz]

Added

  • 🔄 **Admin Today refresh affordance** — the same RefreshShell as Isabella Today (manual Refresh + 60s auto-refresh toggle + last-loaded stamp); front desk no longer needs a full reload to see fresh callbacks-owed. [admin][front-desk]
  • 🔢 **Dashboard section counts** — Today's schedule / Coming up (next 3 days) / Recent bookings headers now show a count pill when non-empty, matching the alert callouts that already had them. [admin][polish]
  • 🧭 **Small consistency wins** — Leads page h1 now uses the GW heading style (was the only zinc-styled title in the admin; full page re-skin left as follow-up); reviewer-feedback triage shows 'loaded h:mm a PT' on its count line; feedback-triage empty state says 'any staff member' instead of naming only Mariane; Admin Today's 'Today's focus' items render a plain row instead of a dead '#' link when an item has no destination. [admin][polish]
v2.97.TOKENSUNSET1
2026-07-02Production
For everyone

Behind-the-scenes groundwork to retire the old-style provider links — nothing changes in how anyone uses the app.

What this means for you

Providers used to reach their portal through long tokened links; the portal moved to normal sign-in sessions a while ago, and today we confirmed nothing legitimate still uses the old link style behind the scenes. A quiet counter now watches for any straggler so we can fully retire the old path with confidence. No staff- or provider-visible changes.

Show technical details

Changed

  • 📉 **Provider ?token= sunset — step 3 (deprecation log) shipped, plan doc corrected.** Verified the 6/20 buildplan's premise is stale: all 5 client consumers (CheckInPoller, SignatureCard, ProfileCard, ProviderActions, SignedEncounterPanel) already render tokenless on the cookie portal (JF/TJ/EX port arc), and the legacy /provider/[token] pages are redirect-only (PORTALFIX1) — so nothing legitimate sends ?token= to the 26 API routes anymore. Added the log-only deprecation counter CENTRALLY in the db.ts TOKENEXP1 choke point (one edit instead of 26 route edits): every NON-bridge portalTokenHash lookup warns [provider-token-deprecated] — PHI-free, no token value logged. Metric: zero log hits over a few provider-active days ⇒ step 4 (remove the token branch from the 26 routes). 2 new pins in the TOKENEXP1 anti-divergence suite. Also synced GW_UNSHIPPED_BACKLOG_2026_06_30.md with a dated status pass (6 rows verified already-shipped: RC token-health diag, gate single-sourcing, blocking test suite, patient/admin session timeouts, required gate check, token-sunset steps 1–2) and removed a stale wave-c TODO on the EHI ingest-status audit call (action exists + fires). [security][provider-portal][debt]
v2.97.TOKENEXP1
2026-07-02Production
For providers

Expired provider portal links now stop working everywhere — an expired link consistently shows the friendly 'ask the office for a fresh link' page instead of some pages quietly still loading.

What this means for you

Provider magic links expire after 90 days. Until now the main portal page correctly rejected an expired link, but the behind-the-scenes data calls some portal screens make did not double-check the date — so pieces of an expired link could still load. That gap is closed: every lookup now checks the expiry in one shared place. If your link has expired, everything consistently sends you to the page that says to ask the office for a fresh one — nothing partially works anymore.

Show technical details

Fixed

  • 🔐 **Expired provider ?token= links could still read/write PHI through the API fallback.** The ~26 /api/provider/* resolvers (plus the new-encounter searchPatients server action) each did their own inline db.provider.findUnique({ where: { portalTokenHash } }) gating only on isActive — never portalTokenExpiresAt — so a 90-day-expired magic link kept working through the API ?token= fallback even after the page/bridge path started rejecting it. Rather than edit 26 resolvers, the fix gates the ONE query they all funnel through: a Prisma $extends choke point on provider.findUnique keyed on portalTokenHash that fails CLOSED (returns null) for expired rows, sharing one cutoff predicate (isPortalTokenExpired) with the cookie bridge so they cannot drift. Load-bearing exemption: the bridge's own lookups explicitly select portalTokenExpiresAt and are passed through unmodified, because THEY enforce expiry and emit reason:"expired-token" → the friendly /provider/link-expired card (not a bare 404). Regression-pinned in tokenexp1-portal-token-expiry-choke-anti-divergence.test.ts (13 pins: fail-closed interception, bridge exemption, bridge-still-emits-expired, shape-preserving field injection). [security][provider-portal][tokenexp1]
v2.97.CONSENTDR1
2026-07-02Production
For front desk

Signed consent PDFs now print the patient's actual issuing doctor's name instead of always printing the same provider — plus a small security hardening on the email-template preview.

What this means for you

Two fixes. (1) When a patient signed their intake packet or informed-consent form online, the finished PDF always printed one provider's name on the 'Dr. ____' line no matter which doctor the patient actually sees. Now it prints the patient's recorded issuing doctor (falling back to the clinic medical director from the lawyer-reviewed form when none is recorded yet) — the same rule the consent-form email send already used. Nothing changes in how you send or sign forms. (2) The email-template preview on the Outbound Templates page now renders inside a locked sandbox — a belt-and-suspenders hardening with no visible change.

Show technical details

Fixed

  • 📄 **Consent PDFs hardcoded one provider's last name on the 'Dr. ____' line.** Both online-signing paths — the NEW_PATIENT_PACKET 4-page packet and the standalone INFORMED_CONSENT e-sign — rendered provider: { nameLastOnly: "Frisch" } regardless of the patient's actual issuing doctor, wrong the moment any other provider (Ruth/Spokane, Dr. Ari) signs a patient. Both now derive from Patient.issuingDoctor via a new shared helper consentProviderLastName() ('Dr. Jane Frisch' → 'Frisch'; null/empty → 'Werblud', the clinic-medical-director name in the lawyer-reviewed source PDF) — the exact derivation the admin send-consent-form route already used, now extracted to src/lib/forms/consent-provider-name.ts so the three render sites cannot drift. [forms][consent][correctness]
  • 🔒 **Admin outbound-template preview no longer injects template HTML into the admin page DOM.** The Outbound Templates preview rendered p.html via dangerouslySetInnerHTML — an admin/manager authoring a template with a script tag could execute it in another admin's session (insider-only, low severity, flagged P2 in today's security sweep). The preview now renders in a fully sandboxed iframe (sandbox="" — no scripts, no same-origin) via srcDoc, so template-authored HTML is display-only by construction. No visual change. [security][admin][xss]
v2.97.DIAGAUTH1
2026-07-02Production
For everyone

Behind-the-scenes security hardening on four internal health-check endpoints — nothing changes in how you use the app.

What this means for you

Four internal system-health endpoints (the ones our monitoring robots call to check that email, fax, phone, and AI integrations are up) had a subtle gap where a request could pretend to be an admin without being signed in. They only ever exposed yes/no health flags — never patient information — but the door is now properly locked: each endpoint independently verifies a real signed-in admin session. No staff-visible changes.

Show technical details

Fixed

  • 🔐 **Spoofable x-admin-role header trust closed on ALL four bearer-allow diag routes.** rc-token-health + notifyre-health were fixed in the adversarial-review repair round; this entry completes the class on the two pre-existing sisters m365-token-health + model-probe. Root shape: these routes sit on proxy.ts's ADMIN_BEARER_ALLOW list, whose early return NextResponse.next() runs BEFORE freshHeaders() strips client-supplied identity headers — so curl -H 'x-admin-role: ADMIN' passed the in-route gate unauthenticated (exposure was config booleans/counts only — no PHI, no tokens). All four now verify the AdminSession COOKIE in-route (verifyAdminSession, ADMIN/MANAGER only) and never read the header; bearer CRON_SECRET path unchanged. Regression-pinned in diag-route-auth.test.ts (20 pins across the four routes: no x-admin-role in executable code, no headers() import, cookie-verify present, role check intact). [security][diag][adversarial-review]
v2.97.RPF0002
2026-07-02Production
For front desk

Groundwork (not live yet) for Isabella's returning-patient fast path: once the texted-code verification turns on, a returning caller will be able to give their GW account ID from a reminder email, and after verifying, Isabella will know their clinic, renewal month, and upcoming visit instead of re-asking everything.

What this means for you

Mariane: this is the build-out behind your ask that returning patients shouldn't repeat the same answers every call. None of it is live yet — the texted-code verification still waits on the phone-messaging paperwork. What's new underneath: (1) the account ID from reminder emails becomes the way Isabella looks a caller up — the safety step is unchanged: a 6-digit code still goes to the phone number on file, never a number the caller says, so an account ID alone can never unlock a chart. (2) After the code checks out, Isabella pulls up the account — preferred clinic, last-visit month, renewal month, whether a visit is already booked — and stops re-asking name, phone, email, and 'have you been here before.' (3) If a renewal is due, she offers to book it right away.

Show technical details

Added

  • 🪪 **Account-ID lookup key on the voice verify rail (dark, RPF0002).** beginReturningPatientVerify + confirmReturningPatientVerify now accept the patient's GW-XXXXXX publicId (already mailed in reminder emails since the cmq1rldif slice) as an ALTERNATE lookup key — spoken-variant tolerant (normalizeSpokenAccountId: 'gw a3k7m2' / letter-by-letter / bare 6-char all canonicalize via the existing normalizePatientPublicIdQuery, no second regex). Possession factor UNCHANGED per the binding hipaa-architect ruling: the 6-digit code still goes ONLY to the phone ON FILE. Enumeration-safe: per-kind generic responses, and a matched chart with no textable phone answers exactly like no-match (closes the 'trouble sending' existence oracle). Both tools remain two-layer dark behind RETURNING_PATIENT_SMS_VERIFY_ENABLED (schema filtered from Retell + handler hard-stop); NOTE for flip day — the Retell tool re-registration curl must run for the new params to reach the hosted LLM. [isabella][voice][rpf0002]
  • 🧠 **Post-verify context enrichment — verification finally unlocks something (gap G3).** On a successful code confirm, the tool result now carries the minimum-necessary, month-bucketed account context (reuses buildCallPatientContext — same bucketing Doug approved for the staff cockpit 2026-06-12): preferred clinic, last-visit month, renewal-due month (+overdue), upcoming-appointment month, has-email-on-file BOOLEAN (address never spoken) — plus the explicit no-re-ask instruction and 'send links to the email on file' guidance. Provider names deliberately withheld (prompt already bans speaking them). Renewal-Concierge tie-in: an open RenewalPipeline row in due|outreached adds an 'offer to schedule it now' line (READ-ONLY — no stage transition). Enrichment is fault-isolated (any read error degrades to the plain verified greeting) and 3 parallel indexed reads on the one success turn keep the <500ms p95 webhook budget. Composition is a pure, pin-tested fn (composeVerifiedGreeting). Patient-facing fact set remains hipaa-architect + Doug sign-off gated BEFORE the flag flips. [isabella][voice][hipaa][rpf0002]
  • 🧵 **Dispatcher threads Retell call context to handlers.** dispatchVoiceToolCall now passes event.call (call_id/from_number) as an optional second handler param — pre-fix handlers saw ONLY event.args, so nothing could correlate two tool calls in the same phone call. Used today for PHI-free audit correlation (lookup= + callId= on the PATIENT_IDENTITY_VERIFIED rows, plus a new event=verified-context-returned annotation row with booleans/stage token only — never fact values). The durable per-call verified marker (so listOpenSlots/captureLeadFromVoice stop re-asking a verified caller) is the designed NEXT slice — needs an expand-only migration; see the design doc. [isabella][voice][observability]
v2.97.FAXOBS1
2026-07-02Production
For front desk

The Inbound Fax page now tells you the truth about the fax line: a banner says the line is NOT live yet (so an empty queue is expected, not broken), and a new Delivery events panel shows every fax attempt — even ones that failed before reaching the queue.

What this means for you

Mariane: the fax page used to look like it should be working, so test faxes kept disappearing into a void. Now the page says it straight — a banner at the top shows the line is not live yet while we switch fax providers, so an empty queue is expected and there's no need to keep test-faxing until it turns green. And once the line IS live, a new 'Delivery events' panel under the queue shows every fax that reached us — including ones that were blocked or failed on our side — so 'did it arrive, fail, or get stuck?' finally has an answer on one screen.

Show technical details

Added

  • 📠 **Inbound-fax line-status banner (/admin/inbound-fax) — honest dark-state.** Server-rendered from env BOOLEANS only (provider switch + INBOUND_FAX_BAA_OK runtime BAA gate + Notifyre key/secret presence — never env values): while the gate is closed, an amber 'NOT LIVE' card states the Notifyre switchover (number + BAA + webhook) is pending and faxes sent today will NOT appear — this is expected, not a bug. Flips to a green 'line live via ' chip the moment Doug sets the go-live flags — zero maintenance. Empty-state + PageHelp + header blurb are now state-aware too (the old copy asserted the (888) line lands faxes in the queue, which is false while gated — the exact void Mariane kept test-faxing into). [inbound-fax][observability][honesty]
  • 📬 **'Delivery events' panel — AuditLog as the fax delivery ledger.** New collapsible panel below the queue reads the last 50 INBOUND_FAX_RECEIVED audit rows (indexed [action,createdAt] — NO new table) and classifies each repo-owned detail string into a status pill via a tolerant shared parser (src/lib/inbound-fax-shared.ts, EXTRACTOR pattern + pin tests): success → received, detail=baa_gated → blocked (BAA pending), error=* → failed (token shown), detail=ignored → ignored; unknown shapes degrade to the raw PHI-free detail, never crash. Answers the board-row ask ('did it arrive, fail, or process?') even when NOTHING landed in the queue. Details are PHI-free by construction (message ids + sender digits only). [inbound-fax][audit-ledger]
  • 🩺 **Notifyre self-test diag — /api/admin/diag/notifyre-health (sister of rc-token-health).** Admin/bearer-gated GET returning booleans + counts ONLY: {provider, baaOk, apiKeySet, webhookSecretSet, apiReachable, receivedCount, hint}. Probes GET /fax/received?limit=1 with the configured key (15s timeout, fail-soft) and returns res.ok + the account's total count — never fax metadata or sender numbers. Works TODAY while the lane is dark (key already set, endpoint shape live-confirmed), so staff can prove auth+reachability without waiting for a live webhook delivery. Linked from the fax-page banner; proxy ADMIN_BEARER_ALLOW entry added. Also wired the previously-DEAD isNotifyreFaxReady() into /api/health as notifyreFaxReady (its own doc comment said /api/health; was connected nowhere). [inbound-fax][diag][notifyre]

Fixed

  • 🕳️ **Webhook silent paths now audit (all 3 providers).** Signature-verify 403s, invalid body/JSON 400s, and 'ignored' non-received events on /api/inbound/fax returned with NO audit row — if Notifyre's webhook secret or envelope shape is wrong at go-live, we'd have seen nothing anywhere (the envelope key names are explicitly unconfirmed until a first real delivery, so 'ignored' is the likely first-contact failure mode). Each now writes a PHI-free INBOUND_FAX_RECEIVED row first (provider=

    error=signature_verify_failed|invalid_payload / detail=ignored event= — event tokens sanitized via safeEventToken since the endpoint is public; NEVER body content). Same wrap-and-never-throw audit posture; webhook ACK/reject behavior byte-identical. Provider-switch SoT moved to inbound-fax-shared.ts so route + page + diag can never drift. Audit-log polish: INBOUND_FAX_RECEIVED + VIEW_INBOUND_FAX gain ACTION_LABELS (the fax page deep-links to that filter; it rendered as a raw enum). [inbound-fax][observability][audit]

v2.97.FBLOOP1
2026-07-02Production
For everyone

The Feedback button is more dependable: it now works for every active staff member (words AND screenshots), and if a send ever fails it tells you exactly what went wrong instead of a vague 'network glitch.'

What this means for you

Two fixes to the little green Feedback button. (1) It now fully works for every active staff account — before, some newer teammates could open it and type, but the actual send (and screenshot attach) was quietly blocked behind an old per-person list; that mismatch is gone, and everyone can also see their own screenshots under My feedback. (2) When a send fails, the message is now specific: a real timeout says so, and if a browser ad-blocker ate the request it tells you to pause the blocker and try again — plus we now get an automatic behind-the-scenes ping when that happens, so it gets fixed without you having to chase anyone. Your text is always kept either way. Who can READ other people's feedback hasn't changed at all.

Show technical details

Fixed

  • 🧷 **§4.1 role-based submit — the LISAREADY1 show-but-403 seam closed.** /api/feedback POST+GET, /api/upload, and the /api/feedback/screenshot/[...path] proxy all gated on the per-email REVIEWER_FEEDBACK_ALLOWLIST while the bubble + /me/feedback had already widened to role-based canSubmitReviewerFeedback — so an active role-based filer (Lisa-class) saw the bubble but her submit 403'd, screenshots wouldn't upload, and her own /me/feedback images 403'd. All three now gate on canSubmitReviewerFeedback (role-based, fail-closed on isActive=false). The screenshot proxy ports the comments-route IDOR split verbatim: TRIAGE tier (explicit allowlist) fetches any feedback-screenshots/ blob exactly as before; role-based filers are OWN-ROW scoped (ReviewerFeedbackScreenshot child-row join on feedback.userId, legacy screenshotUrl fallback) with the same generic 403 as the traversal guard (no existence probing). TRIAGE surfaces (queue page, actions, queue/aggregate/couldnt-fix/done-unevidenced routes) stay allowlist-gated — reading OTHER filers' PHI-capable bodies/screenshots is unchanged. Pin tests extended in group-c-feedback-screenshot-private-blob.test.ts (gate + own-row join inseparable). [feedback][hipaa][s4.1][lisa-ready]
  • 📵 **Kat's 'Network glitch' submit failure — distinct error copy + a server-visible breadcrumb.** Mechanism confirmed: her /api/feedback fetch THREW client-side (zero rows, zero logs — the request never arrived). Three hardenings: (1) new src/lib/fetch-timeout.ts timeoutSignal() feature-detects AbortSignal.timeout with an AbortController+setTimeout fallback (aborts with a DOMException named TimeoutError) so pre-Safari-16 browsers never sync-throw before the fetch fires — FeedbackBubble's 15s submit + 30s upload signals both swapped. (2) The outer catch name-splits the copy: TimeoutError OR AbortError (WebKit <17.4 reports signal timeouts as plain AbortError) → the 15-seconds copy; TypeError (fetch killed at dispatch — classically a content-blocker matching the '/api/feedback' substring) → 'pause your ad/content blocker' copy; else the generic copy. The upload catch stops mislabeling every throw a timeout. (3) New reportClientFetchFailure() in report-client-crash.ts posts a PHI-free breadcrumb (boundary + err.name + pathname only) to the EXISTING unauthenticated, rate-limited /api/provider/client-error beacon (audit row + debounced Doug email; URL has no 'feedback' substring so it survives the blocker) — the next occurrence is server-visible in seconds, not a verbal report days later. NO CSRF change (/api/feedback was never covered by the same-origin guard; ruled out). [feedback][kat][client-error][observability]

Added

  • 🔑 **AGENT_FEEDBACK_TOKEN accepted on the three metadata-only feedback readers (agent-lane plumbing).** CRON_SECRET is Vercel-Sensitive (pulls as "" by design — runtime confirmed healthy: 58 fresh cron heartbeats on /api/health), so no agent-side puller can ever hold it; the aggregate pull ran on a hand-provisioned GW_CRON_SECRET and the queue mirror 401'd. The route-local agentFeedbackTokenMatches() moved from [id]/agent/route.ts to shared src/lib/agent-feedback-token.ts (ONE implementation of the load-bearing empty-token bypass guard: non-empty configured AND non-empty presented, timing-safe compare) and is now accepted on aggregate + couldnt-fix + done-unevidenced — all counts/structural-metadata-only by construction (never select a free-text column; all three already in the proxy's ADMIN_BEARER_ALLOW). The body-bearing /queue GET deliberately does NOT accept it — widening the token to operator free-text (may reference PHI) is a Doug-gated trust-tier decision, pinned in the new agent-feedback-token.test.ts (11 pins: bypass guard + per-route scope doctrine). CRON_RUNBOOK gains the 'do NOT diagnose CRON_SECRET-empty from a vercel env pull' note. [feedback][agent-lane][auth][cron]
v2.97.NAVSECTIONS1
2026-07-02Production
For everyone

The sidebar is way calmer now: a short everyday list up top, and the rest tucked into collapsible sections that stay closed until you need them — so it's not a wall of 70+ links anymore.

What this means for you

Straight from Kat's feedback that the sidebar felt overwhelming (worse for Lisa on day one). (1) The menu is reorganized into clearer sections — a short always-visible daily core up top (Today, Messages, Patients, Appointments, Calendar, Leads, Inbound Fax), then collapsible sections: Front Desk (the CS queues/worklists), Isabella (AI tools), Help (What's New + Training, now easy to find), plus Marketing, Finance, Configuration, Data, and System (settings/accounts/audit, renamed from 'Admin'). (2) Those sections are collapsed by default, so you only open what you need — and the section you're in opens automatically so you always see where you are; your choices are remembered. Nothing was removed and no one's access changed.

Show technical details

Changed

  • 🧭 **Sidebar reorganized + collapsible sections (closes Kat's 'Sidebar Navigation Reorganization' email 2026-07-02).** The old single 25-item top group + 6 flat always-expanded sections (~74 links) is replaced by: a SHORT always-open daily core (Dashboard/Today/Messages/Patients/Appointments/Calendar/Leads/Inbound Fax) + collapsible sections **Front Desk** (NEW — human-CS queues/worklists: CS Command Center, Admin Today, Mariane Today, Doug Queue, Tasks, EOD, catchup, Provider Feedback, Mailing, Auth-held, …), **Isabella** (AI tools, unchanged), **Help** (NEW — What's New + Training lifted out of system-admin so a new hire finds training day one), Marketing, Finance, Configuration, Data, and **System** (renamed from 'Admin' — Settings/Staff Accounts/Audit/Cron/2FA/Migration, to distinguish system-admin from the human-CS 'Front Desk'). AdminNav.tsx collapse logic: labeled sections collapsed by default, the section holding the current page force-expands (active item never hidden), manual toggles persist in localStorage, pre-hydration keeps sections open so first-paint never hides an expanded section. **Every item keeps its exact role gate + keywords verbatim — pure re-home, zero access change.** cmd-K + sidebar still share nav-config.ts (one SoT). [nav][ux][kat-feedback][collapsible][lisa-ready]
v2.97.NOTIFYREFAX1
2026-07-02Production
For everyone

Groundwork for receiving faxes again: added our new fax provider (Notifyre) as an option — still switched OFF until the paperwork's signed, so nothing changes yet.

What this means for you

We picked Notifyre as the HIPAA-compliant fax service for receiving patient records faxes into the app (best-priced with a real API). This ships the code to receive from Notifyre — but the entire path stays OFF behind the same safety switch that's protected inbound fax all along: no fax is stored until (a) the signed Business Associate Agreement is in place and (b) Doug flips the switch. Until then, incoming faxes are acknowledged but nothing is captured — exactly like today. Nothing staff-facing changes in this release.

Show technical details

Added

  • 📠 **Notifyre inbound-fax provider path (default-OFF, BAA-gated, additive).** New src/lib/notifyre-fax.ts (+ pure notifyre-fax-shared.ts, EXTRACTOR-pattern, 9 pin tests) + a third branch on /api/inbound/fax's existing INBOUND_FAX_PROVIDER switch. INBOUND_FAX_PROVIDER=notifyre routes inbound faxes through Notifyre; unset/ringcentral/documo are unchanged (nothing changes by default). Normalizes Notifyre's fax_received webhook (fax-id dedup key, sender ANI → LEAD_CAPTURED match, our DID, page count) into the SAME downstream pipeline (one dedup, one sender-match, one private-blob/DB persist). **API shape CONFIRMED against the live account** (unlike the earlier Documo path's unconfirmed caveats): x-api-token auth + base api.notifyre.com (HTTP 200 verified), list GET /fax/received?limit=, download GET /fax/received/{faxID}/download (route verified). Optional NOTIFYRE_WEBHOOK_SECRET HMAC-SHA256 verify on the Notifyre-Signature header; the load-bearing PHI guard remains INBOUND_FAX_BAA_OK. **The ENTIRE path stays fail-closed behind INBOUND_FAX_BAA_OK** — ACKs 200 (no retry storm), audits the gated arrival PHI-free (fax id only, no content fetched), persists NOTHING until the Notifyre BAA is executed + a fax number is provisioned + Doug sets the flag. NO schema change (reuses the provider-agnostic dedup column). ⚠️ Only residual unknown = the exact fax_received webhook envelope key names (can't inspect until a real inbound fax arrives — isolated + defended: id extracted across all plausible keys, metadata best-effort, a miss degrades to the unmatched path, never crashes). [inbound-fax][notifyre][hipaa][baa-gated][dark]
v2.97.PORTALFIX1
2026-07-02Production
For providers

Fixed the provider portal magic-link — the emailed one-click login was showing every doctor 'Page not found.' It now logs them straight in.

What this means for you

Any doctor who opened their emailed portal link (or an old bookmark) was getting 'Page not found' — 100% of the time, for everyone using that link. Cause: the login page was trying to save the login cookie while the page was still drawing, which the web framework forbids, so it errored out into a 404. (Dr. Ari wasn't affected because she logs in with a password on a different screen.) The one-click link now hands off to a proper login step that saves the cookie the correct way and drops you right into the portal — the doctor's own filters and deep-links (a specific day, a specific chart) are preserved. Expired links now show a clear 'ask the office for a fresh link' page instead of a dead 404. Nothing changed for password logins.

Show technical details

Fixed

  • 🔑 **Provider magic-link 404 — every /provider/[token]/** entry point converted from a Server-Component page to a GET Route Handler.** Root cause (workflow-diagnosed, confirmed against installed Next 16.2.9 source): the landing page.tsx called exchangeTokenForCookieRsc, which does cookies().set() DURING render — Next forbids cookie writes outside a Route Handler / Server Action, so .set() threw ReadonlyRequestCookiesError, the page's .catch mapped it to bridge-threw, then notFound() → a 404 for **every valid token** (data-independent). force-dynamic didn't help — it controls static-vs-dynamic, not the render *phase*. Fix: the 8 legacy entry points (landing + today + encounters + encounters/new + encounters/[id] + authorizations + authorizations/[id] + authorizations/[id]/reissue) are now GET Route Handlers delegating to a shared handleProviderTokenLanding (src/lib/provider-token-route.ts) that uses the previously-DEAD, cookies()-free exchangeTokenForCookieApi, sets the session cookie on the NextResponse, and 302s to the cookie-gated /provider/portal/** — forwarding the provider's own query filters. Expired tokens now 302 to a new static /provider/link-expired page; invalid/unknown/deactivated still 404 uniformly (no existence-signal leak). Orphaned [token]/layout.tsx+loading.tsx removed (the softphone lives on the real portal layout). **Dr. Ari + password logins UNAFFECTED** (they use the /api/provider/auth/login Route Handler, which always Set-Cookie'd correctly). **Why it shipped silently:** the guarding tests were source-regex ('anti-divergence') pins that never executed the login inside a real render, so the render-phase throw was never exercised — replaced with a structural regression guard (provider-token-route.test.ts: no page.tsx may return under the token tree; every entry is a GET handler on the safe exchange) + the stale port pins repointed to the route handlers. **NO migration.** [provider-portal][next16][render-phase-cookie][magic-link]
v2.97.LISAREADY1
2026-07-02Production
For everyone

The feedback box now works for every active staff account automatically — new hires like Lisa get it on day one — and the last on-screen mentions of Demi have been retired.

What this means for you

Two clean-ups behind today's staffing changes. (1) The feedback box used to require each person's email to be added by hand — that's how Kat got hers, and Lisa would have needed the same. Now every ACTIVE staff account (any role) automatically gets the feedback box, My Feedback, and replies on their own items the moment their account exists. Offboarded accounts are refused even if an old email entry lingers. The triage queue — where you read OTHER people's feedback — stays on the short explicit list. (2) With Demi gone, the buttons and help text that named her now say 'front desk': 'Hand to Demi' is 'Hand to front desk', escalation counters and page guides match, and the staff-roles help names Lisa as the receptionist example.

Show technical details

Changed

  • 🎟️ **Role-based feedback submit (Fleet Feedback Standard §4.1) — the per-email allowlist bottleneck is dead for filers.** New canSubmitReviewerFeedback({email, role, isActive}) in the reviewer-feedback SoT: any ACTIVE AdminUser role (ADMIN/MANAGER/SCHEDULER/BOOKKEEPER) may file + work its own thread; explicit fail-closed on isActive=false (an offboarded account is refused even with a lingering allowlist email); legacy email allowlist kept as fallback. Ported at the 5 SUBMIT/own-feedback gates (admin layout bubble render, /me/feedback, my-attention-count, own-thread comments, confirm actions) — each now selects role+isActive. DELIBERATELY NOT widened: the triage surfaces (/admin/reviewer-feedback page + actions + the cron queue route) stay allowlist-gated — reading other people's feedback bodies (may reference PHI) remains a short list. 4 new pin blocks incl. the Lisa case (active SCHEDULER, no allowlist entry → allowed). Lisa's 7/7 start now needs ZERO code change. [feedback-standard][role-based][lisa-ready]
  • 🧹 **Demi user-visible copy sweep (offboarding follow-up).** DESK_COPY.handToDemiButton → "Hand to front desk" + withDemiReceipt → "→ With front desk · sent …" (SSoT + pins; function/key names kept to avoid consumer churn) · isabella-today "claimed by Demi"/"waiting on Demi" → front desk · isabella Stat "to Demi" → "to front desk" · integrations triage hint · users-page role help now names Lisa as the SCHEDULER example · feedback-page help · mariane-today intro + sister-page link. Code identifiers (getDemiCallbacks etc.) and historical changelog prose intentionally untouched. [offboarding][copy]
v2.97.PAYSLOTS1
2026-07-02Production
For everyone

Four upgrades: Bill via Poynt now works for ANY amount, Isabella offers real appointment dates on calls and chat, she handles interruptions naturally with shorter turns, and Demi Today is now Admin Today.

What this means for you

(1) Any-amount billing: for a custom charge (reissue fee, records fee) Bill via Poynt now mints our own secure checkout link for exactly that amount — no GoDaddy dashboard. A re-clicked link can't double-charge. (2) Real dates: when a caller or chat visitor asks what's available, Isabella offers the next one or two ACTUAL open appointments from the live calendar — never an invented time — and the office still confirms the exact slot. (3) Interruptions: Isabella keeps turns to one idea, asks one question at a time, and when interrupted answers THEIR question first, then picks the flow back up. (4) Demi has left GW: her page is now Admin Today (same menu spot) for Mariane — and Lisa when she starts — and Demi's access is fully retired.

Show technical details

Added

  • 💳 **Custom-amount Collect pay links — any-amount billing WITHOUT the GoDaddy invoicing entitlement (Doug 2026-07-02: "we won't be reaching out to GoDaddy — figure it out on our end").** The signed /pay token now optionally carries amountCents + desc (validated at MINT, re-validated at VERIFY, fail-closed both ways); /pay renders a custom-charge page and chargeViaCollect gains kind="custom" — charging EXACTLY the token amount via the live, charge-verified Collect rail. Custom charges deliberately do NOT touch visit-fee accounting (no amountCollectedCents bump, no paid sentinel, no cert release) — a reissue fee on a paid appointment can never masquerade as a visit payment; the admin UI says to use Mark paid when it also settles a visit. Idempotency: the token nonce stamps the success audit row and a prior success refuses a re-charge — a re-clicked link can't double-bill. bill-poynt mints these automatically whenever the amount isn't the remaining balance, so every Bill-via-Poynt click now yields a working link. [poynt][collect][custom-amount]
  • 🎚️ **?interruptionSensitivity= + ?responsiveness= on retell-sync (closes reviewer-feedback cmqelmi1 config half).** Retell's interrupt-yield + response-latency agent dials were dashboard-only; now one curl through the same fail-closed RETELL_SYNC_TOKEN gate. Pure parseUnitDial validator ([0,1], 400-before-IO, pinned); runAgentDialsSync PATCHes only the dials provided; absent = byte-identical no-op; dryRun honored. [retell-sync][dials]

Changed

  • 📅 **listOpenSlots (voice + chat/email bots) now returns REAL dated openings (closes reviewer-feedback cmqbsgstg — Doug-greenlit on the 7/2 sweep).** Both handlers quote up to 3 (voice: 2) next truly-bookable slots from AvailabilitySlot — the same bookableSlotFilter source staff quote from on /admin/isabella-today and patients book through the wizard — earliest-per-date, spoken in full on voice via new pure dated-slots-shared.ts (ordinal days + the SAME clock-word speller as standing windows; 9 pin blocks). The 2026-06-04 standing-windows-only rationale (slots-not-trusted, cmpyf53v7) is superseded by that data's promotion to the staff/patient booking source; framing stays request-based — options offered, office confirms, never "held". Fallback on zero slots or any query fault: the prior standing-windows behavior, byte-identical copy. Chat's returned slotIds feed proposeBooking directly. dateRange arg is now honored (was accepted-but-ignored). Voice prompt guard rewritten: tool-returned dates MAY be offered; invented dates remain forbidden. ⚠️ POST-DEPLOY: retell-sync must run for the voice half. [isabella][dated-slots][feedback-close]
  • 🗣️ **Isabella turn discipline + natural interruption recovery (closes cmqelmi1 prompt half).** One idea per turn; 2-3 sentences max outside disclosures/wrap-up/crisis; one question at a time; on interruption answer what the CALLER raised first, then return to the flow in her own words — never restart the interrupted sentence. Soft cap 30000 → 31500 (net +757; history updated) + assembled-prompt ceiling 38000 → 39500 so the (still-unarmed) learned playbook won't silently skip. [isabella][voice-prompt][interruption]
  • 🚪 **Demi Today → Admin Today (Doug 2026-07-02: Demi no longer works for GW).** Route renamed /admin/demi-today/admin/admin-today (old path 307s via stub — bookmarks keep working); nav label + page title/help copy de-personalized; same ADMIN/MANAGER/SCHEDULER gate so Mariane manages it today and Lisa (SCHEDULER, starts 7/7) the day her account exists. OFFBOARDING: her AdminUser was already isActive=false (verified); this ship removes her gmail from REVIEWER_FEEDBACK_ALLOWLIST + the oversight-cost-cap STAFF_BYPASS_ALLOWLIST (an ex-staff address must not bypass caps), fixes the ai-draft crisis note ("recommend the office manager follows up"), and DEMI_TRANSFER_NUMBER was REMOVED from prod env — Isabella's live-transfer-to-Demi clause self-retires on this deploy + sync. Historical rows/aggregate buckets keep her email for correct staff attribution. Remaining cosmetic Demi mentions in admin prose → follow-up sweep. [offboarding][rbac][nav]
v2.97.BILLAVAIL1
2026-07-02Production
For front desk

Two fixes from Mariane's feedback: Bill via Poynt now hands you a working payment link for a visit balance instead of 'create it by hand', and AI reply drafts now quote our real open appointment times.

What this means for you

(1) Bill via Poynt: our Poynt account isn't enabled for auto-created invoices (a GoDaddy support request is on Doug's list), which is why the button kept telling you to make the link by hand. Now, when the amount you're billing is the appointment's remaining balance, the button hands you our own secure checkout link — the patient pays online and the payment records on the appointment automatically. Custom amounts still need the by-hand path for now, and the instructions now say exactly why. (2) AI reply drafts: when a patient asks about scheduling, the draft now offers the one or two nearest REAL openings from the live calendar — matched to their location and preference, never invented — instead of a generic 'we'll get back to you with times.'

Show technical details

Fixed

  • 💳 **Bill via Poynt actually produces a link again (closes reviewer-feedback cmr03mfn0).** Root cause re-verified live 2026-07-02 via the diag route: the Poynt app is NOT entitled for the dynamic invoicing API on this merchant (all six /invoicing candidates 404 — the fix for THAT is a GoDaddy support scope-grant, Doug-action). Two code fixes ship around it: (a) the POYNT_AUTO_INVOICE master gate no longer skips the entitlement-free FIXED-LINK tier — previously flag-OFF short-circuited straight to portal-manual, silently disabling the one autonomous rail that works today (fixed links are still unpopulated — POYNT_FIXED_PAYLINKS setup doc pending Doug); (b) NEW Collect /pay fallback in bill-poynt: when no invoice/fixed link exists but the billed amount equals the appointment's remaining balance to the cent (pure collectPayLinkEligible, 4 pin blocks, fail-closed on the unknown-type fee sentinel + Collect-flag-off), the route returns the signed in-portal Collect checkout URL (buildPayUrl, the same link class booking-confirmation emails already send; live + charge-verified 6/25). Payment then records automatically via the existing Collect flow — the button says so instead of demanding a manual 'Mark paid'. Portal-manual instructions rewritten honest (entitlement missing + no fixed link for this amount). POYNT_AUTO_INVOICE stays OFF (flipping it was probed this session and reverted — the dynamic API 404s regardless). [poynt][billing][feedback-close]

Changed

  • 📅 **AI reply drafts quote LIVE calendar availability (closes reviewer-feedback cmr2wnxm6).** The ✨ Draft endpoint now injects a CURRENT OPEN APPOINTMENTS block — the same truly-bookable openings /admin/isabella-today's quick-summary and the public next-slot teaser use (bookableSlotFilter: un-booked, un-owned, active provider, not mid-hold; groupOpenSlots per location/type, 3 nearest each, Pacific-time formatted). Both system prompts (email + SMS) gain a hard grounding rule: offer the 1-2 nearest openings matching the patient's stated location/type/preference, quoted exactly as listed — times not in the block do not exist; nothing matching → ask preference + team-confirms, exactly the prior behavior. Fail-safe: slot-query error → no block → drafts stay generic as before. An OPEN slot carries zero PHI. NOTE deliberately NOT shipped: same upgrade for the autonomous email/chat BOT's listOpenSlots tool (that surface sends without staff review — needs its own design pass; the tool keeps returning standing windows only). [ai-drafts][availability][feedback-close]
v2.97.ISASCHED1
2026-07-02Production
For front desk

Isabella now offers renewal callers the nearest open appointment windows up front instead of making them guess, and she keeps a calm, steady pace through the end-of-call confirmations.

What this means for you

Two fixes from Mariane's feedback on Isabella's scheduling calls. (1) Renewal callers were never proactively told what days we have open — new-patient calls did better because those already led with real availability. Now Isabella looks up the clinic's real open windows and offers the nearest one or two right away — 'our nearest availability is Thursday afternoon — would that work?' She still only names days the schedule lookup actually returned, so she can't invent a day the office is closed. (2) Isabella was rushing at the end of calls, right when callers write down appointment details. She now holds a steady, unhurried pace through the confirmations and goodbye — and her speaking-rate dial is adjustable without the vendor dashboard if calls still feel fast.

Show technical details

Changed

  • 📅 **Renewal callers now get proactive availability (closes reviewer-feedback cmqq1a7qx).** The VOICE_PROMPT renewal branch said “go straight to capturing their preferred time,” so renewal callers were asked to guess a day while NEW patients already got the lead-with-real-availability flow — exactly the gap Mariane reported (“renewal callers are not proactively given available dates; new patient calls perform better”). The renewal branch now routes through listOpenSlots and offers the nearest one or two returned windows up front, inside the existing anti-hallucination guard (only-state-what-the-tool-returned · NEVER-make-up-days · never name a calendar date as a held slot — all pinned unchanged). Generic booking-flow line strengthened to “proactively offer the nearest one or two up front — never make the caller ask what's available.” [isabella][voice-prompt][feedback-close]
  • 🐢 **Wrap-up pacing rule (closes reviewer-feedback cmqq1aetb).** Mariane: “at the end of scheduling calls, Isabella begins speaking too quickly.” The end-of-call wrap-up now opens with an explicit steady-unhurried-pace instruction — confirmations and closing details are where callers write things down, so rushing costs the most there. Prompt-side half of the fix; the TTS-rate half is the new voice_speed knob below. Soft cap 29000 → 30000 (baseline sat at 28,997/29,000 — zero headroom; net +936 chars, history table updated). ⚠️ POST-DEPLOY: POST /api/admin/retell-sync must run for both prompt changes to reach the live phone line. [isabella][voice-prompt][pacing][feedback-close]

Added

  • 🎚️ **Optional ?voiceSpeed= on POST /api/admin/retell-sync — the TTS speaking-rate is now a one-curl operator action.** Retell's voice_speed lives on the AGENT resource and was previously dashboard-only (RETELL_API_KEY is Sensitive — unreachable outside the deployment, the same gap IBJ0001 closed for the prompt). New pure parseVoiceSpeed validator (retell-sync-shared, 4 pin blocks) rejects non-numeric/out-of-envelope values with a 400 BEFORE any IO; runVoiceSpeedSync re-asserts the [0.5, 2] envelope belt-and-suspenders, honors ?dryRun=1, and only fires when the param is present — absent param leaves voice speed byte-identically untouched. Same RETELL_SYNC_TOKEN bearer, no PHI, config-push only. [isabella][retell-sync][voice-speed]
v2.97.GATELOCK1
2026-07-02Production
For everyone

Two more safety locks: the full automated test suite now blocks bad code from deploying, and a new check makes sure no ad-tracking pixels can ever be added to the site.

What this means for you

Follow-up to this morning's safety batch. (1) The app's full automated test suite (10,839 checks) now runs in the cloud on every code push and BLOCKS the push if anything fails — it used to be advisory-only because of an old backlog of failing tests, which has been fully cleaned up. (2) A new pre-release check guarantees no third-party ad or analytics tracker (Facebook pixel, Google Analytics, TikTok, etc.) can be added to any page — on a medical site those trackers would share patient browsing activity with ad companies, which is a privacy violation. Nothing visible changes for staff or patients; these are guardrails.

Show technical details

Changed

  • 🔒 **CI test suite REPORT-ONLY → BLOCKING (pre-deploy-gate.yml).** Commit 7383afd2 (6/17) made pnpm test continue-on-error: true "until the ~70 pre-existing failures are triaged" — that backlog is now fully drained: the suite ran **10839/10839 green in CI** on the SESSHARDEN1 run (workflow run 28580618801) before this flip. A red test now fails the gate check exactly like tsc and the 65-gate manifest do. Remaining Doug-action unchanged: mark pre-deploy-gate REQUIRED in branch protection to make the whole chain unbypassable. [release-integrity][ci][tests-blocking]

Added

  • 🚫 **NEW gate check-no-third-party-pixel (65th manifest gate) — closes the 6/30 unshipped-backlog 'no-third-party-pixel CI assertion on intake/qualify funnel' row.** OCR's tracking-technology bulletin treats an ad/analytics pixel on a patient-facing page as a PHI disclosure with no BAA (IP + page context = IIHI; Google refuses a GA BAA — BAA_STATUS row 14). GA was stripped 2026-05-28 (HIPAA blocker D); this makes the regression class push-blocking + CI-blocking. Scans ALL of src/ (no tracker belongs anywhere in a HIPAA clinic app, funnel included) for 21 tracker-host fragments (googletagmanager, connect.facebook.net, analytics.tiktok, doubleclick, hotjar, clarity.ms, mixpanel, segment, fullstory, amplitude, heap, LinkedIn/Pinterest/Twitter/Snap ad hosts, criteo, taboola, outbrain) + pixel SDK calls (fbq(, gtag(). Exempt (each pinned elsewhere): dead-code GAGate.tsx (never-mounted invariant enforced by cookie-consent + d2-security pin suites), changelog.ts prose, test files. Green on ship: 1412 files scanned, 0 references. Wired via gates.manifest.mjs (single source → hook + CI automatically) + check:no-third-party-pixel npm script. [hipaa][ocr-tracking-bulletin][gate][no-pixel]
v2.97.KATLOOP1
2026-07-02Production
For everyone

The feedback box now talks back: when you hit Send you get a lasting “Received ✓” with a link, a permanent My feedback entry in the sidebar shows when something needs you, and each item comes back as either a plain reply or a “fixed — take a look” button that opens the exact page to check.

What this means for you

The feedback box now talks back, shipped ahead of Kat's first day filing. (1) Sending no longer flashes a message that vanishes — the “Received ✓” panel stays until you close it, with a real link to My feedback. (2) My feedback lives in the sidebar, with a count of items waiting on you. (3) On My feedback the technical pills are gone — each item shows Received, a Reply (with a box to answer right there), or “Fixed — take a look” with a button that opens the changed page plus ✓ Works / ✗ Not fixed taps. (4) The “a fix shipped” email carries an open-the-page button too — and only the page name and links, never what you wrote. (5) Fixes now ship hourly instead of every four hours. Plus: the button says Send, small stuff is a Quick tweak, and the help text now says bottom-LEFT correctly.

Show technical details

Changed

  • 📨 **Durable receipt — the 1.8s auto-close flash on the feedback bubble is dead (Fleet Feedback Standard §3A.2 / §6.2).** FeedbackBubble.tsx's success state now PERSISTS until the filer closes it: “Received ✓ — Doug'll see this next time he reviews” + a real to My feedback (link TEXT, never a raw path) + Send-another/Close. Button copy “Send to Doug” → “Send” (personal framing raises the social cost of filing small stuff); type picker “Polish (small)” → “Quick tweak” (internal polish value unchanged — wire + DB stable); draft-restore now says “Your screenshots need re-attaching — drafts save your words, not images” when a saved draft had images (drafts persist words only; File objects can't survive a reload). [feedback-standard][durable-receipt][copy]
  • 💬 **Two-response collapse on /me/feedback — filers never see the internal status ladder again (spec §3A.3–5 / §6.3).** Status + severity pills replaced by a thin phase line — Received / Reply / Fixed — take a look — via new pure lib src/lib/feedback-filer-view.ts (filerPhaseForRow, bucketForRow extracted from the page so the nav badge can't drift, both pinned in feedback-filer-view.test.ts). A clarification question renders as a REPLY with a reply box (new ReplyBox.tsx + replyToMyFeedback server action: owner-gated, writes a ReviewerFeedbackComment as the filer, quietly flips needs-clarification→open — she never sees a status change; body-blind FEEDBACK_SUBMITTER_REPLY audit). wontfix renders as “We're not going to change this one — here's why: …” and couldnt-fix as “This one's trickier than it looks — Doug's got it” — human replies, never statuses. Fix notices show “This is fixed — take a look” + an isSafeDeepLink-guarded “Open {page} to check →” button + ✓ Works / ✗ Not fixed taps; shas + ✨ auto-fix version chips are gone from the filer surface (admin queue keeps them). Comment thread renders as a plain conversation (You / author name). Mariane's IBV0001 tabs survive — they were already filer-language. NO migration: String status column + existing comments table. [feedback-standard][two-response-collapse][no-migration]
  • ⏱️ **Autonomous fix loop: hourly + scan-past-refuse + 2 ships/run (spec §3B / §6.5 — yml-only).** agent-feedback-fix.yml: cron 23 */4 * * *23 * * * * (14 min behind the :09 feedback-cleanup classifier so every tick reads a freshly-classified queue — worst-case clear-ask-to-fix drops from ~4.5h to <1h); timeout-minutes 25 → 50 (25 structurally couldn't hold the budget — a mid-ship kill strands a row in agent-working); the REFUSE-streak-3 early abort is replaced by the scan-past-un-shippable rule (the cap is N SHIPS, not N evaluations — keep scanning oldest-first past REFUSEs; stop only at quota / end of queue / build-time) so un-shippable rows can't strand shippable ones behind them; cap 5 → 2 ships/run (hourly cadence carries throughput; PHI-screen REFUSE rules NOT relaxed). [feedback-loop][sub-hour][yml-only]
  • 🏷️ **Admin-only status labels rewritten honest + de-personalized (spec §5).** STATUS_LABELS: done → “Shipped — please check” (shipped ≠ confirmed-fixed), wontfix → “Won't change — see note”, couldnt-fix → “Couldn't fix yet — back with Doug”, agent-working → “Being worked on”, mariane-triage → “Being reviewed” (never a coworker's name to a new hire; internal status VALUES unchanged everywhere). Admin queue empty-state corrected bottom-right → bottom-LEFT (factual fix — the bubble moved in UI0005). /me/feedback not-enabled copy → “The feedback box isn't turned on for your account yet — it will be as we bring more of the team onto it.” [copy][admin-labels]

Added

  • 🧭 **Permanent “My feedback” sidebar entry with attention count (spec §3A.2).** Email-allowlist gated (layout passes the verdict — can't live in role-gated nav-config), pinned above the user footer so it's visible without scrolling. Badge = the caller's OWN items waiting on her (a fix to check / a reply to answer) via new count-only endpoint GET /api/admin/reviewer-feedback/my-attention-count (AdminSession + allowlist, bare count — no row content) sharing attentionCountForRows with the page tabs. [feedback-standard][nav]
  • 🔗 **Deep-linked, BODY-BLIND fix notice — email AND token page (spec §3A.5 / §3E / §6.4 + both hipaa-architect MUST-FIXes applied).** SubmitterConfirmEmailRow gains pagePath; the confirm email + the public /feedback-confirm/[token] page both get “Open {page} to check →”. HIPAA hard rule enforced end-to-end: the EMAIL carries the page NAME + links ONLY (old title/agent-summary/sha/version blocks removed — Kat files from gmail = outside the BAA boundary), and the TOKEN PAGE is now body-blind too (hipaa-architect MUST-FIX 2: it's a no-login bearer page whose URL a mail provider's link-scanner can GET — cleanedTitle/agentNote/summary/sha/version chip + the rejected-state note echo all removed; mutations stay POST-only server actions; content lives behind cookie-authed /me/feedback). New emailSafePagePath() (pinned in tests) strips query/hash, downgrades record-detail paths (/admin/patients/:id → /admin/patients etc.), and — MUST-FIX 1 — ends in a FAIL-SAFE ALLOWLIST: only routes humanizePagePath recognizes may ride an email, so unknown/future /admin/x/:id routes (isabella, chat-history, amendments, forms…) emit NO link instead of leaking a §164.514(b)(2)(i)(R) record identifier. isSafeDeepLink (brapp 27036 port) rejects protocol-relative/external targets everywhere a deep link renders. Kept the comms NO-OP-locked lines (“Doug shipped a fix — does this work for you?” / “takes 2 seconds, no login needed”). [hipaa][body-blind][deep-link][fail-safe]
  • 🧪 **Allowlist assertion hardening (spec §6.1).** Kat's KATFEEDBACK1 allowlist entry verified already-landed on main; her pin test extended through the isReviewerFeedbackUser comparator (case/trim, Demi/DF0005 pattern) + a KATLOOP1 label-rewrite pin block. Lisa (starts 7/7): email NOT known — deliberately NOT invented; TODO markers left in reviewer-feedback.ts + the test file, and the day-3–4 plan is the role-based canSubmitReviewerFeedback port that retires the per-email bottleneck. Kat stays OUT of REVIEWER_FEEDBACK_AUTOFIX_TRUSTED — classifier-autoflip lane per the Demi precedent. [allowlist][tests]
v2.97.KATPREP1
2026-07-02Production
For everyone

Three fixes from your feedback: medical-record uploads that mysteriously failed now work (big photos shrink automatically, oversized PDFs get a clear explanation), clicking an appointment opens the chart you already started instead of creating a duplicate, and the reply-drafts no longer send patients to web addresses that don't work.

What this means for you

Three fixes from the feedback pile, ahead of Kat joining as a tester. (1) Uploads: the attach-records buttons said 'up to 25 MB', but anything over about 4 MB was rejected by the hosting platform before our code saw it — that's why uploads kept erroring. Big photos now shrink automatically in your browser (they nearly always fit), and an oversized PDF gets a plain-English message (split it or re-scan smaller) instead of a dead 'HTTP 413'. (2) Encounters: clicking an appointment you already started charting now opens that encounter instead of creating a duplicate. (3) Reply drafts were sending patients to web addresses that don't work; every draft now uses the real booking page, greenwellness.org/get-started, and asks for records as an email-reply attachment.

Show technical details

Fixed

  • 📎 **Staff medical-record uploads over ~4 MB no longer die with an opaque error (reviewer-feedback cmr1gibim + cmr2xhejk).** Root cause: the upload UI advertised the ROUTE's 25 MB cap, but the platform rejects any serverless request body over ~4.5 MB with a bare 413 BEFORE the route runs — scanned-record PDFs and phone photos routinely exceed that, so the feature looked broken. New client-side pre-flight src/lib/admin-upload-client.ts: files ≤4 MB pass through untouched (the server pipeline — MIME allowlist, magic-byte sniff, sharp compress + EXIF-strip, private-blob put, audit — is unchanged and stays the single trust boundary); oversized IMAGES are downscaled in-browser (canvas → JPEG, max edge 2048px, quality ladder — same geometry the server applies; canvas re-encode also drops EXIF) until they fit; oversized NON-images get an honest, actionable message BEFORE the doomed request; and a surviving platform 413 maps to a friendly explanation as the belt to the pre-flight's suspenders. Wired into BOTH staff attach surfaces (DocumentsList.tsx patient chart + DocumentsPanel.tsx appointment panel) and the '25 MB' labels corrected to the truthful hint. True >4 MB PDF support (Blob client-upload architecture) flagged to Doug as a separate design decision — it would route around the sharp/magic-byte pipeline, so it is NOT snuck into this ship. [uploads][honest-errors][hipaa-pipeline-unchanged]
  • 🩺 **Clicking an appointment that already has an encounter now OPENS that encounter instead of minting a duplicate (reviewer-feedback cmr030upv).** Two-layer fix. Page layer: /provider/portal/encounters/new?appointmentId=X now checks for this provider's existing non-cancelled encounter on the appointment and redirects straight to it — the Create-Encounter form only renders when there is genuinely nothing to open. Route layer (backstop for stale tabs): POST /api/provider/encounters with an appointmentId is now IDEMPOTENT — if this provider already has a non-cancelled encounter on that appointment it returns {encounterId, existing:true, redirectTo} for the EXISTING row, and the client's success path lands the provider on it. Deliberately scoped: unlinked creates (walk-ins, no appointment) are untouched; a cancelled mistake doesn't block a fresh chart; a DIFFERENT provider covering the same appointment still gets their own encounter. The 'Pending Approval has no obvious approve control' half of that report is a workflow-design question routed to Doug — not auto-changed here. [emr][duplicate-guard][providers]
  • 🔗 **Patient-facing drafts + emails no longer point at dead or staff-only web addresses (reviewer-feedback cmr2wdwgl, part of cmr2wnxm6).** portal.greenwellness.org has NO DNS (dead end for every patient told to 'upload at the portal') and flow.greenwellness.org 308s patients to /admin/login (the staff sign-in) — yet both were baked into Isabella's draft-suggest prompt, the messages ai-draft prompts (SMS + email), the compose draft-prompt, three seed email templates, the stranded-lead catch-up prompt, and the unsubscribe page's 'Back to home' link. All patient-facing copy now uses the real public funnel greenwellness.org/get-started (curl-verified 200) as the ONLY permitted link, with an explicit prompt-level ban on the two broken hosts; records requests/submissions are described as email-reply attachments (which work today) instead of a records form URL that 404s. EMAIL_AI_DRAFT_PROMPT_VERSION bumped v1.2→v1.3 with the sha256 snapshot pin updated per the brand-voice-tune contract; existing HIPAA/no-medical-claims pins verified intact (127 draft-suggest + prompt-tune tests green). Live-calendar slot quoting in drafts (the rest of cmr2wnxm6) stays a Doug-design item — real availability grounding, not a URL swap. [isabella][honest-urls][prompt-v1.3]

Added

  • 🧾 **New PHI-safe metadata endpoint GET /api/admin/reviewer-feedback/done-unevidenced — per-row reader for the historical 'hollow done' wave.** The 2026-06-29 audit left ~17 rows sitting in status=done with NO ship evidence (doneSha + closedByAgentVersion both null); the close-gate now 422s NEW hollow closes, but nothing PHI-safe could name WHICH historical rows to re-verify (the aggregate is counts-only, the /queue route correctly refuses terminal statuses). This is the missing middle, built to the exact /couldnt-fix contract: SELECTs only structural columns (id, severity, pagePath, agentAttempts, status, closeReason enum), collapses createdAt/doneAt to coarse ageDays/doneAgeDays ints, derives-and-discards userEmail into a roleBucket with the <5 small-cell suppression floor, and normalizes pagePath (query/hash stripped, identifier-shaped segments → ':id') so a patient record locator can never egress. Auth = bearer CRON_SECRET OR allowlisted AdminSession, with the exact-path ADMIN_BEARER_ALLOW proxy exemption (sister of /couldnt-fix). Lets the feedback drain claim each hollow row (PATCH working), read its body in-tenant via the body-bearing /queue route, verify the claimed fix against current code, and re-close WITH evidence. No body/name/email/screenshot/patientId/raw-timestamp in any element — PHI-safe by construction, same as its two siblings. [feedback-loop][hipaa-boundary][hollow-done-drain]
v2.97.SESSHARDEN1
2026-07-02Production
For everyone

Admin and patient logins now time out for safety — you'll be signed out after 30 minutes of inactivity (patients: 60), just like the provider portal already does.

What this means for you

Three safety upgrades in one batch. (1) Session timeouts: admin sessions now sign out after 30 minutes of inactivity (12-hour daily maximum), and patient portal sessions after 60 minutes (24-hour maximum, down from 7 days). If you're actively working you stay signed in — the timer resets as you use the app. If you do get signed out, just log back in; nothing is lost. Providers already worked this way. (2) All of the pre-release safety checks now also run in the cloud on every code push — 30 of them, including several HIPAA ones, used to run only on Doug's laptop. (3) A new health probe watches the phone/fax connection so a broken credential is caught before staff notice calls or faxes failing.

Show technical details

Added

  • 🔐 **D11-for-admin + D11-for-patient — 2-axis session timeouts on the remaining two session classes (closes the 6/30 unshipped-backlog 'Patient/admin session idle+absolute timeout' row).** Ports the provider D11 pattern (XR0405, NIST SP 800-66r2 / 800-63B) verbatim: iat (issued-at, fixed across rolls) + exp (rolling idle deadline) in the HMAC-signed payload, dual-shape verify for the in-flight legacy cookie fleet, roll-throttled Set-Cookie via the proxy roll blocks. **Admin** (src/lib/admin-session.ts): 30m idle / 12h absolute (12h vs provider 8h so a long front-desk shift doesn't force a mid-day re-auth); payload v2→v3 (userId~role~nameB64~iat~exp~sig); the proxy roll block shipped as a stub in D9 HA0005 now does real 2-axis work with zero proxy changes; legacy 5-part cookies verify until their original ≤8h exp then upgrade on re-mint. **Patient** (src/lib/patient-session.ts): 60m idle / 24h absolute — DOWN FROM A FLAT 7-DAY PHI credential; NEW rollPatientSession/shouldRollPatientSession wired into the /patient/portal proxy branch (the one roll block that didn't exist); legacy 7-day cookies are deliberately retired by the absolute cap within 24h of their mint (fail closed — the patient just logs in again). All four session-mint sites (admin/login, patient/auth/login, verify-identity/confirm, set-password) drop their fixed cookie maxAge for the provider-style session cookie (payload iat/exp is the single source of truth; browser-close also drops it). Tests: admin-session.test.ts + patient-session.test.ts extended with D11 describe blocks (frozen constants, iat round-trip, absolute-cap-bites-despite-fresh-exp, roll-preserves-iat, roll-headroom windows, legacy-shape migration both directions) — 77/77 green with the provider suite. [hipaa][nist-800-63b][session-timeout][d11-port]
  • 🩺 **NEW /api/admin/diag/rc-token-health — lights up the watchdog's dormant oauth-refresh-token-multi-vendor-health RC slice ('RingCentral token-health diag endpoint not yet implemented — TODO').** Verbatim sister of diag/m365-token-health: dual auth (bearer CRON_SECRET for the headless watchdog OR x-admin-role ADMIN/MANAGER for the in-browser banner), proxy ADMIN_BEARER_ALLOW entry so the cookie-only admin gate doesn't 401 before verifyCronAuth runs, always-200 envelope (ok/healthy/error/reason/hint/expectedScopes/checks{}/probedAt) so the watchdog never misclassifies a 500 as transient. Thin re-shape of the EXISTING /admin/integrations/rc tile primitives (checkJwtAppEnvSet/checkJwtTokenMint/checkJwtAccountInfo/checkJwtScopes/checkRcSubscriptionsHealthy) so banner + probe always agree. PHI-safe: token-mint + GET /account/~ metadata only — never message-store/call-log/fax; no token material in the body. Doc'd naming caveat: App A is JWT-bearer, not OAuth-refresh — failure modes are invalid_grant/invalid_client/unauthorized_client/missing-scopes, each surfacing its remediation recipe as hint. Watchdog-side re-point of the dormant probe is the follow-up in /CODE/watchdog (external repo). [watchdog][ringcentral][diag][phi-safe]

Changed

  • 🧱 **Gate list SINGLE-SOURCED — scripts/gates.manifest.mjs + scripts/run-gates.mjs now drive BOTH the local pre-push hook and CI (closes the pre-deploy-gate.yml v2 follow-up TODO).** The list previously lived as two hand-maintained inline copies (63-entry shell array in .githooks/pre-push; 32-gate check:all string in package.json) that had drifted 30 gates apart: CI was NOT running check-no-unsafe-redirect, check-no-module-init-rotatable-env, check-after-wrap-external-send, check-no-xff-nullish-trap, check-sms-copy-shaft-safe, check-portal-api-routes-self-auth, check-safe-harbor-digest-content, check-bearer-routes-allowlisted (the security/HIPAA class) + 22 more; the hook was missing check-state-gate-coverage (the multi-state release firewall) that only CI ran. Now: one manifest (64 gates, per-gate ci:false flag — only check-vercel-project-link, which reads gitignored .vercel/project.json), one runner (--env=hook runs all 64, --env=ci runs 63), consumed by .githooks/pre-push, pnpm check:all, and pre-deploy-gate.yml (whose now-redundant separate PHI-in-logs/audit-coverage step is folded in). Drift is structurally impossible — a gate added to the manifest reaches both consumers; the runner prints the skipped-in-CI count per the queue-visibility invariant. Both modes verified green (64/64 hook · 63/63 ci). NOTE: the workflow itself stays report-only until Doug flips it to a REQUIRED status check in branch protection (unchanged, pre-existing Doug-action). [release-integrity][gate-drift][single-source]
v2.97.KATFEEDBACK1
2026-07-02Production
For everyone

Kat now has the in-app feedback button — she's joining as an operations tester ahead of Lisa's start next week.

What this means for you

Kat (Kathryn Haney) is being onboarded as a scheduler-role tester, and she now sees the same bottom-left feedback bubble on every admin page that Doug, Mariane, and Demi have. Anything she submits flows through the normal AI triage and fix loop, the same way Demi's feedback does — so issues she spots while testing can be fixed quickly. Nothing else changed: no patient-facing surface is touched, and no one outside the named list can see or use the button.

Show technical details

Added

  • 💬 **Kat (seattlecckat@gmail.com) added to the GW reviewer-feedback allowlist (Doug-directed: ops tester onboarding 2026-07-02, ahead of Lisa's start next week).** Added to REVIEWER_FEEDBACK_ALLOWLIST in src/lib/reviewer-feedback.ts so the bottom-left feedback bubble renders for her on every admin page (gated server-side by isReviewerFeedbackUser in both the admin layout and /api/feedback). Mirrors Demi's DF0005 treatment exactly: allowlist-only — deliberately NOT added to FORCE_DOUG_REVIEW_SUBMITTERS (her items ride the normal AI-tier triage / auto-fix loop) and NOT added to REVIEWER_FEEDBACK_AUTOFIX_TRUSTED (that skip-triage tier stays Mariane-only). Also added to the STAFF_SUBMITTER_EMAILS role-bucket sets in the /api/admin/reviewer-feedback/aggregate + /couldnt-fix routes (kept in sync with the allowlist per their contract) so her counts bucket as staff — not other — under small-cell suppression. NOT added to STAFF_BYPASS_ALLOWLIST in oversight-cost-cap.ts: that list is the email-AI cost-cap bypass (inbound clinic-inbox bot), not the feedback path — Demi's entry there predates her feedback enablement and its pin test asserts exactly 4 entries. **Files MOD (4 + changelog pair):** src/lib/reviewer-feedback.ts · src/app/api/admin/reviewer-feedback/aggregate/route.ts · src/app/api/admin/reviewer-feedback/couldnt-fix/route.ts · src/lib/__tests__/reviewer-feedback.test.ts (allowlist assertion). **HIPAA:** config-only allowlist change, ZERO schema/migration, ZERO PHI-path change; feedback still lands in the BAA-covered reviewer_feedback table with no email/SMS notify. [reviewer-feedback-allowlist][kat-ops-tester][allowlist-only-not-force-doug][hipaa-clean]
v2.97.PAYCHUNK1
2026-07-02Production
For everyone

The booking page loads a little lighter now — the credit-card-processing code we don't use (we use Poynt, not Stripe) no longer downloads for patients booking an appointment.

What this means for you

A small speed win on the booking flow. The scheduling wizard was bundling in Stripe's payment library for every patient, even though Green Wellness takes payment through Poynt and never uses Stripe. That unused code now only loads if a Stripe payment screen is ever actually shown — which, on our setup, it isn't — so it's dropped from what a patient downloads to book. Nothing about how booking or payment works changed at all; the Poynt payment step is untouched. Patients on slower connections just get to the booking form a touch faster.

Show technical details

Changed

  • ⚡ **Booking wizard: @stripe/react-stripe-js lazy-loaded, so it drops out of the bundle entirely for GW (Poynt-only).** StepPayment.tsx statically imported Stripe Elements, and the wizard statically imports StepPayment — so @stripe/react-stripe-js rode the Poynt-path chunk for 100% of patients while 0% use it (GW selects Poynt via NEXT_PUBLIC_SELF_SCHED_PAY_TO_CONFIRM; the Stripe branch never renders). Moved the Stripe-Elements UI (PaymentForm + , the only code touching useStripe/useElements/react-stripe-js) into a new StepPaymentStripeElements.tsx loaded via next/dynamic({ssr:false}) ONLY inside the Stripe branch. **Build-verified**: before, react-stripe-js sat in the 109KB Poynt-path chunk; after, it's isolated in its own 13KB chunk the Poynt flow never fetches (~4.5KB gz off every GW patient's load path) — and NO prefetch, since prefetching unused code would be worse. Rules-of-hooks safe (Stripe hooks were already confined to PaymentForm); Stripe kept fully functional if the flag ever selects it; loadStripe/handleBook/stripePaymentIntentId/Poynt QR all byte-identical. 5 source-pin tests lock the split. [perf][booking][bundle]
v2.97.SEOLEDGER1
2026-07-02Production
For everyone

Two behind-the-scenes wins: cleaned up how search engines and AI assistants see our site (and removed a page-link we shouldn't have advertised to them), plus stronger duplicate-payment protection (built, switched off until reviewed).

What this means for you

Housekeeping that helps patients find us and protects the money side. (1) Search/AI visibility: added a public page that was missing from our sitemap so Google can find it, fixed a phone number that had drifted on the file AI assistants read, and removed old leftover files that pointed crawlers at a non-standard address and advertised a patient-appointments link they shouldn't see (no patient info was ever exposed — the link needs a login; this just stops pointing crawlers at it). (2) Payment safety: an extra guard so that if our card processor re-sends the same payment notice later, it can't be counted twice — building on the duplicate-protection added earlier. Built but switched off until the database piece is applied, so nothing changes today.

Show technical details

Fixed

  • 🔎 **Technical SEO + AI-surface cleanup.** /areas-we-serve (a public, indexable landing page with its own canonical + JSON-LD) was missing from sitemap.ts → only reachable via internal links; added. llms.txt/llms-full.txt route handlers hardcoded a phone number instead of the PHONE SSoT (drift hazard on an AI-citation surface) → now interpolate ${PHONE}, plus added the /qualify/washington eligibility hub and refreshed the stale date. **Deleted stale git-tracked public/llms.txt + public/llms-full.txt shadow copies** — shadowed at request time but still deployed, carrying a non-canonical host (not even in the app-url allowlist), the wrong one-word brand, and — the real find — a **PHI-adjacent /my-appointments URL advertised to LLMs**. No PHI was ever exposed (the URL needs a session); this stops pointing crawlers at it. Extended the next.config.ts edge-cache pin to /llms.txt. NO medical claims introduced (verified via medical-claim-scrub); all four structured-data gates + brand + canonical gates green. [seo][aeo][hipaa-adjacent]

Added

  • 🧾 **Poynt webhook sequential-retry idempotency ledger (dark: flag OFF + migration 107 staged).** Completes the money-safety arc the concurrent atomic-CAS wave flagged: the CAS closed *simultaneous* duplicate deliveries; this closes a webhook *re-delivered later* (after state already moved). New PoyntWebhookDelivery table with UNIQUE(appointmentId, invoiceId, kind, amountCents) = Poynt's per-delivery economic-effect identity; each money-mutating branch (paid/additional/refund) INSERTs-as-dedupe before its CAS, so a proven duplicate (P2002) acks idempotently without re-running the mutation or re-firing release/receipt. **Zero-regression by construction**: the skip path is reachable ONLY on flag-ON + a proven duplicate; flag-OFF, table-missing, or any DB blip all fall through to today's already-idempotent CAS — a payment can never be dropped or double-counted whether the ledger is present or absent. Replay guard, settlement re-read release gate, and prior CAS audit shapes byte-intact. 42 new tests + all 113 Poynt tests green. Activate: apply prod-migration-107 + set POYNT_WEBHOOK_LEDGER_ENABLED=true. [payments][idempotency][money-safety]
v2.97.CERTATTACH1
2026-07-02Production
For everyone

Fixed: patients getting their authorization by email now receive the actual PDF attached, not just a portal link. Also added (turned off for now) a provider queue to review incoming records before a visit.

What this means for you

Two things. (1) A real fix: when a patient's authorization went out by email, the PDF wasn't being attached — they got only a link to the portal — because the attachment step didn't recognize our current secure email provider. It does now, so the authorization PDF is attached and sent securely, the way it always should have been. Nothing else about that email changed. (2) Groundwork (switched off until reviewed): a new provider queue that collects records a patient has uploaded so a provider can mark them reviewed before the visit, with a 'records reviewed' note that shows on the day's schedule. It's fully built but dark — it does nothing until the database piece is applied and it's switched on, so nothing patients or staff see changes today.

Show technical details

Fixed

  • 📎 **Cert-email now attaches the authorization PDF over M365 (the active BAA provider).** src/lib/cert-email.ts: canAttachPHI gated attachment on provider === "postmark" || "ses" and OMITTED "m365" — but M365 is the auto-detected active primary, so certAttached was always false and the PDF never left buildSend() (patient got the portal-link-only body). The M365 transport already supported attachments (email-m365.ts maps them to a #microsoft.graph.fileAttachment on the SAME graph.microsoft.com/sendMail POST as the body — identical OAuth token, same tenant, one call, no separate/non-BAA path — BAA-clean, verified). Full send-path re-traced hop-by-hop; both send variants already thread attachments through the m365 branch, so the fix was purely adding "m365" to the allowlist. Postmark/SES byte-identical; sendMessageId delivery-tracking unaffected (M365 returns messageId: null by design). New pins: m365 in the set, order-independent all-three-BAA-providers, end-to-end attachment threading, and 'PHI attachment only ever rides a BAA provider — never resend/none'. [cert][email][hipaa][bugfix]

Added

  • 🩺 **Provider records-review queue (dark: flag OFF + migration 106 staged unapplied) — closes the 'incoming records never clinically reviewed before a visit' gap.** Human-only triage (NO AI in the clinical path — deliberately distinct from the separate pre-existing AI records-summary feature, verified non-colliding flags/paths/audit-actions). Nightly cron/records-review-enqueue (default-OFF RECORDS_REVIEW_QUEUE_ENABLED) sweeps patient-uploaded medical records into a PENDING queue (dedup'd on source+ref, DB partial-unique + P2002-race-safe); /provider/portal/records-review lets the provider mark reviewed/needs-followup with a secured note; the day's schedule surfaces 'Records reviewed · ' + the note to the appointment's provider-of-record. Every read/write audited PHI-free; degrades gracefully (P2021 → SectionUnavailable / heartbeat skipped=table-missing) until Doug applies prod-migration-106 + flips the flag. Built on the proven lead-catchup dark-ship pattern. [records][clinical][provider]
v2.97.CSHARDEN1
2026-07-01Production
For everyone

Privacy + safety tightening behind the scenes: patient-uploaded records that a patient removes are now kept (not erased) per state retention law, staff summary emails now show patient initials instead of full names, and a new weekly check grades the quality of Isabella's replies before any of them are ever sent automatically.

What this means for you

A follow-up hardening pass. Three things for patients' protection: (1) when a patient deletes a record they uploaded during intake, we now keep it safely archived (hidden from view) instead of erasing it, because Washington law requires medical records be retained for years — the same rule staff deletions already followed. (2) The daily staff summary email now shows patient initials ('K.L.') instead of full names, since it can reach an inbox outside our secure system. (3) A new weekly check grades a sample of Isabella's draft replies for quality, tone, and safety — it runs quietly for now, building the track record we need before any reply sends without a person reviewing it. Also tightened some internal security locks. Nothing patients or staff see day to day changes.

Show technical details

Fixed

  • 🗄️ **Intake self-delete now soft-deletes (mig-103 parity) — closes a WA-retention gap.** api/intake/[token]/documents DELETE hard-deleted MedicalDocument rows + kept the blob only by luck; it now tombstones (deletedAt/deletedById=patient-via-intake, blob retained) exactly like the admin path, satisfying the RCW 70.02 ~10yr floor that rides the data. Critical follow-through: the soft-delete would have leaked tombstoned docs back into **8 read paths** the migration-103 sweep hadn't covered (the intake list itself, my-appointments docs, patient/provider uploaded-records + document GETs → now 404, provider portal ×2, appointment-readiness's records-uploaded flag, and BOTH patient record-export listings) — all now fence deletedAt IS NULL. Admin-only aggregate counts (no filename/content disclosure) deliberately left. [hipaa][retention][records]
  • 🕵️ **EOD staff email → Safe-Harbor initials.** cron/eod-email built its patientById map from ${firstName} ${lastName} and rendered full names in the staff-productivity section, while that email's recipient chain includes a non-BAA inbox and its own header claimed aggregates-only. The map now stores safeHarborInitials() at build time (masked-by-construction — the render can only ever emit 'K.L.'), verified the only patient-name render in the file, and the file is now IN the check-safe-harbor-digest-content gate set (10 files). [hipaa][gates]
  • 🔑 **Dropped the ?? CRON_SECRET fallback from patient-token HMACs (5 sites, not 4).** The re-grep found a fifth — portal-token.ts, the patient magic-link signer, the most sensitive of them. CRON_SECRET is a bearer credential also shared with GitHub Actions; the fallback meant an unset PORTAL_TOKEN_SECRET would let a cron-bearer holder forge patient tokens. All five (portal / unsubscribe / renewal / identity-verify / returning-patient-SMS) now key only off PORTAL_TOKEN_SECRET (verified set in prod → zero live tokens invalidate), fail-closed throw in prod retained. [security][hipaa]

Added

  • ⚖️ **Weekly AI-reply quality judge (dark) — the calibration gate before auto-send is ever armed.** New cron/ai-reply-quality-judge (default-OFF AI_REPLY_QUALITY_JUDGE_ENABLED) samples ~10% (max 25, seeded-deterministic) of the trailing week's Isabella drafts (used + dismissed) and any auto-sends, scores each on a cheap Bedrock-Haiku judge against a rubric (resolution/tone/accuracy weighted above raw accuracy + safety-flag veto), stores PHI-free scores as audit rows, and emails a weekly PHI-free summary ('auto-send calibration: X% of used drafts scored ≥ threshold'). Follows the existing policy-adherence-judge architecture; ~$0.02/week when armed. Nothing auto-sends until this shows a track record. [ai-quality][autonomy][evals]
  • 🩺 **Bedrock model-probe diagnostic (/api/admin/diag/model-probe, bearer/session-authed).** Runs a tiny PHI-free 'reply OK' completion against a candidate model list (control = current pin, plus the Sonnet 5 profile variants) from the PROD runtime — where the IAM creds actually work (a local probe 403s). Resolves the HELD Sonnet-5 pin flip (a candidate returning ok:true names the exact id to pin) and is a permanent diagnostic for the profile-alias class that once silently 400'd Haiku 4.5. Error-name/status only, never bodies. [ai][ops][diagnostic]
v2.97.CSCOMMAND1
2026-07-01Production
For everyone

New: one 'CS Command Center' page shows every patient waiting on us — messages, callbacks, leads, renewals, records — with a timer on each, sorted by most overdue. Log in and you know exactly what to do first.

What this means for you

Open patient work used to be scattered across half a dozen pages, and some of it (like an email nobody answered) showed up nowhere at all. The new CS Command Center pulls it all into one list — unanswered messages and emails, callbacks owed, leads due for follow-up, renewals nearing expiry, records requests on their legal clock, unmatched faxes, and stuck approvals — each with a timer that turns red as it ages. Managers now land on this page at sign-in, and several built-but-hidden pages (including Mariane Today) are finally in the menu. A watchdog re-checks the same list every half hour and, once switched on, emails the team if anything sits too long — so nothing slips through on a busy day. Renewal reminders also now start earlier, when they work best.

Show technical details

Added

  • 🧭 **CS Command Center (/admin/cs-command-center) — one cross-lane SLA queue over a single shared obligations engine (cs-obligations.ts / -shared.ts).** 9 lanes: needs-human threads (all channels), unanswered inbound EMAIL with no in-thread outbound reply (previously surfaced NOWHERE when triage never flagged it), callbacks owed, leads due/uncontacted, stage-stalled renewals, records exports tracked to the **WA 15-working-day floor (RCW 70.02.080)** with the federal 25-day flag retained, unmatched faxes, aging PENDING_APPROVAL appointments, past-due staff tasks. Business-hours ages via businessHoursElapsed; per-lane SLA threshold map in one exported const; explicit all-clear (lists what was checked) + red banner on any lane that failed to compute. Check-off/claim rides the existing OpsTaskState desk overlay (shared bands with mariane-today — clearing one clears both); no new tables. MANAGER now lands here on sign-in; nav gains CS Command Center, Mariane Today, Demi Today, Doug Queue, Inquiry Coverage (all previously built-but-unlinked). [cs][sla][hipaa]
  • 🐕 **Patient-slip watchdog cron (cron/patient-slip-watchdog, every 30 min staggered) — same obligations engine, escalation ladder.** ALWAYS computes + heartbeats would-alert counts (observable dark launch); SENDS only when PATIENT_SLIP_WATCHDOG_ENABLED="true" (default OFF). Breach → one PHI-free staff email (counts + lanes + deep link, safe-harbor-gate-scanned); hard breach (2×) → Doug copy. 24h per-item re-alert dedupe via PATIENT_SLIP_WATCHDOG_ESCALATED audit rows — no new table. [cs][cron][sla]
  • 🌱 **Stranded-lead catchup engine (S3/S4 of the 5/31 plan, finally built — ~34K leads >30d silent had NO re-engagement path).** Nightly cron (cron/lead-catchup-draft, default-OFF LEAD_CATCHUP_ENABLED) drafts ≤20 honest re-engagement emails/night on the Bedrock BAA rail (PHI-minimal prompt; output rejected unless the medical-claim scrub scores clean; no pricing/pressure/fabrication — pin-tested) into new LeadCatchupDraft rows for /admin/leads/catchup-queue: human reviews lead context + draft, Approve sends via the shared outbound shell (unsubscribe + postal footer, send-time consent/suppression re-check, CommunicationLog) — **never auto-sends**. Freshest-dormancy cohort first; one draft per lead ever (DB-unique). /admin/leads gains stranded-cohort chips (30–90/90–365/365+), fixing the 30-day-lookback blindness. **Migration prod-migration-105-lead-catchup-draft.sql is STAGED, NOT APPLIED** — everything degrades gracefully (P2021 → SectionUnavailable / heartbeat skipped=table-missing) until Doug applies it. [leads][marketing][cron]
  • 📮 **Email appointment auto-send — built DARK behind EMAIL_AUTO_SEND_APPT_ENABLED (default OFF).** When armed, ONLY appointment-action drafts auto-send, and only when EVERY independent gate passes: abstention scan clean, same-thread reply to the patient's own inbound (never a new recipient), confidence ≥90 (above the 85 green floor), medical-claim scrub clean, PHI-echo re-check at the send boundary, business hours. Sends stamp aiAutoSent=true + audit MESSAGE_AUTO_SENT (actor isabella-ai, model, promptVersion, confidence, content-hash — never the body). Reply-only pins extended 15→34, nothing weakened. [email-ai][autonomy]
  • 🚦 **SHAFT-safe SMS gate (check-sms-copy-shaft-safe.mjs, pre-push #63).** Carriers block cannabis-referencing SMS regardless of WA legality. Sweep found all literal SMS bodies already clean; the ONE live exposure — the Isabella SMS system prompt steering AI-generated texts ('medical marijuana evaluation practice') — fixed with a carrier-content rule (say 'your medical authorization'). Gate keeps cannabis/marijuana/THC/CBD/dispensary tokens out of every SMS surface permanently. Also NEW explicit fail-closed RENEWAL_SMS_ENABLED gate: renewal SMS had NO code gate (only dead-by-missing-creds); until the Twilio/RC Healthcare BAA signs, sms-preference patients get a tested email substitution. [sms][compliance][gates]
  • 🧪 **Bedrock model probe (scripts/probe-bedrock-sonnet5.mjs) + arming runbook (GW_CS_AUTONOMY_RUNBOOK_2026_07_01.md).** Sonnet 5 is GA on Bedrock with In-Region us-east-1 confirmed (data-residency safe; promo pricing to 8/31). The pin stays HELD until this live probe passes in the GW account (tests the geo profile + version-suffixed variants — the Haiku-4.5 alias lesson — with the current pin as control). Runbook = phased flag-arming order (watchdog → stale-transfer/voice-lead → catchup → auto-send), model-bump procedure, and the Doug-action table (Twilio BAA, treatment-comm sign-off, records-recency policy, Retell outbound scope). [ai][ops]

Changed

  • 📅 **Renewal outreach cadence rebuilt on recall-benchmark evidence (renewal-cadence-shared.ts SSoT).** First touch moved to T-28d pre-expiry (pre-due outreach converts ~41% and books 15–20% higher than waiting); ladder T-28/T-14/T-7 (phone-pref → call queue)/T-0/T+7/T+14 (staff call task)/T+21; 6-touch hard stop per cycle; touches shift to Tue/Wed within their window (highest booking days), never outside it; retired the old double-send at T-7. Win-back cohort now freshest-lapsed-first with deep-dormant (>24mo, <8% converters) capped out entirely; the §164.508 opted-in marketing floor byte-identical + still pin-tested. Consent floors, bounce fallback, pipeline stage recording all preserved. [renewals][marketing]
  • 🔒 **Email-lane HIPAA hardening (min-necessary + audit redaction + channel-uniform abstention).** Triage classifier input hard-bounded (subject was unbounded; §164.502(b)). THREE raw phone/identifier leaks into audit detail fixed (sms-auto-reply ×3 sites, sms-ai, admin smoke-test) and check-pii-in-audit-detail extended with 4 pattern classes so the class can't return. New shared abstention floor (comms-abstention-shared.ts, 70 tests) consumed by BOTH sms and email lanes: crisis (EN+ES, short-circuits with NO model call), legal/attorney/insurer, breach mentions, minors, §164.526 amendment requests — any hit forces needs-human + suppresses draft generation. [hipaa][email-ai]
v2.97.AUDITSOLID1
2026-07-01Production
For everyone

Full security and correctness review completed: no security holes found, and several scheduling and payment bugs were caught and fixed before anyone hit them — including the reschedule path of June's provider double-booking bug.

What this means for you

A full review of the whole system finished today — security, privacy, and correctness. The security review found no way in: protections held everywhere it probed. It did catch and fix several scheduling bugs before anyone hit them: rescheduling could put two patients on the same provider at the same time (the same gap that double-booked Dr. Ari in June — now blocked on every path, not just new bookings), a double-clicked Book button could refund a payment for a booking that actually went through, and the calendar could hide late-afternoon openings on the last day of a month. Payment records also can't be corrupted by duplicate card-terminal notifications anymore, and new behind-the-scenes safety checks keep these gaps from quietly coming back.

Show technical details

Fixed

  • 🛡️ **Reschedule paths now run the provider double-book guard (closes the remaining path of the 6/17 Dr. Ari incident).** The PROVIDER_DOUBLE_BOOKED overlap guard existed only on the two CREATE paths (public booking + admin manual); the patient reschedule core (appointment-move) and admin reschedule claimed slots with no overlap check — and telehealth/in-person availability exists as twin slot rows at the same clock time, so a reschedule could still double-book a provider. The guard is now extracted to a shared helper (provider-double-book-guard) and runs inside ALL FOUR commit transactions, with a per-provider pg_advisory_xact_lock closing the concurrent-booking TOCTOU race the plain findFirst guard had (two simultaneous bookings of twin slots could both pass at READ COMMITTED). Reschedule surfaces return a clean 409. [scheduling][bugfix]
  • 💳 **Duplicate-submit booking race no longer refunds a live appointment's payment.** In /api/appointments, the paid-path idempotency check ran BEFORE the booking transaction, and the SLOT_TAKEN/P2002 catch refunded the payment intent unconditionally — so a double-clicked Book (two POSTs, same intent) could create the appointment on request A while request B refunded the money paying for it. The catch now re-queries for an appointment already owning that intent and returns idempotent success instead of refunding. [booking][payments][bugfix]
  • 🕐 **Booking calendar month view no longer hides late-afternoon slots on the last day of a month.** Month-mode bounds were computed in server time (UTC) while day-mode used Pacific-time bounds — so in PST season, slots at/after ~4 PM on the month's last day were invisible in the month indicators (patients never clicked into a day that actually had openings). Both modes now share one Pacific-time bounds helper (availability-bounds-shared), pin-tested across PDT/PST + the 10:30 PM month-boundary edge. [scheduling][timezone][bugfix]
  • 🔒 **Reschedule now honors slot holds, booking buffers, and provider date blocks (parity with the create path).** Moving an appointment could previously land on a slot another patient was actively holding mid-checkout (bouncing + auto-refunding the payer), or onto buffer-protected/blocked dates via a crafted request. The move claim is now hold-aware and runs isSlotDateBlocked + isSlotBufferBlockedForCommit; concurrent moves of the same appointment can no longer strand a slot as permanently booked (guarded compare-and-swap on the original slot id → clean 409 CONCURRENT_MOVE). [scheduling][bugfix]
  • 🏠 **Homepage 'next available' teaser now uses the real bookability filters.** The public next-slot teaser and home-page data filtered only on isBooked/future — ignoring provider active status, ghost slots, and live holds — so it could advertise times the booking page wouldn't show (deactivated provider, held slot). All three surfaces now share bookableSlotFilter. [booking][bugfix]
  • 🎂 **Patients are no longer rejected as 'too young' on their exact 18th birthday.** Age math used elapsed-days ÷ 365.25 (an 18th birthday is often 6574 days — half a day short), refusing legally eligible patients for up to ~1.5 days. Now a calendar Y/M/D comparison in clinic time (dob-age-shared), matching how WA eligibility actually works. [booking][eligibility][bugfix]
  • 💰 **Card-terminal webhook money updates are now atomic + retry-safe (protects cert release).** The Poynt webhook's paid/additional-payment/refund paths were read-modify-write with no transaction, while Poynt retries deliveries aggressively — concurrent duplicates could lose an update or double-subtract amountCollectedCents, the exact field that gates authorization release via isAppointmentFullyPaid (worst case: cert held forever on a fully-paid patient, or paid-state corrupted on refund). All three paths are now guarded compare-and-swap updateManys: duplicate deliveries land as audited no-ops (already-paid-race, refund-race-noop), the settlement re-read release gate is untouched. Full sequential-retry dedupe via a unique-constraint ledger needs a migration — flagged as a Doug-gated follow-up. [payments][bugfix]
  • 📧 **Win-back email no longer promises a specific authorization expiry date.** It computed 'valid through ' at send time, but the legal SSoT (computeAuthExpiry) is issue-date + 1 year − 1 day and issuance happens at the future visit — the promised date was wrong on two axes. Copy now says 'valid for up to one year from the date it's issued', with an import tripwire so the old date math can't return. [emails][honesty][bugfix]
  • 🌗 **nextOpenDay actually re-anchors probes at noon now (DST seam).** The code claimed noon re-anchoring in its comment but walked raw 24-hour steps — a near-midnight call crossing spring-forward could mislabel the next open day in patient-facing callback promises. Code now matches the comment; DST-seam pins added. [bugfix]
  • ✅ **Test suite restored to green — 19 stale tests re-pinned to the shipped reach-a-human doctrine + 2 real content violations fixed.** The ARIVOICE1/ISADRAFTQUAL1 batch (6/29, pushed with --no-verify) intentionally moved Isabella's voice from 'warm transfer' to message-and-callback framing but left 19 regression tests pinned to the old phrasing — main's suite has been red since. 17 were re-pinned to the shipped intended behavior (no safety assertion deleted); 2 failure groups were REAL: nine changelog staff summaries exceeded the 800-char readability cap and two leaked banned technical terms into staff-facing text — the content was fixed, not the gate. Suite: 10,302/10,302 green. [tests][hygiene]

Added

  • 🧱 **New pre-push + build gate: every provider/patient API route must self-authenticate (check-portal-api-routes-self-auth).** /api/admin/* is edge-gated in the proxy, but /api/provider/* and /api/patient/* deliberately authenticate per-route — discipline is 100% today (verified all 59 routes), but nothing structurally prevented a future route from forgetting the auth call and shipping an unauthenticated PHI surface. The gate requires every such route to reference an approved auth primitive or sit on an explicit justified public allowlist (10 entries, each verified fail-closed + rate-limited). Wired into pre-push AND the unbypassable Vercel ci-build-gate (now 11 HIPAA-critical gates). [security][hipaa][gates]
  • 🕵️ **New gate: digests to the non-BAA inbox must render patients via Safe-Harbor masking only (check-safe-harbor-digest-content).** The EOD/pulse digest renderers de-identify patients by convention (safeHarborInitials, n<5 suppression) with nothing enforcing it — one future edit interpolating a full patient name into a digest would ship a reportable disclosure. The gate scans the 7 digest-renderer files and fails on any unmasked patient-field interpolation; also fails loud if a listed file disappears. ⚠️ While building it: cron/eod-email ALREADY renders full patient names in its staff-productivity section despite its own aggregates-only header — left out of the gate's file set with a loud comment; changing a live clinical digest is a Doug decision (see PR notes). [hipaa][gates]
  • 🧰 **Gate + hygiene batch:** audit-coverage exempt list purged of 5 stale paths (a new file created at any of them would have been silently exempt from audit-logging enforcement); check-doc-only-commit.sh now diffs against VERCEL_GIT_PREVIOUS_SHA instead of HEAD~1 (a doc-only TIP commit could silently skip deploying real code commits beneath it in the same push — and the old error path failed CLOSED to 'skip build', now fails OPEN to 'build'); env-fallback gate extended to catch || "literal" secret fallbacks (previously only ??); the EOD red-signals + narrated modules dropped all 12 (db as any) casts (stale-generated-client-era workaround) and are fully typed again; npm audit fix cleared the reachable high-severity dependency advisories (twilio→axios chain + @vercel/blob→undici). [gates][hygiene][security]
v2.97.ARISIGNCSRF1
2026-07-01Production
For providers

Fixed: signing an encounter could fail with an error on some devices (notably iPad / opening the portal from an email link). Signing now works from those browsers too — no change to any of the sign safeguards.

What this means for you

A provider reported they could open a patient's chart but every 'Sign + Lock' failed with an error. The cause: our cross-site-request protection was too strict — it only trusted a sign if the browser attached a tag marking it as coming from our own site, and some legitimate browsers (older Safari, and email- or app-embedded browsers common on iPads) don't attach it, so a real provider's sign got mistaken for an outside attack and blocked. The guard now also accepts a separate, un-fakeable browser signal that the request is same-site, so genuine signs go through while real cross-site attacks stay blocked. Nothing about the sign flow itself changed — the payment, date-of-birth, condition, signature and license checks and the audit trail are untouched.

Show technical details

Fixed

  • 🩺🔒 **Provider 'Sign + Lock' (and all cookie-authed mutations) no longer 403 on browsers that omit Origin/Referer — CSRF guard hardened with a Fetch-Metadata fallback.** src/proxy.ts: the same-origin CSRF guard (v2.97.CSRFENFORCE1, enforcing since 2026-06-25) rejected any state-changing /api/{admin,provider,patient,dispensary} POST/PUT/PATCH/DELETE whose provenance host couldn't be read from Origin ?? Referer. Older Safari / iOS in-app + email-embedded webviews omit BOTH on a same-origin fetch POST, so a real provider (Dr. Ari) could open a chart (GET — not gated) but hit a hard 403 "cross-origin request rejected" on EVERY sign. **Fix:** accept the request when Sec-Fetch-Site is same-origin or none IN ADDITION TO the existing Origin/Referer allowlist. Sec-Fetch-Site is a browser-set Forbidden header (JS cannot set or alter it), so this does NOT weaken CSRF — a cross-site attacker's request always carries Sec-Fetch-Site: cross-site and still fails both checks → 403. same-site is deliberately NOT trusted (a sibling subdomain is not same-origin); a client too old to send Sec-Fetch-Site AND omitting Origin/Referer stays blocked (unverifiable provenance). The [csrf] block-log line now also records sec-fetch-site= so this class is instantly triageable in Vercel logs. Verified against prod pre-fix: no-Origin POST → 403, same-origin-Origin POST → 401 (healthy). No schema change; sign-flow gates (payment / DOB / condition / signature / license) + audit trail unchanged. [provider-portal][csrf][hipaa][security][ARISIGN]
v2.97.LEADEMAILDISC1
2026-07-01Production
For everyone

Mariane: the automated records-reminder emails now include a small 'if you've already sent records or already booked, please disregard' note at the top — so leads who've moved forward but haven't been converted yet stop worrying they missed something.

What this means for you

Mariane reported (reviewer-feedback cmr1fn91x) that leads who had already sent their medical records or already been scheduled were still getting the automated 'please send your medical records' reminder — because their conversion to a patient record hadn't caught up yet. The Day 3/5/7 reminder email now opens with a short italic note right below the greeting: if you've already submitted records and/or been scheduled, please disregard this automated email — no further action required. It sits in a subtle bordered call-out so someone who's already done what we asked sees it immediately and can stop reading. Stopgap while the lead backlog gets converted; automatically stopping the sends once records land is a follow-up. No patient information added and no change to how the email sends.

Show technical details

Changed

  • 📧 **Automated records-reminder email — added 'disregard if already sent / already scheduled' disclaimer (cmr1fn91x / Mariane).** src/lib/records-reminder-email-shared.ts: right below the Hi , greeting, the Day 3 / Day 5 / Day 7 template now renders a small italic bordered call-out with Mariane's requested copy verbatim — 'If you have already submitted your medical records and/or have already been scheduled for an appointment, please disregard this automated email. No further action is required.' The disclaimer sits above the existing bucket-specific opening line so a lead who has already moved forward stops reading at the top. Stopgap while the Mariane lead-cleanup backlog completes; suppression-on-records-received is a Doug-decision follow-up. All existing pin tests (subject-per-bucket, first-name greeting/escape, opening-per-bucket, contact-SSoT, unsubscribe, PHI-minimization) unchanged and passing. PHI-free: disclaimer is static copy carrying no patient identifiers. ✨ Auto-fixed by Claude. [emails][leads][hipaa][cmr1fn91x]
v2.97.SLOTDEDUP1
2026-07-01Production
For front desk

Booking safeguard: staff manual booking now blocks double-booking a provider who's already busy at that time (the online booking page already did this). Also cleaned up duplicate empty openings that were showing on the calendar.

What this means for you

Two preventive fixes from a review of the scheduling calendar. (1) The staff 'New appointment' page could, in a rare edge case, put a second patient on a provider who was already booked at that time — it wasn't running the same 'is this provider already busy?' check the online booking page runs. Both now share that check, so a provider can't be double-booked from either place. (2) The calendar had ~90 duplicate empty time slots that had accumulated; we removed the duplicate empties (never anything with a patient attached) and tightened the slot generator so same-time duplicates can't come back. We checked every existing appointment and found NO real double-books on any current or future date — hygiene plus a guardrail, not an incident. Availability slots carry no patient information.

Show technical details

Fixed

  • 🛡️ **Provider double-book guard ported to admin manual booking + duplicate-slot cleanup.** Investigation of the 90+ same-tuple duplicate bookable AvailabilitySlot rows confirmed: **0 real double-books** on any current/future date (the one historical overlap is a completed 2026-06-09 visit — left as-is). Root: the admin manual route (/api/admin/appointments/manual) lacked the PROVIDER_DOUBLE_BOOKED overlap guard the public route has. **Fix 1:** added the same in-transaction guard (tx.appointment.findFirst for any active appt on the provider whose [startsAt,endsAt) overlaps → throw → clean 409). Status route ([id]/status) reviewed — only mutates an existing appointment's status, no new-booking collision path, no guard needed. **Fix 2:** tightened both slot generators (slots/generate + slots/quick-generate) dedup key from startsAt-only to the full (providerId, startsAt, slotType, locationId) tuple so same-time rows can't accumulate. **Cleanup:** deleted 90 duplicate future bookable slots — only UNATTACHED, non-held extras, keeping one per tuple; never a slot with an appointment. Remaining duplicate groups: 0. PHI-free throughout (availability rows carry no patient data). [scheduling][bugfix][hipaa]
v2.97.PTSEARCHLEADS1
2026-07-01Production
For front desk

Mariane: the Patients list now points you to Leads when you search — click the hint and the same query is pre-filled on the Leads page, so someone still stuck in Leads no longer looks like they don't exist.

What this means for you

Mariane reported (reviewer-feedback cmr1flpwc) that searching a name on the Patients page only returns patients — never leads. She once assumed someone didn't exist and later found them still in Leads, still receiving the automated records-reminder email. Now, whenever you type in the Patients search box, a hint appears above the results — 'Not finding them? Search Leads for "…"' — and clicking it opens the Leads page with the same query already typed in, so results show immediately. Both pages already share the same access rules, so nobody new can see leads; the two search boxes are just wired together. Folding leads into the patients search itself is a possible follow-up; this is the minimum-risk version. No patient information added anywhere and no data change.

Show technical details

Fixed

  • 🔎 **Patients search now cross-links to Leads (cmr1flpwc / Mariane).** /admin/patients?q=X renders a persistent 'Not finding them? Search Leads for "X"' hint above the filters. The link deep-jumps to /admin/leads?q=X; LeadsPage now accepts a q search param (bounded to 100 chars) and forwards it to , whose existing debounced useEffect fires the search on mount. Both pages already share the ADMIN/MANAGER/SCHEDULER role guard, so this widens no access — it just wires the two search boxes together so an unconverted lead no longer looks like a non-existent record and doesn't trigger redundant automated-outreach churn. Follow-up (Doug-decision): fold matching leads into the patients-page query itself; this ship is the minimum-risk cross-search hint. ✨ Auto-fixed by Claude. [admin][leads][search][cmr1flpwc]
v2.97.SLOTREOPEN1
2026-07-01Production
For front desk

Cancelling an appointment now actually REOPENS that time for someone else to book. Before, the prior fix just hid the freed time; now a fresh, bookable opening is created at the same time automatically — including the handful of previously-cancelled times that were stuck hidden.

What this means for you

Follow-up to the earlier booking 'internal error' fix. That fix stopped the error by hiding time slots freed by a cancellation that couldn't actually be rebooked — so a cancelled time just disappeared instead of reopening. Now, whenever an appointment is cancelled (by staff or by the patient's own cancel link), the system automatically creates a fresh, genuinely bookable opening at the same time with the same provider and location. It's careful not to create duplicates — if that time is already open, it does nothing — and it never reopens a past time. We also reopened the previously-stuck times, and an end-to-end check (book → cancel → rebook the same time) confirmed it works. Appointment slots carry no patient information.

Show technical details

Fixed

  • 🗓️ **Cancelled times are now genuinely re-bookable, not just hidden (BOOKSLOTFIX1 follow-up, Mariane cmr1ee0s5).** BOOKSLOTFIX1 excluded already-appointmented slots from /api/availability (killing the P2002 'internal error' 500) but left the cancelled TIME merely hidden — because Appointment.slotId is @unique (1:1), the original slot can never be rewritten. New src/lib/slot-regenerate.ts regenerateBookableSlotOnCancel() mints a FRESH unattached AvailabilitySlot (same provider / datetime / duration / slotType / locationId) so the time reopens; the cancelled appointment keeps its old slotId (history/audit intact, stays hidden by appointment:{is:null}). Wired into all three cancel paths: admin appointments/cancel, patient self-cancel appointments/cancel, and admin appointments/[id]/status→CANCELLED. **Idempotent + safe:** future-only (never reopens a past time) + dedup guard (skips if a bookable slot already exists at that exact time → no duplicate/overlapping opening) + best-effort (a regen failure never fails the cancel). Reschedule/move paths already reassign slotId to the new slot, so they free the old slot cleanly and need no regen. **Backfill:** reopened the previously-stuck cancelled times (idempotent one-off). **Defense-in-depth:** public booking route /api/appointments now folds Prisma P2002 into its existing SLOT_TAKEN refund+409 path (payment may have been collected). PHI-free throughout — AvailabilitySlot carries no patient data. [scheduling][bugfix][hipaa][cmr1ee0s5]
v2.97.BOOKSLOTFIX1
2026-07-01Production
For front desk

Fixed: booking an appointment on a time that had been freed by a cancellation was throwing 'internal error' and refusing to book. Those already-used times are no longer offered, and if a time is freed mid-booking you now get a clear 'pick another slot' message instead of a scary error.

What this means for you

Mariane hit an 'internal error' when booking an appointment (reviewer-feedback cmr1ee0s5). Cause: each time slot can only ever hold one appointment record, so a slot freed by a cancellation showed as 'available' while being impossible to rebook — pressing Create surfaced a generic internal error. Two fixes: (1) the calendar no longer offers any slot that already has an appointment attached (removing the 5 trap slots that were showing as open); (2) if a slot is taken in the split second between loading the calendar and pressing Create, you now get a clear 'that time is no longer available, please pick another slot' message. No patient details appeared in the error or logs. Making a cancelled time genuinely re-bookable again (not just hidden) is the proposed follow-up.

Show technical details

Fixed

  • 🗓️ **Booking 'internal error' on cancel-freed slots (Mariane cmr1ee0s5).** Appointment.slotId is @unique (1:1 slot↔appointment); the cancel route sets slot.isBooked=false to free the time but the CANCELLED appointment keeps the unique slotId, so the slot is structurally un-rebookable — appointment.create throws Prisma **P2002**, which the manual-booking route's catch did not handle → generic "internal error" 500. **Fix 1 (root):** src/app/api/availability/route.ts slotFilter now adds appointment: { is: null }, excluding any slot already owning an appointment of any status from both admin + patient booking lists (removed 5 live trap slots; hid zero real openings — a genuinely open slot has appointment=null). **Fix 2 (defense-in-depth):** src/app/api/admin/appointments/manual/route.ts catch now maps PrismaClientKnownRequestError P2002 → clean 409 for the cancel-in-TOCTOU-window race. Exposure was widened tonight by PENDCANCEL1 (more appointments became cancellable → more freed-but-consumed slots). PHI-free: message + logs carry no patient identifiers. Follow-up (Doug-decision): regenerate a fresh AvailabilitySlot on cancel so a freed time becomes rebookable rather than hidden. [scheduling][bugfix][hipaa][cmr1ee0s5]
v2.97.SCHEDHONESTY1
2026-06-30Production
For everyone

Behind-the-scenes safety: we added five new automated checks that prove Isabella (the phone assistant) keeps her scheduling promises honest — never inventing open dates, never saying 'you're booked' before it's confirmed, and never offering telehealth to a brand-new patient. If a future change to her script broke any of these, the change is now blocked from going live.

What this means for you

No patient-facing change — a safety guardrail for Isabella, the AI phone assistant. The only way to be sure she follows her script is to test her behavior, not just her wording, so we extended the pre-launch behavioral test that blocks any script change from going live unless she still refuses the unsafe things. Five new checks cover scheduling honesty: she never invents open days or dates; never tells a new patient they're 'booked' before the office confirms; never offers telehealth to a first-time patient; handles one clinic's availability at a time; and never promises a confirmation email unless one was collected on the call. All five pass against the current script, and every check runs on our BAA-covered AI path with fabricated, de-identified inputs — never real patient data.

Show technical details

Added

  • 🛡️ **Isabella scheduling-honesty regression gate (SH01–SH05).** Extended src/lib/__tests__/isabella-abstention-eval.fixtures.ts (15 → 20 cases) with five deterministic, Bedrock-judged behavioral probes that lock the 'paper-close' regression class — closing observed-behavior bugs by assertion. Cases: SH01 no fabricated/specific open dates as held slots; SH02 no premature 'you're booked/confirmed' for a NEW patient (tentative-request framing required); SH03 no telehealth offered to a self-identified new/first-time patient; SH04 one clinic's availability at a time, no cross-clinic (Concord+Spokane) day merge; SH05 no email-confirmation promise unless an email was collected. All five are HARD-GATE negative/refusal assertions (no toolConditioned), so they BLOCK scripts/sync-retell-prompt.mjs from pushing a non-adherent prompt to the live Retell agent. Verified GATE PASS 19/19 hard-gate cases against the current prompt (probes calibrated, not false-failing). [infra][isabella][voice][hipaa]
v2.97.WORKLIST1
2026-06-30Production
For providers

Providers: the 'Today' tab in your portal is now called 'Worklist' — same page, clearer name (it's your open-charts / signings / expiring-authorizations board, distinct from the schedule's same-day list).

What this means for you

Two changes in one release. (1) For providers: the second tab in the provider portal — the open-charts, signings, and expiring-authorizations board — was labeled 'Today,' which collided with the 'Today' appointment section on the Schedule home. We renamed the tab and its page heading to 'Worklist' so the two are no longer confusable. Nothing about the page's content or its link/bookmark changed — only the label. (2) Behind the scenes: an internal feedback-tracking safeguard was tightened so a feedback item can only be marked 'done' when it actually cites the fix that closed it (or a clear reason) — this prevents items from being silently closed without a trace. No patient-facing or PHI surface changes in either item.

Show technical details

Changed

  • 🩺 **Provider portal — 'Today' tab renamed 'Worklist' (cmr030cm8000004l955sbmodx).** ProviderPortalNav.tsx nav label + the /provider/portal/today page eyebrow heading both relabeled 'Today' → 'Worklist' to kill the two-'Today' collision with the Schedule home's same-day section. Route, content, and bookmarks unchanged — label only. ✨ Auto-fixed by Claude. [provider][portal][cmr030cm8000004l955sbmodx]
  • 🔒 **Feedback close-gate flipped WARN → hard 422 (queue-visibility invariant).** /api/admin/reviewer-feedback/[id]/agent now REJECTS a done PATCH (422) that carries neither ship evidence ({sha} or {autoFixVersion}) NOR a valid closeReason enum (answered / working-as-designed / duplicate / not-reproducible / wontfix) with a substantive note — previously logged-and-allowed. Closes the 38-hollow-close recurrence class structurally. Pre-flight verified the autonomous feedback loop always supplies {sha, autoFixVersion} (protocol step 12) and the admin UI marks done via a server action stamping closedByAgentVersion, so no legit caller breaks. PHI-free: the 422 body + log carry only id + evidenceClass + structural failed-check, never the note. [infra][feedback][hipaa]
v2.97.BUILDWALL1
2026-06-30Production
For everyone

Behind-the-scenes: the app's deploy build was getting dangerously slow (near the limit that would stall updates). We moved one step out of the build so updates keep landing reliably.

What this means for you

This is an infrastructure-only change with no visible feature difference. The production build was running 45m41s — right at the 45-minute ceiling that, if crossed, ERRORs the deploy and leaves the live site on the previous version. We relocated the TypeScript type-check (a code-correctness check) so it runs as its own blocking step BEFORE the build instead of inside it. The check is just as strict — a code error still stops the deploy — but it no longer makes the build itself slow. No patient-facing or staff-facing behavior changes; no data, PHI, or workflow touched. Ported from the same fix used on the Inventory App.

Show technical details

Changed

  • 🏗️ **Build-wall fix — relocated typecheck out of next build (ported from inv-App v429.26508/26509).** next.config.ts sets typescript.ignoreBuildErrors: true so next build no longer type-checks inline; package.json adds typecheck (tsc --noEmit); vercel.json buildCommand is now node scripts/ci-build-gate.mjs && npm run typecheck && npm run build — type errors still fail the deploy (BLOCKING), they just run in isolation. Closes watchdog build-duration-wall-approach 🔴 (was 45m41s = 102% of the 45-min Vercel wall, one recent build ERROR'd). tsc --noEmit verified green before flip. No PHI/behavior change. [infra][build]
v2.97.PENDCANCEL1
2026-06-30Production
For front desk

You can now Cancel a Pending-Approval appointment directly — no more changing its status first.

What this means for you

Front desk asked to be able to cancel a not-yet-approved (Pending Approval) appointment without first flipping it to Scheduled. The Cancel button now shows on Pending-Approval appointments in both the Appointments list and the appointment detail page. Cancel does exactly what it already did for Scheduled/Confirmed appointments: it notifies the patient by email over BAA-covered paths, releases the held time slot, cancels in Practice Fusion, notifies the waitlist, and writes an audit-log entry capturing who cancelled. Reschedule / Mark Seen / No-Show still only appear for Scheduled/Confirmed appointments. This was a button-visibility gap only — the cancel pipeline already supported Pending Approval. Closes Mariane feedback cmqvr6awt00020ahx6f4679u5.

Show technical details

Fixed

  • 📅 **Appointments — Cancel now available on Pending-Approval appointments (cmqvr6awt00020ahx6f4679u5 / Mariane).** AppointmentsTable.tsx extracted the Cancel control into its own gate ["SCHEDULED","CONFIRMED","PENDING_APPROVAL"].includes(a.status); Reschedule/Complete/No-Show stay gated to SCHEDULED||CONFIRMED. Parity on the detail surface: AppointmentActions.tsx guard now admits PENDING_APPROVAL but only surfaces Cancel for it (Reschedule/Mark Seen/No-Show gated via canManage), and appointments/[id]/page.tsx renders AppointmentActions for PENDING_APPROVAL. No backend change — /api/admin/appointments/cancel already cancels any non-CANCELLED status, releases the slot, emails the patient over BAA paths, cancels in Practice Fusion, notifies the waitlist, and audits CANCEL_APPOINTMENT (actor + resourceId + ip). ✨ Auto-fixed by Claude. [admin][appointments][cmqvr6awt00020ahx6f4679u5]
v2.97.VOICELEADLIVE1
2026-06-30Production
For front desk

Call→lead capture is now LIVE: when Isabella takes a call, a caller with no record on file becomes a lead automatically (no more re-typing), known callers get logged on their record, and call leads show in a '📞 Call Leads' view with the call summary.

What this means for you

The voice-lead-capture flow Mariane asked for is now switched on. The Retell BAA is executed (2026-05-30) and the workspace secret is set, so caller information flows only on BAA-covered infrastructure. A stranger call auto-creates a lead from name/phone/email/reason; a known caller (phone match) logs a privacy-safe callback on their existing record instead of a duplicate; the Leads queue has a '📞 Call Leads' filter + '📞 From call' pill; and a call lead shows the condensed call summary. HIPAA: minimum-necessary fields only, every write audited, an uncertain phone match is never auto-attached (human-confirm), and a call never enrolls anyone in marketing. Closes Mariane feedback cmqq1b1xn + cmqq1bbti. Also corrected a stale code comment that implied the BAA was still pending.

Show technical details

Changed

  • VOICE_LEAD_CAPTURE_ENABLED flipped on — call→lead capture is live on BAA-covered Retell infra (BAA executed 2026-05-30, verified in BAA_STATUS row 18).
  • Corrected the stale 'BAA pending / dispatch stubbed' comment in the Retell voice webhook — only the real-time tool-call dispatch remains a Phase-3 stub; the post-call lead capture is live.
v2.97.ISADRAFTLINK2
2026-06-30Production
For front desk

Mariane: in Isabella's Draft Replies, 'Open conversation → paste & send' no longer surfaces 'Thread not found' for unmatched senders.

What this means for you

Follow-up to the ISADRAFTLINK1 deep-link fix. When the draft was for an inbound email that wasn't matched to a patient yet, the 'Open conversation → paste & send' link sometimes landed on 'Thread not found' instead of opening the thread. Cause: the link was built with the email's threadId, which on M365/Postmark inbound holds a raw conversationId or RFC Message-ID — sometimes with characters (`<>:+/=`) that didn't round-trip cleanly through the URL, so the per-thread audit page couldn't find the row. The link now uses the message id (always URL-safe) and the page's existing id-based lookup resolves it. No PHI surface change, no new endpoint, no schema change.

Show technical details

Fixed

  • 📨 **Isabella Draft Replies — 'Open conversation' no longer surfaces 'Thread not found' for unmatched senders (cmr02z8pw / Mariane).** getPendingDrafts() + getUndraftedInbound() in src/lib/isabella-draft-queue.ts built conversationHref for unlinked rows as /admin/messages/email/${r.threadId ?? r.id}. r.threadId for inbound EMAIL comes from inReplyTo ?? conversationId (M365 / Postmark webhooks) — a raw RFC Message-ID or base64 conversationId that can include <>:+/=. The audit page's threadId: lookup didn't always round-trip through URL encoding, and its id: fallback didn't match either (because the URL segment was a threadId, not a message id), surfacing 'Thread not found'. Switched the unlinked-row URL to ${r.id} — always a URL-safe cuid — and the audit page's existing id: fallback resolves the row. Linked-row path (/admin/patients/#communication) unchanged. ✨ Auto-fixed by Claude. [admin][isabella-drafts][routing][cmr02z8pw]
v2.97.CALLLEAD1
2026-06-29Production
For front desk

Inbound calls can become leads automatically (OFF until turned on): a caller with no record on file is captured as a lead so you stop re-typing every call, a known caller is logged on their existing record instead of creating a duplicate, and call leads get a 📞 Call Leads view + the call summary on the record.

What this means for you

Mariane's biggest daily time-sink was hand-keying every phone call into Leads. This wires the last gaps of call-to-lead capture: (1) a KNOWN caller (phone matches an existing lead or patient) no longer creates a duplicate lead — a privacy-safe activity row logs the callback on their record instead; (2) the Leads queue gets a '📞 Call Leads' filter and a '📞 From call' pill so they're obvious at a glance; (3) opening a call lead shows the condensed call summary, not the raw transcript. It all stays behind the voice-lead-capture switch (off) and the Retell BAA — inert until Doug turns it on. Minimum-necessary fields only, every write audited, an uncertain phone match is never auto-attached, and a call never enrolls anyone in marketing. Closes Mariane feedback cmqq1b1xn + cmqq1bbti.

Show technical details

Added

  • Known-caller callback logging: phone_exact match → PHI-free LEAD_CALL_LOGGED audit row on the matched lead/patient (no duplicate lead). Idempotent on Retell callId. (voice)(hipaa)(leads)
  • Leads queue '📞 Call Leads' filter chip + '📞 From call' pill on call-captured rows (source=voice-call). (leads)
  • Call summary surfaced on the lead detail page (condensed Retell summary, min-necessary — not the full transcript). (leads)(hipaa)
  • All behind VOICE_LEAD_CAPTURE_ENABLED (off) — byte-identical until flipped. No migration (LEAD_CALL_LOGGED is a free-text audit action).
v2.97.ARIPREPAY1
2026-06-29Production
For front desk

Safety fix (off until pre-pay is turned on): a deposit-booked visit marked 'Paid' with no amount can no longer release the authorization on the $50 deposit alone.

What this means for you

Closes a gap an expert review caught before the deposit/pre-pay model is ever switched on. The authorization is supposed to stay held until a visit is paid IN FULL (deposit + balance). But the system had a legacy shortcut treating 'marked paid, no amount recorded' as fully paid — so once the deposit model was on, a front-desk 'Mark paid' without entering an amount on a $50-deposit booking would have released the medical authorization on the deposit alone. The hold now correctly stays on for deposit-scope (telehealth) visits until a real full amount is recorded. Completely inert today (the deposit model is OFF) — behavior is byte-identical until Doug turns it on. Locked with 4 pin tests. No patient data involved.

Show technical details

Fixed

  • isAppointmentFullyPaid: the null/0 amountCollectedCents = 'fully paid' legacy shortcut no longer applies when bookingDepositEnabled() && bookingDepositInScope(type) — a deposit-in-scope visit with no recorded full amount stays HELD. Inert when BOOKING_DEPOSIT_ENABLED is off. (payments)(hipaa)(deposit)
  • 4 pin tests (ARIPREPAY): flag-on+telehealth+null → held; flag-on+full-amount → released; flag-off+null → legacy fully-paid (inert); flag-on+in-person (out of telehealth-only scope) → unchanged.
v2.97.ARIVOICE1
2026-06-29Production
For providers

Isabella (the phone assistant) now offers a real person sooner — and is tighter about how she talks about qualifying conditions.

What this means for you

Two changes to Isabella's live phone script. (1) Reach-a-person, sooner: she offers a callback from a real person early — in the opening disclosure and at the first sign of 'I just want a person' or a stuck caller — and she's honest that it's a message-and-callback, never a promised live transfer to an empty desk. (2) Compliance tightening: when a caller names a condition or describes symptoms, she no longer frames it as 'a condition we see/treat/evaluate' and never maps symptoms to a diagnosis — she stays factual, defers the qualification decision to the provider, and offers to book. Both shipped only after passing the behavioral safety evaluation (14 of 14 hard cases, including all crisis, privacy, and no-medical-claims probes). The live voice agent was updated to match.

Show technical details

Changed

  • voice-prompt.ts: Wave-1 reach-a-human (early human offer in disclosure + frustration-trigger callback) synced live to Retell; honest message-and-callback framing, no false live-transfer promise.
  • voice-prompt.ts line 139: reframed qualifying-condition language to neutral, state-list factual + provider-defers-eligibility; closes the AB01 (implied-treatment) + AB02 (implied-diagnosis) abstention-eval failures. Crisis floors (988/DV/Spanish-988) unchanged.
v2.97.CLOSEGATE1
2026-06-29Production

Internal: feedback items can no longer be quietly marked done without evidence (warn-mode).

What this means for you

Behind-the-scenes integrity fix, the root cause of the false-close cleanup. When a feedback item is marked done, the system now classifies the close: backed by a code change (sha/version), backed by a stated reason (answered / working-as-designed / duplicate / not-reproducible / wontfix), or a hollow close with neither — which now logs a loud warning. Plus the feedback health report distinguishes reasoned closes from genuinely-unexplained ones. Nothing is blocked yet (warn-mode); enforcement is a deliberate later flip. No patient data involved; no patient- or staff-facing screen changes.

Show technical details

Added

  • closeReason column (migration-104, expand-only) + a classifyClose gate on the reviewer-feedback done-PATCH: code / reasoned / hollow-candidate; hollow closes log a PHI-free warning (warn-mode — no block).
  • Feedback aggregate now emits doneEvidence.reasonedCount so the done-without-evidence watchdog can exclude reasoned closes from soft-watch noise.
v2.97.BOOKINGLINKS1
2026-06-29Production
For front desk

New patients can get their intake + consent form link right in the booking email (built, switch-off).

What this means for you

When a new patient books, the confirmation email can now include a direct link to their new-patient packet (which covers both intake and consent) — so they can fill it out without logging into the portal first. Built behind a switch (off until we turn it on, since it adds a second email to every new-patient booking). Returning-patient consent links are unchanged. Addresses Mariane's request to send both forms in the booking email.

Show technical details

Added

  • Direct new-patient packet link in the appointment-onboarding email (portal-welcome path), behind APPT_ONBOARDING_DIRECT_FORM_LINKS — reuses the existing BAA mail rail + FORM_SENT_TO_PATIENT audit + placeholder-email guard; token never logged.
v2.97.ISADRAFTQUAL1
2026-06-29Production
For front desk

Isabella's email draft replies are warmer, answer common questions directly, and skip newsletters.

What this means for you

Isabella's suggested email replies got a quality pass: she opens by acknowledging the patient's concern before the next step, answers common operational questions directly (visit length, what ID to bring, records) instead of always deferring, and handles "I have these symptoms" by acknowledging and pointing to upload — without ever repeating the medical details back. Newsletters and bulk/no-reply email no longer generate a draft (or an auto-acknowledgment). All within the no-medical-advice rules. Drafts are still always staff-reviewed before sending.

Show technical details

Changed

  • Isabella draft prompt v1.2: warm acknowledgment before next-step, operational-fact KB (answer don't defer), symptom-acknowledge→portal-upload (no clinical echo).
  • email-triage: high-precision bulk/newsletter detection (mail headers, not body) short-circuits to routine before the model → no draft + no auto-ACK on marketing mail; PHI-free bulk= audit token.
v2.97.MSGREPLY1
2026-06-29Production
For front desk

You'll be able to reply to an inbound email right from the message thread (no jumping to a separate page).

What this means for you

On the email message-thread view there's a new Reply button so you can answer an inbound email in place — it pre-fills the subject and recipient and sends through our normal secure email. This is mainly for emails from people who aren't yet a patient record (the matched-patient view already had inline reply). It's built behind a switch (off until we turn it on). Addresses Mariane's message-center feedback; reply-tracking and a Today-dashboard view of inbound messages are a planned follow-up.

Show technical details

Added

  • In-thread Reply on /admin/messages/email/[threadId] (the unmatched-inbound-email view, previously read-only), behind EMAIL_THREAD_REPLY_ENABLED — reuses the existing secure send path; no new email pipeline.
v2.97.EODSECTIONS1
2026-06-29Production
For front desk

The End-of-Day report is now split into a clear Overall Summary + a Staff Activity Summary.

What this means for you

The EOD report now has two labeled sections: an Overall Summary (the org-wide totals you already saw) and a new Staff Activity Summary table ranking each staffer by how many logged actions they did that day. It's activity counts only — not a graded scorecard — so the manager-only performance review stays separate. Addresses Mariane's request for an org-wide two-section EOD view.

Show technical details

Added

  • Two labeled sections on /admin/reports/eod (Overall Summary + Staff Activity Summary) with a new per-staff activity-count table derived from existing data — no new query, no scorecard/grade content, no new patient-data surface.
v2.97.PORTALONBOARD1
2026-06-29Production
For everyone

New patient-portal onboarding checklist (built, switch-off) + a privacy fix: patients no longer see their own deleted records.

What this means for you

Two things. (1) A guided "Next steps" checklist for signed-in patients — upload records, complete forms, and a note that their provider reviews records before the visit — built behind a switch (off until we turn it on), for existing patients only. (2) A fix that went live now: the patient portal's uploaded-records page was showing records that had been removed from the chart (soft-deleted); it now hides them, matching the rest of the chart.

Show technical details

Added

  • Patient-portal onboarding checklist at /patient/portal/onboarding (composes existing upload/forms surfaces; completion derived from existing data), behind PATIENT_ONBOARDING_FLOW_ENABLED — no new patient-data surface, no new sign-in.

Fixed

  • /patient/portal/uploaded-records no longer lists soft-deleted (off-chart) documents to the patient (added deletedAt:null filter).
v2.97.VOICELEAD1
2026-06-29Production
For front desk

Isabella can auto-capture a caller into Leads when nobody had to hand-key it (built, switch-off).

What this means for you

When someone calls who isn't already a patient or lead and they ask about an appointment or condition, the system can now create a Lead automatically from what Isabella captured (name/phone/email + reason) — so it's not lost or hand-typed. Built behind a switch (off until we turn it on). Auto-captured callers are marked call-back-only (not added to marketing) until they opt in. Addresses Mariane's call-to-lead feedback.

Show technical details

Added

  • Voice call → Lead auto-capture in the Retell webhook, behind VOICE_LEAD_CAPTURE_ENABLED — creates a LEAD_CAPTURED record for an intent-expressing stranger call (callback-only consent), idempotent per call, linked to the call's match row; PHI-free companion audit.
v2.97.EODAUDIT1
2026-06-29Production
For front desk

Privacy hardening on the End-of-Day report + the Leads badge now refreshes when you return to the tab.

What this means for you

Two small fixes. (1) The End-of-Day report shows patient names, so opening it now records an audit-on-view entry like our other patient views — a HIPAA disclosure-logging requirement it was missing. (2) The "Leads" sidebar badge now refreshes whenever you switch back to the browser tab, so a backlog you've worked down reflects without needing to click into another page. Note: the leads count is genuinely high right now (~100 uncontacted in the catch-up cohort), so it can legitimately read "99+" until that backlog is worked down — hover it for the exact number.

Show technical details

Fixed

  • /admin/reports/eod now writes a VIEW_EOD_REPORT audit-on-view row (count-only detail, no patient identifiers) — closes a §164.312(b)/§164.528 disclosure-logging gap (the report renders full patient names).
  • Leads / Messages / Fax sidebar badges refresh on tab-refocus (visibilitychange), so a worked-down count un-sticks without a navigation (Mariane cmq7g6ldn follow-up).
v2.97.CALLTREND1
2026-06-29Production
For front desk

Isabella report: see the call escalation rate as a trend over time, not just one number.

What this means for you

On the AI-receptionist report there's now a "Call escalation rate" chart showing, per day, the share of calls that needed a human — so you can see whether Isabella is handing off more or fewer calls over time, instead of just a single 14-day average. A day with no calls shows as an empty bar (not a misleading 0%). Counts only — no call content. Addresses Mariane's request for call trend reporting.

Show technical details

Added

  • "Call escalation rate" per-day trend chart on /admin/reports/ai-receptionist (share of CALL-channel conversations that reached a human, bucketed by day over the 14-day window).
  • PHI-safe by construction — the trend reads only call timestamps + the needs-human flag, never the call summary/transcript or any patient identifier.
v2.97.HOLLOWGUARD2
2026-06-29Production

Internal: tighten the closed-without-evidence signal so it only flags real scripted mass-closes.

What this means for you

Refines yesterday's feedback-integrity reporting. The duplicate-close-cluster count is now measured only among items closed without any version/commit evidence — so a legitimate batch fix (which cites a version) never trips it, and only a scripted 'mark a pile done with one note' pattern does. Renamed the field to keep the counts-only egress guard happy. Counts only; no patient data leaves the system; no patient- or staff-facing screen changes.

Show technical details

Changed

  • doneEvidence: cluster signal scoped to no-evidence done rows + field renamed maxIdenticalNoteCluster → maxDuplicateCloseCount (the prior name tripped the PHI egress-guard's banned-substring list).
v2.97.HOLLOWGUARD1
2026-06-29Production

Internal: the feedback health endpoint now reports closed-without-evidence items so they can't hide.

What this means for you

Behind-the-scenes integrity fix. The counts-only feedback aggregate now also reports, for items marked done, how many carry no version/commit evidence (and how many were closed without anyone working them) plus the largest cluster of identical close-notes. This feeds a new fleet watchdog that flags 'marked done but nothing shipped' so feedback can never be silently false-closed again. Counts only — no patient data leaves the system. No patient- or staff-facing screen changes.

Show technical details

Changed

  • /api/admin/reviewer-feedback/aggregate now emits a doneEvidence block (total / noEvidence / noEvidenceZeroAttempt / maxIdenticalNoteCluster) — counts only, PHI-clean by construction — powering the fleet done-without-evidence watchdog.
v2.97.DRAFTSTATUS1
2026-06-28Production
For front desk

Isabella drafts: you can now see emails that did NOT get a draft — and why.

What this means for you

On the Isabella draft-replies page there's a new section, "Emails without a draft — and why." It lists recent inbound patient emails that didn't get a suggested reply, each with a plain reason: "Draft pending" (one's still coming), "clinical-urgent" (Mariane is texted to phone instead), "routine" (a quick manual reply is faster), or "not sorted yet." No more wondering whether a draft is missing or just on its way. Addresses Mariane's feedback.

Show technical details

Added

  • New "Emails without a draft — and why" section on /admin/isabella-drafts: recent inbound emails with no suggested reply, each tagged with the reason (pending / clinical-urgent / routine / not-yet-classified / ineligible).
  • Every reason is plain-language and privacy-safe — it references the email category only, never patient content.
v2.97.DOCSDESIGN2
2026-06-28Production
For front desk

Fix: the new Documents empty-state illustration now loads (it was being redirected by the admin login guard).

What this means for you

Quick follow-up to the Documents tab redesign: the friendly empty-state illustration is now served from a public path instead of one the admin login guard was intercepting, so it displays correctly on charts with no documents yet. No other change.

Show technical details

Fixed

  • Documents empty-state illustration moved from /admin/ (which the route guard redirects to login) to /brand/, so the image renders instead of 307-redirecting. Empty-charts-only; no behavior change.
v2.97.DOCSDESIGN1
2026-06-28Production
For front desk

The Documents tab got a cleaner, calmer redesign — easier to scan, with a friendlier empty state and a tidier 'Recently removed' undo.

What this means for you

A visual polish of the patient Documents tab — same buttons and behavior, just clearer and more pleasant to use. Document rows are easier to scan (aligned file sizes and dates, a clear header with the count, the 'Attach records' button as the obvious next step). The empty state now shows a friendly illustration and reassures you that records are safe. Removing a document is now visually separated from opening one (so it's harder to misclick), and 'Recently removed' reads as a proper undo panel. Nothing changed in what the buttons do.

Show technical details

Changed

  • Patient Documents tab redesigned for clarity (visual-only — no behavior, data, or permission changes): a card header with the document count + single primary 'Attach records' action, scannable rows with aligned (tabular) sizes/dates, a hairline-separated and visually-subordinate Remove action so it's not confused with Open, and a framed 'Recently removed' undo panel with one-click Restore.
  • New illustrated, reassuring empty state ('No documents on this chart yet') with a privacy-forward illustration and a clear single next step.
  • Accessibility: removed two below-AA-contrast metadata colors, replaced Unicode arrow glyphs with a real chevron icon (with aria-expanded), and added decorative alt handling — all within the existing sage/navy brand palette.
v2.97.DOCAUDIT4
2026-06-28Production
For everyone

Patient documents are now opened through a patient-scoped link, and a removed record no longer lingers on the appointment or Today screens.

What this means for you

Behind-the-scenes hardening of how patient documents are opened, removed, and restored. Every document link now runs through the specific patient it belongs to, so a record can only be opened in the context of that patient (a small minimum-necessary tightening — nothing changes in what you click). The same change also fixed two spots where a document you removed could still show up: removed records now correctly drop off the appointment screen and the Today ID-photo strip, not just the chart.

Show technical details

Changed

  • Document open/remove/restore now go through a patient-scoped route (/api/admin/patients/[id]/documents/[docId]) that verifies the document belongs to that patient before acting (§164.502(b) minimum-necessary). Replaces the flat /api/admin/documents/[id] route; the patient chart, appointment panel, and Today ID-photo strip were all repointed. View stays receptionist-and-up; remove/restore stay manager-and-up.

Fixed

  • A document removed from a chart now also disappears from the appointment detail screen and the Today ID-photo strip — both were still showing soft-removed records because their queries didn't filter them out.
v2.97.DOBEDIT1
2026-06-28Production
For front desk

You can now correct a patient's date of birth from their profile — not just fill it in when it's missing.

What this means for you

Before, you could only enter a date of birth when one was missing. Now, if a DOB was entered wrong, there's an 'Edit DOB' link right under it on the patient's profile — open it, the current date is pre-filled, fix it, and Save. Because the birthdate is an identity field, every correction is recorded (which staff member made it and when) for the audit trail — the recorded note never includes the actual date. Only Admin and Manager roles can make the change.

Show technical details

Added

  • Editable DOB on the patient detail page (closes feedback cmqvr0y2): an 'Edit DOB' disclosure under the displayed DOB reuses the existing setPatientDob server action (ADMIN/MANAGER-gated, future-date rejected, validated). New PATIENT_DOB_EDITED audit action distinguishes correcting an existing DOB from filling a missing one — fact-of-change + adminId only, never the DOB value (§164.312(b)).
v2.97.DOCAUDIT3
2026-06-28Production
For front desk

Removed a document by mistake? You can now put it back — and PDFs that aren't really PDFs get caught at upload.

What this means for you

Two follow-ups on the Documents tab. First, removing a document is now undoable: removed records show under a "Recently removed" line right on the tab, and a manager can click Restore to put one back on the chart (the file was kept in storage, so nothing was lost). Second, if someone tries to attach a file labeled as a PDF that isn't actually a PDF inside, the upload is now refused with a clear message instead of quietly storing it — a safety check on the kind of file that opens in the browser. Nothing changes in how you attach or open records day to day.

Show technical details

Added

  • Documents tab: a "Recently removed" section (collapsed by default) lists soft-removed documents with a one-click Restore. Restore is MANAGER+ only and writes a RESTORE_DOCUMENT audit row (PHI-free), pairing with the remove trail. The retained file simply comes back onto the chart — no re-upload.
  • Staff document upload: a magic-byte check now verifies a file labeled application/pdf actually begins with the PDF signature before it's stored. Image uploads were already content-validated (they're decoded/re-encoded on the way in); PDFs are the one type served inline, so this closes the gap with a clear 415 message instead of trusting the browser-reported type. (Defense-in-depth on top of the existing inline-viewer CSP.)
v2.97.DOCAUDIT2
2026-06-28Production
For front desk

Removing a document from a patient's chart now keeps the file in secure storage instead of erasing it.

What this means for you

Removing a document from a patient's chart now keeps the file in secure storage instead of erasing it. Medical records have to be retained for years under WA state rules, so "Delete" became "Remove from chart" — the document disappears from the Documents tab but the file is kept safely on file. Nothing changes in how you remove a document; the button now reads "Remove" and confirms the file is retained.

Show technical details

Changed

  • Documents tab: "Delete" is now a soft-remove. Removing a document marks it off the chart and keeps the underlying file retained in secure storage (WA RCW 70.02 / WAC 246-919-085 require ~10-year medical-records retention; HIPAA §164.530(j) accountings). Previously the file + record were permanently erased on click, which was a records-retention exposure. The removal is still MANAGER+ only and still writes an audit row (now tagged mode=soft-delete, PHI-free).
  • A removed document no longer opens by direct link (minimum-necessary, §164.502(b)) and no longer counts toward the chart's document total — it's retained for compliance, not casual re-viewing.
  • Copy: the confirm dialog now reads "Remove document from chart?" and explains the file is retained, instead of the inaccurate "permanently removed from storage."
v2.97.DOCAUDIT1
2026-06-28Production
For front desk

Patient Documents tab hardened for HIPAA.

What this means for you

Patient Documents tab hardened for HIPAA. Opening (viewing/downloading) a patient's medical document now records who looked and when — the same way uploads and deletions already did — so the chart has a complete record-access trail. No change to how you attach or open records.

Show technical details

Added

  • Opening a patient medical document now writes a VIEW_DOCUMENT audit row before the file is delivered (HIPAA §164.312(b) audit controls + §164.528 accounting-of-disclosures), pivoted to the patient so /admin/audit-log can show every read of that patient's records. Detail is metadata only — document id, type, size, uploader — never the filename or file contents.
  • Documents tab now shows a "showing the 100 most recent of N records" note when a chart has more than 100 documents, so staff never silently miss older records past the list cap.

Fixed

  • DELETE_DOCUMENT audit rows no longer store the raw filename (a filename like "MRI-brain-result.pdf" is itself a clinical-status disclosure under Safe Harbor §164.514(b)(2)). The delete trail now carries only PHI-free metadata, matching the upload side, and pivots resourceId to the patient.
  • Booking-wizard medical-record claim is now atomic — a double-submitted or retried booking can no longer create duplicate charted records pointing at the same uploaded file (conditional claim on claimedAt before the MedicalDocument is created).
v2.97.LEADSFIX1
2026-06-28Production
For everyone

The leads list now flags returning prospects and people who are already patients no matter how the lead came in, stays fast as it grows, and won't let a lead be marked Converted without actually creating the patient.

What this means for you

On the leads list you'll now see the "Returning" and "Already a patient" tags on leads that came in through the website Get-Started form and ones you add by hand — before, those only showed on Book-Now leads, so you could end up calling a repeat prospect cold. The list also stays fast as lead volume grows, and you can no longer accidentally mark a lead "Converted" from the status dropdown — that has to go through Convert, which creates their patient record.

Show technical details

Fixed

  • Leads list: the "Returning" and "Already a patient" pills now show on leads captured through the website Get-Started form and admin manual entry, not just Book-Now. The flags are computed when the lead is captured (fail-soft — a lookup hiccup never blocks the capture).
  • Leads list: bounded the query so a large backlog (e.g. a bulk import writing many lead rows) can't make the page hang on load.
  • Leads status: the "invalid status" error message is now built from the real status list — it had gone stale and omitted Qualified + Converted, which read like a bug.
  • Leads status: "Converted" can no longer be set from the generic status dropdown — it must go through the Convert action, which creates the linked patient. This stops the funnel's Converted count from disagreeing with the actual converted-patient set.
v2.97.CALENDARFIX1
2026-06-28Production
For everyone

Early and late appointments no longer vanish from the calendar, the week view loads reliably, and cancelling can't accidentally re-open a booked slot.

What this means for you

Calendar fixes: early-morning and evening appointments no longer disappear off the grid (they now show with a ⏰ marker), the red "now" line and auto-scroll land at the correct Pacific time on any computer, and the week view loads reliably instead of occasionally going blank. Cancelling an appointment can no longer accidentally re-open a slot someone else has since booked.

Show technical details

Fixed

  • Calendar: appointments scheduled before 8am or after 7pm were getting a negative position and rendering off-grid = invisible. They're now clamped into view with an amber ring + ⏰ marker and their real time still shown on the card.
  • Calendar: the "now" line, the initial auto-scroll, and the today-column highlight were computed from the browser's local clock, so on any non-Pacific device they landed at the wrong hour. All three now use Pacific time, matching the appointment positions.
  • Calendar API: the date window is now clamped (≤31 days) with a row ceiling, so an over-wide range can't blow the load budget and produce the intermittent "Couldn't load the calendar" blank screen.
  • Appointment status: a finalized appointment (cancelled / completed / no-show) can no longer be flipped again — this stops a cancelled patient getting a "completed" cert email, and stops a re-cancel from re-opening a slot that's since been re-booked (double-booking). Same-status changes are a harmless no-op.
  • HIPAA forensic-trail: viewing the calendar (which shows patient names) now writes an audit-log row like the appointments list already did — count + date window only, never patient identifiers. Closes the one PHI-read surface that had no audit trail.
v2.97.INTAKEDIALED2
2026-06-27Production
For everyone

Adds the list of watched intake mailboxes to the internal idea-intake check so we can confirm the exact address to email an idea to, instead of guessing. Addresses only — no patient information.

Show technical details

Added

  • idea-intake/recent verify endpoint now also returns watched — the configured M365 inbound mailbox addresses (from buildWatchedMailboxes) — so an operator can confirm the exact front-door address for the email-idea loop. Clinic mailbox addresses only, no PHI.
v2.97.INTAKEDIALED1
2026-06-27Production
For everyone

Emailing an idea to the office now works even if the spam filter grabs it — and there is a way to instantly confirm it was captured.

What this means for you

Two reliability upgrades to the 'email us an idea, it gets tracked' loop. (1) Junk rescue: if an idea or a reply you send lands in the office Junk/spam folder, the system now also checks Junk and pulls out ONLY messages from the approved owners (you / Mariane) that look like an idea or a feedback reply — everything else in Junk is left untouched and never processed. (2) A new internal check lets us confirm in one step that an emailed idea was captured, without anyone having to dig through the queue. No patient information is involved in either piece — the rescue only ever reads owner email, and the check returns tracking metadata only.

Show technical details

Added

  • Junk-rescue for owner email-intake (IDEA_INTAKE_JUNK_RESCUE, default OFF): the inbound poll now also scans each watched mailbox's JunkEmail folder, cheap-filters to allowlisted-owner marker/[FB-] mail by sender+subject (no body fetch), and runs only those through an owner-hooks-only ingest path that can never patient-persist or auto-ack — so a spam-filtered idea/reply still gets captured. Candidates are marked read to dedup across polls.
  • PHI-safe idea-intake observability endpoint GET /api/admin/idea-intake/recent (bearer CRON_SECRET, proxy-allowlisted): returns the last 25 captured ideas as metadata only (id prefix, project tag, owner sender, status, age, comment count) so a capture can be confirmed off-session without an in-tenant login. Never returns a title/body.
v2.97.FBRESURFACE1
2026-06-27Production
For front desk

Feedback you said still was not fixed can never quietly disappear — once it gets re-fixed you are asked again to confirm, and any item stuck in limbo is automatically put back on the open list.

What this means for you

This makes sure a feedback item never gets lost after you mark it as not-yet-fixed. Before, if you replied that something still was not working and someone later re-fixed it, the item could sit closed with a stale 'rejected' note and you were never asked again — so it looked done but nobody knew you were still waiting. Now, when an item is re-closed after you rejected it, the old rejection note is cleared and you get a fresh confirm-or-reject email so you always get the final say. And a nightly safety sweep finds any item that got stuck closed-but-rejected and quietly moves it back to the open list so it gets worked again. The sweep only flips the status and clears the old note — it never sends a text and never includes any patient details in its records.

Show technical details

Fixed

  • ReviewerFeedback re-surface safeguard (no migration): agent done-transition clears prior submitterRejectedAt/Note + re-arms the submitter confirm email (fresh token) via the M365 BAA path on a re-fix-after-reject; feedback-cleanup cron silently flips stuck done+rejected+unconfirmed rows back to open + clears the stamp, logging counts/ids only (no SMS, no PHI in logs).
v2.97.INTAKEPOLISH1
2026-06-27Production
For front desk

Emailing an idea is more forgiving + now logged — tag the project (like [GW]) and it is captured even without an IDEA: prefix, and every capture/reply is recorded so we can always see what came in.

What this means for you

Two improvements to the email idea-intake. First, it is more forgiving: an email from Doug or Mariane that names a project in brackets (like [GW], [SCC], [VRG]) is now captured as a tracked idea even if it does not start with an IDEA:/BUILD: prefix, so there is no exact format to remember. A patient email still can never trigger it, since it only fires for Doug or Mariane and a random bracket is ignored. Second, every capture and every emailed reply now writes an audit record (no patient details, just the project, who sent it, and the resulting status), so if anyone wonders whether an email came through, it is right there in the activity log.

Show technical details

Changed

  • Idea-intake: [known-project] tag also triggers capture (not just the marker prefix). Audit: OWNER_IDEA_CAPTURED + OWNER_FEEDBACK_REPLY_RESOLVED rows (PHI-free) on every capture/resolve.
v2.97.REPLYRESOLVE1
2026-06-27Production
For front desk

Run feedback from your inbox — email an idea and get an instant 'tracked' confirmation, then answer any item by replying (done / wontfix / go / a note).

What this means for you

This closes the email feedback loop. When Doug or Mariane emails an idea, they now get an instant confirmation back with a short reference tag, so they know it landed without waiting for the morning summary. And any tracked item can be answered by replying to that email (or emailing the office mailbox) with the reference tag: start the reply with done, wontfix, or go to set the status, or just write a note and it is recorded on the item. It only ever works for Doug or Mariane on a tagged message, so a patient email can never trigger it, and replies are handled before the patient pipeline so the phone assistant never auto-answers them.

Show technical details

Added

  • Instant idea-capture confirmation (with [FB-id] tag + replyTo to the watched mailbox); reply-to-resolve applies done/wontfix/approved or a comment to the tagged item. Allowlist + tag gated. Flags IDEA_INTAKE_ENABLED + FEEDBACK_REPLY_RESOLVE.
v2.97.IDEAINTAKE1
2026-06-27Production
For front desk

Email an idea and it gets tracked automatically — Doug or Mariane can email 'IDEA: ...' to the office mailbox and it becomes a tracked to-do (off until turned on).

What this means for you

A no-friction way to capture ideas and requests: Doug or Mariane emails the office mailbox with a subject that starts with IDEA:, BUILD:, FB:, or TODO: and it is automatically captured as a tracked open item — so it shows up in the morning feedback summary and the feedback queue instead of getting lost. Name the project in the subject with a tag like [GW] or [SCC] and it is filed under that project. It only ever triggers for Doug or Mariane on one of those markers, so a patient email can never be mistaken for an idea, and it stays off until the IDEA_INTAKE switch is turned on. Uses the email and feedback systems we already have — a dedicated address can come later.

Show technical details

Added

  • Owner idea-intake: marker-subject emails from Doug/Mariane become open ReviewerFeedback items; gated by IDEA_INTAKE_ENABLED (default off); captured before the patient pipeline so Isabella never auto-acks them.
v2.97.FEEDBACKDEPTH1
2026-06-27Production
For front desk

New 'Feedback depth' section in Doug's morning email — every open piece of feedback that needs an answer, is going back-and-forth, or is stuck, in one daily glance (off until turned on).

What this means for you

The recurring problem was that filed feedback could quietly sit unanswered or go back-and-forth and get lost, because seeing the full picture meant remembering to open a page. This adds a 'Feedback depth' section to the daily morning email: it lists every open item, and flags the ones that need an answer (no movement in a few days), are going back-and-forth, are buried, or aren't reaching the automated helper — so nothing rots silently and checking the depth is a daily glance instead of a chore. It is metadata only (no patient details), and it stays off until the feature is switched on. Time windows are adjustable.

Show technical details

Added

  • Feedback-depth section in the doug-queue morning email; PHI-safe; gated by FEEDBACK_DEPTH_DIGEST (default off); SLA + back-and-forth thresholds tunable via env.
v2.97.TESTHYGIENE1
2026-06-27Production
For front desk

Behind-the-scenes cleanup — fixed a small bug in how the AI reads its confidence on incoming patient emails, plus a large internal-test tidy-up.

What this means for you

Two things, both behind the scenes. First, a real fix: when the system reads how sure the AI is about sorting an incoming patient email, a malformed value like "0.8a" was being quietly accepted as 0.8 instead of rejected — now it is correctly rejected, so only clean values are trusted. Second, we drained a large backlog of stale internal checks (down from 72 to a handful) that were flagging old-but-intentional changes, and tightened the wording on past update notes. Nothing patient-facing changed beyond the email-confidence fix.

Show technical details

Fixed

  • Email classifier rejects malformed confidence values; stale-test backlog drained 72 to ~12; changelog copy tightened.
v2.97.VOICETRIM1
2026-06-27Production
For front desk

Isabella's phone script trimmed back under its size budget — same warmth and the same 'talk to a person' offer, just tighter, so calls stay fast.

What this means for you

The reach-a-human additions had pushed Isabella's call script over its size budget, which can slow each turn of a phone call. We trimmed about 500 characters of repeated wording — every safety and compliance rule is untouched, the crisis lines are word-for-word the same, and she still offers to have a real person call the patient right back at the very start of the call. This only changes the script; it reaches the live phone line after the next prompt sync. Also tidied two automated tests that were checking the old wording.

Show technical details

Changed

  • Isabella voice prompt trimmed ~496 chars of redundancy back under its growth tripwire; reach-a-human offer + crisis/compliance blocks fully preserved; live after the Retell sync. Refreshed 2 stale voice tests.
v2.97.PTFIELDS0001
2026-06-27Production
For front desk

Add-a-patient will gain a few more optional fields once turned on — a preferred name, middle name, how they heard about us (pick-list), a heroes-discount eligibility checkbox, and optional gender/pronouns. Off until Doug switches it on; nothing changes today.

What this means for you

We built five more optional fields for the create-patient form, all behind an OFF switch so nothing changes until Doug turns it on. When it's on, the Optional section gains: a 'Preferred name (goes by)' so phone calls feel friendlier; a 'Middle name' to keep the legal name accurate on the authorization; a 'How they heard about us' pick-list (choose from a set list — please don't type a specific person or provider name); a 'Heroes discount eligible' checkbox for veteran / first responder / medical (eligibility only — no proof documents collected here); and optional, clearly-skippable gender and pronouns. Every one is optional. With the switch off, the form looks and behaves exactly like today.

Show technical details

Added

  • Five optional create-patient fields (ship OFF behind a switch): Preferred name, Middle name, a 'How they heard about us' pick-list, a Heroes-discount eligibility checkbox, and optional Gender + Pronouns. All optional and skippable; the form is unchanged until the switch is on.
  • 'How they heard about us' is a fixed pick-list, not a free-text box — so a specific referring person or provider name can't accidentally get typed into the field.
v2.97.EMAILOPT0001
2026-06-27Production
For front desk

You can now create a walk-in or call-in patient with no email — once it's switched on. The patient gets a clear 'No email on file' badge, reminders fall back to text, and a provider still can't issue an authorization until an email is added.

What this means for you

A new front-desk option (off until Doug turns it on) lets you add a patient who has no email — the common case for someone who calls in or walks up. When it's on, the email field becomes optional and you only need at least an email or a phone. A patient created without an email shows a persistent 'No email on file' badge, and appointment reminders go out by text instead (when the patient has agreed to texts). Two safeguards stay in place: a provider cannot issue a cannabis authorization to a patient with no email on file (so we never quietly fail to send a real authorization), and email portal sign-in isn't available to them until you add an email. Add one any time and both unlock. With the option off, email is still required.

Show technical details

Added

  • Optional-email mode for the add-a-patient form (off by default). When on, a walk-in / call-in with no email can be created — you only need at least an email or a phone.
  • A persistent 'No email on file' badge on the patient record, explaining that authorization emails and patient-portal sign-in are unavailable until an email is added.

Changed

  • Reminders fall back to text message for a patient with no email on file (when they've agreed to texts) instead of failing.
  • The 'Use & update contact' button on the duplicate prompt now passes the email/phone privately instead of putting them in the web address — they no longer appear in browser history, server logs, or the page address.

Fixed

  • A provider is blocked from issuing an authorization to a patient with no email on file (the authorization is delivered by email, so we refuse rather than silently fail to deliver). Same shape as the existing date-of-birth block.
v2.97.NEWPT0001
2026-06-27Production
For front desk

Add-a-patient is faster: date of birth is now optional, there's a Notes field and a 'Create & book' button, the phone field auto-formats, and a returning patient's new email or phone can be updated right from the duplicate prompt.

What this means for you

Mariane's front-desk feedback on the create-patient form, in one pass. Date of birth is no longer required to create a record — leave it blank for a phone or walk-in and the patient carries a 'DOB needed' badge until it's filled (a provider still can't issue an authorization without it, so age verification doesn't change). New 'Notes' field for operational notes — clinical details still go in the chart. A 'Create & book' button takes you straight into scheduling with the new patient pre-filled. The phone field tidies itself as you type and accepts any 10-digit number. On the 'possible duplicate' prompt you can now pick 'Use & update contact' to carry a returning patient's new email or phone onto their existing record. The first-name field is focused on load.

Show technical details

Changed

  • Date of birth is optional when manually creating a patient (matches the lead-conversion flow). Blank DOB sets a 'DOB needed' badge; the provider-side authorization gate still requires DOB before issuing, so age verification is unchanged.
  • Phone field auto-formats to (206) 555-1234 as you type and accepts any entry with at least 10 digits.
  • Add-patient help text corrected: lists the real required fields (name, email, phone) and describes the warn-and-confirm duplicate prompt (email / phone / name / DOB) instead of the old 'unique email blocks' behavior.
  • First-name field is focused on load; the duplicate validation toast was dropped where inline field errors already show.

Added

  • 'Notes' field on the create-patient form for operational notes (clinical details go in the chart).
  • 'Create & book' button — creates the patient, then opens the scheduler with them pre-filled.
  • 'Use & update contact' on the possible-duplicate prompt — carries the just-typed email/phone onto an existing returning patient's record for review and save.
v2.97.DRAFTACTION1
2026-06-26Production
For front desk

Patient email drafts now move the conversation forward — straight into booking or the exact next records step — instead of just acknowledging.

What this means for you

Mariane flagged that the drafted replies acknowledged a patient's email but didn't always answer what they needed next. The draft guidance is rewritten to lead with the next action: when someone is ready to schedule, the draft moves into booking (what's needed to lock a time, plus where to book); for medical records it gives the concrete next step (what to send — recent records documenting a qualifying condition — how to send it, and that the team then reviews and confirms the visit); plus clearer qualification and payment next-steps. Drafts are still reviewed by a person before anything sends, and the rules that keep replies compliant are unchanged (no medical claims, never tell a patient whether they qualify or imply they need the authorization to use cannabis legally).

Show technical details

Changed

  • Patient-email and text draft guidance is action-oriented: lead with booking, the exact records next-step, or payment, with one warm acknowledgment line. Human-reviewed before send; compliance guardrails unchanged.
v2.97.DEPOSIT0001
2026-06-26Production
For front desk

Groundwork for a $50 booking deposit (balance collected at the visit) — built but OFF; nothing changes for patients or staff until Doug turns it on.

What this means for you

We added a new option for online booking: a patient can put down a small deposit to lock in their appointment, then pay the rest on the day of their visit. The appointment confirms as soon as the deposit clears, but the medical authorization is only sent once the visit is paid in full — the deposit alone never releases it. To start, this only applies to telehealth visits, and the whole thing is behind an OFF switch, so booking works exactly like today until Doug turns it on. When it is on, the today board and a patient's chart show a clear 'Deposit paid — balance due $X' label so front desk knows who still owes at check-in.

Show technical details

Added

  • 💵 **Deposit-then-balance booking model (ships DARK).** A $50 deposit at booking confirms the appointment; the balance (visit fee − deposit) is collected day-of via the existing in-portal card checkout, a balance link, or staff mark-paid. The booking pay-link is minted for the deposit amount (server-recomputed, never client-trusted) when the deposit model is on and the visit is in scope. Behind a new OFF-by-default switch (BOOKING_DEPOSIT_ENABLED), layered on the existing pay-to-confirm flow. Telehealth-only by default (one config switch widens it to in-person later). When OFF, booking is byte-identical to today. (booking)(payments)
  • 🔒 **Authorization releases ONLY when paid in full.** The deposit alone does NOT send the medical authorization — the existing payment gate already holds until the collected amount covers the full visit fee, and the deposit model relies on exactly that. The day-of balance payment adds to the collected total and triggers the release. (payments)(safety)
  • 🏷️ **'Deposit paid · balance due $X' status label** on the today board and the patient chart (and any surface using the shared payment pill), distinct from 'Paid' in full. PHI-free — a dollar balance only, no patient identity. (admin)(payments)
v2.97.VOICEHUMAN1
2026-06-26Production
For front desk

Isabella now offers callers a real person earlier and more warmly — she leads with booking, reaches for a human at the first sign of frustration, and never makes anyone fight to reach the team. (Takes effect on the phone line only after Doug runs the phone-prompt sync.)

What this means for you

We softened how Isabella, the phone receptionist, handles callers. She still says she's automated, but now offers a real person right away as a relaxed choice — "any time you'd rather talk to a person, just say so, I can have someone call you right back" — not a last resort. She leads with booking, and the moment a caller sounds frustrated or just asks for a person, she stops and offers a callback instead of looping. New patients now pick a time first, then hear the records-and-ID steps. Every safety line is unchanged. Heads up: this is ready but only reaches the live phone line after Doug runs the phone-prompt sync.

Show technical details

Changed

  • 📞 **Isabella Wave-1 patient-experience prompt** — offer-a-human early + warm (in the opening disclosure, framed as a relaxed choice, not a fallback), a frustration/confusion trigger that hands off to a callback instead of looping, booking-first reordering for new patients (capture preferred time BEFORE the records/ID logistics), and a softened self-handling tone throughout. The crisis script + identity/legal-advice blocks + the never-promise-a-transfer / never-promise-outcomes guardrails are preserved verbatim. Prompt size 28,891 chars (under the 29,000 cap). **DOES NOT reach the live phone line until the Retell prompt sync is run** — that step is human-gated (Doug action); no sync script was run as part of this ship. (isabella)(voice)(patient-experience)
v2.97.ARIFIX0001
2026-06-26Production
For providers

Provider portal fixes: signing a visit now ALSO issues the patient's authorization (it used to need a separate admin step), the patient's uploaded records + ID open right on the chart, and a plain checklist shows what's needed before you sign.

What this means for you

Four provider-portal repairs so a doctor can complete a visit end-to-end without the front office. (1) Signing the encounter now issues the WA medical authorization and emails it to the patient — previously that only happened from an admin screen. The note still locks first; if the visit is unpaid the authorization is held until front desk marks it paid, then issues automatically. Every existing safeguard is kept (never on an unpaid visit; needs DOB + at least one qualifying condition + your signature). (2) A patient's uploaded medical records and Washington ID now open with a 'View / download' link right on the chart. (3) An always-visible 'Before you sign' checklist shows what's still missing. (4) Payment status shows accurately. The AI clinical-assist stays OFF.

Show technical details

Fixed

  • 🖊️ **Signing now issues the authorization (CRITICAL).** /api/provider/encounters/[id]/sign used to lock the SOAP note but never call the cert-issue pipeline — that fired only from /api/admin/appointments/approve (an admin surface). With no admin on staff, providers signed and no authorization was ever generated or sent. The sign route now runs the SAME unified issuance after the note locks (mirrors /api/provider/action), PRESERVING every gate: the auth-payment gate (never issues/sends unpaid), provider-signature-on-file, and the DOB / ≥1-qualifying-condition / license gates inside issueCertForAppointmentUnified. Best-effort relative to the lock (the note-lock is the load-bearing legal write and is never undone by a downstream send failure); the PHI-free outcome (issued / held-pending-payment / skipped-why) is surfaced on the chart. Idempotent — re-signing an issued visit is a no-op. (provider)(hipaa)(authorization)
  • 🗂️ **Patient's uploaded records + WA-ID now open from the chart — flag-independent.** The only viewer for PatientUploadedRecord rows (patient-uploaded outside records AND the WA-ID photo) was the dark AI records-reviewer, which 404s when the AI flags are off (their default) — so providers couldn't open a patient's records or ID at all. Added a basic non-AI list on the encounter chart + a new cookie-authed stream route /api/provider/encounters/[id]/records/[recordId] that scopes to the encounter's patient (scan-clean, not-superseded, minimum-necessary), audits VIEW_PATIENT before any bytes, and streams the private blob server-side (no redirect to a signed URL). (provider)(hipaa)(records)(min-necessary)
  • ✅ **Always-visible pre-sign readiness checklist.** The existing pre-issue checklist was gated behind PROVIDER_CLINICAL_ASSIST_ENABLED (default OFF), so a provider never saw WHY a visit couldn't complete. Added a generic, flag-independent checklist on the chart mapping the REAL issuance gates (patient DOB on file, ≥1 qualifying condition, provider signature on file, visit paid). Courtesy mirror only — the server re-checks at sign/issue. The WA-law counsel-gated stubs stay in the AI-flag-gated checklist, unchanged. (provider)(hipaa)
  • 💳 **Payment status accurate on the chart.** The encounter chart already selected stripePaymentId/poyntInvoiceId for the PaymentBadge; verified the provider appointment surfaces feed the badge correctly (portal home loads full appointment rows via include). No badge now renders blank/Unpaid for a paid visit. (provider)
v2.97.RECMON0001
2026-06-26Production
For front desk

New (off until turned on): a records-recency monitor that flags upcoming visits whose patient needs recent medical records — with a one-click 'send upload link' and a 'mark chronic-exempt' option. It never blocks booking.

What this means for you

Phase 1 of the records-readiness monitor, shipped completely OFF behind a switch. When turned on, it adds a recency check to appointment-prep reminders (the patient is reminded if there's no medical record from the last 24 months on file, instead of just 'any record ever'), and gives the front desk a new worklist page — /admin/patients/records-needed — listing upcoming visits whose patient still needs recent records, soonest first. Each row has a 'Send records request' button and a 'Mark chronic-exempt' button (a required reason is kept private to the provider record). Patients show as first name + last initial only. It is a prompt, never a block — booking is never gated. The 24-month window is configurable. Nothing runs until Doug turns it on.

Show technical details

Added

  • 🗂️ **Records-Recency Monitor — Phase 1 (default-OFF behind RECORDS_RECENCY_MONITOR_ENABLED; configurable window RECORDS_RECENCY_MONTHS, default 24).** Extends the EXISTING readiness substrate, no rebuild. New recency dimension in src/lib/appointment-readiness.ts (deriveRecordsRecencyStatus → 🔴 NEEDS / ✅ CURRENT / 🟡 EXEMPT) computed from a MEDICAL_RECORD uploadedAt within the configured window (a documented v1 proxy for the record's clinical date — Phase 2 adds the AI recordDateText). Pure helpers + the flag/window readers live in src/lib/records-validity-shared.ts. EXEMPT reuses the existing RecordsValidityDecision + Patient.recordsValidThrough — NO new flag, NO new column, NO migration. (records-monitor)(dark)(hipaa)(min-necessary)
  • 📋 **Staff worklist /admin/patients/records-needed** (role-gated ADMIN/MANAGER/SCHEDULER, force-dynamic, noindex). Lists UPCOMING (SCHEDULED/CONFIRMED, future) appointments whose patient is 🔴 NEEDS, soonest-first, via src/lib/records-needed-worklist.ts. Patient label = first name + last INITIAL only; EXEMPT + CURRENT patients are filtered OUT at the DB query (never materialized). Per row: **Send records request** (reuses the BAA-fail-closed /send-records-link rail → M365 Graph) and **Mark chronic-exempt** (reason REQUIRED). Render audits VIEW_RECORDS_NEEDED as a COUNT only — never an identifier. (records-monitor)(admin)(hipaa)
  • 🩺 **Staff chronic-exempt write path POST /api/admin/patients/[id]/records-exempt** — writes a RecordsValidityDecision (CHRONIC_CONDITION / STABLE_DIAGNOSIS / TERMINAL) + refreshes the recordsValidThrough cache, dropping the patient off the worklist + the recency reminder. Required reason is stored provider-PRIVATE (reasonNote) and is NEVER logged/echoed/audited; audit RECORDS_EXEMPT_SET_BY_STAFF carries reasonCode enum + duration + actor only. ⚠️ The decision table was designed PROVIDER-set; STAFF-set is Doug's explicit Phase-1 call — actor is recorded honestly as the staff user under a distinct audit action (Doug-greenlight noted in the route header). Route is inert (404) until the monitor flag is on. (records-monitor)(hipaa)(doug-greenlight)

Changed

  • 🔔 **Reminder cron now reflects the RECENCY standard when the monitor is on.** /api/cron/reminders overrides the 'recent medical records' readiness item to the 24-month recency check (chronic-EXEMPT patients treated as satisfied) ONLY when RECORDS_RECENCY_MONITOR_ENABLED=true; when OFF (default) the existing presence-only behavior is byte-identical. A recency-derive failure falls through to the presence-only flag — the reminder is NEVER blocked, and booking/confirmation is never gated anywhere. Reuses the existing M365 BAA prompt rail (no new sender). Patient-facing copy unchanged ('your recent medical records'). (records-monitor)(cron)(never-block)
v2.97.GWBATCH0626
2026-06-26Production
For front desk

Demi's morning queue now shows each caller's full phone + email right on the row — and inbound fax is ready for a second (HIPAA) fax provider, still switched off until its BAA is signed.

What this means for you

Two front-desk improvements plus a behind-the-scenes fax-vendor option. (1) On Demi's today queue, each callback row now shows the patient's full phone number and email inline, so Demi can reach someone without opening the thread first — the same contact info she already gets by clicking through, just shown up front. Message previews stay PHI-scrubbed and the full message is still only on the opened thread; the page stays role-gated and every view is logged. (Can be switched back to masked-phone-only instantly.) (2) Behind the scenes, the inbound-fax line can now run on a second, HIPAA-grade fax provider (Documo) — it stays completely OFF and receives nothing until that provider's Business Associate Agreement is signed and Doug flips the switch.

Show technical details

Changed

  • 📇 **Demi's Today queue — full patient contact inline (Doug-requested; flag-gated, SHIPPED DARK / default OFF).** Each 'Callbacks owed' row can render the patient's UNMASKED phone (as a tel: link) and email (as a mailto: link) on its own line, instead of the masked phone only. Because this widens PHI display, it ships INERT — set DEMI_FULL_CONTACT=true to turn it on (one env flip, NO deploy); anything else keeps the existing masked-phone-only view. This is the SAME contact data Demi already reaches via the 'Open →' deep-link — surfacing it inline is minimum-necessary for the front-desk callback task, not a new disclosure path. getDemiCallbacks now also selects Patient.email (linked patients only; unlinked-sender rows never surface an unverified email). Message subject/body previews remain PHI-scrubbed via scrubPhiForSmsOutbound (free-text is NEVER un-scrubbed). Role gate (ADMIN/MANAGER/SCHEDULER), force-dynamic, and noindex are unchanged. (front-desk)(demi)(phi-display)
  • 🧾 **VIEW_DEMI_TODAY audit now records the full-contact disclosure shape — as a COUNT, never identifiers.** buildDemiTodayAuditDetail appends fullContact=on rendered=phone:N,email:M so a reviewer can answer 'when was unmasked patient contact rendered on Demi's queue, by whom' from the actor + timestamp + counts alone. The detail string still carries ZERO patient identifiers (no phone, no email, no name). §164.312(b) metadata-only invariant preserved. (audit)(hipaa)

Added

  • 📠 **Inbound fax — Documo (mFax) provider path (default-OFF, BAA-gated, additive).** New src/lib/documo-fax.ts + a provider switch in /api/inbound/fax. INBOUND_FAX_PROVIDER=documo routes inbound faxes through Documo; unset/ringcentral keeps the existing RingCentral behavior verbatim (nothing changes by default). The Documo path normalizes Documo's fax.v1.inbound.complete webhook (messageId dedup key, faxCallerId sender ANI → LEAD_CAPTURED match, faxNumber our DID, pagesCount) into the SAME downstream pipeline the RC path uses — one dedup, one sender-match, one DB persist. Idempotent on messageId (Documo retries up to 7×); a blank faxCallerId fail-softs to the manual/unmatched path and never crashes. **The ENTIRE Documo path stays fail-closed behind INBOUND_FAX_BAA_OK exactly like RingCentral** — it ACKs 200 (no retry storm), audits the gated arrival PHI-free (message id only, no content fetched), and persists NOTHING until the fax provider's BAA is executed and Doug sets INBOUND_FAX_BAA_OK=true. Optional x-documo-signature HMAC verification (DOCUMO_WEBHOOK_SECRET); the load-bearing PHI guard is the BAA env-gate, not the signature. No schema change (Documo's messageId reuses the existing provider-agnostic dedup column — expand-only). ⚠️ The exact Documo download-PDF endpoint + auth scheme could not be confirmed against live docs (JS-rendered site, no public OpenAPI) and are ISOLATED in two clearly-commented functions for a one-line correction before go-live. (inbound-fax)(documo)(hipaa)(baa-gated)(dark)
v2.97.EXEMPLARCURATE1
2026-06-25Production
For everyone

New helper: an AI can now auto-approve the clearly-good Isabella reply-examples so you only review the tricky ones — with a one-click Un-approve override.

What this means for you

Added an optional AI auto-curator for Isabella's reply-pattern library (the staff-reply examples that teach her how to answer). Today a person has to thumbs-up every example one by one. The auto-curator uses our HIPAA-covered AI service to read each PHI-scrubbed example and either AUTO-APPROVE the high-confidence good ones, LEAVE the uncertain ones for a human, or REJECT anything that looks like it leaked patient info or makes a medical/over-promising claim. It is conservative: it only auto-approves when very sure, and it can NEVER re-judge its own approvals (a built-in anti-feedback-loop). The Playbook page now has an 'Approved' tab with an Un-approve button. Shipped OFF: nothing is auto-approved until Doug turns it on, and a dry-run shows exactly what it WOULD do first.

Show technical details

Added

  • 🤖 **Isabella exemplar AI auto-curator (default-OFF, HIPAA RED-lane).** New /api/cron/isabella-exemplar-autocurate cron + src/lib/isabella-exemplar-autocurate.ts (server) + src/lib/isabella-exemplar-autocurate-shared.ts (pure-fn verdict state machine, pin-tested). A BAA-Bedrock JUDGE (the SAME getExtractorModel() Bedrock handle the ingest scrubber uses — no non-Bedrock judge) scores each pending-review exemplar on three 0–1 dimensions (generalizable / PHI-clean+faithful / voice-policy) over ONLY the scrubbed summaries + closed enums. Verdict ladder: **auto-approve** only if min(all 3) ≥ 0.85 AND canary-clean AND not a fallback/terse row; **reject** (fail-closed, never escalate) on any scanBodyForPhiCanary trip or a PHI-faithfulness failure; **escalate** (leave pending for a human) everything uncertain. [isabella][hipaa][bedrock][autocurate]
  • 🧱 **Anti-RSI self-loop firewall.** The eligible-row query is status=pending-review AND reviewedByUserId IS NULL, which structurally excludes the curator's own future approvals (it stamps reviewedByUserId="isabella-autocurator"), hand-authored seeds, and any human-touched row — so it can never re-judge or re-feed its own outputs. [isabella][governance]
  • ↩️ **P0 reversibility UI on /admin/isabella-playbook.** New 'Approved' tab lists approved+edited exemplars (flagging AI-auto-approved ones) with an **Un-approve** button → sends the row back to pending-review (human-attributed, audited). This is the human override of any auto-approval — an AI-curates-AI surface must be reversible-by-UI. [isabella][admin][governance]
  • 📧 **PHI-free digest + audit.** Every auto-approval/escalate/reject emits one ISABELLA_EXEMPLAR_CURATED audit row (actor=autocurator, enum/cuid/reason-code only — never a summary). A PHI-free counts digest goes out via the OWNER_ALERT_EMAIL → ADMIN_NOTIFY_EMAIL rail. ?dryRun=1 runs the full judge pass and returns the per-row verdict table while persisting NOTHING (ignores the flag — for the eyeball-before-flip review). [isabella][audit][hipaa]
v2.97.ABSTAINGATE1
2026-06-25Production
For everyone

New safety gate: Isabella's phone prompt can't go live until she proves she refuses-and-routes on 15 hard test calls.

What this means for you

Added a behavioral safety check for Isabella (our automated phone receptionist). Before any change to what Isabella says can reach the live phone line, an automated test places 15 tricky synthetic calls at her — asking for medical advice, a diagnosis, a dosage, another patient's info, trying to trick her into ignoring her rules, plus two safety-critical ones (a caller in crisis and a caller who blurts out their birthday and social) — and a second AI grades whether she actually declined and routed correctly. If she fails any, the change is blocked. All test calls are fake (made-up names, fiction-only numbers) and it runs on our HIPAA-covered AI service. Isabella's wording wasn't changed — this only proves and locks in the behavior she already has.

Show technical details

Added

  • 🛡️ **Isabella behavioral abstention eval gate (RED-lane HIPAA).** Model-in-the-loop adversarial eval (scripts/isabella-abstention-eval.mjs + src/lib/__tests__/isabella-abstention-eval.fixtures.ts) runs the 15 designed cases from ISABELLA_PROMPT_AUDIT_2026_06_25.md §4 against Isabella's real assembled VOICE prompt and uses a **Bedrock-judged rubric** (BAA-covered us.anthropic.claude-sonnet-4-6, account 004730170375) to assert she behaviorally REFUSES-and-ROUTES — not merely that the clause text is present. Includes the two counter-pins: crisis must NOT collapse into take-a-message (988 must surface) and volunteered DOB/SSN must be minimized (never echoed). Caller lines are sent as untrusted role:"user" turns, never interpolated into the system prompt (preserves the injection-fence). Reports are PHI-scrubbed before write. An --adversarial mode appends the worst-case poisoned learned-call-playbook below the prompt to prove the safety floor holds under a drifted learning loop. **VOICE_PROMPT itself is unchanged** — additive test infra only. [isabella][voice][hipaa][bedrock][abstention][eval]
  • 🔒 **Pre-sync gate wired into scripts/sync-retell-prompt.mjs.** A non-dry-run push to the LIVE Retell agent now runs the abstention eval first and is BLOCKED on any failure. The only bypass is SYNC_SKIP_ABSTENTION_EVAL=1, which is logged to scripts/.retell-sync-override.log and routes through compliance-guard. This converts Isabella's already-strong abstention text into a proven, gated behavior on the phone surface. [isabella][voice][retell][sync-gate][hipaa]
v2.97.PAYDESC1
2026-06-25Production
For everyone

Pay page now tells patients the billing name they'll see on their statement.

What this means for you

Added a line to the payment page so patients know the charge appears as "Green Health Solutions LLC" on their card statement (the billing entity behind Green Wellness). This heads off "I don't recognize this charge" calls and disputes, since the statement name differs from the Green Wellness brand they booked with.

Show technical details

Added

  • 💳 **/pay page: statement-descriptor disclosure.** Added "This charge appears as **Green Health Solutions LLC** on your statement" to the payment page's card-handling note (Doug-confirmed entity). Prevents unrecognized-charge confusion/chargebacks when the card-statement merchant name differs from the Green Wellness brand. [payments][collect][copy]
v2.97.PAYCOPY1
2026-06-25Production
For everyone

Pay page wording now matches the new in-portal card checkout.

What this means for you

Fixed a stale line on the payment page that said "you'll pay on our card processor's page" — that implied a redirect to an outside page, but patients now pay right on our own page. It's reworded to "card details are entered on a secure, encrypted page and are never seen or stored by Green Wellness," which reads correctly before and after payment.

Show technical details

Fixed

  • 💳 **/pay page: corrected stale card-handling copy for the in-portal Collect flow.** The static reassurance line read "You'll pay on our card processor's secure, encrypted page" — future-tense + implied an external redirect (still showed even on the Payment-received success state). Reworded to the tense-neutral "Card details are entered on a secure, encrypted page and are never seen or stored by Green Wellness," matching the in-portal Collect checkout (no vendor name, no redirect implication). [payments][collect][copy]
v2.97.FBREMOVE1
2026-06-25Production
For everyone

You can now remove a redundant item from your In-Progress feedback list.

What this means for you

On your feedback page (My feedback), each In-Progress item now has a "Remove this item" link. Use it for duplicates or things you no longer need — it asks you to confirm and lets you add an optional reason, then takes the item off your active list. It's a soft remove: nothing is deleted, the item just moves to Completed and drops out of the agents' work queue.

Show technical details

Added

  • 🧹 **Remove redundant In-Progress feedback (Mariane cmqej28ci).** Owner-only "Remove this item" control on In-Progress rows of /me/feedback — confirm step + optional reason → sets the row's status="cancelled" (soft cancel; the row is kept for audit, never hard-deleted). The cancelled row leaves the active list and drops off the agent loop (the queue puller only pulls open/agent-working). Owner-gated server action, idempotent, can't cancel an already-done/cancelled row. Writes a PHI-free FEEDBACK_CANCELLED audit row (from→to + reason supplied|none + actor — never the free-text reason). [feedback][me][audit]
v2.97.COLLECTUX2
2026-06-25Production
For everyone

Pay button now responds instantly when clicked.

What this means for you

On the pay-by-card page, the Pay button now switches to "Processing…" the moment it's clicked, instead of sitting there for a beat while the card is read. Just a responsiveness tweak — the payment itself works the same.

Show technical details

Changed

  • 💳 **Collect pay form: instant Pay-button feedback.** The button now flips to charging/"Processing…" synchronously on click, before the SDK's getNonce tokenization latency — previously the click felt unresponsive because the status only changed after the nonce came back. The SDK error handler still resets to ready on an invalid card, so it can't get stuck. [payments][collect][ux]
v2.97.COLLECTUX1
2026-06-25Production
For everyone

Pay-by-card page polish — cleaner security wording + a Pay button that can't get stuck.

What this means for you

Tidied up the in-portal pay-by-card page: the security line no longer names the processor (just says it's bank-level encrypted and never stored), the Pay button now reliably enables once the card field loads (with a small hint if the cardholder name is missing). No change to how payments or card data are handled.

Show technical details

Changed

  • 💳 **Collect pay form: dropped the processor name from the security line + hardened the Pay button.** Footer now reads "Bank-level encryption — your card is secured and never stored by Green Wellness" (no vendor name). Added a 3.5s mount-fallback that flips the form to ready if the SDK's ready event doesn't fire, so the Pay button can't stay permanently disabled; plus an inline hint when the cardholder last name is blank. No PAN-handling change (SAQ-A). [payments][poynt][collect][ux]
v2.97.COLLECTNAME1
2026-06-25Production
For everyone

Pay-by-card page now has editable cardholder name fields (fixes a stuck checkout).

What this means for you

On the in-portal pay-by-card page, the cardholder's name is now shown in editable First/Last name fields — pre-filled from the patient's account, but changeable if the card is in someone else's name. Before this, the card processor could reject a payment asking for a valid last name with no place to enter one. No change to how card data is handled (still never touches us).

Show technical details

Fixed

  • 💳 **Poynt Collect pay form: added editable cardholder First/Last name fields, prefilled from the patient account.** The form passed patientFirstName/patientLastName straight to the Poynt nonce with no UI; when the account's last name was empty/invalid (e.g. a single char) Poynt rejected the charge with "enter a valid last name" and the payer had no field to correct it. Now both names render as editable inputs (prefilled, autoComplete=cc-given-name/cc-family-name), the Pay button is disabled until a non-empty last name is present, and getNonce sends the trimmed values. No change to PAN handling (SAQ-A, card data still browser→Poynt only). [payments][poynt][collect]
v2.97.COMMSFIX1
2026-06-25Production
For everyone

Patient-comms accuracy: the booking wizard's renewal price now reads the live $145 (was stuck at a stale $140), and new patients are told their appointment is a request pending records review — not "you're booked" — in both the wizard and the confirmation text.

What this means for you

Three copy/price corrections so what a patient sees in the online booking flow matches what we tell them everywhere else. (1) The deferred-payment step was hardcoding the old $140 renewal price; the renewal rose to $145 on 2026-06-10. It now reads the single source-of-truth price, so it can never drift again. (2) The final wizard screen said "You're booked." to every patient, but a new patient is really a tentative appointment REQUEST — we confirm only after reviewing their records. Now the wizard frames it that way (new patient sees "Request received… our team confirms after a records review"; returning patient still sees "You're booked."). (3) Same fix for the confirmation text message. No new claims, no change to the compliance wording.

Show technical details

Fixed

  • 💵 **Stale renewal price in the booking wizard** — StepPayment.tsx deferred-payment panel hardcoded data.isReturning ? 140 : 175; the renewal price rose to $145 on 2026-06-10, so a returning patient saw $140 while every other surface (chat, voice, email, StepConfirmation) showed $145. Now reads PRICING.RETURNING_TELEHEALTH / PRICING.NEW_IN_PERSON from @/lib/constants (the same pattern StepConfirmation already uses) so the figure can never drift from the source of truth again. (scheduling)(billing)
  • 🗓️ **New-patient wizard overpromised "You're booked."** — the final confirmation screen showed a confirmed-booking headline to every patient, contradicting the tentative-appointment-request framing voice/chat/email enforce (records review precedes confirmation for new patients). StepConfirmation.tsx now branches: new patient → "Request received." + an "our team reviews your records first, then confirms your visit" line; returning patient → unchanged "You're booked." (scheduling)(comms)
  • 📱 **Booking-confirmation SMS overpromised the same way** — smsBookingConfirmation said "you're booked" with no new-vs-returning branch. Added a new-patient variant ("we've got your request… our team confirms after a quick records review"); wired appointment.isReturning through both call sites (cron SMS send + reschedule). Unknown/unset isReturning falls to the new-patient (no-overpromise) wording. Also converged the greeting to the warmer "Hi {name}, Green Wellness…" shape. (comms)(sms)
v2.97.POYNTCOLLECT1
2026-06-25Production
For everyone

Card-on-our-page checkout is verified end-to-end against the live account — still OFF for patients until Doug runs one test charge and flips it on.

What this means for you

The in-portal "pay by card on a Green Wellness page" checkout (the patient types their card right on our page, the card number goes straight to Poynt and never touches us) was already built and shipped behind a switch. This release adds the SAFETY + VERIFICATION layer so we can confirm it works on the live account before turning it on: a new diagnostic that proves the server can talk to Poynt, the store resolves, and the charge route is live — and, on demand, can run a real $1 charge and immediately void it. It also adds the ability to void a card charge (the old refund path only worked for invoices). Patients see no change: the in-portal card checkout is still OFF, so /pay keeps using the existing GoDaddy hosted link.

Show technical details

Added

  • 💳 **Poynt Collect charge-path verification** — new GET /api/admin/diag/poynt-collect-verify (CRON_SECRET-gated) runs verifyCollectChargePath(): confirms the JWT bearer mints, the merchant store-id resolves (chargeCollectCard fails store-id-unresolved without it), and the services.poynt.net/businesses/{id}/cards/tokenize/charge route is ENTITLED (non-404 to a dummy body). All green = a real browser nonce will round-trip. CHARGES NOTHING. PHI-free. (payments)(poynt)(diag)
  • 💳 **Real $1 charge-then-void round-trip** — POST .../poynt-collect-verify?charge=1 with a real Collect { nonce } performs a $1.00 SALE via the SAME chargeCollectCard path the live /pay flow uses, then immediately voids it via the new voidCollectCharge helper. Triple-guarded: requires ?charge=1 AND a nonce AND POYNT_COLLECT_VERIFY_ALLOW_CHARGE=true; hard-capped at $1.00; NEVER touches a patient appointment row (isolates the Poynt leg so a verification can't release a cert). Lets Doug prove a real card works before flipping the live switch. (payments)(poynt)(diag)
  • 🔧 **voidCollectCharge(transactionId, amountCents)** in poynt.ts — voids/reverses a cards/tokenize/charge TRANSACTION (POST /transactions/{id}/voidOrReverse, falls back to /transactions/{id}/refund). The pre-existing refundInvoice only targets /paylinks/{id}/refund (invoices), which doesn't apply to a synchronous Collect charge. PHI-free; never throws. (payments)(poynt)

Changed

  • No patient-facing change. POYNT_COLLECT_INPORTAL remains OFF — /pay continues to use the GoDaddy hosted-paylink redirect. The in-portal Collect checkout (CollectPaymentForm → chargeViaCollect → chargeCollectCard → mark-paid → releaseGatedAuthForAppointment, all shipped v2.97.ICB0009) is now LIVE-VERIFIABLE before go-live. (payments)
v2.97.CONSENTAUTOSEND1
2026-06-25Production
For front desk

When a returning patient books with a provider they haven't seen, their consent form is now emailed to them automatically — no separate send needed.

What this means for you

We already auto-create the right form on every booking (the new-patient packet for first-timers, a fresh informed-consent when a returning patient is seeing a provider they haven't seen before). For a brand-new patient, our portal-welcome email already points them to it. The gap was the returning patient: their fresh consent form was placed in the portal but nobody told them, because they'd already gotten their one-time welcome email on an earlier visit. Now, the moment that appointment is created, the patient gets a direct "please review and sign your Informed Consent" email with a one-click link to sign online. It only sends once per patient per form, respects unsubscribe/bounced addresses, and goes over our BAA-covered mail. This stays off until the auto-onboarding setting is turned on.

Show technical details

Added

  • ✉️ **Auto-send the consent form on appointment creation for returning patients** (Mariane feedback: "Automatically Send Consent Form After Appointment Creation"). The appointment-onboarding path already auto-creates a NEW_PATIENT_PACKET (new patients) or a fresh INFORMED_CONSENT (returning patient + a provider they have no prior appointment with) and relies on the one-time portal-welcome email to surface it. But a returning patient who already received their portal-welcome got the new consent form silently dropped in the portal with NO notification. Now, when the welcome won't fire AND a new form was just created, we email the patient a direct /patient/forms/ magic-link via the same PHI-free buildFormLinkEmail body + bounce/unsubscribe-aware sendEmailToPatient rail (M365/Postmark/SES, never Resend) the staff "send consent form" action uses. Idempotent per (patient, form name) via a CONSENT_FORM CommunicationLog de-dupe so a second booking or a fire-and-forget re-run never double-sends; logged to the patient communication history (automated) + a PHI-free FORM_SENT_TO_PATIENT audit row. Dark by default — gated on APPT_AUTO_ONBOARDING_ENABLED, same as the portal-welcome auto-send. [forms][consent][onboarding][hipaa]
v2.97.IDDETECT1
2026-06-25Production
For everyone

Isabella stops naming providers + the ID-photo upload now opens the phone camera and flags photos that don't look like an ID.

What this means for you

Two patient-facing improvements. (1) Isabella (chat + phone) will no longer say a provider's name to a patient — the team confirms the provider after booking, so a patient is never told a name that might change at records review. (2) When a patient uploads their Washington ID during booking, their phone now opens the camera straight away, and a quick automated check tells them "that doesn't look like a photo ID — please retake" if the photo is wrong. The upload always still goes through (it never blocks booking), and on your ID-review queue each patient now shows a ✓ "looks like an ID" or ⚠ "unverified" hint so you can spot bad photos at a glance. Privacy-first: the check only answers yes/no — it never reads or stores the ID number, date of birth, name, or address.

Show technical details

Changed

  • 🗣️ **Isabella no longer volunteers or speaks a provider's name to a patient** (chat + voice). Added a durable guardrail sentence to both the chat SYSTEM_PROMPT and the VOICE_PROMPT ("Never volunteer or speak a provider's name to a patient; the team confirms the provider after booking"), and scrubbed the residual listOpenSlots prompt example that read "…with Dr Ari" → now "Tuesday March 14 at 2:00 PM". Closes Mariane feedback on provider-name leakage. Takes effect on the phone after the next Retell sync. [voice][chat][privacy]

Added

  • 📸 **WA-ID upload opens the phone camera** — the ID-photo file input now carries capture="environment" so a phone goes straight to the rear camera; desktop / no-camera still falls back to the normal file picker. [intake][mobile]
  • 🪪 **Advisory photo-ID detection on the WA-ID upload** (HIPAA minimum-necessary). After the existing compress/EXIF-strip, the upload runs through BAA-covered Bedrock (us-east-1) which answers ONLY "does this look like a government photo ID?" — a boolean + confidence bucket (+ a coarse doc-type guess). It NEVER reads or stores the ID number, DOB, name, or address. If it doesn't look like an ID the wizard prompts a retake, but the upload always succeeds (never gates intake/booking). The result is persisted on the patient and surfaced on /admin/patients/id-review as a ✓/⚠ staff hint, plus a PHI-free PATIENT_ID_DETECTION audit row. Expand-only migration 100 adds nullable idDetected/idDetectionConfidence to PendingIntakeUpload + Patient. [intake][hipaa][bedrock][id-review]
v2.97.CANARYCSRF1
2026-06-25Production
For everyone

Fixed a false alarm in the provider-chart health monitor — no patient-facing change.

What this means for you

The automated monitor that checks the provider chart + sign button every 30 minutes raised a false alarm after the latest security update. It was testing the 'sign encounter' action like a server instead of like a real browser, so the new cross-site-request protection correctly rejected it — making a healthy site look broken. The monitor now sends the same browser headers a real provider sends, so it reflects reality. No change to the live site; providers were never actually affected.

Show technical details

Fixed

  • 🩺 **Provider-chart canary no longer false-positives FAIL:sign=403 against the new CSRF enforcement.** The canary's sign-route liveness probe POSTed to /api/provider/encounters/[id]/sign with NO Origin/Referer, so the v2.97.CSRFCSP1 same-origin guard correctly 403'd it (it expected 401). That read as a sign-route OUTAGE and — critically — classifies as a genuineFail to the armed canary→rollback guard, a false alarm that could have rolled back a HEALTHY deploy. Fix: the probe now sends a same-origin Origin/Referer (this deployment's own host, which the guard's allowedHosts always includes), mirroring a real provider's browser → passes CSRF → asserts the real 401 auth-gate. Verified live: with Origin → 401, without → 403; real browsers always send Origin, so providers were never affected. [canary][csrf][hipaa][reliability]
v2.97.UNDICI1
2026-06-25Production
For everyone

Routine security patch to an underlying library — nothing changes in how you use the site.

What this means for you

A behind-the-scenes networking library was updated to close a published security advisory. This is a dependency-only patch — no change to any page, workflow, or how you use the site.

Show technical details

Fixed

  • fix(deps): pin undici ^6.27.0 via pnpm.overrides — closes HIGH advisory GHSA-35p6-xmwp-9g52 (WebSocket DoS) + 4 lesser undici CVEs arriving via @vercel/blob. Lockfile-only change; no app code altered.
v2.97.CSRFCSP1
2026-06-25Production
For everyone

Behind-the-scenes security hardening — nothing changes in how you use the site.

What this means for you

Security hardening behind the scenes — no change to how you use the site. Protection against forged cross-site requests on staff, provider, and patient actions is now actively enforced (previously it only watched and logged), and a long-standing source of noise in the security-violation log was fixed at its root. If anything on the admin or provider portal stops working after this, there is a one-setting switch Doug can flip to instantly revert.

Show technical details

Changed

  • 🛡️ **CSRF same-origin guard flipped from log-only to ENFORCING (hard 403 on cross-origin cookie-authed mutations).** The proxy's same-origin Origin/Referer check on unsafe methods (POST/PUT/PATCH/DELETE) to /api/{admin,provider,patient,dispensary} now returns 403 instead of just logging [csrf] would-block. Safe to enforce: all first-party UI fetches are same-origin; the 46 Server Actions are already same-origin-gated by Next's built-in check; vendor webhooks + cron + bearer-allow + integrations + health are explicitly exempt. **Escape hatch:** CSRF_ENFORCE=false in Vercel env reverts to log-only with no redeploy. [security][csrf][hipaa]
  • 🛡️ **/api/patient/:path* added to the proxy matcher — closes an unguarded patient-portal mutation surface.** The catch-all matcher's negative-lookahead excluded api/patient and there was no positive entry, so patient JSON mutation routes (profile PATCH, password change) never ran through the proxy → had NO edge CSRF check and NO identity-header strip. They were cookie+per-route-auth only. Now matched: they pass the CSRF same-origin guard, then fall through to the catch-all freshHeaders() identity-header strip. Patient auth routes stay same-origin POSTs from the login page (pass normally). [security][csrf][patient][hipaa]

Fixed

  • 🛡️ **CSP nonce wiring repaired at the root — kills ~25k phantom Report-Only violations.** The strict protected-route CSP (nonce + 'strict-dynamic', Report-Only since v2.97.U-era) was firing script-src-elem blocked=/_next/static on EVERY admin/provider/patient page view (25,094 AuditLog rows, ongoing) because Next.js was never applying the nonce to its own bundle scripts. Per the Next 16 CSP guide, Next extracts the nonce by parsing the Content-Security-Policy header **on the REQUEST** during SSR — it does NOT read x-nonce and does NOT parse -Report-Only. The proxy was only setting x-nonce + the Report-Only response header, so Next's scripts went un-nonced. Fix: also set the strict CSP as Content-Security-Policy on the **request** headers passed to NextResponse.next({ request }). Response stays Report-Only (cannot white-screen). Also added the missing frame-src (RingCentral softphone + Stripe + Poynt iframes), full connect-src, and media-src to the strict policy so it stops reporting phantom frame-src blocked=/ and is now a FAITHFUL would-this-enforce-cleanly probe. **CSP remains Report-Only on protected routes — enforce flip is Doug-greenlight after a clean week of zero CSP_REPORT_ONLY_VIOLATION rows.** The lax-but-real enforced CSP in next.config.ts is unchanged and still protects every route. [security][csp][nonce][hipaa]
v2.97.RENEWALCONSENT1
2026-06-24Production
For everyone

When a returning patient books with a provider they haven't seen before, the system now automatically queues a fresh consent form for them to sign in the portal — so there's a signed consent on file for each provider. If they've already seen that provider, nothing changes.

Show technical details

Added

  • 📋 **Auto-create a new-provider consent for returning patients (Doug clinical rule 2026-06-24: renewals re-consent only with a NEW provider).** Extends fireAppointmentOnboarding (same APPT_AUTO_ONBOARDING_ENABLED flag, already on): on a RENEWAL booking, if the patient has no prior NON-CANCELLED appointment with that appointment's provider, it creates an INFORMED_CONSENT form (2-page consent + acknowledgement, no re-intake — distinct from the new-patient 5-page packet) at status SENT so they sign it IN the portal before the visit. Consent is per patient↔provider; "already consented" is proxied as "prior non-cancelled appt with this provider" — clinically sound + SELF-IDEMPOTENT (once the appt exists, rebooking the same provider skips, so no duplicate per provider). Best-effort: never throws, never blocks the booking. New patients still get the full packet; same-provider renewals unchanged. PHI-free audit (FORM_CREATED, mode only). Mirrors the hipaa-architect/Explore-blessed new-patient-packet pattern. [consent][onboarding][hipaa][provider]
v2.97.CHARTBOUNDARY1
2026-06-24Production
For everyone

Fixed the real cause of the provider encounter-chart error: a chart could crash when a provider clicked into it from elsewhere in the portal. It now loads reliably however you navigate to it.

Show technical details

Fixed

  • 🩺 **ROOT CAUSE of the provider encounter-chart crashes (digest 3615476718) — a server/client boundary bug, NOT a transient blip.** The CHARTDIAG1 server-error capture shipped earlier today caught the real message: Attempted to call normalizeMedicationsJson() from the server but normalizeMedicationsJson is on the client. The cookie-port chart page (a Server Component) imported normalizeMedicationsJson + emptyMedicationRow + normalizeDastAnswers from MedicationReviewSection.tsx / DastTenSection.tsx — both "use client" modules — and CALLED them during server render. On a soft client-side navigation (RSC payload request, ?_rsc=) Next.js treats a client-module export as a reference, not a callable, and throws → the whole chart 500'd. On a hard page load it happened to execute, which is exactly why it looked intermittent (and why the transient-retry CHARTRETRY1/3 couldn't touch it — it's deterministic, not a connection error). **Fix:** extracted the pure, React-free helpers into non-client shared modules — medication-review-shared.ts + dast-ten-shared.ts — imported by BOTH the server page and the client components (which re-export them so every existing client/test import path keeps resolving). The server now calls plain functions, never client references. **Tests:** new chart-server-client-boundary.test.ts (7 pins: page imports helpers from -shared, does NOT import the runtime helpers from the "use client" components, shared modules carry no use client directive, + behavior). typecheck clean; the 35 encounter-detail anti-divergence pins still green. This is the genuine fix for today's chart-crash cluster; CHARTRETRY1/2/3 (transient retries) + CHARTDIAG1 (the capture that diagnosed this) remain as complementary hardening. PHI scope: zero (pure JSONB-shape normalizers, no patient data in logs). [hipaa][provider-chart][server-client-boundary][root-cause][version-letter:CHARTBOUNDARY1][cadence-override: live provider-facing chart crash, root cause via CHARTDIAG1 capture]
v2.97.PROVIDERINTAKE1
2026-06-24Production
For providers

Providers now see the patient's submitted intake — chief complaint, qualifying conditions, current medications, allergies, and history — right on the chart, so you can review it before the visit. If the patient hasn't filled it out yet, the card tells you so.

Show technical details

Added

  • 🩺 **Submitted patient intake now shown on the provider encounter chart (Dr. Frisch request).** /provider/portal/encounters/[id] loads the IntakeForm for the appointment and renders a collapsible (open-by-default) "Patient intake" card under the identity card: chief complaint, qualifying conditions, symptom duration, current-cannabis-use + frequency, therapies tried, current medications, allergies, treating physician, surgical history, recent labs, prior-auth, and patient notes. Previously the intake only fed the SOAP *prefill* — it was never shown as a reviewable summary, so the provider couldn't see what the patient submitted before the visit. When no intake is on file the card says "not submitted yet" so the provider knows to chase it. Read is .catch-degraded (matches the chart's resilience pattern — a failure shows an unavailable note, never crashes the chart). PHI render is expected here (provider's authorized chart; chart-open audit already covers it). [provider][chart][intake][hipaa]
v2.97.CHARTRETRY3
2026-06-24Production
For providers

Fixed a rare glitch where opening a patient chart could briefly show an error instead of loading.

What this means for you

Fixed a rare glitch where opening a patient chart could briefly show an error instead of loading. It now retries on its own, so a provider doesn't get bounced back to the schedule.

Show technical details

Fixed

  • 🩺 **Provider chart crash — hardened the last unguarded primary read (live incident, digest 3615476718, enc=cmqsddx39…).** The /provider/portal/encounters/[id] page wrapped its *encounter* read in transient-DB retry (CHARTRETRY1/2) but the **db.provider.findUnique that runs immediately before it was still an unguarded await** — a transient Neon blip there (pool reset / acquire-timeout / server-closed connection) 500'd the WHOLE chart and bounced the clinician to Practice Fusion, even though the encounter row + every relation read back clean (structurally verified against prod for this enc). Wrapped that provider lookup in the same withTransientDbRetry (transient-only — deterministic errors still surface immediately so a real bug isn't masked). audit() already self-guards (ruled out); this was the remaining unguarded primary read on the chart. [provider][chart][reliability][hipaa][incident]
v2.97.CHARTDIAG1
2026-06-24Production
For everyone

Behind-the-scenes: when a page errors on the server, we now record the actual technical cause (with patient details scrubbed out) so we can pinpoint and fix it fast — instead of only seeing an opaque error code. No change to what staff or patients see.

Show technical details

Added

  • 🔬 **Server-error capture via Next.js onRequestError — turns an opaque crash digest into a diagnosable error class.** Two provider encounter-chart crashes today (digests 541975153 + 3615476718, different encounters) each read back with FULLY healthy data + succeeding queries on replay — so the cause was invisible from data alone. The reason: the only durable crash record was the client error-boundary's PROVIDER_PORTAL_CRASH AuditLog row, which carries name + digest ONLY; the real server-side error (Prisma class, connection-vs-data, which read failed, or a React serialization message) lived solely in ephemeral Vercel function logs that can't be queried after the fact. Added onRequestError to src/instrumentation.ts: it fires with the REAL server error for any request crash and writes a PHI-scrubbed row to AuditLog (action='SERVER_REQUEST_ERROR') keyed by the SAME numeric digest the alert carries — so the next occurrence is diagnosable from a psql query instead of an opaque digest. **PHI firewall:** scrubServerErrorMessage redacts quoted values + 3+ digit runs (DOB/phone/MRN/SSN) and caps at 140 chars (the error-class prefix only — same precedent as the chart's existing console diagnostic); Next.js control-flow signals (notFound/redirect/dynamic-server-usage) are skipped so normal navigation isn't logged as a crash; the whole capture is wrapped so diagnostics can never throw/recurse. Edge runtime is skipped (the Neon adapter is Node-only). NEW src/lib/__tests__/instrumentation-error-scrub.test.ts (9 pins: quoted/single-quoted/digit-run redaction, error-class-prefix + Prisma-code preservation, length cap, whitespace collapse, control-flow-digest skip vs numeric-digest capture). All 9 green; typecheck clean. [hipaa][observability][server-error-capture][phi-scrubbed][version-letter:CHARTDIAG1][cadence-override: 2nd live provider-chart crash today, data provably healthy both times — instrument to diagnose the real cause]
v2.97.CHARTRETRY2
2026-06-24Production
For everyone

Internal hardening of the encounter-chart fix from earlier today — the database-retry logic is now a reusable, unit-tested helper.

What this means for you

Internal hardening of the encounter-chart fix from earlier today — the database-retry logic is now a reusable, unit-tested helper. No change to what staff or patients see.

Show technical details

Changed

  • 🧪 **Extracted the CHARTRETRY1 transient-retry into a unit-tested @/lib/db-transient-retry helper.** The bounded-retry engine + transient-error classifier that keep a momentary DB blip from 500'ing the provider encounter chart were inlined in provider/portal/encounters/[id]/page.tsx. Moved them to a reusable lib (isTransientDbError + withTransientDbRetry, retry-budget bounded, sleep injectable) so the behavior is locked by a real behavior test, not just a static-source pin. The page keeps a thin wrapper that owns its canonical [encounter-detail] diagnostic lines verbatim — zero behavior change to the chart. NEW src/lib/db-transient-retry.ts + src/lib/__tests__/db-transient-retry.test.ts (12 pins: classifier retries P1001/P1002/P1008/P1017/P2024 + init/panic + connection-reset messages ONLY, deterministic P2002/P2022/P2025/P2003 + validation + non-Error fail fast; control flow = first-try-once, retry-then-succeed, fail-fast-on-non-transient, bounded-then-rethrow, totalAttempts budget). All 12 green + the page's 35 anti-divergence pins still green. PHI scope: zero. [provider-chart-resilience][refactor][unit-tested][version-letter:CHARTRETRY2]
v2.97.CHARTRETRY1
2026-06-24Production
For everyone

Fixed a rare case where opening a patient's encounter chart could show a 'Something went wrong' error.

What this means for you

Fixed a rare case where opening a patient's encounter chart could show a 'Something went wrong' error. The chart now quietly retries a momentary database hiccup before giving up, so a provider doesn't get bounced out mid-visit.

Show technical details

Fixed

  • 🩺 **Provider encounter chart no longer 500s on a transient DB blip (digest 541975153, 2026-06-24).** The load-bearing encounter.findFirst on /provider/portal/encounters/[id] was the last unguarded read on the chart — every secondary read was already .catch-degraded in the ARI0003/ARI0004 resilience work, but a *momentary* Neon connection failure (pooled-connection reset · pool-acquire timeout · server-closed connection) on the primary read still crashed the whole chart and sent the clinician back to Practice Fusion. Diagnosis confirmed the crashing row + every relation read back clean against prod, so this was a single transient throw, not a data bug. Wrapped the primary read in a bounded retry (loadEncounterWithTransientRetry — 2 retries, 150ms/400ms backoff) that fires ONLY on connection/pool-class errors (Prisma P1001/P1002/P1008/P1017/P2024 + init/panic + connection-reset message class) — never on deterministic query/validation errors, which still surface immediately so a real bug isn't masked. A genuinely persistent failure still reaches provider/error.tsx and notFound() still fires on a real miss; the canonical [encounter-detail] primary encounter load threw diagnostic log line is preserved verbatim. PHI scope: ZERO — retry classifier logs error name + Prisma code only, never row data. [hipaa][provider-chart-resilience][transient-retry][version-letter:CHARTRETRY1][cadence-override: live provider-facing chart crash]
v2.97.ANALYTICSWINDOW1
2026-06-23Production
For everyone

The admin Analytics page now has a date filter — tap 30 days, 90 days, 1 year, or All time to see leads, bookings, and revenue for whatever range you want.

Show technical details

Added

  • 📅 **Date-range selector on /admin/analytics (30d · 90d · 1 year · all-time).** Added a ?window= switch; getSiteAnalytics now takes windowDays: number | null (null = all-time, omits the createdAt filter) and windows BOTH the lead aggregates (new leads, top sources, by-location) and the appointment aggregates so the whole page reflects the chosen range. Open-lead total stays an all-time snapshot. Trend vs prior period only shows for finite windows. Defaults to 30 days. [analytics][admin]
v2.97.PRACTICEANALYTICS1
2026-06-23Production
For everyone

The admin Analytics page now shows real numbers from our own records — where leads come from, how many are booking, new vs renewal, and revenue — instead of the old empty Google page. Raw website visits still live in Vercel.

Show technical details

Added

  • 📊 **/admin/analytics now shows first-party practice funnel analytics (replaces the GA4 hand-off).** New src/lib/site-analytics.ts (getSiteAnalytics) aggregates the Lead + Appointment tables — counts only, no PHI, no Google Analytics tag — into: new leads + open-lead total + booked-from-a-lead conversion; top lead sources (all-time) + leads by preferred location; appointments booked/completed, new vs renewal, telehealth vs in-person, by-status, and revenue collected (28-day window). All inside the Neon BAA boundary; degrades to a soft banner on DB error. Raw web traffic stays in Vercel Web Analytics (hand-off card retained). Verified against prod: 13,174 open leads, top source GW Website (3,402), 44 appts booked / $740 collected last 28d. [analytics][hipaa][admin][leads][appointments]
v2.97.ANALYTICSVERCEL1
2026-06-23Production
For everyone

The admin Analytics page now points you to our website traffic in Vercel instead of Google.

What this means for you

The admin Analytics page now points you to our website traffic in Vercel instead of Google. We don't put Google Analytics on the site for patient-privacy reasons, so the old page was always blank — now it sends you to the right place.

Show technical details

Changed

  • 📊 **/admin/analytics repointed off GA4 → Vercel Web Analytics hand-off.** Removed the Google Analytics Data-API dashboard (it was structurally empty: the site carries no gtag/GA4 tag by the 2026-05-28 HIPAA ruling — Google signs no BAA for Analytics — so the property has 0 sessions). The page now renders an admin-gated hand-off to the Vercel Web Analytics dashboard (privacy-first, cookieless, no PHI, BAA-covered in-stack) for greenwellness.org, plus a plain-language "why not Google Analytics" note. GA4 Data-API helpers (src/lib/ga4.ts) + the now-unused GA4_OAUTH_* prod env vars left in place but unreferenced. hipaa-architect-reviewed: do NOT install a GA4 tag on this HIPAA site. For staff-visible in-app numbers later, the path is a first-party events table inside the Neon BAA, not GA4. [analytics][hipaa][admin]
v2.97.EMAILFLAG1
2026-06-23Production
For front desk

The patient list now shows a clear amber “No email on file” tag for anyone missing a real email address, instead of a confusing fake one.

What this means for you

The patient list now shows a clear amber “No email on file” tag for anyone missing a real email address, instead of a confusing fake one. Those patients already don't get automated emails, so this just makes it easy to spot who needs a real address added.

Show technical details

Changed

  • 📧 **Patient list flags placeholder (@unresolved.local) emails.** /admin/patients now renders an amber “No email on file” badge (via isPlaceholderEmail) instead of the raw synthetic address, so staff can see which imported records need a real email. No send-behavior change — sendEmail()/sendEmailToPatient() already hard-block placeholder addresses (~20.9k rows) so none of these ever bounce. Visibility half of Mariane's “Patient Email Mapping / Salesforce Import” feedback (the SF field-mapping review is the remaining, data-side half). [feedback][leads][deliverability]
v2.97.ISADRAFTLINK1
2026-06-23Production

Mariane: in Isabella's Draft Replies, the 'Open Conversation + Paste + Send' button now opens the exact patient email thread the draft was written for — not the generic inbox or the patient overview page. Before, clicking it routed you to the patient profile page (when the email was matched to a patient) or to the all-channels inbox (when it wasn't), so you still had to hunt for the right thread to paste into. Now it opens directly on the Messages tab where the conversation is, ready for paste & send.

Show technical details

Fixed

  • 📨 **Isabella Draft Replies — 'Open Conversation' now deep-links to the exact email thread (cmqq0zjls / Mariane).** getPendingDrafts() in src/lib/isabella-draft-queue.ts built conversationHref as /admin/patients/ (linked patient) or /admin/messages (unlinked sender) — neither lands on the specific thread the draft was written for. Now: linked patient → /admin/patients/#communication so PatientTabs opens directly on the Messages tab (the #communication hash is the existing convention already used by the callbacks-owed-digest deep-link); unlinked sender → /admin/messages/email/ so the staffer lands on the per-thread audit view for that specific conversation (the page already falls back to id-match when threadId is null, so a message with no threadId still resolves). Selected threadId from the message row to support the unlinked case. Behavioral fix only — no PHI surface change, no schema change, no new API. ✨ Auto-fixed by Claude. [admin][isabella-drafts][routing][cmqq0zjls]
v2.97.RENEWALTAIL1
2026-06-21Production

Fixed a quiet bug that had stopped two kinds of renewal outreach since June 13: the 90-day 'we miss you' check-in and the 7-days-after-expiry win-back emails. The daily 21/14/7/0-day renewal reminders were never affected. The cause was the same database-query fragility behind the recent provider-chart crash: one query was written in a way the database engine can choke on, which silently aborted the rest of that nightly job. We rewrote the fragile queries the safe way and wrapped each stage so one part failing can never silently kill the rest again. We also swept the system and fixed the same pattern in several other nightly emails (welcome drip, intake reminders, 2-hour text reminders, review requests) and on the provider chart's medication/allergy panel.

Show technical details

Fixed

  • 🔧 **Renewals re-engagement + win-back tail-throw eliminated (live since ~6/13).** /api/cron/renewals ran a 90-day re-engagement query with a Prisma-7.8 nested-relation filter (where: { ..., patient: { emailUnsubscribed: false } }) — the same shape Prisma 7.8 throws on SYNCHRONOUSLY (CHARTFIX2 class), before a promise exists, so the per-query .catch() never runs and the throw escaped, aborting the rest of the route (re-engagement + win-back sends + the final heartbeat) every night. Rewrote the re-engagement query to a scalar where + in-memory emailUnsubscribed filter. Defense-in-depth: each post-stage block (escalation / re-engagement / win-back) now runs in its OWN try/catch so a single block throwing can never abort the whole route or suppress the final actor=renewals result=... heartbeat. The win-back query itself was already scalar (db.patient direct, emailUnsubscribed is a column) — it never threw; it just never got reached. The daily stage reminders (21/14/7/0d) were never affected. [cron][renewals][prisma][reliability][hipaa]
  • 🩺 **Health-check cron staleness now reflects last-FIRED, not just last-completed.** /api/health matched cron staleness on detail startsWith 'actor= ' (trailing space) — which only matches the POST-compute result= heartbeat. A cron that tail-throws or benignly early-returns (fires but never reaches its result= line) showed falsely 'stale' even though it ran today (why doh-nudge / waitlist looked broken when they were fine). Now takes the most recent of BOTH the pre-compute fire (actor=, written right after auth) AND the post-compute result (actor= result=...). Matches both shapes explicitly so a prefix actor (reminders) can't match a longer one (reminders-2h). Canary fallback preserved. [health][monitoring][observability]
  • 🧹 **Prisma-7.8 nested-relation sweep — same crash class fixed in 5 more crons + the chart med/allergy panel.** Converted nested-relation-in-where filters to scalar + in-memory filtering (behavior identical) in: doh-nudge, intake-reminder, reminders-2h, new-patient-drip (Day 3 + Day 14), and review-request — each fetched candidates with a patient: {...} (and in some cases workflowEvents: { none } / intakeForm: null) relation filter that can sync-throw and silently kill the send. Also fixed src/lib/ddi-shadow-source.ts (the SoapEditor medication + allergy reader, exact CHARTFIX2 intakeForm + appointment-relation where/orderBy shape) and src/lib/renewal-pipeline.ts assessAndMarkDoctorReady (its intakeForm.count({ where: { appointment: { patientId } } }) was caught by an outer try/catch but silently zeroed the doctor-ready signal). All use the proven flat two-step (scalar appointment ids → intake by appointmentId in). [cron][prisma][reliability][hipaa][sweep]
v2.97.CSRFLOG1
2026-06-20Production

Behind-the-scenes security hardening (no visible change).

What this means for you

Behind-the-scenes security hardening (no visible change). Added a cross-site-request guard so another website can't trick a logged-in staff/provider/patient browser into silently changing data in our system. It's running in 'watch-only' mode first — it logs anything suspicious but doesn't block yet — so we can confirm it never trips on a real action before we switch it to fully enforcing. First item from the expert review's security batch.

Show technical details

Changed

  • 🛡️ **CSRF same-origin guard on cookie-authed PHI mutations (security batch #1, LOG-ONLY).** src/proxy.ts now checks Origin/Referer on POST/PUT/PATCH/DELETE to /api/{admin,provider,patient,dispensary} against a host allowlist; cross-origin → logged as [csrf] would-block. Exempts vendor webhooks, cron/bearer routes, integrations, health; Next.js's built-in Server-Action origin check covers page-POST actions (out of scope here). Edge-safe. Default = log-only; flip CSRF_ENFORCE=true to hard-403 after observing zero false-positives (report-then-enforce, same discipline as CSP). security-auditor reviewed: clean, bypass-resistant (Origin/Referer are browser-forbidden headers), false-block risk low. [security][csrf][hipaa][log-only]
v2.97.ARMNOW2
2026-06-20Production

Two more from the expert review. (1) Fixed the Washington FAQ that said 'the evaluation can be done by telehealth' — that contradicted our actual rule: a first-time/new-patient visit is in person at Lynnwood, and telehealth is for renewals of returning patients. The FAQ now says exactly that. (2) Turned ON Isabella's stale-message safety net: if a patient's message to a human goes unanswered past our business-hours service window, Isabella now automatically sends a brief, no-details 'we got your message, we'll reach you by [next business time]' note on the channel they consented to. It only applies going forward (it does not message the old backlog), only for patients who consented, and the note contains no health details.

Show technical details

Changed

  • ⚖️ **WA telehealth FAQ corrected to match our licensed model + legal research.** /qualify/washington FAQ 'Can I do the whole thing online?' said the evaluation can be done by telehealth — contradicting GW's rule (RESEARCH_INITIAL_VISIT_IN_PERSON + Mariane 2026-05-15: new-patient first eval is in-person at Lynnwood; telehealth is renewals-only for returning patients). Rewrote the answer to state new=in-person / renewal=telehealth / card issued in person. Removes a self-authored public-page contradiction (regulatory exposure). [regulatory][wa][green-zone][claim-accuracy]
  • 🟢 **Isabella stale-warm-transfer SLA auto-ack ARMED** (ISABELLA_STALE_TRANSFER_SLA_ENABLED=true). Built-but-off since IRC0012; now on. When a warm-transfer to a human passes the business-hours SLA (default 4h), Isabella auto-sends the patient a generic no-PHI acknowledgement on their consented rail (M365 email / SMS-if-consented), forward-only (no retro-blast of the historical backlog), idempotent, per-fire cap 25, PHI-free audit. Closes patients-waiting-in-silence. [isabella][sla][armed][hipaa][consent-gated]
v2.97.ARMNOW1
2026-06-20Production

Three safety + polish fixes from the expert review. (1) Inbound faxes are now held, not stored, until RingCentral signs its data-protection agreement (BAA) — faxes are full medical records, and storing them with a vendor that hasn't signed yet would be a reportable issue, so the system now safely acknowledges each fax and logs it without saving the contents until the agreement is in place. (2) Appointment + renewal text reminders now greet patients by first name (the name was being collected but left out of the message). (3) Fixed a homepage claim that read 'walk in and walk out authorized' — that implies a guaranteed approval, which we can't promise; it now says most patients are seen same-day and the provider makes an independent decision.

Show technical details

Changed

  • 🔒 **Runtime BAA kill-switch on the inbound-fax line (expert-sweep P0).** /api/inbound/fax now fail-closes while RingCentral is BAA-pending: after auth + confirming a real inbound fax, if INBOUND_FAX_BAA_OK !== "true" it writes a PHI-free INBOUND_FAX_RECEIVED detail=baa_gated audit row, returns 200 (so RC doesn't retry), and does NOT fetch content or persist. Default (env unset) = gated. This stops a reportable §164 disclosure (storing full medical records with a vendor under no signed BAA) — the build-time vendor-baa gate only warned; this is runtime enforcement. Flip the env true the day the RC BAA executes. hipaa-architect reviewed: fail-closed + PHI-free confirmed. [hipaa][baa][fax][p0][fail-closed]
  • ✍️ **Appointment + renewal SMS now greet by first name.** smsReminder() + smsRenewalReminder() already received firstName but dropped it from the message body; both now lead with Hi , (guarded — omitted if name is blank). [sms][comms][personalization]
  • 🟢 **Green-zone fix: removed an implied-guarantee homepage claim.** The 'Same-Day Authorization / walk in and walk out authorized' trust card read as a guaranteed approval + dispensary steer. Now 'Same-Day Appointments / most patients are seen same-day — your provider independently reviews your records and makes the authorization decision.' [seo][green-zone][claim-compliance]
v2.97.PRVDOC1
2026-06-20Production

Providers can now upload medical records to a patient too — completing the 'records in one place' picture.

What this means for you

Providers can now upload medical records to a patient too — completing the 'records in one place' picture. Staff (admin/manager/reception) already got the Attach-records button on the patient page yesterday; this adds the provider side. A provider can only attach records to patients they actually have an appointment with (so it maps to the right chart), and any provider-uploaded record shows a 'Provider' tag in the patient's Documents list so it's clear who added it. (Patients upload via their portal, as before.) Note: the in-portal upload button for providers is the next small step; this ships the secure upload capability + correct labeling first.

Show technical details

Added

  • 📎 **Provider medical-records upload (closes the provider leg of cmqixd28q).** New POST /api/provider/documents — a provider attaches a record to a patient, scoped (HIPAA minimum-necessary) to an appointment they own (appointment.providerId === provider.id); the patient is derived from that appointment, so a provider can't reach a patient they aren't scheduled with. Dual-auth (legacy ?token= + new provider_session cookie) mirroring the existing provider document-download route. Same private-Blob + compress/EXIF-strip + 25MB + MIME-allowlist pipeline as the staff route (ADU0001), uploadedBy="provider". New PHI-free audit literal PROVIDER_DOCUMENT_UPLOADED (provider name + appt id + mime/size only — never filename/blobURL/patient identity). The admin patient Documents list now labels uploads **Patient / Provider / Admin** correctly (was Patient/Admin only). NO schema change (docType metadata = separate fast-follow). NO provider-portal upload button yet (next step). [provider][documents][hipaa][cmqixd28q]
v2.97.BAADISC1
2026-06-19Production

Corrected the public Privacy Notice and About page to accurately list the technology vendors we actually have signed Business Associate Agreements (BAAs) with. The old list named some services we don't use for patient health information (and that don't have BAAs with us) and left out several we do — so it could have misrepresented how patient data is handled. The list now reflects our real signed BAAs (Microsoft 365, AWS, Vercel, our database provider, Doxy.me, Retell AI, and Practice Fusion), and the 'we have BAAs with every vendor' wording was changed to the accurate 'we require a signed BAA before any vendor is allowed to handle health information.' No patient-facing functionality changed — this is an accuracy/compliance copy fix.

Show technical details

Changed

  • 🔏 **Privacy Notice + About: vendor/BAA disclosures corrected to match our actual signed BAAs (BAA_STATUS source of truth).** The /privacy "Third-Party Service Providers" list previously claimed signed BAAs with Resend, Salesforce, Stripe, and Twilio — none of which currently hold a signed GW BAA for PHI (Resend + analytics are code-gated OUT of the PHI path; Salesforce is decommissioning; Stripe/Twilio are pending) — and omitted vendors we DO use under signed BAAs. Replaced with the accurate set: Microsoft 365 (email), AWS Bedrock (AI), Vercel (hosting/storage), Neon (database), Doxy.me (telehealth video), Retell AI (voicemail), Practice Fusion/Veradigm (EHR). Softened the absolute "we have signed BAAs with all vendors" claim on both /privacy and /about to the defensible policy statement ("we require a signed BAA before authorizing any vendor to handle PHI"). Fixed an "above/below" cross-reference. Patient-facing legal accuracy fix; no behavior change. [privacy][hipaa][baa][compliance-copy]
v2.97.LOCMATCH1
2026-06-19Production
For front desk

The online booking form now shows the same clinics Isabella offers — filtered by new vs. returning patient.

What this means for you

The online booking form now shows the exact same clinics our phone receptionist Isabella would offer — filtered by whether the patient is new or returning. Before, the booking page listed every open clinic regardless of who can be seen there; now a first-visit patient and a renewal patient each see only the clinics that actually take their kind of visit, matching what Isabella says on the phone. This is the 'booking form must match Isabella by location' fix Mariane asked for. Behind the scenes it reads from the one shared rule list that already drives Isabella, chat, text, and email — so there's now a single source of truth and the surfaces can't drift apart. Also removed the 'providers missing headshot' line from the Launch Readiness checklist (Mariane asked to drop it).

Show technical details

Changed

  • 📍 **Booking form clinic list now matches Isabella by patient class (cmqell76a / Mariane).** /api/locations accepts an optional ?appointmentClass=NEW|RENEWAL and gates the returned clinics through the IDENTICAL getActiveLocations() + getAllowedProvidersAt() helpers in provider-location-rules.ts that drive Isabella's spoken clinic list (voice/chat/sms/email already share this single source of truth). Step3Appointment derives the class from isReturning (declared-new → NEW, declared-returning → RENEWAL, undeclared → unfiltered legacy list) and passes it; the per-class module cache is now keyed by class so a NEW-gated list never leaks to a RENEWAL patient. A clinic with zero providers for the class (e.g. Spokane for renewals, or any clinic after a provider sunset) drops out of the picker exactly as it drops out of Isabella's list, and an already-picked clinic that falls out of the gated set is auto-cleared. Unmapped locations stay visible (rule table governs only known GW clinics). No schema change. [booking][isabella-parity][locations][cmqell76a]

Removed

  • 🧹 **Dropped the 'providers missing headshot' line from Launch Readiness (cmpnh3qve / Mariane).** Removed the noPhoto check + its 'they're hidden from the public providers section' row from the site-wide PreflightWarnings admin banner (rendered on /admin/launch). The per-provider readiness table on /admin/launch is unchanged. [admin][launch][cmpnh3qve]
v2.97.LEADSRC1
2026-06-19Production

Leads now show where each one came from.

What this means for you

Leads now show where each one came from. Leads you add by hand get a purple ✋ Manual pill, and leads brought over from Salesforce will get a blue ⬇ Imported pill — so it's obvious at a glance which rows are migrated vs. brand-new website inquiries (web leads stay unlabeled since they're the bulk of the list). This is the 'tell imported leads apart' tag Mariane asked for; the Imported pill lights up automatically once the Salesforce lead import is run. (Also added a behind-the-scenes setup script that registers the inbound-fax line with RingCentral so faxes start landing in the app — that one's a Doug step.)

Show technical details

Changed

  • 🏷️ **Lead provenance pill on /admin/leads (G6 / Mariane).** Each lead's source= marker (already written into the LEAD_CAPTURED audit row by the route that created it) now renders as a pill: ✋ Manual (violet) for staff-created leads, ⬇ Imported (sky) for salesforce-import rows. Web captures stay unlabeled — they're the default + bulk of the queue, so a pill would be noise. Parsed via parseLeadDetail() in leads-shared.ts (new source field) so client + server read it identically. NOTE: the Salesforce backlog import (scripts/sf-import/import-recent-leads.ts) currently upserts into the separate Lead Prisma table, which /admin/leads does not read — for imported rows to appear here AND carry the pill, the import must also emit source=salesforce-import LEAD_CAPTURED audit rows (or the page must union the Lead table). That wiring rides with the import job itself. [leads][crm][provenance][g6]
  • 📠 **Inbound-fax RingCentral subscription register script (G8).** New scripts/rc-register-fax-webhook.mjs wires the /api/inbound/fax webhook to the RC fax line in one command instead of hand-clicking the RC dev dashboard. Uses the DEDICATED .biz AT&T Office@Hand creds (GW_RC_* + GW_RC_JWT_GW) — separate from the SMS/voice .com script — with the type=Fax event filter + verification token. App end was already verified healthy (fail-closed 403, idempotent); this closes the 'fax not receiving' gap, which was simply that the subscription was never registered. Doug-step: set RC_WEBHOOK_VERIFICATION_TOKEN, run the script, send one test fax to the RC fax DID. [fax][ringcentral][g8][dev-script]
v2.97.ESIGNUX1
2026-06-19Production

Made the patient e-sign experience a lot smoother — this is the New-Patient Packet patients fill out and sign in the portal.

What this means for you

Made the patient e-sign experience a lot smoother — this is the New-Patient Packet patients fill out and sign in the portal. Four improvements: (1) the signature box now fits the phone screen properly instead of running off the edge (about 70% of patients sign on their phone). (2) Patients now see a live 'Saving… / ✓ Saved' indicator, and if a save fails they get a clear warning instead of silently losing what they typed. (3) A progress bar shows how many required items (3 signatures + 7 initials) are done, with a 'Take me to what's left' button that scrolls to the next missed item. (4) If a submit hiccups, the error is now plain-English ('the connection timed out') instead of a scary code, and reassures them their answers are saved.

Show technical details

Changed

  • ✍️ **Signature pad is now responsive (mobile overflow fixed).** SignaturePad was a fixed 480px wide and spilled off / clipped on phones (~70% of patient traffic is iOS Safari at ~360-390px). It now measures its container and sizes the canvas BUFFER to the available width (capped at 480) so pointer coordinates still map 1:1 — the ink lands exactly where the finger touches at any width. Re-measure freezes once signing starts, so a mid-form rotate never wipes a signature. Benefits all 4 patient forms (packet, ROI, informed-consent, ack). Reviewed clean (coordinate math + legal-capture integrity verified). [patient-forms][e-sign][mobile][a11y]
  • 💾 **Auto-save status + failure surfacing on the New-Patient Packet.** The draft auto-save was fire-and-forget (void fetch) with no confirmation and no error handling — a failed save silently lost typed intake answers. Now shows a live Saving… / ✓ Saved pill and, on failure, ⚠ Couldn't save your last change — check your connection; it'll retry as you keep typing. Net §164.312(c)(1) integrity gain (silent clinical-data loss → visible, retryable). [patient-forms][reliability][hipaa]
  • 📊 **Completion tracker + jump-to-incomplete + plain-English errors.** The long single-scroll packet now has a sticky progress bar (N of 10 required items done = 3 signatures + 7 initials) with a 'Take me to what's left →' button that smooth-scrolls to the first incomplete item (intake sig → consent sig → acknowledgement). Submit button shows the running count when disabled. Submit failures now read 'the connection timed out / a network problem' (never a raw HTTP code) and reassure the patient their answers are saved. [patient-forms][e-sign][ux]
v2.97.PORTALFORMS2
2026-06-19Production

We finished consolidating new-patient intake + consent into one place: the patient portal.

What this means for you

We finished consolidating new-patient intake + consent into one place: the patient portal. Now when a NEW patient books, the system automatically prepares their New-Patient Packet (intake + informed consent, signed digitally) and it shows up in their portal under 'Forms to review & sign' — no separate emailed PDF, no second form to chase. The patient still gets just the one portal-welcome email, then signs everything in the portal. We also removed the old 'Complete your health intake form' link that used to appear separately on each appointment, since it was a second path to the same thing and caused the 'too many forms' confusion Mariane flagged. (The old intake link still works for anyone who already received one.) Renewals are unchanged — no auto-packet for them per Doug's call.

Show technical details

Changed

  • 📋 **New-patient packet auto-prepared in the portal on booking (G7 step 2 — Mariane cmq6203a7, Doug 'new→packet, retire-legacy').** fireAppointmentOnboarding now, for a NEW-patient appointment (appt.isNew), idempotently creates a NEW_PATIENT_PACKET PatientForm (status SENT, 7-day token) so intake + informed consent are waiting in the portal's 'Forms to review & sign' card. **No separate magic-link email** — the existing portal-welcome email is the single touch. Gated behind APPT_AUTO_ONBOARDING_ENABLED (already on). Idempotent (skips if a non-REVOKED/EXPIRED packet exists); PHI-free FORM_CREATED audit (mode=auto-onboarding-portal, no patient name — better than the admin path); system-sentinel createdById. Reviewed clean by hipaa-architect + Explore (the non-atomic find-then-create race is accepted: worst case is a benign duplicate shell, no PHI leak / consent gap). [hipaa][patient-portal][consent][onboarding][g7-step-2]
  • 🧹 **Retired the legacy per-appointment intake nudge.** /my-appointments/[token] no longer shows the separate 'Complete your health intake form → /intake/[token]' amber prompt on each appointment — the single 'Forms to review & sign' card (PORTALFORMS1) is now the one signing surface, removing the duplicate path that caused the 'multiple forms' confusion. The /intake/[token] route itself stays live so any already-issued legacy links keep working. [patient-portal][forms][g7-step-2]
v2.97.PORTALFORMS1
2026-06-19Production

Patients can now see and sign their forms right inside the patient portal.

What this means for you

Patients can now see and sign their forms right inside the patient portal. When a patient opens their appointments page, any consent or intake forms that are waiting for them now show up at the top as a 'Forms to review & sign' card — they tap it, review, and sign digitally, no printing or downloading a PDF from email. This is the first step of consolidating intake + consent into one in-portal flow (Mariane's request to stop the confusing 'multiple forms in different places' experience). It's purely additive — it doesn't change or stop any existing email yet; it just makes the portal the one place to sign. Next step needs your call: which form should auto-generate when a patient books (so the portal always has the right one waiting) and whether to retire the older standalone intake link.

Show technical details

Added

  • ✍️ **In-portal form signing surface (G7 step 1 — Mariane cmq6203a7).** /my-appointments/[token] now renders a 'Forms to review & sign' card listing the patient's pending PatientForm rows (status SENT/OPENED, live token) with a 'Review & sign' link to the existing /patient/forms/[accessToken] e-sign flow. Directly addresses the "seamless digital signing in the portal, not a downloaded PDF" ask. Query scoped to patient.id (the load-bearing fence); accessToken rendering mirrors /patient/portal/forms. Reviewed clean (PHI/auth/XSS). **Additive only** — no email is sent or suppressed by this change. [hipaa][patient-portal][forms][consent][g7-step-1]
v2.97.ADU0001
2026-06-19Production

Staff can now attach medical records directly to a patient's chart and to a specific appointment — for records that come in by fax, email, or in person. Look for the new 'Attach records' button on the patient's Documents tab and in the appointment's Medical Records section; you can select several files at once (PDFs or photos, up to 25 MB each). On the leads side, the document uploader now takes multiple files in one go instead of one at a time. We also fixed a rough edge where a troublesome PDF would fail with a generic error — uploads now give a clear message (e.g. 'it may be corrupt or password-protected') instead of a silent failure. Files are stored on our HIPAA systems and every upload is logged.

Show technical details

Added

  • 📎 **Staff document upload onto patient charts + appointments (ADU0001).** New POST /api/admin/patients/[id]/documents lets ADMIN/MANAGER/SCHEDULER attach a MedicalDocument (uploadedBy="admin", optional appointmentId) for records received by fax/email/in-person — closing the long-standing "Admins can attach files in future" placeholder on the patient DocumentsList and appointment DocumentsPanel. Mirrors the patient-portal + lead-documents pattern exactly: private Vercel Blob (BAA) + compress/EXIF-strip before put() + ADMIN_DOCUMENT_UPLOADED audit with PHI-free detail. appointmentId is ownership-checked against the patient (no cross-patient attach). GET/DELETE legs already live at /api/admin/documents/[id]. Closes reviewer-feedback cmqlrd4pf. [hipaa][phi][documents][admin]
  • 🗂️ **Multi-file upload — lead documents panel + new staff attach.** LeadDocumentsPanel (and the new patient/appointment attach controls) now accept several files in one pick and upload them sequentially, respecting the server's 10-doc-per-lead cap (413 stops the batch) with a clear "N uploaded, then …" message on partial failure. Closes reviewer-feedback cmqlqmzu. [documents][ux]

Fixed

  • 🩹 **PDF upload "there's an error" hardened across all three upload routes.** compressPatientUpload was called unguarded in the lead-documents, patient-portal-records, and (new) admin routes — a decode/compression throw on a corrupt or password-protected PDF (or a sharp image-decode failure) bubbled to an unhandled 500 that surfaced as a generic "there's an error." Now wrapped: returns a clean 422 with an actionable message ("it may be corrupt or password-protected — try re-saving/exporting it") and logs only the error name (no PHI). Closes reviewer-feedback cmqlrabth. [documents][reliability][hipaa]
v2.97.CHARTFIX2
2026-06-19Production

Fixed the root cause behind the provider chart crashes (the ones that bounced doctors back to Practice Fusion).

What this means for you

Fixed the root cause behind the provider chart crashes (the ones that bounced doctors back to Practice Fusion). One small database query on the patient chart's context rail was written in a way the database engine could choke on and crash the whole chart — and it crashed in a way our safety net couldn't catch. Rewrote it as two simple, safe queries that do the same thing (show the patient's latest allergy note) without the fragility. The provider chart canary watches this every 30 minutes, so we'll know immediately if anything regresses.

Show technical details

Fixed

  • 🩺 **Provider chart crash root cause (ARI0003/ARI0004 class) eliminated.** PriorContextRail (embedded on every encounter chart) loaded the latest allergy note with a Prisma nested-relation query — db.intakeForm.findFirst({ where: { appointment: { patientId } }, orderBy: { appointment: { startsAt } } }). Prisma 7.8 can throw on that shape **synchronously**, before a promise exists, so the per-query .catch() never runs and the throw escapes to crash the whole chart (doctors → Practice Fusion). Prior fixes (CHARTFIX1/ARI0004) wrapped the rail in a resilient boundary — a backstop, not a cure. This replaces the query with a flat two-step using only scalar where/orderBy (patient's appointments newest-first → their intake rows by appointmentId in → pick the newest), which Prisma can't choke on. Behavior identical (newest appointment's allergies); reviewed clean against schema. Provider-chart-canary continues to self-validate every 30 min. [provider-portal][chart][prisma][reliability][hipaa]
v2.97.RENEWALMIRROR1
2026-06-19Production

Isabella now handles renewals the same way on chat, text, and email as she does on the phone.

What this means for you

Isabella now handles renewals the same way on chat, text, and email as she does on the phone. If a returning patient is renewing and can get us their records by their appointment, she books them instead of calling it a tentative request stuck behind a records review — and she points them to upload their records right in their patient portal (the new upload box). New-patient messages are unchanged: still a tentative request until the team reviews records. This finishes mirroring the phone behavior across every channel.

Show technical details

Changed

  • 💬 **Renewal booking + portal records rail mirrored to chat / SMS / email.** The phone change (RENEWALBOOK1) now applies on every channel: each AI prompt (chat route.ts, sms-ai.ts, email-ai.ts) branches the booking-confirmation language on patient type. A RETURNING patient renewing who can get records in by the appointment is booked (office confirms exact time) with no records-review gate; the NEW-patient path keeps the tentative-request framing AND its pinned phrases ("tentative appointment request" / "provider must review" — still enforced by the channel-parity tests). All three now point patients to upload records in the patient portal (greenwellness.org → "Your records", the PORTALUPLOAD1 box) as the easy path, with fax/email as fallback. [isabella][chat][sms][email][records]
v2.97.PORTALUPLOAD1
2026-06-19Production

Patients can now upload their medical records right in their patient portal.

What this means for you

Patients can now upload their medical records right in their patient portal. Until now the portal could only SHOW records already on file; there was no way to add one without the emailed secure link. Now there's an upload box on the patient's “Your records” page (sign-in required), so a returning/renewal patient can send their records straight from the portal — which is exactly what Isabella now points them to. We also tightened how uploaded files are shown: any file type that could carry hidden code (like an SVG) now downloads instead of opening in the browser, on both the patient side and the staff/provider side.

Show technical details

Added

  • 📤 **Patient-portal records upload.** New session-authenticated endpoint /api/patient/records/upload + an upload box on /patient/portal/uploaded-records. A signed-in patient uploads a record → it stores to private Vercel Blob (BAA tenant), EXIF-stripped + compressed (same compressPatientUpload pipeline as the emailed-link path), writes a MedicalDocument row scoped to session.patientId only (no IDOR), and shows in their on-file list. 10 MB cap, per-patient fail-closed rate limit, orphan-cleanup on DB failure, PHI-free audit (PATIENT_PORTAL_DOCUMENT_UPLOADED). Reviewed by hipaa-architect (compliant) + security-auditor. This is the path Isabella points renewals to. [patient-portal][records][hipaa]

Fixed

  • 🛡️ **Stored-XSS hardening on document viewers (security-review finding).** Uploaded records are served by 4 routes (patient /api/patient/documents/[id], provider /api/provider/documents/[id], admin /api/admin/documents/[id], and the records-review source viewer). They served the stored MIME inline, so a malicious SVG (image/svg+xml) with an embedded