Files
dyolink/docs/specs/voice-treatment-entry/spec.md
Amin Mousavi c1d3a241a5 docs: record that a prosthesis dictation reaches teeth only through the merged row
Decision 39 couples teeth and jobs on a lab-dependent type, so unticking the
merged row and then the type row applies a detail with no teeth at all. master
kept them, because the rows were separate. Approved as an accepted consequence
rather than patched, since applying teeth the clinician just unticked would
break the sheet-is-a-contract rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:20:00 +08:00

65 KiB
Raw Blame History

type, status, created, repos, aliases
type status created repos aliases
task active 2026-08-20
repo path branch base role spec_slug merge_after
dyolink ~/PersonalProjects/dyolink feat/treatment/add-voice-input-for-new-treatment-form origin/master extraction contract + resolvers + review sheet voice-treatment-entry
voice treatment entry
voice input
dictation
mic

Voice treatment entry

Status: v1 is merged to master (c07f550dc10d8d). The treatment form was then overhauled on top of it (a3c14a1, 7f92e73, 72f885d, 5d3597f): one prosthesis type per tooth became stacked jobs, jaw-level appliances and a category tree. Voice still compiles against that model but can no longer express it, and in two places now writes data the form itself refuses. This revision adapts the extraction contract (§5), the resolvers (§6) and the review sheet (§7), and carries two recording defects found on Safari (§2, §9). Still blocked on the ASR spike (§11 item 1) before it is trustworthy in front of patients Area: Treatment workspace (CLINIC orgs) Created: 2026-08-20 · Revised: 2026-09-07

Fill a TreatmentDetail — including its lab dispatch — by speaking, instead of by tapping through the type dropdown, the FDI chart, the prosthesis job popover and the lab picker.


1. Goal

A clinician on the Treatment tab taps a microphone, describes the treatment for the already-selected patient in one utterance, and is shown a review sheet of what was understood. Fields they tick are applied to the open detail chip. Nothing is written to the form without confirmation.

In scope

One recording produces exactly one TreatmentDetail, and may fill every field of it:

Field Source
treatmentType catalog code, matched against locale labels
teeth FDI codes, via tooth-intent resolver
toothSelectionGroups connected (bridge) / single spans
comment cleaned dictated notes
lab: LabCaseToothProsthesis rows one assignment list — a target is a tooth or a jaw, and carries a stack of job codes
lab: destinationOrganizationId matched against the clinic's linked labs
lab: dueDate via due-date intent resolver

Out of scope (v1)

  • Multiple detail chips from one recording.
  • attachmentIds — files cannot be dictated.
  • Editing an existing detail by voice ("no, make that 15"). Confirming a recording always creates a new detail (see §2).
  • Creating the treatment or selecting the patient by voice. A patient is already selected; voice only fills the form.
  • Lab-side (LAB org) usage. Clinic only.

2. User flow and UI integration

The control: Add detail, split

The Add detail button gains a second segment holding the mic. The halves read as siblings — both end in a new detail — but they are independent actions:

  • Add half — unchanged. Same onAddDetail, same seeding, same setEntryStep. It gains a neighbour and nothing else. Its logic is not modified, wrapped or made conditional.
  • Mic half — starts a recording. Nothing is created until confirm (below).

The Add detail <Button> in TreatmentDetailsEditor becomes a segmented control built exactly like the detail chip's trash affordance in the same file (TreatmentDetailsEditor.tsx, the chip <div> + label <button> + remove <button>):

  • an inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] wrapper;
  • two raw <button> children divided by border-s, each with its own disabled, title, aria-label and focus-visible:ring-inset;
  • styled primary (bg-primary text-white) to preserve the button's current look;
  • w-full sm:w-auto on the wrapper with the Add half flex-1, reproducing today's fullWidth + sm:w-auto shrink-0 behaviour.

The shared Button component is not reusable for the halves: it hardcodes rounded-[var(--radius-md)] on each instance and owns auto-pending state, both of which fight a segmented control. This is precisely why the chip pattern uses raw <button>s, and this control follows it.

Side: the mic is the second flex child with border-s — the logical end, exactly like the trash. Visually right in en/nl, visually left in fa. No physical left/right anywhere, per the repo's RTL rule.

en / nl (LTR)          fa (RTL)
[  Add detail │ 🎤 ]   [ 🎤 │  افزودن  ]
[ ترمیم 14,15 │ 🗑 ]   [ 🗑 │ ترمیم 14,15 ]
        ↑ same side as the chip's trash, in both directions

Flow

[editable day]
        │
        ▼  tap 🎤        ← mic swaps to ■ (red); Add half disabled
   recording ─────────── inline bar below the header row:
        │                ● 0:12 / 2:00  ▁▃▇▅▂▆█▄▁   [لغو]
        │                auto-stops at the 2:00 cap
        ▼  tap ■
   processing ────────── ● transcribing…   ○ extracting     [cancel]
        │
        ▼
   review sheet
     ☑ Type        ترمیم
     ☑ Teeth       [mini FDI chart]  14 15
     ☑ Notes       حساسیت به سرما
     ☐ Lab         لابراتوار سینا      ⚠ similar name
        │
        ▼  [Apply n fields]
   detail created or filled → normal save flow

No layout shift. The segmented control never changes size; only the mic's icon and colour change. The timer and level meter live in a full-width bar inserted between the header row and the chip strip — the header is sm:justify-between, so growing the button mid-recording would shove the row on every start and every stop.

Duration is capped at 2 minutes (maxMs, configurable, v1 default 120_000). The timer shows elapsed / 2:00 and the recorder auto-stops at the cap. maxMs: null means uncapped and remains supported, but is not the v1 default.

Two minutes is generous against the longest realistic utterance — a full prosthesis dictation with type, several teeth, a bridge, prosthesis type, lab and due date — while bounding worst-case spend. It also sits comfortably inside the vendor limits (§4): 2 minutes of webm/opus is well under 1 MB against a 25 MB ceiling, and transcribes in a few seconds against a 60s processing timeout.

Cost per recording is therefore bounded at $0.012 of ASR (2 min × $0.006) plus ~$0.0008 of extraction — about 1.3¢ worst case, against ~0.3¢ for a typical 20s utterance.

What confirm does

The detail is created on confirm, never on tap. Tapping the mic starts a recording and nothing else, so cancelling, a vendor failure, a rate-limit, or navigating away leaves the chip strip untouched — there is no orphan state to clean up.

Confirm always appends a new detail. One unconditional rule, no dependence on invisible state: append newDetail() seeded with defaultTreatmentTypeForAppointment, make it active, setEntryStep('treatment'), then apply the ticked rows on top — so an extracted type overrides the seed, and unticking the type row leaves the seeded default in place.

This reuses newDetail() and the same state transitions, but it is a separate code path. onAddDetail is not called and not changed.

setEntryStep('treatment') matters: showChrome is always on, so the control is visible during the Lab wizard step too. Confirming there returns to the treatment step.

