Skip to main content

Treatment Plan — Pricing-Toggle Hardening + Reusable Custom Clinical Phase — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: When a clinic turns OFF documentSettings.treatmentPlanPricingEnabled, hide the 4 remaining money leaks in the treatment-plan UI; and let plan authors quick-add reusable, clinic-wide custom Clinical Phases. Architecture: Pricing leaks are gated client-side with the existing treatmentPlanPricingEnabled state (no new data plumbing). Custom phases are stored in the existing clinics.documentSettings JSONB (no migration) and written via a new treatment-plans-scoped POST endpoint (so doctors, who can’t call admin-only PUT /clinics/:id, can still add). A pure, unit-tested merge helper handles validation/dedupe. Tech Stack: Hono (server routes), Drizzle ORM, Vitest, React + shadcn/ui (Select, Dialog), TanStack Query. Spec: docs/superpowers/specs/2026-06-10-treatment-plan-pricing-hide-and-custom-phase-design.md

File structure

  • Create server/src/lib/treatment-plan-phases.tsDEFAULT_TREATMENT_PLAN_PHASES const + pure mergeCustomPhase() (validation + case-insensitive dedupe).
  • Create server/src/lib/treatment-plan-phases.test.ts — Vitest unit tests for the helper.
  • Modify server/src/schema/clinics.ts — add treatmentPlanCustomPhases?: string[] to the documentSettings $type.
  • Modify server/src/routes/treatment-plans.ts — new POST /phases handler.
  • Modify ui/src/lib/serverComm.tsaddTreatmentPlanPhase() client fn.
  • Modify ui/src/components/doctor/TreatmentPlanning.tsx — 4 pricing gates + custom-phase state/dropdown/dialog.
  • Modify docs/api-reference.md — document the new endpoint.

Task 1: Pure phase-merge helper (TDD)

Files:
  • Create: server/src/lib/treatment-plan-phases.ts
  • Test: server/src/lib/treatment-plan-phases.test.ts
  • Step 1: Write the failing test
Create server/src/lib/treatment-plan-phases.test.ts:
import { describe, it, expect } from 'vitest';
import { mergeCustomPhase, DEFAULT_TREATMENT_PLAN_PHASES } from './treatment-plan-phases';

describe('mergeCustomPhase', () => {
  it('appends a new phase', () => {
    const r = mergeCustomPhase([], 'Phase VI: Implant Surgery');
    expect(r.status).toBe('added');
    expect(r.phases).toEqual(['Phase VI: Implant Surgery']);
  });

  it('trims and collapses internal whitespace', () => {
    const r = mergeCustomPhase([], '  Phase   VI  ');
    expect(r.status).toBe('added');
    expect(r.phases).toEqual(['Phase VI']);
  });

  it('rejects an empty / whitespace-only name', () => {
    const r = mergeCustomPhase([], '   ');
    expect(r.status).toBe('invalid');
    expect(r.phases).toEqual([]);
  });

  it('rejects names longer than 60 chars', () => {
    const r = mergeCustomPhase([], 'x'.repeat(61));
    expect(r.status).toBe('invalid');
    expect(r.phases).toEqual([]);
  });

  it('dedupes against built-in defaults, case-insensitively', () => {
    const r = mergeCustomPhase([], 'phase i: urgent care');
    expect(r.status).toBe('duplicate');
    expect(r.phases).toEqual([]);
  });

  it('dedupes against existing custom phases, case-insensitively', () => {
    const r = mergeCustomPhase(['Phase VI'], 'PHASE VI');
    expect(r.status).toBe('duplicate');
    expect(r.phases).toEqual(['Phase VI']);
  });

  it('tolerates a non-array existing value', () => {
    const r = mergeCustomPhase(undefined as unknown as string[], 'Phase VI');
    expect(r.status).toBe('added');
    expect(r.phases).toEqual(['Phase VI']);
  });

  it('exposes exactly the 5 built-in phases', () => {
    expect(DEFAULT_TREATMENT_PLAN_PHASES).toHaveLength(5);
    expect(DEFAULT_TREATMENT_PLAN_PHASES[0]).toBe('Phase I: Urgent Care');
  });
});
  • Step 2: Run the test to verify it fails
