Files
dyolink/docs/specs/voice-treatment-entry/progress.md
2026-09-07 14:14:05 +08:00

36 KiB
Raw Blame History

type, status, repos
type status repos
task-progress active
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

PROGRESS — Voice Treatment Entry

Spec: spec.md

Adapting the merged voice feature to the overhauled prosthesis model (spec §5§7, decisions 3446). 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.tsprosthesis: WireProsthesisAssignment[]
6 Resolver: assignments, arch derivation, leaf-vs-category classification dyolink §5, §6, extraction.resolver.tsresolveProsthesisAssignment, 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. resolveVoiceIntenthasResolvedAssignment
9 Prompt: present the catalog as a tree; teach stacks and jaw-level codes dyolink §5, extraction.prompt.tsprosthesisTree() renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge
10 Backend Jest suites for items 68, 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.tsVoiceProsthesisAssignment[], 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 3446. 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, and partial_denture already carries chartRegion: 'crown'.
  • Stack rules stay frontend-only. The backend's dispatch check knows nothing about stacking; porting canStackLeaf would 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 persistDraft prunes 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 CatalogEntityKind values and seeded translations, rather than bare untranslated codes in the prompt on fa.
  • 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 --dry with a gapNote, so the surveyor re-runs instead of replaying its cached gaps_found verdict.
  • 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-entry and backup/pre-rebase-voice are 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

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. 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.

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.

"روکش" 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_quadrant with 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 chartRegion of the assignment's codes.

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.

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 APIresolved 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 4748
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.


All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.