Confirm also saves. The new detail is persisted immediately (persistDraft({force:true})), and when the result carries a lab, a due date or a prosthesis map the lab case is saved with it (persistLabCases). Not politeness — the autosave effect watches details only, so a lab draft left in component state alone loses the destination lab, the due date and the whole prosthesis map on the next reload. The detail survives, which is what makes that loss look like a successful save.

One guard on it: persistDraft returns a preview treatment instead of saving when any detail still lacks a treatment type — the blank chip the workspace opens with is enough — and a preview's detail id falls back to the client id. Confirm therefore checks what came back, not the precondition, and skips the lab-case save when it did not get a real id; posting a lab case against an id the server has never seen fails the whole save. Checking the result rather than the condition keeps this true for every early return persistDraft has.

Accepted consequences:

  • Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the usual trash affordance.
  • That same blank chip blocks confirm's immediate lab-case save until it is given a type or removed; the lab rows stay in local state until the ordinary Lab-step save.
  • Dictating into an existing detail is not supported in v1 — voice always makes a new one.

Render policy

Three different reasons for "no", rendered differently:

Reason Condition Render
Technical no voice profile for the locale; no MediaRecorder segment absent — the control is byte-for-byte today's plain Add button
Commercial Plan.features.voiceTreatmentEntry false segment absent — ⚠ not enforced in v1, see §8
Contextual !canEdit || disabled — past day, read-only load, no TAB_TREATMENT_EDIT both segments render and disable together, like the chip's trash (disabled:opacity-40 disabled:cursor-not-allowed)

"This feature isn't yours" and "not right now" are different statements. Absence avoids a permanently dead control; disabling avoids the button resizing as the day strip moves.

The technical row was specified and never implemented. voiceForEditor gated the mic on the server's availability response alone — enabled && locales.includes(locale) — and never called isMediaRecorderSupported(). So the control rendered on a browser that cannot record and failed on tap, which is how the Safari report arrived. It now tests both. That is what "segment absent" meant all along.

TAB_TREATMENT_EDIT is resolved via common/membership-permissions.ts, never by reading membership.permissions directly.

Component API

TreatmentDetailsEditor gains exactly one optional prop. All behaviour — MediaRecorder, the API call, error state — lives in lib/voice/useVoiceCapture.ts (hooks belong in lib/ per AGENTS.md) and is owned by TreatmentWorkspace. The editor stays presentational and renders both the segment and the recording bar with its own classes, keeping the segmented styling beside the chip pattern it mirrors.

/** Omit when voice is unavailable — the Add button then renders unsplit. */
voice?: {
  phase: 'idle' | 'recording' | 'processing';
  elapsedMs: number;
  level: number;   // 0..1, for the meter
  maxMs: number | null;   // v1: 120_000 (2 min). null = uncapped, supported but not default
  onStart: () => void;
  onStop: () => void;
  onCancel: () => void;
};

voice === undefined is the absent state above — availability is expressed by presence rather than a separate flag, so the two cannot disagree.

Accessibility

  • Mic segment carries both title and aria-label, like the chip's trash, and its label changes with phase.
  • Announce phase transitions via a role="status" aria-live="polite" region — the same idiom as the existing autosave status line. Do not put aria-live on the ticking timer.
  • Focus ring is focus-visible:ring-inset tinted primary when idle, red while recording.

3. Architecture

Two sequential stages, both server-side. The vendor API key never reaches the browser.

browser ──audio(base64)──► POST /treatments/voice-extract
                                    │
                          ┌─────────┴─────────┐
                          │ 1. AsrProvider    │  audio + locale hint → transcript
                          └─────────┬─────────┘
                          ┌─────────┴─────────┐
                          │ 2. ExtractionProv │  transcript + catalog + ctx → VoiceIntent
                          └─────────┬─────────┘
                          ┌─────────┴─────────┐
                          │ 3. resolvers      │  intents → FDI codes, ISO date
                          │    (pure, tested) │
                          └─────────┬─────────┘
                                    ▼
                          VoiceExtractionResult  (resolved values + the intents
                                                  that produced them, for display)

Why intents and not final values: the model never emits an FDI code and never does calendar arithmetic. It emits what it heard; deterministic, unit-tested code decides what that means. This is what makes the two highest-consequence mappings — quadrant mirroring and Jalali conversion — testable instead of hopeful.

Endpoint

POST /voice/extract@Controller('voice') + @Post('extract'), so the full path behind the global prefix is /api/voice/extract. Earlier revisions of this spec named it /treatments/voice-extract, which was never the shipped route. No work item moves it.

⚠ The path is duplicated in common/body-parsers.ts, which matches /api/voice/extract to select the large JSON body limit. Moving the route without moving VOICE_EXTRACT_PATH makes every real recording 413 against the 100 kb default, which reads as a broken microphone rather than a routing change.

  • Guards: JwtAuthGuard + ClinicOrgGuard.
  • Service-level check of TAB_TREATMENT_EDIT. The plan flag is not checked in v1 (§8).
  • Per-user throttle via @nestjs/throttler (present in package.json, currently wired nowhere in src/ — this is its first use, so the module must be registered in app.module.ts). Configurable; v1 default 6 requests / 60s per user (VOICE_THROTTLE_LIMIT, VOICE_THROTTLE_TTL). A human cannot approach that — a recording plus processing takes ten seconds at minimum — so it is purely an abuse and runaway-loop guard, which is doing more work than usual given v1 is ungated and uncapped (open item 14).
  • Body: base64 audio + declared format + clientTodayIso + IANA timeZone + treatmentDetailId/clientId for context.

clientTodayIso and timeZone come from the client per the existing house rule (AGENTS.md): never derive the clinic's civil day from Date#getDay() on the UTC server. Reuse common/zoned-civil-time.ts.


4. Provider registry

ASR and extraction are separate swappable roles. They will not come from the same vendor for every locale.

interface AsrProvider {
  transcribe(audio: AudioInput, localeHint: string): Promise<{ text: string; usage: AsrUsage }>;
}

interface ExtractionProvider {
  extract(transcript: string, catalog: CatalogPrompt, ctx: ExtractionContext): Promise<VoiceIntent>;
}

Resolved through a registry keyed by locale.

At launch all three locales use the same profile — OpenRouter with openai/whisper-1 for ASR. The per-locale indirection is kept anyway, because the locale is the axis along which this is most likely to diverge: Persian ASR is the weakest link (§11), and swapping only fa to a Persian-specialist vendor must not be a code change.

VOICE_PROFILE_FA = openrouter:openai/whisper-1 | openrouter:<llm-model>
VOICE_PROFILE_EN = openrouter:openai/whisper-1 | openrouter:<llm-model>
VOICE_PROFILE_NL = openrouter:openai/whisper-1 | openrouter:<llm-model>

The language hint is the profile's locale as ISO-639-1 — fa, en, nl — not a constant.