Run: cd server && npx vitest run src/lib/treatment-plan-phases.test.ts Expected: FAIL — cannot resolve ./treatment-plan-phases (module does not exist yet).
  • Step 3: Write the implementation
Create server/src/lib/treatment-plan-phases.ts:
// Built-in Clinical Phase labels offered in the treatment-plan editor.
// Kept in sync with the UI `phaseOptions` array in
// ui/src/components/doctor/TreatmentPlanning.tsx. Used server-side to dedupe
// clinic-defined custom phases against the defaults.
export const DEFAULT_TREATMENT_PLAN_PHASES = [
  'Phase I: Urgent Care',
  'Phase II: Hygiene & Prevention',
  'Phase III: Restorative',
  'Phase IV: Major Rehabilitation',
  'Phase V: Maintenance',
] as const;

export interface MergePhaseResult {
  /** Updated CUSTOM phase list (never includes the built-in defaults). */
  phases: string[];
  status: 'added' | 'duplicate' | 'invalid';
  reason?: string;
}

const MAX_PHASE_LENGTH = 60;

/**
 * Validate + dedupe a clinic-defined custom phase against the built-in
 * defaults and the clinic's existing custom phases. Pure — no I/O.
 * - invalid: empty after trim, or longer than 60 chars (list unchanged)
 * - duplicate: case-insensitive match against defaults or existing (unchanged)
 * - added: appended to the returned list
 */
export function mergeCustomPhase(existingCustom: string[], rawInput: string): MergePhaseResult {
  const existing = Array.isArray(existingCustom) ? existingCustom : [];
  const trimmed = (rawInput ?? '').trim().replace(/\s+/g, ' ');

  if (!trimmed) {
    return { phases: existing, status: 'invalid', reason: 'Phase name is required' };
  }
  if (trimmed.length > MAX_PHASE_LENGTH) {
    return { phases: existing, status: 'invalid', reason: `Phase name must be ${MAX_PHASE_LENGTH} characters or fewer` };
  }

  const known = [...DEFAULT_TREATMENT_PLAN_PHASES, ...existing].map((p) => p.toLowerCase());
  if (known.includes(trimmed.toLowerCase())) {
    return { phases: existing, status: 'duplicate' };
  }

  return { phases: [...existing, trimmed], status: 'added' };
}
  • Step 4: Run the test to verify it passes
Run: cd server && npx vitest run src/lib/treatment-plan-phases.test.ts Expected: PASS — all 8 tests green.
  • Step 5: Commit
git add server/src/lib/treatment-plan-phases.ts server/src/lib/treatment-plan-phases.test.ts
git commit -m "feat(treatment-plans): add pure custom-phase merge/dedupe helper"

Task 2: Schema field for custom phases (no migration)

Files:
  • Modify: server/src/schema/clinics.ts:62 (inside the documentSettings $type)
  • Step 1: Add the field to the type
In server/src/schema/clinics.ts, immediately after the treatmentPlanPricingEnabled?: boolean; line (currently line 62), add:
    // Clinic-defined extra Clinical Phase labels, appended after the 5 built-in
    // phases in the treatment-plan editor. Stored verbatim — extend freely, no
    // migration (missing key = none). Mirrors the termsLibrary precedent.
    treatmentPlanCustomPhases?: string[];
  • Step 2: Verify it type-checks
Run: cd server && npx tsc --noEmit Expected: PASS (no new type errors). This is a JSONB $type change only — no DB migration is required because sanitizeDocumentSettingsForStorage (document-numbering.ts:434) spreads all keys.
  • Step 3: Commit
git add server/src/schema/clinics.ts
git commit -m "feat(schema): documentSettings.treatmentPlanCustomPhases (no migration)"

Task 3: POST /treatment-plans/phases endpoint

Files:
  • Modify: server/src/routes/treatment-plans.ts (add a new handler; e.g. right after the POST '/' create handler that ends ~line 460)
Context already available in this file: getReadDb, clinics, eq, and, AppError, handleError are imported; the route is guarded by requirePermissionByMethod('clinical.treatment_plans.view', 'clinical.treatment_plans.create'), which maps the POST method to the create permission — so any plan author (doctor/admin) is authorized.
  • Step 1: Add the helper import
At the top of server/src/routes/treatment-plans.ts, add to the imports:
import { mergeCustomPhase } from '../lib/treatment-plan-phases';
  • Step 2: Add the handler