Backend

  • backend/prisma/schema.prismaPROSTHESIS_CATEGORY, PROSTHESIS_SUBCATEGORY added to CatalogEntityKind (additive, no data loss).
  • backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql — hand-written ALTER TYPE ... ADD VALUE migration, 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.tsPROSTHESIS_CATEGORY_LABELS (7) and PROSTHESIS_SUBCATEGORY_LABELS (5), worded from the frontend's existing category_*/sub_* message keys (decision 47/48), folded into CATALOG_TRANSLATIONS.
  • backend/src/common/fdi.tsARCH_TOOTH_UPPER/ARCH_TOOTH_LOWER sentinels, mirroring the frontend's prosthesisTree.ts convention so both sides speak the same jaw target.
  • backend/src/modules/voice/voice.types.tsProsthesisAssignment replaces ProsthesisIntent (default+overrides retired, decision 35); UnresolvedReason gains prosthesis_type_ambiguous, arch_not_spoken, code_not_valid_for_target, loses tooth_not_selected; UnresolvedItem.assignmentIndex added (decision 50).
  • backend/src/modules/voice/extraction.wire.ts — wire schema carries prosthesis: WireProsthesisAssignment[]; WireToothIntent.arch gains 'both'.
  • backend/src/modules/voice/extraction.resolver.ts — rewritten: resolveAssignmentTarget (tooth vs. jaw, mirrors the old tooth_missing_quadrant/new arch_not_spoken split), classifyTypeCode (leaf / category / subcategory, disjoint namespaces), and resolveProsthesisAssignment composing both plus the region-validity check (deferred for a mixed-region category — removable today, decision 49 — computed generically from the catalog's own chart regions rather than hardcoding the category name). resolveVoiceIntent forces treatmentType to prosthesis when any assignment resolves a target (decision 41). resolveProsthesis/ResolvedProsthesis retired outright.
  • backend/src/modules/voice/extraction.prompt.tsprosthesisTree() renders the catalog as CATEGORY → (SUBCATEGORY →) leaf from the data buildCatalog supplies; no catalog knowledge hardcoded in the prompt text itself.
  • backend/src/modules/voice/voice.providers.ts, voice.service.tsExtractionCatalog carries the full leaf shape plus prosthesisCategories/prosthesisSubcategories; buildCatalog no longer strips category/subcategory/chartRegion/stackGroup.
  • backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.tslistCategories()/listSubcategories(), resolved through CatalogLabelService like every other catalog label.
  • Jest: extraction.resolver.spec.ts rewritten around resolveProsthesisAssignment (stacks, jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the live PROSTHESIS_TYPES, region validity both ways, the removable deferral, assignment-index attribution); extraction.wire.spec.ts and openrouter.provider.spec.ts updated for the new wire shape. 209 backend tests pass (121 in modules/voice).

Frontend

  • frontend/src/lib/voice/audioFormat.tspickRecordingMimeType's final fallback is now '' instead of null (item 1's Safari fix — modern Safari's isTypeSupported can reject every preferred container yet still record when let choose).
  • frontend/src/components/ui/treatment/TreatmentWorkspace.tsxvoiceForEditor now also requires isMediaRecorderSupported(); applyVoiceResult rewritten: teeth vs. prosthesis are mutually exclusive per isLabDependentResult, the prosthesis stack is built through prosthesisTargetLines (which routes every leaf through applyLeafToJobs), and jaw targets write LabCaseToothProsthesisDraft rows keyed on the UA/LA sentinels — no separate arch-specific code path needed beyond what prosthesisTree.ts already provides.
  • frontend/src/types/voice.tsVoiceProsthesisAssignment[] replaces the byTooth map; VoiceUnresolvedItem.assignmentIndex.
  • frontend/src/components/treatment/voiceReviewRows.ts — rewritten: isLabDependentResult, merged-row voiceRowAvailability, withChosenArch/withChosenProsthesisLeaf (decision 50), prosthesisTargetLines (previews the stack via applyLeafToJobs, 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 own voiceReviewRows helper; voiceProsthesisIncomplete warning removed (all-or-nothing retired).
  • i18n: en.json/fa.json/nl.jsonvoiceUnresolved.* updated for the new/retired reasons, voiceTeethAndProsthesis, voicePickJaw, voiceNoProsthesisHeard, voiceStackRefused added, voiceProsthesisIncomplete removed.
  • frontend/package.json, frontend/vitest.config.tsvitest@3.2.7 (pinned to a version whose peer @types/node range still includes the repo's ^20; vitest 4/5 require >=22), npm run testvitest run, alias-only config (@src/).
  • frontend/src/components/treatment/prosthesisTree.spec.ts, voiceReviewRows.spec.ts — 37 Vitest cases covering stack legality, applyLeafToJobs precedence, toothRegionColors, row availability, chip folding, the merged-row preview, and the jobless/pending distinction.
  • CLAUDE.md — Tests section rewritten; frontend command table gains npx vitest run.

Verification run

  • cd backend && npm test — 209/209 pass (121 in modules/voice).
  • cd backend && npm run build — clean (after npm install, which pulled in @sentry/nestjs that node_modules was missing — unrelated to this change, pre-existing on this checkout).
  • cd backend && npx prisma generate — succeeds against the updated schema (no DB needed); confirms CatalogEntityKind.PROSTHESIS_CATEGORY/PROSTHESIS_SUBCATEGORY compile 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:seed against a live Postgres — this sandbox has no running Docker daemon (docker info never came up after several minutes and open -a Docker did not launch it), so the migration was never applied to a database and the new CatalogTranslation rows 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, with types: [] on its assignment — not excluded from the assignment's targets array. The sheet (frontend) treats types.length === 0 with no matching prosthesis_type_ambiguous unresolved 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 from unresolved — documented in both extraction.resolver.ts and voiceReviewRows.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 for removable by name. This also defers implant (which spans root and crown via screw_retained) — the spec's prose says "only removable today" 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.ts needed 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 incompletecorrectness 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

  1. VoiceReviewSheet.tsx:169 — a picked tooth chip is silently dropped. pickCandidate sets teeth: prev.teeth || available.teeth, but available is memoised from effective, which depends on chosenTeeth. Both setChosenTeeth and setSelection run in the same handler, so the updater closes over the pre-pick available, where available.teeth is false because result.teeth is empty. prev.teeth is 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 — "ترمیم برای دندون دو".
  2. VoiceReviewSheet.tsx:213 + TreatmentWorkspace.tsx:2215 — decision 41 not implemented. The treatmentType row is a plain toggle('treatmentType') with no lock, and applyVoiceResult derives labDependent from result.treatmentType rather than the detail.treatmentType it writes. Untick the type row on a forced-prosthesis recording and a restoration detail 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-risk lens. 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.

  1. Already fixed in f1a4594 — the spec file's filter(Boolean) left two tsc errors. It added a fact worth keeping: .gitea/workflows/* run no test, lint or typecheck step at all. nest build excludes **/*spec.ts and ts-jest runs transpile-only under isolatedModules, so nothing in this repo would ever have caught it. Repo-wide gap, not this branch's.
  2. 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.

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.