Reachability is a property of where you deploy, not of the code. The same image serves an Iran-hosted instance with a domestic fa profile and a Europe-hosted instance with an OpenRouter profile; only config differs. A locale with no configured profile has no microphone button at all (§2).

The frontend must learn which locales are enabled from the API, not from a NEXT_PUBLIC_* var — those are baked in at build time, so an env-var approach would make enabling a locale require rebuilding and repushing the frontend image.

v1 provider: OpenRouter

Both roles, one API key.

ASRPOST https://openrouter.ai/api/v1/audio/transcriptions

{
  "model": "openai/whisper-1",
  "input_audio": { "data": "<base64>", "format": "webm" },
  "language": "fa"
}
  • Accepted formats: WAV, MP3, FLAC, M4A, OGG, WebM, AAC. Chrome/Android webm/opus and Safari/iPad mp4/aac both go through unmodified — no transcode dependency is required.
  • Limits: 25 MB; 60s upstream processing timeout. The 2-minute recording cap (§2) sits comfortably inside both.
  • Request shape verified against OpenRouter's STT docs: base64 JSON input_audio is the documented primary path (multipart file is the OpenAI-compatible alternative), and both default model slugs exist on the live models API.
  • The cap is enforced twice: the client's reported durationMs, and again against the vendor's own usage.seconds — the client's figure is a claim, not enforcement. The server allows a 2s tolerance, because the client measures length after the recorder stops and a recording that runs to the cap always reports slightly over it.
  • Response: { text, usage: { seconds, total_tokens, cost } }.
  • Price: openai/whisper-1 is $0.006/minute, billed to the nearest second → $0.002 for a typical 20s utterance, $0.012 at the 2-minute cap. OpenRouter forwards this model directly to OpenAI.

Extraction — OpenRouter chat completions with a JSON schema constraining the output to VoiceIntent. Model id is config (VOICE_LLM_MODEL).

Chosen: google/gemini-3.7-flash.

Cost is deliberately not the selection axis. The call is ~1,250 input tokens (system prompt + catalog labels + JSON schema + linked labs + transcript) and ~200 output, so the whole candidate field spans about one cent per recording:

Model in / out per M tokens ≈ per call
google/gemini-3.7-flash $0.375 / $1.875 $0.0008
qwen/qwen3.8-27b $0.45 / $3.20 $0.0012
qwen/qwen3.8-max $2 / $6 $0.0037
openai/gpt-5.6-terra $2 / $12 $0.0049
anthropic/claude-opus-5 $5 / $25 $0.0113

Select for Persian comprehension and reliable constrained JSON instead. Gemini Flash wins on the reasoning that this task is not reasoning-heavy — read a short sentence, pick codes from a supplied closed list, emit small JSON — and it is simultaneously the cheapest and lowest-latency candidate, which matters on a pipeline already at 310s.

Escalation path if Persian comprehension proves weak on real transcripts (item 1): qwen/qwen3.8-max (leads current multilingual rankings, ~4.6× the cost) and then anthropic/claude-opus-5 as the accuracy ceiling. Both are a config change — VOICE_LLM_MODEL — not a code change. Worth knowing when judging that ranking evidence: it is marked provisional and was measured on the previous Qwen generation.


5. Extraction contract

The model returns intents only. Illustrative shape:

type VoiceIntent = {
  treatmentType: string | null;          // catalog code, from the supplied closed list
  teeth: ToothIntent[];
  connectedSpans: { from: ToothIntent; to: ToothIntent }[];
  comment: string | null;
  prosthesis: ProsthesisAssignment[];    // empty array, never null
  labId: string | null;                  // must be one of the supplied linked-lab ids
  labMatchExact: boolean;
  due: DueIntent | null;
};

/** One spoken instruction: these targets get these jobs. */
type ProsthesisAssignment = {
  targets: ToothIntent[];   // each one a tooth, or a jaw — see below
  types: string[];          // leaf codes, or one category / subcategory code
  spoken: string;           // the transcript span, echoed back to the clinician
};

type ToothIntent =
  | { kind: 'explicit'; fdi: string; spoken: string }
  | { kind: 'positional'; arch: 'upper' | 'lower' | 'both';
      side: 'patient_right' | 'patient_left'; position: number; spoken: string };

type DueIntent =
  | { kind: 'weekday'; weekday: string; which: 'this' | 'next' }
  | { kind: 'offset'; unit: 'day' | 'week' | 'month'; amount: number }
  | { kind: 'jalali'; jy: number; jm: number; jd: number }
  | { kind: 'gregorian'; y: number; m: number; d: number };

The wire form stays deliberately flat (extraction.wire.ts): strict json_schema mode has poor support for discriminated unions, so every variant field is present and nullable and toVoiceIntent narrows it.

One assignment list, no default

prosthesisDefaultType and prosthesisOverrides are gone. A default that fills every tooth and is then overwritten per tooth has a precedence rule, and a precedence rule has a wrong side — the first live prosthesis test previewed PFZ on tooth 13 and filled PFM. A single list where every entry names its own targets has no precedence to get wrong.

Targets are ToothIntents; the examples below write them as bare FDI codes for brevity.

  • { targets: [12, 13], types: ['pfm_crown'] } — two teeth, one job. Everything the default expressed, without the default.
  • { targets: [12], types: ['zirconia_abutment', 'monolithic_zirconia'] } — one tooth, a stack. This is what the old shape could not say at all.

A target is a tooth or a jaw, and the code decides which

Nothing on the wire declares "this is a jaw". A target that names an arch and no position is a jaw, and the resolver confirms that against the chartRegion of the assignment's codes. arch gains 'both', which the old enum could not express.

Deriving it costs no second source of truth. The catalog already answers the question, and partial_denture — a removable code that is nonetheless assigned per tooth — already ships with chartRegion: 'crown' in catalog-seed-data.ts, so the exception needs no special case either.

A code whose region contradicts its target is reported, never coerced. An arch appliance aimed at tooth 12, or a crown aimed at the upper jaw, resolves to code_not_valid_for_target (§6).

A category whose leaves span both regions defers the check. removable is the only one today: complete_denture and overdenture are chartRegion: 'arch' while partial_denture is 'crown'. There is no region to check until a leaf is chosen, so such an assignment resolves to prosthesis_type_ambiguous and the region is confirmed against the leaf the clinician picks — never emitted as code_not_valid_for_target while it is still a category.

types[] may hold a leaf or a category

"روکش" is crown — a category with nine leaves, not a material. The model returns the category code it actually heard rather than guessing pfm_crown, and the review sheet offers the leaves as chips (§7).

This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 category names and 5 subcategory names (veneer, inlay, onlay, overlay, night_guard), no collisions. The resolver classifies by lookup. The test asserts disjointness against the live catalog, not against these counts, because a future entry named crown would quietly turn a leaf into an ambiguity and a hardcoded count would not notice.

