# Tech debt — what it costs to not pay this yet

> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
> [documentation clean-up chain](../../archive/clarify-chain/README.md).

Debt is different from [backlog.md](../status/backlog.md): a backlog item blocks a specific flow. A debt item
doesn't block anything today — it raises the cost of *everything that comes after it*. The phase brief named
six candidates to assess (items 2, 3, 4, 5, 7, 8 below); two of those (3, 4) turned out less severe than the
framing assumed once checked against the code, and this phase adds two more (1, 6) found while doing that
checking. Each entry below states what it costs now, what it costs at meaningfully more usage, and the
trigger that should cause it to get paid.

---

## 1. No contract-generation pipeline — client DTOs hand-drift from the server

**What it is:** `docs/integration/openapi/swagger.v1.json` is a regenerated, dated snapshot, but nothing turns
it into client types. `client/package.json` has no `openapi-typescript`/NSwag/orval step — every service
domain's TypeScript types are hand-written and hand-kept in sync with the real server DTOs.

| Now | At meaningfully more usage |
| --- | --- |
| The cost has already been paid twice, expensively: phase 2 found **24 phantom client-side endpoints** nobody had checked against swagger; phase 3 found **34 client seam operations that would 404 on a mock→real flip** and several DTO shape mismatches severe enough to *break*, not just show stale data (verification, refunds, payouts, patient-records, partner-center — see [implemented.md](../status/implemented.md)). Both discoveries took a dedicated full-session audit to surface. | Every future domain repeats this discovery cost, and nothing stops a **new** drift from being introduced tomorrow — there is no CI check comparing client types to the live contract. This compounds with domain count, not with traffic: the more domains behind mocks, the more of these audits eventually needed. |

**Trigger to pay it:** don't wait for the next silent break — the next 2-3 units in [next-up.md](next-up.md)
are exactly "flip a mocked domain to real." Wiring a generated-types step (even scoped to just the domains
being de-mocked next, not a big-bang rewrite) pays for itself on the very next flip.
**Rough size: M.**

## 2. Single-instance in-process scheduler