Insert this handler in the file (after the POST '/' create handler):
// Add a reusable, clinic-wide custom Clinical Phase. Authorized via the
// route-level create permission, so doctors (who cannot call admin-only
// PUT /clinics/:id) can still add phases while building a plan.
treatmentPlansRoute.post('/phases', async (c) => {
  try {
    const db = getReadDb();
    const clinicContext = c.get('clinicContext');
    const clinicId = clinicContext?.currentClinicId || '';
    if (!clinicId) throw new AppError('No clinic context', 400);

    const body = await c.req.json() as { phase?: string };

    const [clinic] = await db
      .select({ documentSettings: clinics.documentSettings })
      .from(clinics)
      .where(eq(clinics.id, clinicId))
      .limit(1);
    if (!clinic) throw new AppError('Clinic not found', 404);

    const current = (clinic.documentSettings || {}) as Record<string, any>;
    const existingCustom: string[] = Array.isArray(current.treatmentPlanCustomPhases)
      ? current.treatmentPlanCustomPhases
      : [];

    const result = mergeCustomPhase(existingCustom, body.phase || '');
    if (result.status === 'invalid') {
      throw new AppError(result.reason || 'Invalid phase name', 400);
    }

    if (result.status === 'added') {
      const updatedSettings = { ...current, treatmentPlanCustomPhases: result.phases };
      await db.update(clinics)
        .set({ documentSettings: updatedSettings })
        .where(eq(clinics.id, clinicId));
    }

    // Idempotent for duplicates: returns the unchanged list.
    return c.json({ phases: result.phases });
  } catch (error) {
    return handleError(error, c);
  }
});
  • Step 3: Verify the route file type-checks
Run: cd server && npx tsc --noEmit Expected: PASS. (If eq is reported missing, it is already imported in this file at the create handler — confirm and reuse; do NOT add a duplicate import.)
  • Step 4: Confirm the permission guard covers the new POST
Read the top of server/src/routes/treatment-plans.ts and confirm requirePermissionByMethod(...) is applied via a wildcard .use(...) so it covers all methods/paths including the new POST /phases. If it is per-path instead, attach the same guard to this route explicitly. Expected: POST is gated by clinical.treatment_plans.create.
  • Step 5: Commit
git add server/src/routes/treatment-plans.ts
git commit -m "feat(treatment-plans): POST /phases to store reusable clinic custom phases"

Task 4: addTreatmentPlanPhase client function

Files:
  • Modify: ui/src/lib/serverComm.ts (add near updateClinicNotificationPrefs, ~line 2533, which shows the fetchWithAuth POST pattern)
  • Step 1: Add the function
export async function addTreatmentPlanPhase(phase: string): Promise<{ phases: string[] }> {
  const response = await fetchWithAuth('/api/v1/protected/treatment-plans/phases', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ phase }),
  });
  if (!response.ok) {
    let msg = 'Failed to add custom phase';
    try {
      const j = await response.json();
      msg = j?.error || j?.message || msg;
    } catch { /* ignore parse error, keep default message */ }
    throw new Error(msg);
  }
  return response.json();
}
  • Step 2: Verify it type-checks
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 3: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(ui): addTreatmentPlanPhase serverComm client"

Task 5: Gate the 4 pricing leaks (visual verification)

Files:
  • Modify: ui/src/components/doctor/TreatmentPlanning.tsx (lines ~1006–1021, ~1063, ~1070, ~1114–1116, ~1162–1180, ~1408–1414)
treatmentPlanPricingEnabled state and cn are already in scope in this file.
  • Step 1: Leak #1 — procedure picker price (~line 1410)
Find:
                                        {parseFloat(item.defaultPrice) > 0 && (
                                          <span className="text-xs text-muted-foreground tabular-nums">
                                            {formatCurrency(parseFloat(item.defaultPrice))}
                                          </span>
                                        )}
Replace the condition so the price hides when pricing is off:
                                        {treatmentPlanPricingEnabled && parseFloat(item.defaultPrice) > 0 && (
                                          <span className="text-xs text-muted-foreground tabular-nums">
                                            {formatCurrency(parseFloat(item.defaultPrice))}
                                          </span>
                                        )}
  • Step 2: Leak #2 — “Est. Pipeline” stat card + grid columns (~lines 1007–1021)
