OdontoX v2.0 Stable — internal release notes
Audience: OdontoX engineering + ops. Window covered: 2026-07-26 → 2026-09-20 (~45 entries inRELEASES.md, 79 commits v1.10..1c43fdc9). Items already written up in v1.10 (revenue analytics, prescription fixes, permission caching, required fields, conflict detection) are not repeated here.
Login tag at cut: v2.0 (ui/src/components/ui/sign-in.tsx APP_VERSION; it was still reading v1.9 because the bump was missed at v1.10).
Public name: v2.0.
Deploy targets: odontox-server Worker (production env), odontox-app Cloudflare Pages canonical, marketplace-app Cloudflare Pages.
Database: odontox-prod Neon project, schema app.
This document mirrors the public doc plus full technical depth. Sections marked “Superadmin” are internal-only and must never be cross-posted to q.odontox.io or any clinic-visible surface.
Why this is a major version: finance moves from hand-maintained balance columns to an append-only ledger with database triggers and closed periods. How money is calculated changes fundamentally. RBAC also moves to a layered resolver with custom roles, and the per-user permission editor is retired as a write surface.
What shipped in v2.0 — feature headlines
New features- Append-only money ledger (
invoice_ledger), receipt cancellation, Reset to Zero, day close / reopen with forward restatement (§2) - Editable issued invoices with staff-only edit trail (§3.1)
- Configurable day-end report: sections, 12 selectable headline tiles, manual cash lines with audit trail (§3.1)
- CANCELLED stamp on invoice document and PDF; ledger-derived cost/profit (§3.1)
- Custom clinic roles, single Roles surface, v2 permission editor, “Primary User” naming, Change Role from the staff list (§3.1)
- Calendar status filter (multi-select, live counts), persisted Hide cancelled (§3.3)
- Clinic-wide Clinical Notes with server pagination and search; Add Note from the patient record (§3.2)
- Dental chart on tablets and phones; chart history and auto-save snapshots; URL-synced deep links (§3.2)
- Medical History tab rebuilt (§3.2)
- WhatsApp inbox: X-rays card, per-conversation drafts with DRAFT badge (§3.3)
- HR documents: blank or letterhead export, re-render of issued documents (§3.1)
- Mobile push notification backend (outbox, preferences, device logout). No shipping client yet (§4)
- Landing: separate boot path, Lighthouse mobile 55 → 96 (median 86–96, see §11), hero film (§3.5)
- Credit notes silently discarded by the next payment’s balance recalculation
- A 15% early-exit fee withheld from refunds for clinics that never configured one
PUT /invoices/:idaccepted client-suppliedtotalAmount/status/patientId- Medical History never saved (missing
clinic_idcolumn) andGET /patients/:idreturned 500 - Clinical Notes only saw the 50 most recently registered patients
- Every WhatsApp attachment failed to send from 16 May to 16 Sep 2026
- WhatsApp auto-confirm sent repeated “couldn’t find an appointment” replies on Meta retries
- DeepSeek credit drained by timed-out generations × SDK retries × query retries
- Several permission save paths reported success without persisting; per-user snapshots froze permissions
- Patient role could be granted invoice-create and send permissions
- Inventory item delete returned 404 (route never implemented); doctors had no Inventory nav entry
- Landing white-flash / auto-reload loop, a hero
<video>pointing at a file that never existed, 2.4 MB of print-resolution logos
1. Header — scope and deploy artifacts
2. Headline milestone — the money ledger
Symptom that forced it. There were four different formulas forinvoices.balance: receipt payment (total − paid), invoice amendment, credit note and store credit. Only the credit-note one was right. Recording any payment after a credit note put the invoice back to its original amount. One live clinic ended up with an 82,500 invoice, an unintended refund and a 15% fee they had never configured, because the only way to correct a mis-keyed receipt was to reshape the invoice and chase it with credit notes.
Design.
- New append-only
app.invoice_ledger. The balance isSUM(amount), and the cachedinvoices.balance/total_cost/profit/statusare rollups written bysyncInvoiceRollups. - Each entry carries three independent signed axes:
amount(what the patient owes),cash_amount(what moved through the till) andrevenue_effect(what was earned), pluscost_effect. A store-credit note moves the balance and reverses revenue but moves no cash. An early-exit fee moves the balance and books revenue but moves no cash. Collapsing those axes is what made credit notes invisible to every report. - Credit-note arithmetic, stated once in
lib/finance/movements.ts: post−creditedRevenue, and the excess re-enters asfee + refund + storeCredit. That identity is asserted at write time, so a note that cannot settle to zero is rejected. For a fully settled cash invoice, recognised revenue equals net cash. - Opening charges and receipt payments are posted by DB triggers (
invoice_ledger_open_charge_trg,invoice_ledger_receipt_payment_trg). Invoices are created from 13 call sites. Posting from application code would hold only until someone added the 14th. - Amendments post a
charge_adjustmentdelta instead of rewriting the total. That is what allows an invoice in a closed period to be corrected. - Day close.
app.day_closesstores the signed-off snapshot.resolvePeriod()refuses to date an entry into a closed day and restates it forward withis_restatement/restates_date.GET /day-close/:dateserves the stored snapshot and recomputes alongside it, so drift is loud. Reopen requires a reason. - Receipt cancel reverses the payment with a ledger entry and keeps the receipt (status cancelled).
- Reset to Zero cancels receipts, voids credit notes, returns store credit and reverses the charge. Each reversal is dated to the original movement’s day, and the invoice is then cancelled. Reason required, audit-logged.
- Voiding a credit note now asks whether refunded cash was actually recovered. Previously it restored the invoice to fully paid while the cash had already left the till. A store-credit shortfall (patient already spent it) is reported instead of being absorbed by a
GREATEST(0, …)clamp. - Early-exit fee defaults to 0 unless configured. Previously it was 15% on every refund, booked as income.
- Nothing in finance is hard deleted. Draft credit-note discard is a soft delete (
deleted_at/deleted_by/delete_reason).app.invoicescascades to receipts, credit notes, payments and line items, so a hard delete would destroy the record of cash physically in the drawer. - Tests: 24 in
lib/finance/__tests__/ledger.test.ts, including the live incident and its remedy.
3. All public headlines, plus extra technical detail
3.1 Owners & Admins (Primary User)
Finance — fixes woven in- Credit notes lost on the next payment. Root cause: the receipt path’s
total − paidformula (§2). Fixed by the ledger. - Refunds and credit notes absent from the day-end.
reports.tsfiltered credit notes byissued_at::date(UTC) and receipts byreceipt_date(clinic-local), so an early-morning credit landed in a different period from the payment it reversed. It also subtractedcost_reversalfor store-credit notes without subtracting revenue, which inflated profit. Day-end and reports now read from the ledger, with a fallback to legacy receipt-based figures for periods the ledger does not yet cover. - Part-paid overdue invoices dropped off the overdue list. Status is re-derived from what has been settled.
- Store credit could drive a balance negative, which reduced the clinic’s outstanding total.
- Credit-note numbering could jam. Discarding a draft rewound the counter onto a number already in use.
- Stale balances across views. Credit-note mutations now invalidate the invoice list, revenue overview and day-end.
PUT /invoices/:idwasinvoiceCreateSchema.partial()spread into the update. It acceptedtotalAmount,statusandpatientId, never recomputed the balance, and bypassed every guard on/edit. It is now a strict non-money patch (dueDate,notes, cancel-only status), and cancelling through it triggers a rollup re-sync.metrics/financial.tsexcludes cancelled invoices from billed revenue and nets offamount_credited. Before this, exactly one route read that column.- Cancelled invoices reported their original profit (
profit/total_costwere written once at creation). They are now ledger-derived, and the list also guards at render time for legacy rows not yet re-synced. The cancelled badge fell through to the amber “unpaid” style. - CANCELLED stamp: an inline SVG on screen (CSS-rotated text is dropped by print and html-to-canvas pipelines) with
print-color-adjust: exact, and react-pdf primitives incomponents/CancelledWatermark.tsxfor the PDF.
PUT /api/v1/protected/invoices/:id/edit)
- Gated on
billing.invoices.editalone. The route’s genericbilling.invoices.createwrite gate opts out viaskipPathSuffixes.billing.invoices.editis now granted wherever.createis, in bothserver/src/lib/permissions.tsandui/src/lib/permissions.ts(the parity test enforces this). - Server recomputes subtotal, tax, surcharge, total, cost, profit and balance. Items and the invoice row are written in one transaction. Status is re-derived, including the born-overdue rule.
- Refused below amount paid. Hidden for cancelled, payment-plan and split invoices. Invoices with a credit note are editable again: they were locked in the first cut and unlocked once amendments posted deltas.
- New columns
edited_at,edited_by,edited_by_role,edit_count,last_edit_reason. - Leak protection:
stripEditTrail()inroutes/invoices.tsxfor patient-role reads, androutes/public-documents.tsdrops the columns from itsgetTableColumns(invoices)spread. The PDF,InvoiceDocumentand the share email are untouched by design. - Audit: an
editedrow vialogInvoiceEventwith a per-field diff plus anitemsdiff (itemsSnapshotis ignored by the timeline differ). Staff are notified; the patient deliberately is not.
app.eod_settings(one row per clinic) andapp.eod_manual_entries.sections/tilesare opaque JSON. The catalogue lives inui/src/components/reports/eod-catalogue.ts, so adding a section or tile is a UI-only change. A layout saved before a new section existed shows the new section by default.- Layout and manual entries are also returned in the day-end payload, so the PDF and nightly email honour the same configuration as the screen.
- Manual lines move cash, never revenue.
include_in_totalsseparates cash-moving lines from reference lines. They are soft-deleted, and every create, edit and delete writes a field-level diff toaudit_logs(GET /eod-config/entries/:id/history). Blocked on closed days. - Fix (09-09b): layout reset on refresh, and a save could wipe config.
EODCustomiseSheetseeded fromDEFAULT_SECTIONSwhenever the report was absent, so saving from a fresh page overwrote the real layout with defaults. It now owns an['eod-layout']query, seeds once per open from a resolved response, disables Save while loading, and is mounted at page top level. - Fix (09-09d): manual lines didn’t appear until the report refetched.
EODManualEntriesowns['eod-manual-entries', date]. ui/src/lib/actor.tsx(<Actor>,formatActor(),useIsMe()) provides the “(you)” marker acrossActivityTimeline.
clinic_rolestable, plusrole_idon assignments and invitations (migration 0072, mirrored inschema-ensureand/run-migration).- Resolver order: plan defaults → role layer (template or custom role) → per-user deltas → role core → superadmin overrides. The per-clinic role beats the global user role.
- Fixes: staff invite, staff create and one editor save path reported success without persisting. Saving one person’s permissions snapshotted the full set, so later role edits never reached them. Only deltas are stored now, and legacy snapshots were purged (
96c0d89e). A false “grants outside plan will stay inactive” warning was removed. Unknown keys return 400 instead of being dropped. All mutations are audit-logged and flush the permission cache. - Single write surface (09-03). Roles is the only place permissions are changed. The per-person screen is read-only with a “Reset to role” action; 2 of 25 active assignments carried per-user exceptions at cut, and
user_permission_overrideswas empty. The editor is one accordion overPERMISSION_TREE, verified to cover all 204 server keys. adminis labelled Primary User vialib/roleLabels.ts. The role key is unchanged.- Security fix: the Roles screen offered all 204 keys to the Patient role, and
invoices.tsxgates on keys with no role check, so a portal patient could have been granted invoice create/send/share.PATIENT_ALLOWED_PERMISSIONSnow caps this in the editor, on template and custom-role writes, and inmaskPermissionsForRoleafter the resolve. ROLE_PERMISSION_FLOOR→ROLE_CORE_PERMISSIONS, now advisory at the owner’s request. It renders “Recommended” and asks for confirmation before switching off. Known trade-off: an admin can now leave a doctor unable to open a chart.- New keys:
settings.roles.view/settings.roles.manage,inventory.delete(admin-only, deliberately not in the legacymanage_inventorygroup), and a distinct supplier-delete key. Staff-management actions are individually gated. - Change Role guards: not yourself, not the last admin, only an admin can promote to admin.
- Permission queries refetch on window focus.
- Blank header/footer by default. Options are Standard, Physical letterhead (gaps) and Digital letterhead. The physical/letterhead top gap is floored at 60 mm (bottom 25 mm), because multi-doctor mastheads are 55–60 mm and the 40 mm document default overlapped them.
GET /hr/documents/:id/renderre-derives title, body and disclaimer from the storedmerge_data. No schema change.
DELETE /inventory/:idwas never implemented, so the UI got a Hono 404. It is now implemented. The blanketrequirePermissionByMethodgate collapsed every write ontoinventory.create;skipMethodsnow defers DELETE to per-route middleware.inventory_alertsandstock_transactionshave no physical FK in the live DB despite Drizzle’sonDelete: cascade, so dependants are removed explicitly. The UI now gates Edit, Delete and stock actions on the server’s keys.
3.2 Doctors
Clinical Notes- Root cause: there was no clinic-wide endpoint.
getAllClinicalNotes()fetched page 1 of/patients(50 rows,createdAt DESC) and issued one request per patient: a 50-patient window and 51 round-trips. Each new registration pushed the oldest of the 50 out of view, along with their notes. The New Note and SOAP voice pickers used the same list. - New
GET /clinical-notes(clinic-scoped,page,limitcapped at 200,patientId/doctorId,filter=all|this-week|follow-up, serversearchover name, patient number, complaint and diagnosis, orderedvisit_date DESC, created_at DESC, id DESC) andGET /clinical-notes/stats.GET /:idjoins the patient label.GET /patient/:idis now newest-first. - Cache invalidation moved into
serverComm(create/update/deleteClinicalNote); notes are written from six call sites. Added the missingdeleteClinicalNotebinding. OdontogramChartresolves the patient viagetPatient(id). This fixes the blank panel, and the child-mode detection that never read the age, for patients outside the 50-row window.- Index
clinical_notes_clinic_visit_idx(drizzle0074, created byensureClinicalNotesSchema()). Tests:routes/__tests__/clinical-notes-list.test.ts(6). - Add Note from the patient record (
c47b9dce), gated on the clinical-notes write permission.
- Container queries (
@container odontogram) replace viewport media queries. Panels switch between slide-over and Vaul drawer by device, and the tablet bottom nav is enabled. - Chart history: patient-level auto-save snapshots.
odontogram_snapshots.appointment_idis now nullable (ALTER … DROP NOT NULLinschema-ensure), and the table itself is ensured on cold start.odontogram-snapshots-api.tswas committed to stop version regression on clean builds. - Bidirectional
?patientId=sync. Deep links skip the empty-state flash. - Per-tooth notes trigger auto-save. The dark-mode note popover is appended inside
.odontogram-root, so it inherits theme variables. - Odontogram v2 Black classification and periodontal panels arrived in the
4ea94cc3WIP commit (previously live only via working-tree deploys).
- Root cause:
app.patient_medical_historywas created withoutclinic_id, andoral_habitsexisted only in drizzle0027(not applied to prod). Drizzle expandsselect()to an explicit column list, so every read and write failed with42703.GET /patients/:idreturned 500, andserverComm.getPatientswallowed that tonull, which is why the tab rendered empty. ensureMedicalHistorySchema()adds the columns, aclinic_idindex and a unique index onpatient_id, then backfills. It runs before the patient-create transaction so DDL never runs inside it, with separate try blocks so a pre-existing duplicate cannot block the repair.drizzle/0073andscripts/apply-0073.tsare committed as a record only.oralHabitsadded to the zod schema (it was being stripped).smokingStatusmaps''toundefined. Chip drafts are folded into the save. The form re-seeds from the save response instead of a stale cache.- Regression test
schema/patient_medical_history.test.tsasserts every Drizzle-selected column exists inbase_schema.sql.
3.3 Receptionists
Calendarui/src/lib/appointment-status.tsis the single visibility rule: an explicit selection wins, otherwise cancelled is dropped whenhideCancelledis on. The grid, mini-calendar dots, per-doctor counts and utilization all use it.- Scheduler context:
filterStatus: string | null→filterStatuses: string[], plus role-persistedhideCancelled.useDayIndexreturnshiddenByStatus. bookedMinutesForskipscancelled,no_showandmissed(occupiesChairTime).- Fix:
missed(a live enum value on historical rows; the auto-marker cron was retired 2026-05-23) was missing fromstatus-style.ts, both event-variant mappers, the Queue badge map andAppointmentStatus. It rendered as blue “scheduled”. - Fix: sidebar status pills navigated to the Queue screen, which is not mounted in any dashboard (see §11). They now filter in place.
viewRangeKeys()scopes counts to the visible range. 25 new tests.- Reschedule dialog:
calculateAvailableSlotsacceptsdurationMinutes.
- Attachments failed from 16 May to 16 Sep.
a92593d9moved the picker to/files/<id>/download, butPATIENT_FILE_DOWNLOAD_REin the send path matched only/patient-files. Unmatched URLs fell through to Meta’slink:with a relative, auth-gated path, anddeferMetaSendmarked the rows failed. Classification now lives inserver/src/lib/attachment-urls.ts(13 tests), and thelink:fallback throws a named error on a non-https://URL. ?preview=trueuploadspreviewKey(converted PNG) with the matching mime and filename, instead of raw DICOM/TIFF.GET /patient-files?patientId=now honoursfileType,category,source, a newradiology=true,order=descandlimit. It previously ignored every filter. Default ordering is unchanged. The X-ray card fetches once, with a thumbnail concurrency cap of 4, and blob URLs are revoked on switch.- Drafts leaked across conversations.
Composerwas not keyed, so the save effect wrote the previous conversation’s text under the new key. It is now keyed byconversationId.ui/src/lib/chat-drafts.tsstores{ text, attachments }metadata in localStorage, with an in-memory preview map anduseSyncExternalStorehooks. LegacychatV2.draft.<id>keys are migrated. 14 tests. - Auto-confirm repeat replies. The CONFIRM/CANCEL branches returned before the only insert that stamped
messages.wamid, so the idempotency guard never matched a Meta re-delivery.claimIntentMessage()writes the row with its wamid first. Handlers return'done' | 'noop' | 'none', so “nothing found” is sent once per sender, not once per matching patient row. Already-confirmed is silent. Work runs underwaitUntilso Meta is acknowledged immediately.ensureMessagesSchemaaddswamidand its index, and is actually invoked on the inbound path (f7905737).
3.4 Ruby (AI)
- DeepSeek balance drain (09-03).
client.tshadtimeout: 30_000, while structured briefs take 60–90 s (reception-day-briefsucceeded at 75.4 s). A client timeout does not cancel upstream generation, so each timed-out generation was billed in full. That was multiplied by theopenaiSDK defaultmaxRetries: 2(Langfuse showed 91.3 s ≈ 3 × 30 s) and by TanStackretry: 1. Over 2–3 Sep there were 96 errors against 10 successes (Langfuse projectcmnyttcqe001uad07z13swsqc). - Fixes:
maxRetries: 0,REQUEST_TIMEOUT_MS = 120_000, and a per-promptNamecircuit breaker (3 failures → 10-minute cooldown, checked before the prompt or Langfuse is resolved; per-isolate best effort).AIInsightsPagenudges queryretry: 0. Tests:lib/ai/__tests__/cost-guards.test.ts. 89cc51a4:reception-day-brief,reception-prep-hintsandappointment-nudgesare now cached inai_insight_cache. Nudges are cached per clinic, day-brief per user (the name is in the prompt), and prep-hints by an order-independent hash of appointment IDs. Output caps went from 32000 toMAX_TOKENS_STRUCTURED_BRIEF(8000), and the truncation retry from 64000 to 16000. Cache hit/miss tokens and a per-call USD estimate at peak/off-peak rates (peak is PKT 06:00–09:00 and 11:00–15:00) are emitted to Langfuse and an[ai-cost]log line.- WhatsApp voice agent (Dental Square pilot). Fixes shipped: the global webhook now admits
field:'calls'; there is a forced agent start after the 5.5 s welcome fallback; caller audio is wired after accept; a duplicate-offer guard stops a Meta retry clobbering a live call;pre_acceptis sent before accept; and there is SDP/ICE diagnostics logging. The pilot is not live. ICE never completed in live probes. Keep it off the public notes.
3.5 Patients, portal and marketing site
- Patient-role reads strip the invoice edit trail (§3.1). The portal medical-history save endpoint gets the same schema fix (§3.2).
- Landing (09-20, six commits).
Landingwaslazy(), andFullPageLoadingran a 12 slocation.reload()watchdog, which caused the white flash and the reload loop on slow mobile. The watchdog is now connection-aware (30 s plus a manual button on saveData / 2g / 3g).main.tsxhas two boot paths.isLandingOnlyRoute()(which must stay identical topublic/prerender-guard.js) boots<ThemeProvider><Landing/></ThemeProvider>, andAppis imported dynamically. Entry JS went from 219 KB to 82 KB gzip.LandingNavbarusedhasAuthCookie()instead of importingserverComm.- Logos were 6250 px masters (2.4 MB per page). They are re-encoded to 480 px and versioned (
logo-v2.webp,logo-light-v2.webp), because_headerspinned a 7-day cache on the unversioned URL. - Inline head scripts (
theme-init,prerender-guard,chunk-recovery). Session replay and surveys are off on public marketing views (isPublicMarketingView()). GTM is gated to the conversion paths and click-ID traffic, so Ads attribution is preserved. /auth_hero.webmnever existed: Pages served SPA HTML for the 404. The hero film is now on R2 (hero-film.{webm,mp4}, 0.52 MB, 0.75×), desktop-only viahidden md:blockplus a lazy poster.HeroAppPreview,BrandFilmandLazyVideoare deleted.- Mobile heading clip fixed (
min-w-0). Touch targets are now at least 36 px. The contact form usesreadApiError()and aCONTACT_MESSAGE_MINcounter. Cookie consent moved to localStorage. Smooth scroll usesscroll-padding-topandprefetch-on-intent.ts. The mobile navbar blur was dropped. - Brand spelled
odontoXon marketing surfaces only (regex-bounded, identifiers untouched). Email, PDF and legal text still read “OdontoX”.
4. Architecture & data-flow notes
- Finance source of truth is now
invoice_ledger.invoices.balance,total_cost,profitandstatusare caches. Anything reading money should read the ledger or the synced rollups, never recomputetotal − paid. - Triggers own posting for opening charges and receipt payments. Application code posts credit notes, refunds, store credit, fees, amendments and reversals via
lib/finance/movements.ts. - Closed periods are immutable. Writes resolve their period through
resolvePeriod(), and anything dated into a closed day restates forward. - Permissions: there is a single write surface (Roles), and the resolver layers are listed in §3.1. The UI mirrors
ROLE_CORE_PERMISSIONSandPATIENT_ALLOWED_PERMISSIONS, andpermissions-ui-parity.test.tsfails on drift. - Push: every in-app notification enqueues to
notification_outbox. Delivery runs immediately viaexecutionCtx.waitUntil(processOutbox+checkExpoReceipts). The cron path was removed (985f40bc). Producers: WhatsApp inbound (known and unknown senders), Ruby escalation (high priority), appointment changes, prescriptions and billing. - Shared numeric input:
<Input>renders controlled numeric fields astype="text"withinputMode.onChangestill receives the unformatted value, so all ~103 call sites are unchanged. Precision comes fromstepand negatives frommin, andformatThousands={false}opts out. Nothing in the codebase readsvalueAsNumberor usesstepUp/stepDown. 12 tests. - Page width:
--page-max-width(1600px),--page-pad-xand--page-pad-yon:root, and.page-container/.page-padutilities. 18 hardcoded caps were replaced across 14 screens. Marketing and onboarding screens keep narrow caps on purpose.
5. Schema changes — migrations + lazy-ensured tables
The drizzle migration pipeline is not applied to production. Everything below self-heals throughserver/src/lib/schema-ensure.ts on first use, unless noted otherwise.
6. New backend modules / files (significant)
server/src/lib/finance/movements.ts, the ledger posting helpers,scripts/reconcile-finance.tsserver/src/lib/attachment-urls.tsserver/src/lib/ai/client.ts(timeouts, breaker, cost telemetry; rewritten, not new)server/src/lib/push-enqueue.tsand the outbox processor- Day-close and
/eod-configroutes;/clinic/rolesroutes; HR render route - UI:
lib/actor.tsx,lib/chat-drafts.ts,lib/appointment-status.ts,lib/roleLabels.ts,components/reports/eod-catalogue.ts,components/CancelledWatermark.tsx,prefetch-on-intent.ts,HeroFilm
7. API surface changes (selected)
Update
docs/api-reference.md with the rows above.
8. Cron jobs added / changed
- Removed: push outbox delivery and receipt-check cron handlers (
scheduled.ts). Delivery is immediate viawaitUntil, and token cleanup happens as a side effect of delivery. - Nightly day-end email is unchanged in schedule. It now honours
eod_settingsand reads from the ledger.
9. Configuration / env vars
- No new required production env vars in this window.
- Not in this release:
DB_DRIVER=neon-httpfor the Oracle UATneon-compat-proxyis an uncommitted change inserver/src/lib/db.tsat cut time.
10. Superadmin tooling (internal-only)
- Superadmin permission overrides remain the top resolver layer (§3.1).
GET /api/v1/protected/push/diagnostics(routes/push-diagnostics.ts,e7a8f06a) for inspecting push delivery state.- Generated role and permission reference at
internal-docs/rbac-reference.md(regenerate withserver/scripts/generate-rbac-doc.ts).
11. Known issues / follow-ups
scripts/reconcile-finance.tshas not been run against production. Until--applyruns, pre-ledger invoices fall back to legacy figures, and rows raised before 09-09 may still carry staleprofit/total_cost(it reports these ascancelled-with-profit). Run it read-only first and review the drift report.- The Queue & Action Required screen is orphaned.
AppointmentQueueViewis reachable only viaAppointmentsFullPage, which nothing imports (dropped ina6bed91b). The queue fixes (cancelled excluded, Missed tab, virtualization) are correct but unreachable. Remounting it is a product call. - 50-row patient cap still in seven pickers:
InsuranceClaims,BillingModule,AppointmentInvoiceDialog,AppointmentInvoiceSheet,LabWorkTracking,StaffManagement, and thePatientsTabreport. They need to move toPatientPicker. ui/src/components/patients/PatientClinicNotes.tsxis orphaned and does not compile (it imports five non-existentserverCommmembers).messages_wamid_idxis non-unique, so two simultaneous deliveries can both pass the claim. The already-confirmed guard makes this harmless in practice. A unique index needs a duplicate cleanup first.- Push has no shipping client. Known gaps:
channelId, per-category preferences, andnotificationIdon payloads; several producers still missing. - WhatsApp voice agent is not live (ICE never completes; see §3.4).
- Landing mobile LCP is bimodal (medians 86–96).
createRoot()discards the prerendered DOM and re-creates the<h1>. The fix is hydration. The 562 KB render-blocking stylesheet is still shared with the app. - The R2
assets.odontox.iobucket still holds the 285 KB logo master forgetStaticAssetUrl()callers (in-app onboarding and upgrade pages). ROLE_CORE_PERMISSIONSis advisory, so an admin can lock a doctor out of charts. This trade-off was accepted.- The circuit breaker is per-isolate, not global.
12. Deploy checklist
A. Migrations. None to apply by hand. Confirmschema-ensure ran on prod by checking that app.invoice_ledger, app.day_closes, app.eod_settings, app.eod_manual_entries and app.clinic_roles exist, along with patient_medical_history.clinic_id and messages.wamid. Then run scripts/reconcile-finance.ts read-only and review before any --apply.
B. Worker. Deploy odontox-server production. Tail for [ai-cost] lines and ledger assertion errors.
C. UI. Build odontox-app with APP_VERSION = 'v2.0', deploy, and force-promote canonical. Hard-reload and confirm the login tag reads v2.0 and logo-v2.webp is served.
D. Marketplace. No change.
E. Bridge. No change.
F. Smoke.
- Invoice: take a payment, raise a credit note, take another payment, and confirm the balance holds.
- Cancel a receipt.
- Reset to Zero on a test invoice.
- Close and reopen a day.
- Add a manual line.
- Save a custom role.
- Filter the calendar by status.
- Send an X-ray over WhatsApp.
- Save Medical History and reload.
- Open Clinical Notes for a long-standing patient.
- Run Lighthouse against odontox.io.

