# External services are adapters with fakes; money rules are config

The repository's architectural decision, rationale and consequences.

# External services are adapters with fakes; money rules are config

**Status:** accepted · **Date:** 2026-11-13 · **Implements:** product ADR-06, ADR-07, RN03–RN07, RN10, RNF04, RNF08, RNF09

**Enforcement:** `[test]` `instructor.rules.test.ts` (US-03 preview, RN04 deposit) and, from S3/S6, the C3 formula test (€7,502 net, €6,496–9,038) and 10 Stripe split scenarios (C7) · `[convention]` no vendor SDK outside its adapter.

## Decision [#decision]

- **Four adapters**, each a small interface with a deterministic fake used in dev and tests: Stripe Connect (test mode; separate charges and transfers), the AI model (route + message; EU processing, no training — vendor `[TBD]`), translation, and the public-academy data source (source and licence `[TBD]`). Selection is by env var; the fake is the default when no key is configured, except payments: `PAYMENTS_PROVIDER` defaults to `off` (no money moves, `PAYMENTS_DISABLED`), and the fake runs only when set to `fake` (ADR-0025 note).
- **AI suggests, rules decide:** the model only orders stops and drafts text; `*.rules.ts` leaf modules check dates, distances and money and produce every number shown. Estimates always carry a range, the formula and the label "estimated"; the word "guaranteed" is used only for the agreed minimum guarantee.
- **Money hypotheses** (70/30 split, 25 % deposit, 4 % buyer fee, D+2 payout, 7-day cancellation) live in `MONEY_HYPOTHESES`, in euro cents with integer arithmetic. Card data never touches our servers.

## Amendment (S3): the route model and the estimate [#amendment-s3-the-route-model-and-the-estimate]

- **Adapter:** `features/route/route.model.ts`. `RouteModel.suggest(input, signal)` returns ordered City ids and a one-sentence rationale. `ROUTE_AI_PROVIDER` unset or `fake` uses `fakeRouteModel`; `ai` reuses the assistant's provider (`AI_PROVIDER`, `AI_MODEL`, its key) through TanStack AI structured output, and falls back to the fake when no key is configured. The prompt carries City ids, fog state, distance, likely turnout, price and format only, never names of people.
- **Fake:** the base (when the Instructor knows an academy there) and the must-visit Cities, then network Cities ranked by priority (earnings: likely share; network: half-light first; rest: closest) up to the Seminar limit; ordered by trying every order up to 8 Stops (nearest neighbour + 2-opt above) under `comparePlans`; the lowest-ranked extra City is dropped until the rules accept.
- **Budget (RNF04):** `proposeWithinBudget` is the one Tier 2 step: `Effect.timeoutOrElse` at 20 s aborts the model; a timeout or a model error returns the newest stored suggestion for the window with `stale: true`, or `RouteModelUnavailable` when there is none. An order the rules refuse is `RouteRejected` (`BAD_REQUEST` with the problems), not a fallback. `route.suggest` runs synchronously inside the request for now; the `route.requested` job of ADR-0021 can wrap the same program later.
- **Rules (`route.rules.ts`, a leaf):** the model's order is checked (`checkOrder`), dated (`assignDates`: travel and rest gaps, blocked days, big academies moved to Saturday, a Saturday → Sunday double only when the next academy asked for Sunday and the train needs no travel day), checked again (`checkRoute`, C2/US-23, each conflict with its earliest fix) and priced (`estimateTour`).
- **Estimate (C3):** per Stop `max(⌊likely × price × 70 %⌋, guarantee)`, low and high turnout likewise; tour net = Σ share − travel, travel = legs base → Stops → base. Sample travel: rail at 95 km/h domestic, 65 km/h international over the straight line; ≤ 3 h 30 needs no travel day; > 6 h is a €40 budget flight; rail fare €5 + €0.20/km rounded to €5. Turnout and prices per City are samples in `route.turnout.ts` until S4/S5 bring academy data. Caio's route A: 199 likely students, €7,502 (€6,496–9,038); route B €6,450; A − B = €1,052 and 2 travel days. A detour cost of €0.20/km ranks orders only and is never shown as money.

## Amendment: the outreach message writer and translator [#amendment-the-outreach-message-writer-and-translator]

- **Adapter:** `features/outreach/outreach.model.ts`. `OUTREACH_AI_PROVIDER` unset or `fake` uses `fakeOutreachWriter` (`outreach.writer.ts`); `ai` reuses the assistant's provider (`AI_PROVIDER`, `AI_MODEL`, its key) through TanStack AI structured output, like the route model, and falls back to the fake when no key is configured. `OutreachModel` (`write`, `translate`) is the only seam that calls a vendor; tests stub it.
- **Prose only (RN10, HANDOFF rule 3):** the prompt carries the terms as `termPhrases` (date, format, hours, price, split, guarantee in the message language) that the model must copy verbatim, the instructor's and recipient's names and the Introduction, never an email (RN08). `outreach.rules.ts` checks the answer: every phrase present, the Introduction cited, no "guaranteed/garantiert/garantido" outside the agreed minimum guarantee, no euro amount other than price, guarantee or deposit (25 % of the guarantee), German to an Academy in "Sie", ≤ 2,000 characters. A translation must keep every number of the original.
- **Budget:** `draftWithinBudget`/`translateWithinBudget` run the call under `Effect.timeoutOrElse` at 15 s (aborting the provider), no retry. A timeout, an error or a refused text returns the fake's text with `generatedBy: "fallback"`; the AI's own text is `"ai"`, the fake's `"fake"`. Drafting and reading never fail because of the model, so a send is never blocked. The services reach it through a Promise facade over `runEdge`.