Find the stats grid opening tag:
        <div className="grid gap-4 md:grid-cols-4">
Replace with a conditional column count (3 columns when the money card is hidden):
        <div className={cn('grid gap-4', treatmentPlanPricingEnabled ? 'md:grid-cols-4' : 'md:grid-cols-3')}>
Then wrap the entire “Est. Pipeline” <Card> (the one containing formatCurrency(stats.totalValue ...), lines ~1015–1021) in a conditional:
          {treatmentPlanPricingEnabled && (
            <Card className="border-none shadow-sm">
              <CardHeader className="p-4 pb-0"><CardDescription className="text-[10px] font-bold tracking-widest uppercase text-muted-foreground">Est. Pipeline</CardDescription></CardHeader>
              <CardContent className="p-4 flex items-center justify-between">
                <div className="text-2xl font-extrabold">{formatCurrency(stats.totalValue, { decimals: 0 })}</div>
                <TrendingUp className="h-5 w-5 text-emerald-500 opacity-40" />
              </CardContent>
            </Card>
          )}
  • Step 3: Leak #3 — list “Est. Cost” column header + cell + empty-state colSpan
Header (~line 1063) — wrap:
                  {treatmentPlanPricingEnabled && (
                    <TableHead className="text-[10px] font-bold tracking-widest uppercase text-right">Est. Cost</TableHead>
                  )}
Empty-state colSpan (~line 1070) — change colSpan={6} to:
                    <TableCell colSpan={treatmentPlanPricingEnabled ? 6 : 5} className="py-24 text-center text-muted-foreground opacity-30">
Data cell (~lines 1114–1116) — wrap:
                      {treatmentPlanPricingEnabled && (
                        <TableCell className="text-right text-sm font-bold">
                          {formatCurrency(parseFloat(plan.estimatedCost || '0'), { decimals: 0 })}
                        </TableCell>
                      )}
  • Step 4: Leak #4 — preview “Est. Cost” card + its grid (~lines 1163–1180)
Find the preview grid opening tag:
              <div className="grid grid-cols-2 gap-3">
Replace with a conditional column count:
              <div className={cn('grid gap-3', treatmentPlanPricingEnabled ? 'grid-cols-2' : 'grid-cols-1')}>
Then wrap the “Est. Cost” <Card> (lines ~1172–1179) in a conditional:
                {treatmentPlanPricingEnabled && (
                  <Card className="border-none bg-muted/30">
                    <CardContent className="p-3">
                      <div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Est. Cost</div>
                      <div className="mt-1 text-sm font-semibold">
                        {formatCurrency(parseFloat(previewPlan.estimatedCost || '0'), { decimals: 0 })}
                      </div>
                    </CardContent>
                  </Card>
                )}
  • Step 5: Type-check
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 6: Visual verification (required — tsc does not prove UI)
Temporarily set the test tenant (ssh & Associates) documentSettings.treatmentPlanPricingEnabled = false (via the Document Settings toggle in the running app, or read it off an already-off clinic). Run the UI, open the Treatment Planning page and the Case Architect, and confirm with a screenshot / Playwright snapshot:
  • Plans list: no “Est. Cost” column header or cells; stats grid shows 3 cards (no “Est. Pipeline”), reflowed cleanly with no empty slot.
  • Preview panel: “Est. Cost” card gone; “Status” card spans full width.
  • Procedure picker dropdown: no prices beside procedure names.
  • Editor Case Projection + per-procedure cost: already hidden (regression check). Then flip the toggle ON and confirm all money reappears.
  • Step 7: Commit
git add ui/src/components/doctor/TreatmentPlanning.tsx
git commit -m "fix(treatment-plans): hide remaining pricing leaks when pricing toggle is off"

Task 6: Custom-phase dropdown + add dialog

Files:
  • Modify: ui/src/components/doctor/TreatmentPlanning.tsx (imports; module-level sentinel; component state ~line 284; clinic-load effect ~line 290; phase Select ~lines 1428–1436; new Dialog in render)
  • Step 1: Add imports