Category labels need a backend source — build it

The prompt cannot offer a category the model can name unless that category has a label in the actor's locale, and today it has none. CatalogEntityKind covers only TREATMENT_TYPE, PROSTHESIS_TYPE and LAB_WORKFLOW_STEP (schema.prisma), so CatalogLabelService cannot resolve a category at all. The only category labels in the repo are the prosthesis.category_* and prosthesis.sub_* keys in frontend/messages/*.json, plus a hardcoded single-locale map in the dev export script. Sending bare codes would leave crown and post_core untranslated beside fully-labelled leaves, on the locale this feature exists for.

So this branch adds them as first-class catalog entities:

  • add PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind — one migration, no data loss, the enum is additive;
  • seed CatalogTranslation rows for the 7 categories and the 5 subcategories in fa, en and nl, taking the wording from the existing prosthesis.category_* / prosthesis.sub_* keys so the two surfaces read identically on day one;
  • resolve them through CatalogLabelService like every other catalog label. No label is hardcoded in backend code, and the house rule holds unchanged.

The sub_* key set is wider than the subcategory set — it also carries technique names (sub_full_contour, sub_layered) and two leaf codes. Seed only the five real subcategories.

Accepted consequence: the frontend keeps its own category_* / sub_* message keys, so the wording now lives in two places. Migrating the frontend to read these labels from the API is out of scope for this branch — it would touch the prosthesis picker, the case sheet and the print layout, none of which this task otherwise opens. The seed is written from the message files precisely so the two agree at the point they diverge.

Prosthesis work implies the treatment type

prosthesis is the only labDependent treatment type. Any resolved assignment therefore forces treatmentType to prosthesis, and the sheet locks that row while the prosthesis row is ticked. Without it, a recording that names an appliance but no treatment type seeds the type from the appointment purpose, prosthesisAssignActive stays false, the plain chart renders, and the lab rows are orphaned behind a chart that cannot show them.

Closed lists

Every code-valued field is constrained to a closed list supplied in the prompt:

  • Treatment types and prosthesis types come from CatalogLabelService in the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of prosthesis and monolithic_zirconia rather than being asked to translate. Catalog entities store a stable code and no label — never hardcode a label.
  • The prosthesis list is no longer flat. buildCatalog already receives category, subcategory, chartRegion and stackGroup from ProsthesisCatalogService and throws all four away. It now passes them through, so the prompt presents the catalog as the tree it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the prompt — the shape comes from the data.
  • Lab candidates are the clinic's linked labs only (OrganizationLink), passed as { id, name }. The model may return one of those ids or null, nothing else.

Every unresolved or rejected item is reported, never silently dropped.


6. Resolvers

Both live in backend/src/, pure and Jest-covered. That placement was originally forced — the frontend had no test runner at all. This revision adds Vitest for the frontend's pure helpers (§12), so the split is now a judgement rather than a constraint: intent resolution stays on the backend because it is the trust boundary, and the stack rules stay on the frontend because that is where the form's own rules already live.