**What it is:** weekly payout-batch generation, and (once cron'd) the credential-expiry scan and EVV no-show
sweep, all run as in-process jobs with no distributed lock. Correct today because exactly one API instance
runs. Same root cause as [BL-220](deferred.md#scale-later-infrastructure) (Redis not deployed).

| Now | At meaningfully more usage |
| --- | --- |
| Zero cost — one instance, no race. | A rolling deploy that briefly runs two instances already risks a **duplicate weekly payout batch** — real money, generated twice. Horizontal scaling for any other reason (traffic, availability) can't happen without this being solved first; it's a hidden prerequisite baked into "just add another container." |

**Trigger to pay it:** the moment a second instance is even considered, for any reason — not just load.
**Rough size: M** (a distributed lock via Redis, per BL-220's own scoping).

## 3. Testing-convention asymmetry between the two projects

**Reassessed — this is not a raw-count gap.** Server test files (119, spanning `Baya.Test.Api` WebApplication-
Factory integration tests and `Baya.Test.Foundation` handler/unit tests) and client test files (125, Jest) are
close in count, and money-path server coverage is genuinely strong: Payments, Bookings, BNPL, Payouts, and
Refunds each have dedicated API-integration **and** handler-level test files. The real asymmetry is in
**enforcement**:

- [docs/rules/server/cqrs.md](../rules/server/cqrs.md) makes testing a mandatory step for every new
  feature: "Add handler unit tests **and** at least one `WebApplicationFactory` integration test for the
  area: happy path 200, unauthenticated 401, validation 400."
- Root [CLAUDE.md](../../CLAUDE.md)'s own client gate is reactive, not mandatory: "`npm run test:ci`
  **if you touched a tested component**." New client code has no enforced testing bar at all.

| Now | At meaningfully more usage |
| --- | --- |
| 125 client tests exist, but — consistent with the flow atlas's own mock-vs-real accounting — coverage almost certainly concentrates on the domains that were real early and thins out on the ones still mocked. | As the mocked domains in [next-up.md](next-up.md) flip to real, the newly-real client code lands with no enforced test the way a newly-shipped server handler always does. Regressions there have no automated net; the flow atlas's manual, one-session verification (§4 below) is the only thing that has ever caught them. |

**Trigger to pay it:** natural to pair with each domain de-mock in [next-up.md](next-up.md) rather than run
as its own initiative — write the rule once, backfill tests as each domain is touched anyway.
**Rough size: S** to write the convention, **M per domain** to backfill.

## 4. No automated test crosses the client↔server boundary

**Reassessed — "absence of E2E tests over the money paths" overstates it.** The money paths have real
automated coverage at the handler and API-integration layers (§3). What's genuinely missing is anything that
drives a **real browser against a real running API** — no Playwright, no Cypress, nothing in
`client/package.json`'s scripts beyond Jest. That gap is exactly why phase 3's flow atlas had to boot the
whole stack and manually click/curl through all 23 flows to get a trustworthy status — a deliberate,
one-session, human/agent-driven substitute for automated E2E.

| Now | At meaningfully more usage |
| --- | --- |
| Absorbed as a one-time cost per audit — expensive (a full session), but infrequent. | This cost doesn't scale with traffic, it scales with **how often you need to trust a full-stack claim** — every future audit like phase 3's repeats the same manual walkthrough from scratch, because nothing keeps the previous one's findings mechanically re-checkable. |

**Trigger to pay it:** before the next full-stack audit is needed, or the first time a money-path regression
reaches production undetected — whichever comes first. **Rough size: M** — the server side is already well
tested, so this only needs to close the browser↔API gap: a handful of Playwright specs over the critical path
(login → search → book → pay → cancel), not a rewrite of anything existing.

## 5. Search without Elasticsearch

`SqlNurseSearch` over `nurse_search_index` is real, correct, and — per
[decisions.md](../status/decisions.md) — the deliberate MVP implementation, same item as
[BL-221](deferred.md#scale-later-infrastructure).

| Now | At meaningfully more usage |
| --- | --- |
| Fine at MVP data volume; no measured strain. | A wide denormalized SQL index degrades on filter/sort/paging combinations well before a purpose-built search engine would. | 

**Trigger to pay it:** measured latency/throughput strain on `nurse_search_index` — not a date.
**Rough size: L.**

## 6. The ESLint unused-vars gate is a no-op

[BL-250](deferred.md#cleanup-of-convenience-and-data-integrity-hygiene): the config patches an export path
that doesn't carry the rule, so `@typescript-eslint/no-unused-vars` never actually runs — despite
`client/CLAUDE.md`'s own golden rule 11 claiming it does.

| Now | At meaningfully more usage |
| --- | --- |
| A safety net that looks present but isn't — dead code accumulates invisibly, with no signal to anyone that it's happening. | The longer this stays broken, the more has silently piled up by the time someone finally looks — this is a debt that compounds purely with time and commit count, independent of traffic or scale. |

**Trigger to pay it:** a dedicated infra task (this item's own stated trigger). **Rough size: S** — it's a
config-path bug, not a redesign.

## 7. Two remaining raw-state admin forms

[BL-217](../status/backlog.md#minor-115): `GrantRoleDialog` and `PreviewBatchDialog` are the only two
survivors of an otherwise-complete app-wide react-hook-form migration.

| Now | At meaningfully more usage |
| --- | --- |
| Trivial — two isolated components. | Still trivial. This is the one item on this list that genuinely doesn't get worse with scale; it's listed only because it's a rough edge for anyone reading the code expecting one form pattern everywhere. |

**Trigger to pay it:** whenever either dialog is next touched for an unrelated reason — not worth a dedicated
pass. **Rough size: S.**

## 8. Windows-generated client lockfile

Documented directly in [DEPLOY.md](../../DEPLOY.md#known-wrinkle-the-client-lockfile-is-windows-generated):
`client/package-lock.json`, generated on Windows, omits wasm32-only optional packages Linux's npm wants,
so a bare `npm ci` fails in the container build. Absorbed today by a `npm install --package-lock-only` step
baked into the client Dockerfile before `npm ci`.

| Now | At meaningfully more usage |
| --- | --- |
| Zero operational cost — the workaround runs on every image build without incident. | Still zero cost at scale; this is environment debt, not scale debt. The real risk is a future edit: anyone who "cleans up" the Dockerfile without knowing *why* that line is there reintroduces a build failure that only reproduces on Linux/CI, not on the Windows machine that likely made the edit. |

**Trigger to pay it:** DEPLOY.md already gives the exact fix — regenerate the lockfile on Linux once and
commit it, then delete the workaround line. **Rough size: S.** Cheapest item on this list to close permanently.

---

## What didn't make this list

[`archive/clarify-chain/README.md`](../../archive/clarify-chain/README.md)'s own diagnosis (260+ markdown files, ~10 rule sources, contract drift) is the
documentation debt this entire phase chain exists to retire — phases 0-4 already paid most of it down, and
phase 6/7 finish the job. It isn't repeated here because it isn't *code* debt, and because tracking it twice
would just be two ledgers again, the exact problem this chain was created to end.