Add the Dialog import alongside the other ../ui/* imports (after the dialog file’s standard shadcn exports):
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog';
Add addTreatmentPlanPhase to the existing import statement that already brings in getClinicDetails from @/lib/serverComm (find that import and append the name to its braces).
  • Step 2: Add the module-level sentinel
Just below the phaseOptions array (line 133):
// Sentinel value for the "+ Add custom phase…" dropdown item. Never persisted.
const ADD_PHASE_SENTINEL = '__add_custom_phase__';
  • Step 3: Add component state
Next to the treatmentPlanPricingEnabled state (~line 284), add:
  const [customPhases, setCustomPhases] = useState<string[]>([]);
  const [phaseDialogProcId, setPhaseDialogProcId] = useState<string | null>(null);
  const [newPhaseInput, setNewPhaseInput] = useState('');
  const [phaseDialogError, setPhaseDialogError] = useState<string | null>(null);
  const [savingPhase, setSavingPhase] = useState(false);
  • Step 4: Load custom phases in the clinic-load effect
In the getClinicDetails(...).then(clinic => { ... }) block (~line 292, right after setTreatmentPlanPricingEnabled(...)), add:
        setCustomPhases(
          Array.isArray((clinic as any)?.documentSettings?.treatmentPlanCustomPhases)
            ? (clinic as any).documentSettings.treatmentPlanCustomPhases
            : []
        );
  • Step 5: Add the save handler
Add this handler inside the component (near other handlers such as updateProcedure):
  const handleAddCustomPhase = async () => {
    const trimmed = newPhaseInput.trim();
    if (!trimmed) { setPhaseDialogError('Phase name is required'); return; }
    if (trimmed.length > 60) { setPhaseDialogError('Keep it under 60 characters'); return; }
    setSavingPhase(true);
    try {
      const { phases } = await addTreatmentPlanPhase(trimmed);
      setCustomPhases(phases);
      if (phaseDialogProcId) updateProcedure(phaseDialogProcId, 'phase', trimmed);
      setPhaseDialogProcId(null);
      setNewPhaseInput('');
      setPhaseDialogError(null);
      toast.success('Custom phase added');
    } catch (e: any) {
      setPhaseDialogError(e?.message || 'Could not add phase');
    } finally {
      setSavingPhase(false);
    }
  };
  • Step 6: Wire the phase dropdown (~lines 1428–1436)
Replace the existing phase <Select> block:
                        <Select value={proc.phase} onValueChange={(val) => updateProcedure(proc.id, 'phase', val)}>
                          <SelectTrigger className="bg-muted/30 border-none h-10 rounded-lg">
                            <SelectValue placeholder="Select phase..." />
                          </SelectTrigger>
                          <SelectContent>
                            {phaseOptions.map(opt => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
                          </SelectContent>
                        </Select>
with (merges defaults + customs, dedupes defensively, adds the ”+ Add custom phase…” item, and intercepts the sentinel):
                        <Select
                          value={proc.phase}
                          onValueChange={(val) => {
                            if (val === ADD_PHASE_SENTINEL) {
                              setPhaseDialogProcId(proc.id);
                              setNewPhaseInput('');
                              setPhaseDialogError(null);
                              return;
                            }
                            updateProcedure(proc.id, 'phase', val);
                          }}
                        >
                          <SelectTrigger className="bg-muted/30 border-none h-10 rounded-lg">
                            <SelectValue placeholder="Select phase..." />
                          </SelectTrigger>
                          <SelectContent>
                            {[...phaseOptions, ...customPhases.filter(p => !phaseOptions.includes(p))]
                              .map(opt => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
                            <SelectItem value={ADD_PHASE_SENTINEL} className="text-primary font-semibold">
                              + Add custom phase…
                            </SelectItem>
                          </SelectContent>
                        </Select>
  • Step 7: Add the dialog to the render tree
Place this near the other top-level overlays in the component’s returned JSX (e.g. alongside <RightPreviewPanel ... />):
        <Dialog
          open={!!phaseDialogProcId}
          onOpenChange={(open) => {
            if (!open) { setPhaseDialogProcId(null); setPhaseDialogError(null); }
          }}
        >
          <DialogContent className="sm:max-w-md">
            <DialogHeader>
              <DialogTitle>Add custom clinical phase</DialogTitle>
            </DialogHeader>
            <div className="space-y-2">
              <Label className="text-xs">Phase name</Label>
              <Input
                autoFocus
                value={newPhaseInput}
                onChange={(e) => { setNewPhaseInput(e.target.value); setPhaseDialogError(null); }}
                onKeyDown={(e) => { if (e.key === 'Enter' && !savingPhase) handleAddCustomPhase(); }}
                placeholder="e.g. Phase VI: Implant Surgery"
                maxLength={60}
                className="bg-muted/30 border-none h-10 rounded-lg"
              />
              {phaseDialogError && <p className="text-xs text-destructive">{phaseDialogError}</p>}
              <p className="text-[10px] text-muted-foreground">
                Saved for your whole clinic and reusable on future plans.
              </p>
            </div>
            <DialogFooter>
              <Button variant="ghost" onClick={() => setPhaseDialogProcId(null)} disabled={savingPhase}>
                Cancel
              </Button>
              <Button onClick={handleAddCustomPhase} disabled={savingPhase || !newPhaseInput.trim()}>
                {savingPhase ? 'Saving…' : 'Save phase'}
              </Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>
  • Step 8: Type-check
Run: cd ui && npx tsc --noEmit Expected: PASS.
  • Step 9: Visual + functional verification
In the running app (test tenant), open Case Architect → Add Procedure → Clinical Phase dropdown:
  • Confirm the 5 defaults plus a trailing ”+ Add custom phase…” item.
  • Click it → dialog opens → type “Phase VI: Implant Surgery” → Save → toast, dialog closes, the procedure’s phase shows the new value.
  • Reload the page, open a NEW plan / new procedure → confirm “Phase VI: Implant Surgery” now appears in the dropdown (persisted clinic-wide).
  • Re-adding the same name (any case) is idempotent (no duplicate item). Empty / >60 chars shows the inline error. Capture a screenshot of the dropdown with the custom phase present.
  • Step 10: Commit
git add ui/src/components/doctor/TreatmentPlanning.tsx
git commit -m "feat(treatment-plans): quick-add reusable clinic-wide custom clinical phase"

Task 7: API reference docs

Files:
  • Modify: docs/api-reference.md
  • Step 1: Document the endpoint
Add an entry under the treatment-plans section:
### POST /api/v1/protected/treatment-plans/phases

Add a reusable, clinic-wide custom Clinical Phase shown in the treatment-plan editor.

**Permission:** `clinical.treatment_plans.create` (any plan author — doctor or admin).

**Body:** `{ "phase": "Phase VI: Implant Surgery" }`

**Behaviour:** Trims/validates (1–60 chars), dedupes case-insensitively against the 5
built-in phases and the clinic's existing custom phases, and stores the result in
`clinics.documentSettings.treatmentPlanCustomPhases`. Idempotent on duplicates.

**Response:** `200 { "phases": ["Phase VI: Implant Surgery", ...] }` (custom list only).
**Errors:** `400` invalid name / no clinic context, `404` clinic not found.
  • Step 2: Commit
git add docs/api-reference.md
git commit -m "docs(api): document POST /treatment-plans/phases"

Task 8: Full build verification

  • Step 1: Server tests + typecheck
Run: cd server && npx vitest run src/lib/treatment-plan-phases.test.ts && npx tsc --noEmit Expected: tests PASS, no type errors.
  • Step 2: UI typecheck + build
Run: cd ui && npx tsc --noEmit && npm run build Expected: clean build (per the stale-dist landmine memory, a passing build is required, not just tsc).
  • Step 3: Final review
Confirm all 8 tasks’ commits are present and the working tree is clean for the touched files (explicit-path staging only — do not git add -A). Deployment is a separate, user-initiated step via the odontox-commit-deploy skill.

Self-review notes

  • Spec coverage: All 4 leaks (Task 5) + custom-phase storage/endpoint/UI (Tasks 1–4, 6) + persona/permission (Task 3 guard) + docs (Task 7) are covered. Billing tab deliberately untouched.
  • Type consistency: mergeCustomPhase/MergePhaseResult/DEFAULT_TREATMENT_PLAN_PHASES names match across Tasks 1 & 3; addTreatmentPlanPhase returns { phases } consumed identically in Task 6; ADD_PHASE_SENTINEL defined once (Task 6 Step 2) and used once.
  • No migration: documentSettings is JSONB and the sanitizer spreads all keys (verified).