resolveToothIntent()

  • Owns the patient-right convention in exactly one place: upper + patient_right → quadrant 1, upper + patient_left → 2, lower + patient_left → 3, lower + patient_right → 4. This is the mirroring bug, and it becomes a unit test.
  • Rejects out-of-range positions rather than clamping. Position 9 is unresolved, never 8.
  • Permanent dentition only — FDI 1118/2128/3138/4148, matching FDI_UPPER_LEFT_TO_RIGHT / FDI_LOWER_LEFT_TO_RIGHT. Deciduous references ("دندان شیری") must resolve to unresolved, never snap to a permanent tooth.
  • Locale-neutral by construction. ToothIntent carries arch/side/position, not words, so the resolver needs no per-locale branches. The locale-specific part is the prompt: each enabled locale needs its own spoken tooth vocabulary (شش بالا راست, upper right six, rechtsboven zes).
  • A spoken tooth number is an FDI code, in every locale. This is how clinicians actually dictate — "بیست و شش" is tooth 26 — so the prompt teaches the notation (first digit = quadrant from the patient's own point of view, second = position from the midline) rather than refusing it. arch/side/position is the reading of a tooth that was described instead of numbered, where a single digit is a position and the quadrant comes from words. Revised after the first live test; the original design had this backwards and made the descriptive form the only supported path.
  • A single digit alone is never resolved. "دندون دو" names four teeth. It is reported as tooth_missing_quadrant with the candidate codes attached — narrowed by whatever was said, so "دو بالا" offers two — and the review sheet turns them into chips. The clinician chooses; the resolver still never guesses.
  • Digits arrive in three scripts. normalizeFdiCode (common/fdi.ts) folds Persian and Arabic-Indic digits to ASCII and strips the spaces of a digit-by-digit dictation before anything is matched, at both the wire branch choice and the final validation.

resolveDueDate()

  • Takes clientTodayIso + IANA timeZone; reuses common/zoned-civil-time.ts.
  • Week start is per locale, because "next Thursday" is week-relative: fa starts Saturday, en and nl start Monday. Hardcoding Saturday put an en/nl clinician's deadline a week out. One place (weekStartForLocale), tested in both directions.
  • 'this' is occurrence-anchored (soonest strictly-future, never resolves into the past); 'next' is week-anchored. A missing qualifier is read as 'this' — a bare weekday carries none, and failing would discard a real spoken deadline.
  • Jalali conversion is arithmetic, not inference: port jalaliToGregorian and toLatinDigits from frontend/src/lib/i18n/persianCalendar.ts into backend/src/common/jalali.ts with a spec. It is dependency-free integer math (~60 relevant lines) and the calendar does not change, so the duplication is stable.

Group / prosthesis rules

  • Connected spans validate through the shipped helpers — areArchNeighbors, sameArch, teethBetweenInclusive. Never a 1-tooth connected group. Anything invalid degrades to singles and is flagged on the review sheet. Spans stay their own array rather than folding into assignments: linking is offered on the plain chart too (the non-prosthesis onToggleLink branch in TreatmentWorkspace.tsx), so it is not prosthesis-only.
  • An assignment target is a selection. On a prosthesis detail a tooth exists only by carrying a job: applyCodes in ProsthesisAssignChart returns early on an empty code list, and pruneDetailTeethToJobs strips the rest before every save. So tooth_not_selected is unreachable for prosthesis work and is retired.
  • A tooth with no job is named and left out. "۱۲ و ۱۳، روکش برای ۱۲" adds only 12; 13 appears struck through in the sheet reading no prosthesis heard. Keeping it would manufacture a state the manual chart cannot produce and persistDraft would delete on the way out.
  • Stack legality is not a resolver concern. canStackLeaf, categoryDisabledForJobs and the screw-retained exclusion live in frontend/src/components/treatment/prosthesisTree.ts, and the backend's own dispatch check knows nothing about stacking. Porting them would make the voice path stricter than the manual path and create two copies of a rule that must never disagree. The sheet and the apply path both route through applyLeafToJobs instead (§7). The backend checks only that a code exists and that its region suits its target.
  • All-or-nothing is retired. assertCompleteToothProsthesisMap still requires every remaining tooth on a prosthesis detail to carry a code, but the new form guarantees that by pruning rather than by refusing, and it now also accepts a detail with no teeth and an arch job. So an incomplete map no longer blocks a tick; the field survives only to name the targets that will be dropped.
  • Apply order is teeth → groups → prosthesis, so pruneToothProsthesisForGroups behaves.

Unresolved reasons

Every one is rendered with what was heard, so the clinician sees what the system did not understand. Three of them carry candidates and become chips (§7).

Reason Meaning Carries
not_permanent_tooth deciduous, or outside the permanent set
position_out_of_range a position outside 18
tooth_missing_quadrant a lone digit — four teeth match, fewer when an arch or side was also heard candidates: FDI codes
prosthesis_type_ambiguous a category or subcategory was heard, not a material candidates: leaf codes
arch_not_spoken a jaw-level appliance with no jaw candidates: upper, lower
code_not_valid_for_target an arch code aimed at a tooth, or a tooth code aimed at a jaw
unknown_catalog_code a code the supplied catalog does not contain
span_not_same_arch a connected span crossing arches
malformed neither an FDI code nor a usable description
invalid_date a date the resolver cannot build

tooth_not_selected is removed — assignments now define their own teeth.

An unresolved item that came from an assignment carries that assignment's index. Without it a chip is decorative: picking a quadrant for "دندون دو" on a prosthesis detail produces a tooth with no job, which the jobless tooth is left out rule above then discards, so the tap changes nothing. tooth_missing_quadrant, arch_not_spoken and prosthesis_type_ambiguous therefore carry assignmentIndex, and a picked chip inherits that assignment's types[] — or, for prosthesis_type_ambiguous, supplies the missing leaf to that assignment's existing targets. An item raised outside any assignment (a tooth spoken in the teeth array alone) carries no index and folds into teeth as it does today.


7. Review sheet

Presentation: a modal on desktop; a full-screen overlay on mobile.

⚠ On mobile this must be an overlay rendered in place — not a Next.js route. A real navigation unmounts TreatmentWorkspace and destroys the in-progress draft. This is the same constraint the realtime soft-refresh already lives under: never remount the treatment form, never clear a draft.

  • Renders the transcript, then one row per extracted field in the app's own vocabulary: translated catalog labels, and a mini FDI chart for the teeth rather than a list of numbers.
  • Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also what creates the new detail — see §2.
  • Rows default to ticked except the lab row when labMatchExact is false — shipping to a lab always requires a deliberate tick.

Teeth and prosthesis are one row

For a lab-dependent type they are not independent, and two checkboxes let the clinician save a state the form immediately undoes. On master today, ticking teeth and unticking prosthesis on a prosthesis detail saves an empty detail: persistDraft prunes lab-dependent details to their jobs and applyVoiceResult persists straight after applying.

So a lab-dependent type gets one Teeth and prosthesis row that applies together. Every other type keeps a plain teeth row and has no prosthesis row at all. The merged row is:

  • the existing read-only mini FdiToothChart, now fed crownColors / rootColors from toothRegionColors and archHighlight from the arch jobs. The chart already accepts all three, so showing stacks and jaw work costs no new component;
  • a line underneath naming each target and what lands on it — ۱۲: ایمپلنت + روکش زیرکونیا · ۱۳: روکش پی‌اف‌ام · فک بالا: نایت گارد.

The chart catches a misheard tooth number at a glance; the list confirms the material. Either one alone is the weaker check.

Accepted consequence: on a prosthesis dictation the teeth are reachable only through this row. voiceRowAvailability sets teeth: false for a lab-dependent result, so no plain teeth row exists and selection.teeth never becomes true. Untick the merged row — which releases the decision-41 lock — then untick the type row, and the detail applies with no teeth at all. master kept them, because the rows were separate.

This is decision 39 doing what it was chosen for, and it follows from the sheet is a contract below: the row that displayed those teeth was unticked, so applying them anyway would apply something the clinician refused. The cost is real and accepted — a clinician who wants only the teeth from a prosthesis dictation, intending to pick materials by hand, has to tap them in. If that turns out to matter in use, the fix is a spec change, not a patch.

The sheet previews the stack that will actually land

The merged row builds its preview through applyLeafToJobs — the same function the manual chart writes through — and applyVoiceResult applies through it too. A job the stack rules refuse, such as an implant plus a post & core on one tooth, is shown struck through and named. Not silently dropped, and not silently applied. The rules stay in one file (§6).

Three chip kinds, one pattern

An item carrying candidates renders them as tappable chips — the one place the sheet is interactive. Picking one folds the choice into the result, so an under-specified item is one tap from resolved instead of a dead end. Everything the sheet renders comes from that folded result, not the raw one.

Heard Missing Chips
"دندون دو" the quadrant the four FDI candidates, narrowed by any arch or side also heard
"روکش" the material the leaves of that category or subcategory
"نایت گارد" the jaw Upper / Lower, multi-pick — picking both is how a both-jaw appliance is expressed, since the manual chart has no both control either

Nothing is guessed on the clinician's behalf. A per-category default material would have the same shape as the auto-fill this design already rejects.

The sheet is a contract

Confirm fills exactly what the sheet previewed — no more. Any per-detail convenience that would top the case up afterwards has to be suppressed for a voice-created case, because a default that quietly adds a prosthesis type to a tooth the sheet never mentioned turns the confirmation step into a lie about what it was going to do — which is the whole reason the step exists.

The v1 branch carried an exemption for one such default, the dispatch panel's remembered-prosthesis auto-fill. master deleted that feature outright (f52ad6b), so the exemption went with it and nothing enforces this rule in code today. It is a constraint on whatever gets added next, not a description of something that exists.

This is also why a missing material becomes chips rather than a per-category default, and why a jobless tooth is named rather than quietly filled.

RTL

Logical text-start / text-end only, never text-left / text-right. Dates via lib/i18n/format.ts. The merged row's per-target line reads right to left in fa, so the separator is a bare · with no direction of its own.


8. Gating and configuration

Gate = TAB_TREATMENT_EDITcanEditTreatmentForDay ∧ configured locale profile ∧ Plan.features.voiceTreatmentEntry. How each failing condition renders is in §2.

Plan.features is a Json column that already exists on the Plan model and is seeded as {} for all five plans — and is read nowhere in backend/src/. Voice is its first consumer, so:

  • No migration is needed.
  • The read should go through a small generic helper, since this establishes the pattern for every future flag.
  • Voice is metered vendor spend, which is why the gate is commercial (plan) rather than a new TAB_* permission — a clinician who can already edit the form gains no capability from voice, only speed.

The per-user throttle is a separate, non-commercial abuse control.

v1 ships ungated

The plan flag is designed but not enforced in v1 — voice is open to every clinic user who can edit treatments, in every configured locale. Plan.features.voiceTreatmentEntry and the availability API stay documented here as the intended gate, deferred rather than dropped, so turning them on later is additive.

Consequence to accept deliberately: with no plan gate, the per-user throttle and the 2-minute recording cap are the only controls on metered vendor spend. See open item 14.


9. Errors

Per the three-layer contract: a code in common/errors/error-codes.ts, the throw site, and an errors.X key in all three of frontend/messages/{en,fa,nl}.json. Never a raw English Nest exception for a user-facing failure.

Code When
VOICE_MIC_DENIED microphone permission actually refused, or no input device — client-side only: needs the errors.X key in all three message files, but no ErrorCode entry and no throw site. Reserved for a real permission failure: see the note below
VOICE_CLIP_TOO_LONG over maxMs (server-side re-check), over vendor limits, or a request body past the DTO's size cap
VOICE_UNSUPPORTED_FORMAT the browser cannot record at all — no MediaRecorder, no container both it and the API accept, or a recorder that throws after permission was granted; and server-side, a format outside VOICE_AUDIO_FORMATS
VOICE_ASR_FAILED transcription stage failed
VOICE_EXTRACT_FAILED transcript obtained, structuring failed
VOICE_NOTHING_RECOGNIZED empty or unusable transcript
VOICE_NOT_AVAILABLE no profile for locale (v1); plan flag off, once enforced
VOICE_RATE_LIMITED throttle

Two of these are raised by DTO validation, not by a throw site. validationExceptionFactory returns a constraint's message verbatim when the message is itself a known ErrorCode, so the voice DTO names its own failures: @MaxLength(…, { message: ErrorCode.VOICE_CLIP_TOO_LONG }) and @IsIn(…, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT }). Left to the shared constraint map they fall through to VALIDATION_FIELD_REQUIRED and VALIDATION_LANGUAGE_INVALID — an oversized recording telling the clinician a field is missing, and an unsupported container telling them their language is invalid. Any new voice constraint should name its code the same way.

VOICE_MIC_DENIED is only for a real permission failure. Three client paths used to report it for something else entirely — no MediaRecorder, no acceptable container, and a recorder that throws after permission was already granted. All three are "this browser cannot record" and now report VOICE_UNSUPPORTED_FORMAT; blaming the microphone sends the clinician hunting in site settings for a permission nothing ever asked for.

The container list is a preference, not a requirement. pickRecordingMimeType returned null when MediaRecorder.isTypeSupported rejected all six candidates — five of which are WebM or OGG, which Safari cannot record. So Safari was refused outright, even though it records audio/mp4, mimeTypeToFormat already maps that to m4a, and the backend accepts m4a. It now falls back to the empty hint, which is the let the browser choose path the function already had for Safari versions that shipped no isTypeSupported; onstop derives the real container from recorder.mimeType, as it already did. VOICE_UNSUPPORTED_FORMAT is left for a browser that genuinely cannot record.

Transcript salvage — specified, NOT built. The backend half exists: VOICE_EXTRACT_FAILED carries details.transcript and HttpExceptionFilter forwards it. The client half was never written — onError only resolves a message through getUserFacingError, which never reads details, so the transcript is shipped in an error body and dropped. Either build the dialog below or stop returning the transcript; shipping dictation to the client and discarding it is the worst of both.

When ASR succeeded and only extraction failed, the response still carries the transcript and the failure dialog offers "افزودن به یادداشت". That action creates a new detail with only comment set to the transcript — everything else left at newDetail() defaults. The words were captured and paid for; only the structure was lost.

This keeps the feature's one invariant intact: voice never writes into an existing detail. Dictating into an already-filled detail is a separate, later feature with its own voice-to-text control scoped to that field (§1, out of scope).

It also does not bypass the confirmation rule — the dialog shows the transcript, and the dentist taps to accept it. That review matters, because a raw transcript carries ASR errors and may contain the patient's spoken name, and comment is persisted (§10).


10. Data handling

  • Audio is held in memory for the request only. Never written to disk, never a Prisma row. Note this is deliberately unlike treatment attachments, which do persist to backend/uploads/treatments.
  • The transcript goes to the browser for the review sheet and dies with it.
  • The comment field persists a cleaned version of what was said — that is legitimate clinical record-keeping and is the only durable trace.
  • Telemetry is structured and patient-free: clip duration, which fields resolved, unresolved count, vendor latency, usage.cost, outcome (applied / discarded / failed), locale. Never the transcript, never audio, never a patient identifier. The destination is an open question — this repo has no metrics infrastructure yet (open item 9). Emit it as structured log lines in the interim so the fields exist and can be routed later without changing call sites.

Cancelling aborts the in-flight vendor call via AbortController, rather than letting it settle and discarding the result. Note this reduces spend but does not eliminate it: work already performed upstream may still be billed.

Recordings are clinical descriptions of identifiable patients leaving the server for a third party. Confirm the provider's retention and training policy in writing before enabling this for real clinics.


11. Open items

  1. Persian ASR accuracy on tooth numbers is unmeasured, and it decides the feature. Everything downstream assumes a usable transcript; no design choice above compensates for a bad one. Run this before writing feature code.

    Why tooth numbers specifically, not general accuracy: a transcript can score well on WER and still be useless here, because the errors land on the digits. چهار (4) / چهارده (14) / چهل (40) differ by one syllable. FDI spoken as یک چهار may return as ۱۴, 14, or یک چهار. Persian and Latin digit scripts mix within one transcript. Jargon is loanwords (زیرکونیا, پرسلن فیوزد تو متال, اینله/آنله) and clinicians code-switch into English mid-sentence. Suction and handpiece run in the background.

    Protocol: ~25 utterances from a dentist reading a script covering explicit FDI, quadrant-relative phrasing, bridges, prosthesis types, due dates and notes — recorded on the real device, ideally once quiet and once with the operatory running. POST each to /api/v1/audio/transcriptions with language: "fa". Score per-tooth-reference accuracy (of every tooth spoken, how many survive recoverably?) and jargon recognition separately. Notes accuracy barely matters. ~6 minutes of audio ≈ $0.04.

    Decision it drives: if tooth accuracy holds, build. If not, the fix is not prompt tuning — it is pointing the fa ASR slot at a Persian-specialist vendor while en/nl keep whisper-1. That is a config change precisely because of decision 23.

    Unverified lead for that fallback: recent Persian-ASR benchmark work reports Qwen3-Omni as the strongest open Persian ASR. Not confirmed as available on OpenRouter's transcription endpoint — check before relying on it.

    Byproduct: the recordings become the fixture corpus for item 2.

  2. VOICE_LLM_MODELresolved: google/gemini-3.7-flash (§4). Still worth validating on real whisper output during item 1, since clean transcripts flatter every model; the escalation path if Persian comprehension disappoints is documented in §4 and is a config change.

  3. Production reachabilityresolved: confirmed reachable from the Iranian production host. The per-locale registry is retained regardless (decision 23), so the fa profile can still be repointed at a domestic vendor if item 1 goes badly.

  4. Provider retention/training policyresolved: restricted via OpenRouter's account-level privacy/data-policy settings. Note this is an account setting, not a per-request one: re-verify it if the API key or the OpenRouter account changes, and remember whisper-1 is forwarded to OpenAI, so the effective policy is OpenRouter's plus that provider's.

  5. English tooth numberingresolved: a bare two-digit number is read as FDI in all three locales. FDI is what the product is built on and what clinicians dictate. Known trade-off, accepted: a clinician trained under Universal numbering says "tooth 14" and means a different tooth, so an en clinic needs either training or a later per-org notation setting. Revisit if a US clinic is onboarded.

  6. nl and en have no spike data. The Persian spike (item 1) should be repeated per locale before that locale's mic is enabled for real users — same protocol, same scoring, different speaker.

  7. Transcript salvage has no targetresolved: salvage creates a new detail with only comment set to the transcript (§9). Preserves the invariant that voice never writes into an existing detail; dictating into a filled detail becomes a separate later feature with its own field-scoped control.

  8. Throttle limitsresolved: configurable, v1 default 6 requests / 60s per user (§3). Unreachable by a human; a pure abuse guard.

  9. Telemetry has no sink. §10 defines exactly what to record but not where it goes — this repo has no metrics or analytics infrastructure yet. Deliberately deferred until it does. Interim: structured log lines, so the fields exist and can be routed later without touching call sites.

  10. Review sheet modalityresolved: modal on desktop, full-screen overlay on mobile (§7). Not a route — navigating would unmount TreatmentWorkspace and destroy the draft.

  11. Cancel and in-flight requestsresolved: abort via AbortController to limit spend (§10). Upstream work already performed may still be billed.

  12. Recording duration capresolved: 2 minutes, configurable via maxMs (§2). Still worth timing a realistic worst-case prosthesis dictation during item 1 to confirm 2 minutes is comfortable rather than tight.

  13. Availability APIresolved for v1: voice ships open to everyone with a configured locale profile. No plan check. The Plan.features design in §8 is deferred, not dropped.

    Corrected 2026-09-07: this item previously read "no availability endpoint", which contradicted §4 and the shipped code. GET /voice/availability does exist and is what voiceForEditor reads; it reports the configured locales and maxRecordingMs, and carries no plan check. Only the plan gate is deferred, not the endpoint.

New

  1. Cost exposure is bounded but ungated in v1. With the 2-minute cap (12) and the 6/60s throttle (8), worst case is 12 audio-minutes per user-minute ≈ $0.072/min, or ~$4.30 per hour of sustained abuse by one user — bounded, not free. Normal use is far below this: a clinician doing 60 recordings a day at ~20s each costs about $0.18/day.

    What remains open is that there is no per-organization limit at all, because v1 ships ungated (13). Worth deciding what to watch and at what number to react. Mitigations, already designed and each a config or flag change: enforce the Plan.features gate, lower maxMs or the throttle, or add an org-level monthly minute budget.

  2. The voice body limit is raised before any guard runs. isVoiceExtractPath in common/body-parsers.ts selects the large JSON limit by path, and Express body parsers run ahead of JwtAuthGuard. So an unauthenticated request to that path may upload the full clip-sized body before anything rejects it. The throttle does not help: it is a guard too. Bounded by the DTO cap and by the reverse proxy's own limit, but it is a pre-authentication allocation and nobody has decided whether that is acceptable. Not changed in this revision.

  3. Transcript salvage is still specified and not built. VOICE_EXTRACT_FAILED carries details.transcript, HttpExceptionFilter forwards it, and nothing on the client reads it — getUserFacingError resolves a message only. The field also does not match ApiError['details'], which is an array. Either build the dialog in §9 or stop returning the transcript. Carried forward unchanged; it is orthogonal to the prosthesis model.


12. Verification

Machine gates

  • cd backend && npm test — the existing suites, plus new coverage for the revised contract:
    • an assignment resolves its targets, and a target that names an arch with no position resolves to the UA / LA sentinels;
    • a leaf code, a category code and a subcategory code are each classified correctly, and the three namespaces are asserted disjoint;
    • an arch code aimed at a tooth, and a tooth code aimed at a jaw, both resolve to code_not_valid_for_target;
    • a resolved assignment forces treatmentType to prosthesis;
    • a target with no types is reported and excluded, and does not fail the whole assignment;
    • a removable category defers its region check instead of emitting code_not_valid_for_target;
    • every one of the 7 categories and 5 subcategories resolves a non-empty label in fa, en and nl — the check that catches a missed CatalogTranslation seed row;
    • an unresolved item raised inside an assignment carries that assignment's index, and one raised outside any assignment does not. The pre-existing suites stay: resolveToothIntent in all four quadrants, out-of-range rejection, deciduous → unresolved, resolveDueDate per-locale week start, "this" vs "next", Jalali leap year and month-end, and connected-span validation.
  • cd backend && npm run build — cross-cutting backend gate.
  • cd backend && npm run prisma:migrate && npm run prisma:seed — the new CatalogEntityKind values and their translation rows. The seed never wipes, so re-running it is safe.
  • cd frontend && npx vitest runnew. One dev dependency, one config, one script, covering the pure helpers only: prosthesisTree.ts (stack legality, applyLeafToJobs precedence, toothRegionColors) and voiceReviewRows.ts (row availability, the merged row, folding chips into the result). No React, no DOM. CLAUDE.md is updated in the same commit — "there are no frontend tests" stops being true.
  • cd frontend && npx tsc --noEmit — frontend type gate.
  • cd frontend && npm run build — production build.
  • ESLint on every touched file, no new warnings.

Manual

fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked lab. Then:

Carried forward, still required

  • past day → both segments disabled, control still split (not absent);
  • locale with no profile → control renders unsplit, identical to today;
  • fa vs en → mic sits at the logical end in both, on the same side as the chip's trash;
  • cancel mid-recording → chip strip unchanged, no orphan detail;
  • confirm → always appends a new chip, whatever the active detail contains;
  • Add half → behaves exactly as it did before this change;
  • no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
  • hold past 2:00 → auto-stops and proceeds to processing, not an error;
  • cancel during processing → the vendor request is actually aborted;
  • review sheet on mobile → full-screen overlay; closing it leaves the draft intact;
  • confirm with a lab, a due date or a prosthesis map, then reload → all three are still there.

New to this revision

  • Safari on macOS, and Safari on iPad → the mic records, and the clip reaches the server as m4a. This is the report that started the revision;
  • a browser with no MediaRecorder → the Add button renders unsplit, and no mic appears;
  • stack — "دندون ۱۲ ایمپلنت با روکش زیرکونیا" → the sheet shows both jobs on 12, the chart colours crown and root differently, and the chart after apply shows the same stack;
  • jaw appliance — "نایت گارد فک بالا" → the sheet shows an upper-jaw row, the treatment type row reads prosthesis and is locked, and apply produces a UA row with no teeth;
  • jaw not spoken — "نایت گارد" → Upper / Lower chips; tapping both produces UA and LA;
  • material not spoken — "دندون ۱۲ روکش" → chips for the nine crown leaves, and the row reads روکش in Persian, not crown. Nothing is applied until one is picked;
  • quadrant chip on a prosthesis detail — "دندون دو روکش زیرکونیا" → picking a quadrant chip produces a tooth carrying that assignment's job, not a jobless tooth that is then discarded;
  • tooth with no job — "۱۲ و ۱۳، روکش پی‌اف‌ام برای ۱۲" → 13 is struck through in the sheet, apply adds only 12, and 13 is not silently saved and then deleted;
  • illegal stack — "دندون ۱۲ ایمپلنت و پست و کور" → the refused job is struck through and named; apply writes only the legal one, and the manual chart agrees;
  • contradiction — "دندون ۱۲ نایت گارد" → reported as code_not_valid_for_target, nothing applied for that assignment;
  • two different materials — "۱۲ روکش پی‌اف‌ام، ۱۳ روکش پی‌اف‌زد" → the form shows both. Nothing may rewrite a per-tooth type the sheet already showed;
  • non-lab-dependent type — "ترمیم برای دندون ۱۴" → a plain teeth row, no prosthesis row, and the teeth survive the save.

13. Decision log

Settled in a grilling session on 2026-08-20.

# Question Decision
1 Scope Everything including lab dispatch
2 AI supply chain Domestic provider originally; OpenRouter for v1, registry keeps both open
3 Apply model Review sheet, then apply
4 Speech → FDI LLM emits intent, code resolves. A spoken number is the FDI code (revised 2026-08-21, §6)
5 Cardinality One detail per recording
6 Lab destination Closed list of linked labs, explicit confirm, unticked when inexact
7 Due date Intent + deterministic resolver
8 Resolver location Backend, Jalali math ported
9 Prosthesis Default type + overrides, all-or-nothing
10 Retention Discard audio and transcript, non-PHI telemetry only
11 Capture Tap to start/stop, hard cap (see 27)
12 Failure UX Stage-aware codes, transcript salvage
13 Locales Provider registry per locale; all three locales enabled
14 Reachability Registry now, slots filled per deployment
23 ASR model openai/whisper-1 for every locale; registry kept so fa can diverge
24 Extraction model google/gemini-3.7-flash; escalation path documented in §4
25 Salvage target Creates a new detail with only comment set — voice never writes into an existing detail
26 Throttle Configurable; v1 default 6 requests / 60s per user
27 Duration cap 2 minutes, configurable via maxMs
28 Review sheet Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part
29 Cancel Aborts the in-flight vendor call
30 v1 gating Open to everyone; Plan.features gate deferred, not dropped

Added while getting the first live recordings working (2026-08-21):

# Question Decision
31 Tooth numbering A spoken number is its FDI code, in all three locales. A lone digit stays unresolved and offers its candidate teeth as chips (§6, §7)
32 What confirm writes Confirm persists the detail and its lab case, because autosave watches details only — but skips the lab-case save when it got a preview treatment back (§2)
33 Preview as contract Applying a voice result fills exactly what the sheet showed. Per-detail conveniences that would add more are suppressed for that case (§7)
15 Gating Plan.features flag — its first consumer

UI placement settled in a second grilling session on 2026-08-20.

# Question Decision
16 Mic action Independent record action; the Add half's logic is untouched
17 Record UI Mic segment toggles ▶/■; inline bar below the header row
18 Unavailable Absent for technical + commercial; disabled for contextual
19 Component API One optional voice object prop; undefined means absent
20 Side Logical end (border-s), exactly like the chip's trash
21 Creation On confirm, never on tap
22 Creation rule Confirm always appends a new detail — no blank-reuse guard

Adapting to the overhauled treatment form, in a grilling session on 2026-09-07.

# Question Decision
34 Scope Full parity — voice reaches stacked jobs, jaw-level appliances and the category tree
35 Intent shape One prosthesis assignment list. prosthesisDefaultType + prosthesisOverrides are removed, and with them the precedence rule that mis-filled tooth 13 (§5)
36 Arch targets Derived, not declared — an arch with no position, confirmed against the code's chartRegion. arch gains 'both' (§5)
37 Partial codes types[] may hold a category or subcategory code; the namespaces are disjoint and a test asserts it (§5)
38 Stack legality Frontend only, in prosthesisTree.ts. Both the sheet and the apply path route through applyLeafToJobs; the backend never learns the rules (§6)
39 Sheet rows Teeth and prosthesis merge into one row for lab-dependent types — they are not independent, and two ticks could save an empty detail (§7)
40 Tooth with no job Named and left out. The manual chart cannot produce a jobless tooth, so voice must not either (§6)
41 Type coupling A resolved assignment forces treatmentType to prosthesis and locks that row (§5)
42 Missing material Chips for the category's leaves. Never a per-category default — that is the auto-fill shape this design rejects (§7)
43 Missing jaw Upper / Lower chips, multi-pick. Picking both is how a both-jaw appliance is expressed (§7)
44 Frontend tests Vitest added for the pure helpers. The split between backend and frontend resolvers becomes a judgement rather than a constraint (§6, §12)
45 Recording defects Carried in this branch: the container fallback that refused Safari, and the render gate that never checked isMediaRecorderSupported() (§2, §9)
46 Delivery One merge request

Closing the gaps the /orchestrate surveyor found on 2026-09-07, before any code was written.

# Question Decision
47 Category labels Add PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind, one migration, translations seeded from the existing prosthesis.category_* / sub_* message keys, resolved through CatalogLabelService. Bare codes were the alternative and would have been weakest on fa (§5)
48 Frontend label source Out of scope. The frontend keeps its own message keys; the wording lives in two places by choice, and the seed is written from the message files so they agree (§5)
49 Mixed-region categories A category whose leaves span crown and arch — only removable today — defers its region check to the picked leaf, and is never reported as code_not_valid_for_target while still a category (§5)
50 Chips that came from an assignment tooth_missing_quadrant, arch_not_spoken and prosthesis_type_ambiguous carry assignmentIndex, so a picked chip inherits that assignment's jobs. Without it the chip resolves to a jobless tooth, which decision 40 discards — a chip that does nothing (§6)

Corrections to the v1 text found in the same pass: the endpoint is POST /voice/extract, not /treatments/voice-extract (§3); GET /voice/availability does exist and only the plan check is deferred (§11 item 13); the catalog has 5 subcategories, not 4, and the disjointness test asserts against the live catalog rather than a written count (§5).