applyVoiceResult decided what to write and wrote it, in one callback inside a
3200-line client component. Nothing could reach it — not exported, and rendering
its component means mocking next-intl, the i18n router and six axios modules.
Three of the five defects found in live use sat in that callback, while 209
green tests covered the helpers around it.
buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft } now
holds the decision and writes nothing. The callback keeps only what a component
must do: setDetails, the ref writes the in-flight save reads, and the order that
lets lab rows carry a real treatmentDetailId. newDetail and newLabCaseDraft move
to treatmentDetailRules.ts so the pure module can build a draft without
importing a component.
21 tests. Verified they bite by reverting each bug in place: reading
labDependent from result.treatmentType fails 1, merging the plain teeth list
onto a prosthesis detail fails 2.
One group asserts an invariant that belongs to the backend — every
toothProsthesis row naming a real tooth must be in detail.teeth, which is
TREATMENT_TOOTH_NOT_ON_DETAIL at treatments.service.ts:806. It spans two
processes, so neither side could state it alone before.
Not covered, still manual: the labCaseDraftsRef timing needs a real render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
39 KiB
type, status, repos
| type | status | repos | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| task-progress | active |
|
PROGRESS — Voice Treatment Entry
Spec: spec.md
Adapting the merged voice feature to the overhauled prosthesis model (spec §5–§7,
decisions 34–46). v1 shipped on master; this branch revises it.
Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
| # | Work item | Repo | Status | Notes / refs |
|---|---|---|---|---|
| 1 | pickRecordingMimeType falls back to the empty hint instead of null |
dyolink |
✅ | §9. frontend/src/lib/voice/audioFormat.ts — the final return null after the loop is now return '' |
| 2 | voiceForEditor also checks isMediaRecorderSupported() |
dyolink |
✅ | §2 render policy, TreatmentWorkspace.tsx |
| 3 | Add Vitest for the frontend's pure helpers; update CLAUDE.md |
dyolink |
✅ | §12. vitest@3.2.7, frontend/vitest.config.ts, npm run test script; CLAUDE.md Tests section rewritten |
| 4 | buildCatalog passes category / subcategory / chartRegion / stackGroup through |
dyolink |
✅ | §5. voice.service.ts now forwards the full ProsthesisTypeCatalogEntry[] plus category/subcategory lists |
| 5 | Wire schema: prosthesisAssignments, arch: 'both'; drop default + overrides |
dyolink |
✅ | §5, extraction.wire.ts — prosthesis: WireProsthesisAssignment[] |
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | dyolink |
✅ | §5, §6, extraction.resolver.ts — resolveProsthesisAssignment, resolveAssignmentTarget, classifyTypeCode |
| 7 | Resolver: new unresolved reasons; retire tooth_not_selected |
dyolink |
✅ | §6 reason table — voice.types.ts |
| 8 | Resolver: a resolved assignment forces treatmentType to prosthesis |
dyolink |
✅ | §5. resolveVoiceIntent — hasResolvedAssignment |
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | dyolink |
✅ | §5, extraction.prompt.ts — prosthesisTree() renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge |
| 10 | Backend Jest suites for items 6–8, including the namespace-disjointness assertion | dyolink |
✅ | §12. extraction.resolver.spec.ts — disjointness test reads catalog-seed-data.ts's live PROSTHESIS_TYPES, not a written count |
| 11 | Frontend types follow the new ResolvedExtraction |
dyolink |
✅ | types/voice.ts — VoiceProsthesisAssignment[], assignmentIndex |
| 12 | voiceReviewRows: merged row, chip folding, retire complete as a blocker |
dyolink |
✅ | §6, §7 — prosthesisTargetLines, joblessProsthesisTargets, withChosenArch/withChosenProsthesisLeaf |
| 13 | VoiceReviewSheet: merged row, chart colours, three chip kinds |
dyolink |
✅ | §7. Merged "Teeth and prosthesis" row; tooth / jaw / material chip kinds |
| 14 | applyVoiceResult writes through applyLeafToJobs; handles arch rows |
dyolink |
✅ | §6, §7 — via prosthesisTargetLines, which routes every stack through applyLeafToJobs |
| 15 | Vitest specs for prosthesisTree.ts and voiceReviewRows.ts |
dyolink |
✅ | §12. 37 tests total, npx vitest run green |
| 16 | New user-visible strings in en.json, fa.json, nl.json |
dyolink |
✅ | i18n is mandatory, not a follow-up — new voiceUnresolved.* reasons, voiceTeethAndProsthesis, voicePickJaw, voiceNoProsthesisHeard, voiceStackRefused; retired voiceProsthesisIncomplete (all-or-nothing gone) |
| 17 | Run every gate in §12, then the manual pass including Safari and iPad | dyolink |
🟡 | Every machine gate green, migration and seed now applied and verified (2026-09-07 entry below). Manual pass still not run — Safari, iPad, and the stack / jaw / chip flows in fa |
| 18 | PROSTHESIS_CATEGORY + PROSTHESIS_SUBCATEGORY in CatalogEntityKind; migration; seed fa/en/nl translations |
dyolink |
✅ | §5, decision 47. Applied via migrate deploy and seeded: enum carries both kinds, 7 categories + 5 subcategories per locale, crown → روکشها |
| 19 | Unresolved items carry assignmentIndex; a picked chip inherits that assignment's jobs |
dyolink |
✅ | §6, decision 50. UnresolvedItem.assignmentIndex; VoiceReviewSheet chips inherit via withChosenTeeth(...,index) / withChosenArch / withChosenProsthesisLeaf |
Key decisions
Full table in the spec's §13, rows 34–46. The load-bearing ones:
- One assignment list, no default type. A default plus per-tooth overrides has a precedence rule, and that rule already mis-filled tooth 13 once.
- An arch target is derived from the code's
chartRegion, not declared on the wire. The catalog already answers it, andpartial_denturealready carrieschartRegion: 'crown'. - Stack rules stay frontend-only. The backend's dispatch check knows nothing about
stacking; porting
canStackLeafwould make voice stricter than the manual path and create a second copy of a rule that must never disagree. - Teeth and prosthesis merge into one sheet row for lab-dependent types. Two ticks can
save an empty detail today, because
persistDraftprunes lab-dependent details to their jobs. - Nothing is guessed. A missing material, a missing jaw and a missing quadrant all become chips. A jobless tooth is named and left out.
- Categories become real catalog entities (decision 47). Two new
CatalogEntityKindvalues and seeded translations, rather than bare untranslated codes in the prompt onfa. - A chip must inherit its assignment's jobs (decision 50), or it resolves to a jobless tooth and the tap does nothing.
Deviations from spec
(none yet)
Next steps / open questions
- Re-run
/orchestrate --drywith agapNote, so the surveyor re-runs instead of replaying its cachedgaps_foundverdict. - Amin reviews and approves this spec revision. That approval is the only human gate before
/orchestrate --dry. - Open items 15 and 16 in §11 are carried forward unchanged and are not in this branch: the pre-authentication body limit on the voice path, and transcript salvage still being specified but not built.
feat/voice-treatment-entryandbackup/pre-rebase-voiceare stale local branches from the v1 work. Safe to delete once this lands.
2026-09-07 — Gap-check halted (nothing built, nothing pushed)
- Phase: Gap-check
- Reason: the spec has 1 gap that blocks implementation — nothing was built.
- Repo: dyolink @
feat/treatment/add-voice-input-for-new-treatment-form
Blocking gap 1 — missing_constraint (dyolink)
Summary: The prompt must present prosthesis categories and subcategories so the model can
return crown for "روکش", but no localized label for a category or subcategory exists anywhere
the backend can read. CatalogEntityKind has only TREATMENT_TYPE / PROSTHESIS_TYPE /
LAB_WORKFLOW_STEP, and the only category labels in the repo are frontend message keys
(category_crown, sub_night_guard) plus a hardcoded single-locale map in a dev export script.
Spec quotes:
The prosthesis list is no longer flat.
buildCatalogalready receivescategory,subcategory,chartRegionandstackGroupfromProsthesisCatalogServiceand 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.
Treatment types and prosthesis types come from
CatalogLabelServicein the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms ofprosthesisandmonolithic_zirconiarather than being asked to translate. Catalog entities store a stablecodeand no label — never hardcode a label.
"روکش" 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).
Why blocking: Work item 9 ("Prompt: present the catalog as a tree") cannot be built.
ProsthesisCatalogService.list() returns category and subcategory as bare codes with no label
(prosthesis-catalog.service.ts:82-95), and CatalogLabelService cannot resolve them because
CatalogEntityKind has no category kind (schema.prisma:331-335). The three ways out have
different costs and one breaks an explicit repo rule, so a builder cannot choose: (a) add
PROSTHESIS_CATEGORY / PROSTHESIS_SUBCATEGORY to the enum plus a migration and seeded translations
— a schema change the spec never mentions; (b) hardcode a fa/en/nl label map in the backend, which
the spec and CLAUDE.md both forbid; (c) send bare codes only, which makes the whole category
feature unmeasured on the weakest locale.
Question: Where do the localized labels for the 7 prosthesis categories and 5 subcategories
come from for the extraction prompt: a new CatalogEntityKind (PROSTHESIS_CATEGORY /
PROSTHESIS_SUBCATEGORY) with a migration and seeded CatalogTranslation rows, or bare codes with
no labels in the prompt?
Suggested spec change: Add to §5: "Category and subcategory labels have no backend source
today — CatalogEntityKind covers only TREATMENT_TYPE, PROSTHESIS_TYPE and LAB_WORKFLOW_STEP, and
category_* / sub_* exist only in frontend/messages/*.json. Add PROSTHESIS_CATEGORY and
PROSTHESIS_SUBCATEGORY to CatalogEntityKind (one migration), seed their fa/en/nl translations
from the existing message keys, and resolve them through CatalogLabelService like every other
catalog label."
Evidence: backend/prisma/schema.prisma:331; backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:82; frontend/messages/en.json:1124; backend/prisma/export-prosthesis-catalog.ts:16
Non-blocking notes
Note 1 — ambiguity (dyolink). An unresolved item carries only spoken, reason and
candidates, with no link back to the assignment it came from. On a prosthesis detail a
tooth_missing_quadrant chip therefore resolves to a tooth with no job, which the "tooth with no
job is named and left out" rule then discards — so tapping the chip does nothing.
A single digit alone is never resolved. "دندون دو" names four teeth. It is reported as
tooth_missing_quadrantwith the candidate codes attached
A tooth with no job is named and left out.
Picking one folds the choice into the result, so an under-specified item is one tap from resolved instead of a dead end.
Why not blocking: a builder can add an assignment reference to the unresolved item. But §6's
reason table reads as a complete contract and its "Carries" column names only candidates, so a
builder following it literally ships a dead chip on exactly the prosthesis flow §12 asks them to
test.
Question: Should VoiceUnresolvedItem gain an assignment reference (for example
assignmentIndex) so a chosen quadrant chip inherits that assignment's job stack?
Suggested spec change: In §6's reason table, add: "tooth_missing_quadrant and
arch_not_spoken also carry the index of the assignment they came from, so a picked chip inherits
that assignment's types[] rather than becoming a jobless tooth."
Evidence: frontend/src/types/voice.ts:15-25; frontend/src/components/treatment/voiceReviewRows.ts:66
Note 2 — ambiguity (dyolink). "The resolver confirms [jaw vs tooth] against the
chartRegion of the assignment's codes" is undefined when types[] holds a category whose leaves
have mixed regions. removable is exactly that: complete_denture and overdenture are
chartRegion: 'arch', partial_denture is 'crown'.
A target that names an arch and no position is a jaw, and the resolver confirms that against the
chartRegionof the assignment's codes.
partial_denture— aremovablecode that is nonetheless assigned per tooth — already ships withchartRegion: 'crown'incatalog-seed-data.ts, so the exception needs no special case either.
A code whose region contradicts its target is reported, never coerced.
Why not blocking: deferring the region check until the ambiguity chip is picked is a reasonable
default. Worth stating so the builder does not emit code_not_valid_for_target for a category that
has not been narrowed yet.
Question: When types[] holds a category whose leaves span both crown and arch regions (only
removable today), is the region check deferred until the clinician picks a leaf chip?
Suggested spec change: In §5 under "A target is a tooth or a jaw": "When types[] holds a
category whose leaves have more than one chartRegion, the region check is deferred — the item
resolves to prosthesis_type_ambiguous and the region is confirmed against the leaf the clinician
picks."
Evidence: backend/prisma/catalog-seed-data.ts:266-288
Note 3 — brownfield (dyolink). §3 names the endpoint POST /treatments/voice-extract; the
shipped route is POST /voice/extract (@Controller('voice') + @Post('extract')), and
common/body-parsers.ts hardcodes /api/voice/extract to select the 10 MB body limit.
POST /treatments/voice-extract
Why not blocking: no work item asks to move the route. But if a builder aligned the route with
§3, isVoiceExtractPath would stop matching and every real recording would 413 against the 100 kb
default, which reads as a broken microphone rather than a route change.
Question: Should §3 be corrected to the shipped path POST /voice/extract, since no work item
moves the route?
Suggested spec change: Change §3's heading to POST /voice/extract and note that
VOICE_EXTRACT_PATH in common/body-parsers.ts must move with it if it ever changes.
Evidence: backend/src/modules/voice/voice.controller.ts:23,36; backend/src/common/body-parsers.ts:10
Note 4 — contradiction (dyolink). §11 item 13 says v1 ships with "no availability endpoint",
but §4 requires the frontend to learn enabled locales from the API, and GET /voice/availability
exists and is what voiceForEditor reads today.
Availability API— resolved for v1: voice ships open to everyone with a configured locale profile. No plan check, no availability endpoint.
The frontend must learn which locales are enabled from the API, not from a
NEXT_PUBLIC_*var
Why not blocking: work item 2 ("voiceForEditor also checks isMediaRecorderSupported()")
presupposes voiceAvailability stays, so the working reading is obvious. The stale line just needs
correcting so nobody deletes a live endpoint.
Question: Confirm the availability endpoint stays in v1 and only the plan check is deferred?
Suggested spec change: Reword item 13 to "no plan check; the availability endpoint ships and
reports configured locales and maxRecordingMs only."
Evidence: backend/src/modules/voice/voice.controller.ts:28; frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625
Note 5 — brownfield (dyolink). §5's disjointness counts are slightly off: the seed defines 41
distinct prosthesis codes (not 42) and 5 subcategory names — veneer, inlay, onlay, overlay,
night_guard (not 4). The disjointness claim itself holds: no leaf code equals any category or
subcategory name.
This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 category names and 4 subcategory names, no collisions.
Why not blocking: the test asserts disjointness, not counts, and disjointness is true against the current seed. Flagged so the builder writes the test against the live catalog rather than a hardcoded 42/7/4. Question: May the disjointness test assert against the live catalog instead of the counts written in §5? Suggested spec change: Drop the exact counts and keep only "the leaf, category and subcategory namespaces are disjoint; a test asserts it against the live catalog". Evidence: backend/prisma/catalog-seed-data.ts:121-400
Files read during the gap-check
docs/specs/voice-treatment-entry/spec.md, docs/specs/voice-treatment-entry/progress.md,
backend/src/modules/voice/extraction.prompt.ts, backend/src/modules/voice/extraction.wire.ts,
backend/src/modules/voice/voice.providers.ts, backend/src/modules/voice/voice.service.ts:243,
backend/src/modules/voice/voice.controller.ts:23,
backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:72,
backend/prisma/catalog-seed-data.ts, backend/prisma/schema.prisma:331,
backend/prisma/export-prosthesis-catalog.ts:16, backend/src/common/fdi.ts,
backend/src/common/body-parsers.ts:10,
backend/src/modules/treatments/lab-case-send.validation.ts:116,
frontend/src/components/treatment/prosthesisTree.ts,
frontend/src/components/treatment/voiceReviewRows.ts, frontend/src/types/voice.ts,
frontend/src/lib/voice/audioFormat.ts, frontend/src/lib/voice/useVoiceCapture.ts:157,
frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625 and :2184,
frontend/src/components/ui/treatment/FdiToothChart.tsx:218,
frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx:200,
frontend/messages/en.json:1124, frontend/package.json
(all paths relative to /Users/aminmsvi/PersonalProjects/dyolink)
How to resume
Close the gaps in the spec, then re-run. Pass gapNote describing what you changed, or the
analyst replays this same verdict from cache.
2026-09-07 — Gaps closed, ready to re-run
All five findings are now answered in the spec. Nothing was built in the halted run, so there is no code to revisit.
| Finding | Resolution | Spec |
|---|---|---|
| Blocking — no backend source for category labels | Add PROSTHESIS_CATEGORY + PROSTHESIS_SUBCATEGORY to CatalogEntityKind, one migration, seed fa/en/nl from the existing prosthesis.category_* / sub_* keys, resolve via CatalogLabelService. Frontend keeps its own keys — migrating it is out of scope |
§5, decisions 47–48 |
| Note 1 — chip with no assignment link | Unresolved items raised inside an assignment carry assignmentIndex; a picked chip inherits that assignment's types[] |
§6, decision 50 |
| Note 2 — mixed-region category | removable defers its region check to the picked leaf; never code_not_valid_for_target while still a category |
§5, decision 49 |
| Note 3 — wrong endpoint path in §3 | Corrected to POST /voice/extract, with the body-parsers.ts coupling called out. No work item moves the route |
§3 |
| Note 4 — availability endpoint contradiction | §11 item 13 corrected: the endpoint exists, only the plan check is deferred | §11 |
| Note 5 — disjointness counts | Verified independently: 42 leaf codes (the surveyor's 41 missed screw_retained, a multi-line implant( call) and 5 subcategories (the spec said 4, missing night_guard). The test now asserts against the live catalog, not a written count |
§5, §12 |
Referee relays: 0. Gate repairs: 0. Neither phase was reached.
2026-09-07 — Implementation (dyolink, extraction contract + resolvers + review sheet)
All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.
Backend
backend/prisma/schema.prisma—PROSTHESIS_CATEGORY,PROSTHESIS_SUBCATEGORYadded toCatalogEntityKind(additive, no data loss).backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql— hand-writtenALTER TYPE ... ADD VALUEmigration, following the exact pattern of the repo's one precedent (20260718180000_lab_case_activity_task_assigned). Not applied — see "Not run" below.backend/prisma/catalog-seed-data.ts—PROSTHESIS_CATEGORY_LABELS(7) andPROSTHESIS_SUBCATEGORY_LABELS(5), worded from the frontend's existingcategory_*/sub_*message keys (decision 47/48), folded intoCATALOG_TRANSLATIONS.backend/src/common/fdi.ts—ARCH_TOOTH_UPPER/ARCH_TOOTH_LOWERsentinels, mirroring the frontend'sprosthesisTree.tsconvention so both sides speak the same jaw target.backend/src/modules/voice/voice.types.ts—ProsthesisAssignmentreplacesProsthesisIntent(default+overrides retired, decision 35);UnresolvedReasongainsprosthesis_type_ambiguous,arch_not_spoken,code_not_valid_for_target, losestooth_not_selected;UnresolvedItem.assignmentIndexadded (decision 50).backend/src/modules/voice/extraction.wire.ts— wire schema carriesprosthesis: WireProsthesisAssignment[];WireToothIntent.archgains'both'.backend/src/modules/voice/extraction.resolver.ts— rewritten:resolveAssignmentTarget(tooth vs. jaw, mirrors the oldtooth_missing_quadrant/newarch_not_spokensplit),classifyTypeCode(leaf / category / subcategory, disjoint namespaces), andresolveProsthesisAssignmentcomposing both plus the region-validity check (deferred for a mixed-region category —removabletoday, decision 49 — computed generically from the catalog's own chart regions rather than hardcoding the category name).resolveVoiceIntentforcestreatmentTypetoprosthesiswhen any assignment resolves a target (decision 41).resolveProsthesis/ResolvedProsthesisretired outright.backend/src/modules/voice/extraction.prompt.ts—prosthesisTree()renders the catalog as CATEGORY → (SUBCATEGORY →) leaf from the databuildCatalogsupplies; no catalog knowledge hardcoded in the prompt text itself.backend/src/modules/voice/voice.providers.ts,voice.service.ts—ExtractionCatalogcarries the full leaf shape plusprosthesisCategories/prosthesisSubcategories;buildCatalogno longer stripscategory/subcategory/chartRegion/stackGroup.backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts—listCategories()/listSubcategories(), resolved throughCatalogLabelServicelike every other catalog label.- Jest:
extraction.resolver.spec.tsrewritten aroundresolveProsthesisAssignment(stacks, jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the livePROSTHESIS_TYPES, region validity both ways, theremovabledeferral, assignment-index attribution);extraction.wire.spec.tsandopenrouter.provider.spec.tsupdated for the new wire shape. 209 backend tests pass (121 inmodules/voice).
Frontend
frontend/src/lib/voice/audioFormat.ts—pickRecordingMimeType's final fallback is now''instead ofnull(item 1's Safari fix — modern Safari'sisTypeSupportedcan reject every preferred container yet still record when let choose).frontend/src/components/ui/treatment/TreatmentWorkspace.tsx—voiceForEditornow also requiresisMediaRecorderSupported();applyVoiceResultrewritten: teeth vs. prosthesis are mutually exclusive perisLabDependentResult, the prosthesis stack is built throughprosthesisTargetLines(which routes every leaf throughapplyLeafToJobs), and jaw targets writeLabCaseToothProsthesisDraftrows keyed on theUA/LAsentinels — no separate arch-specific code path needed beyond whatprosthesisTree.tsalready provides.frontend/src/types/voice.ts—VoiceProsthesisAssignment[]replaces the byTooth map;VoiceUnresolvedItem.assignmentIndex.frontend/src/components/treatment/voiceReviewRows.ts— rewritten:isLabDependentResult, merged-rowvoiceRowAvailability,withChosenArch/withChosenProsthesisLeaf(decision 50),prosthesisTargetLines(previews the stack viaapplyLeafToJobs, names refused jobs),joblessProsthesisTargets(decision 40 — named, struck through, never silently dropped or silently applied),prosthesisChartData(crown/root/arch tints for the merged row's chart).frontend/src/components/ui/treatment/VoiceReviewSheet.tsx— merged "Teeth and prosthesis" row for a labDependent type; three independent candidate-chip kinds (tooth, jaw, leaf) each folding through their ownvoiceReviewRowshelper;voiceProsthesisIncompletewarning removed (all-or-nothing retired).- i18n:
en.json/fa.json/nl.json—voiceUnresolved.*updated for the new/retired reasons,voiceTeethAndProsthesis,voicePickJaw,voiceNoProsthesisHeard,voiceStackRefusedadded,voiceProsthesisIncompleteremoved. frontend/package.json,frontend/vitest.config.ts—vitest@3.2.7(pinned to a version whose peer@types/noderange still includes the repo's^20; vitest 4/5 require>=22),npm run test→vitest run, alias-only config (@→src/).frontend/src/components/treatment/prosthesisTree.spec.ts,voiceReviewRows.spec.ts— 37 Vitest cases covering stack legality,applyLeafToJobsprecedence,toothRegionColors, row availability, chip folding, the merged-row preview, and the jobless/pending distinction.CLAUDE.md— Tests section rewritten; frontend command table gainsnpx vitest run.
Verification run
cd backend && npm test— 209/209 pass (121 inmodules/voice).cd backend && npm run build— clean (afternpm install, which pulled in@sentry/nestjsthatnode_moduleswas missing — unrelated to this change, pre-existing on this checkout).cd backend && npx prisma generate— succeeds against the updated schema (no DB needed); confirmsCatalogEntityKind.PROSTHESIS_CATEGORY/PROSTHESIS_SUBCATEGORYcompile everywhere they're used.cd frontend && npx tsc --noEmit— clean.cd frontend && npx vitest run— 37/37 pass.cd frontend && npm run build— production build succeeds.- ESLint on every touched file — 0 errors, 0 new warnings (pre-existing warnings elsewhere in
TreatmentWorkspace.tsx, unrelated to this change, left untouched).
Not run (environment limitation, not a design gap)
npm run prisma:migrate && npm run prisma:seedagainst a live Postgres — this sandbox has no running Docker daemon (docker infonever came up after several minutes andopen -a Dockerdid not launch it), so the migration was never applied to a database and the newCatalogTranslationrows were never seeded. The migration SQL and seed data are written and reviewed against the one existing precedent in this repo; run both before merging.- The full manual pass in §12 (Safari/iPad recording, live extraction against the real OpenRouter API, the specific stack/jaw/ambiguity scenarios) — needs a browser and a live backend, neither available in this session.
Deviations from spec / judgement calls made while implementing
- A target with empty
types[]still resolves as a target, withtypes: []on its assignment — not excluded from the assignment'stargetsarray. The sheet (frontend) treatstypes.length === 0with no matchingprosthesis_type_ambiguousunresolved item as "jobless, struck through" (decision 40), and the same empty-types-plus-ambiguous-item combination as "pending a material pick" instead. This keeps the wire contract simple (no extra field) at the cost of the frontend doing that one bit of inference fromunresolved— documented in bothextraction.resolver.tsandvoiceReviewRows.ts. - The mixed-region deferral (decision 49) is computed generically from each category's
actual leaf chart-regions (
regions.size === 1→ validate immediately, else defer) rather than special-cased forremovableby name. This also defersimplant(which spansrootandcrownviascrew_retained) — the spec's prose says "onlyremovabletoday" as an observation about the current catalog, not an instruction to hardcode that name, and deferring a category no test forbids deferring is the safer default. voice.dto.ts/voice.controller.tsneeded no changes. The scout's item 6 ("validation for new schema shape") does not apply: the DTO validates the client's audio submission (audio/format/timeZone/durationMs/locale), which is unrelated to the LLM's structured output shape that changed. Confirmed by reading both files; not a silent skip.
2026-09-07 — Re-run reached Ship; stopped, and the challenge phase is incomplete
Recorded by hand: the record-stop:Ship agent failed on the session spend limit before it
could write this.
Stop reason: no MR template found in dyolink — refusing to invent a description. Expected
and flagged at preflight: .gitea/ holds only workflows/, and the remote is Gitea, not GitLab.
A --dry run pushes nothing regardless.
| Phase | Result |
|---|---|
| Gap-check | clear — the five gaps closed in 77e2ed4 were accepted |
| Scout | 1 of 3 Explore sweeps returned; 2 ended without structured output |
| Implement | done — all 19 work items, ~3,100 insertions across 25 files + 4 new files |
| Gate | green — backend 16 suites / 209 tests, nest build 0, prisma validate ok; frontend Vitest 2 files / 37 tests, tsc --noEmit 0, next build 0 |
| Refute | incomplete — correctness refuted with 2 findings; regression-risk never ran (spend limit) |
| Ship | stopped, no template. clerk also failed on the spend limit |
Gate repairs: 0. Referee relays: 0. Nothing committed, nothing pushed.
Two confirmed findings — verified by hand, not taken on the critic's word
VoiceReviewSheet.tsx:169— a picked tooth chip is silently dropped.pickCandidatesetsteeth: prev.teeth || available.teeth, butavailableis memoised fromeffective, which depends onchosenTeeth. BothsetChosenTeethandsetSelectionrun in the same handler, so the updater closes over the pre-pickavailable, whereavailable.teethisfalsebecauseresult.teethis empty.prev.teethis false too, so it stays false permanently. The row then renders with the tooth on the chart and the box unticked, and Apply drops it. This is a variant of the original live-test failure — "ترمیم برای دندون دو".VoiceReviewSheet.tsx:213+TreatmentWorkspace.tsx:2215— decision 41 not implemented. The treatmentType row is a plaintoggle('treatmentType')with no lock, andapplyVoiceResultderiveslabDependentfromresult.treatmentTyperather than thedetail.treatmentTypeit writes. Untick the type row on a forced-prosthesis recording and arestorationdetail is saved carrying prosthesis lab rows — the state decision 41 exists to make unreachable.
Lint
Backend touched files: 0 errors, 0 warnings. Frontend touched files: 10 warnings, all
pre-existing in TreatmentWorkspace.tsx. The repo-wide backend baseline (1,288 errors, 1,107
prettier-fixable) is untouched by this diff.
Next steps / open questions
- The diff is unreviewed on the
regression-risklens. Green gate plus one refuting lens is not the design's bar; the script continued only because the threshold counts refusals and the second critic errored rather than refused. - Fix the two findings, then resume from Refute so both lenses grade the same diff.
- The Ship phase cannot pass in this repo until there is an MR template, or until the MR is opened by hand. Opening one is gated regardless.
2026-09-07 — Second refute round, both lenses, on the repaired diff
Ran both critics directly rather than resuming the workflow: a resume would have replayed the
correctness verdict from cache against a diff that had changed, and re-run the Ship phase that
cannot pass in this repo anyway.
correctness refuted with 4 findings — all confirmed by hand, all fixed in f1a4594.
Three were the same root cause: the region check never actually ran. Each wrote a job the manual chart cannot produce, and each passed all 209 tests.
| Finding | Effect |
|---|---|
| The deferred region check had nothing to complete it | "پروتز متحرک برای دندون ۱۲" wrote a complete denture onto tooth 12 |
some over a stack's regions |
['pfm_crown','night_guard_soft'] on tooth 12 wrote a night guard onto that tooth |
regions.size === 1 also deferred implant (root + crown, both tooth regions) |
an implant aimed at a jaw resolved as a UA target |
The e271858 lock ticked the row and the payload but not the count |
the sheet read "Apply 1 field" while two landed |
Fix: targets resolve before types, so the target kind narrows a category's candidates and an
impossible leaf is never offered. The deferral concept is gone. Region validation is now
every, and a category with no usable leaf drops the assignment's targets rather than leaving
them jobless. Four tests added.
regression-risk refuted with 2 findings.
- Already fixed in
f1a4594— the spec file'sfilter(Boolean)left twotscerrors. It added a fact worth keeping:.gitea/workflows/*run no test, lint or typecheck step at all.nest buildexcludes**/*spec.tsand ts-jest runs transpile-only underisolatedModules, so nothing in this repo would ever have caught it. Repo-wide gap, not this branch's. - Open — needs a decision, not a fix. See below.
Open question: teeth are unreachable when the merged row is unticked
On a forced-prosthesis result voiceRowAvailability sets teeth: false, so no teeth row renders
and selection.teeth can never become true. Untick the merged prosthesis row (which releases the
decision-41 lock) and then untick the type row, and both write paths in applyVoiceResult are
skipped — the detail is appended with teeth: []. master kept the teeth here, because the rows
were separate.
This is decision 39 behaving exactly as approved: teeth and prosthesis apply together. But it means a clinician who wants only the teeth from a prosthesis dictation, intending to pick materials by hand, has no way to get them. Not a spec violation — an unstated consequence. Deliberately not changed unilaterally.
2026-09-07 — Migration and seed proven against dev Postgres
The last unverified gate. The inspector had run prisma validate, which only parses the schema;
the build compiled solely because the generated client in node_modules already carried the new
enum values.
Found five migrations pending, not one — this dev database had not been migrated since the
prosthesis overhaul landed on master on 1 September (a3c14a1, 72f885d). Prisma applies in
order, so proving one required applying all five.
Used prisma migrate deploy, not migrate dev: deploy applies pending migrations and never
offers to reset, so there is no path where a drift prompt drops dev data. §12 should say deploy
for this reason.
Counted the DELETE in …_prosthesis_tree_multi_type before applying rather than trusting its
own "no-op for current data" comment: 0 rows matched, against 2 rows total in
lab_case_tooth_prosthesis. Both login accounts survived (users = 2).
Verified after seeding:
| Check | Result |
|---|---|
enum_range(CatalogEntityKind) |
now 5 values, including PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY |
Seeded PROSTHESIS_CATEGORY rows |
7 per locale × fa/en/nl |
Seeded PROSTHESIS_SUBCATEGORY rows |
5 per locale × fa/en/nl |
crown in fa |
روکشها — the label that makes "روکش" resolvable to a category |
| Code path | prosthesis-catalog.service.ts:108,119 resolves both kinds via CatalogLabelService |
Repo-wide gap worth acting on separately: .gitea/workflows/* run no test, lint or typecheck
step — only build and deploy. Combined with nest build excluding **/*spec.ts and ts-jest
running transpile-only under isolatedModules, nothing in this repo would ever catch a type error
in a spec file. That is how two of them reached this branch unnoticed.
2026-09-11 — note triggers and where a note ends (dyolink)
Two reports from live dictation in fa, both prompt-side, no resolver change.
- «یادداشت» / «یادداشتها» produced no trigger, so the note was discarded. The locale notes listed only verbs that ask («بنویس», «یادداشت کن»); naming the field itself was not in the vocabulary. Added the field-name shape for all three locales — fa «یادداشت/یادداشتها/توضیحات/ کامنت», en "note/notes/comments/remarks", nl "notitie/notities/opmerkingen".
- The note swallowed the next instruction. For «توی توضیحات بنویس سلام چطوری و بفرست برای
لابراتوآر سانشاین» the model sometimes put the whole tail in
comment, sometimes only «سلام چطوری». Added a no-overlap invariant: a clause reported as a lab, tooth, prosthesis or deadline is an instruction and must not also appear incomment, plus that exact sentence as a worked example.
Both rules are stated twice on purpose — in extraction.prompt.ts and in the comment /
commentTrigger descriptions in extraction.wire.ts, since the schema description is what the
model reads while filling the field.
No code enforcement for (2). Stripping an extracted clause out of free-speech note text by string matching is guesswork, and the review sheet shows the note before it is saved.
Files: backend/src/modules/voice/extraction.prompt.ts,
backend/src/modules/voice/extraction.wire.ts, docs/specs/voice-treatment-entry/spec.md.
Verified: npx eslint src/modules/voice/**/*.ts clean, npm test -- extraction 74/74.
No unit test — both are prompt text, which the extraction specs deliberately do not assert on.
2026-09-11 — the apply decision is now testable (dyolink)
Pulled the decision out of applyVoiceResult into
frontend/src/components/treatment/voiceApply.ts — buildVoiceApplyPlan(result, selection, ctx)
returns { detail, labCaseDraft } and writes nothing. The callback shrank from ~115 lines to the
commit it always should have been: two setStates, two ref writes, and the save order.
newDetail / newLabCaseDraft moved from TreatmentWorkspace.tsx to treatmentDetailRules.ts,
so the pure module can build a draft without importing a component.
21 new tests in voiceApply.spec.ts. Proved they bite by reverting each bug in place:
| Reintroduced bug | Failing tests |
|---|---|
labDependent read from result.treatmentType (decision 41) |
1 |
plain teeth merged onto a prosthesis detail (decision 40) |
2 |
One group of tests asserts an invariant that belongs to the backend: every toothProsthesis
row naming a real tooth must appear in detail.teeth. That is
TREATMENT_TOOTH_NOT_ON_DETAIL (treatments.service.ts:806) — the error the clinic saw — now
checked on the client side of the wire, across the four shapes that produced it: two separated
teeth, one tooth, a two-code stack, and a jaw plus a tooth.
Still not covered, and still manual (§12): the labCaseDraftsRef timing, which needs a real
render, and the persist ordering.
Gate: tsc --noEmit clean, vitest run 73/73 (was 52), next build compiled, eslint back to its
pre-change 10 warnings (the 11th was an import I had orphaned).
Files: frontend/src/components/treatment/voiceApply.ts (new),
frontend/src/components/treatment/voiceApply.spec.ts (new),
frontend/src/components/treatment/treatmentDetailRules.ts,
frontend/src/components/ui/treatment/TreatmentWorkspace.tsx,
docs/specs/voice-treatment-entry/spec.md, CLAUDE.md.
Remaining before this is shippable
- The manual pass in §12 — none of it has been run. Safari and iPad especially, since that is
the report that started the revision, and the stack / jaw / chip flows in
fa. - The Ship phase cannot pass in this repo: no MR template exists, and opening a merge request is gated regardless.