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
- 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_PROVIDERdefaults tooff(no money moves,PAYMENTS_DISABLED), and the fake runs only when set tofake(ADR-0025 note). - AI suggests, rules decide: the model only orders stops and drafts text;
*.rules.tsleaf 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
- Adapter:
features/route/route.model.ts.RouteModel.suggest(input, signal)returns ordered City ids and a one-sentence rationale.ROUTE_AI_PROVIDERunset orfakeusesfakeRouteModel;aireuses 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):
proposeWithinBudgetis the one Tier 2 step:Effect.timeoutOrElseat 20 s aborts the model; a timeout or a model error returns the newest stored suggestion for the window withstale: true, orRouteModelUnavailablewhen there is none. An order the rules refuse isRouteRejected(BAD_REQUESTwith the problems), not a fallback.route.suggestruns synchronously inside the request for now; theroute.requestedjob 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 inroute.turnout.tsuntil 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
- Adapter:
features/outreach/outreach.model.ts.OUTREACH_AI_PROVIDERunset orfakeusesfakeOutreachWriter(outreach.writer.ts);aireuses 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.tschecks 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/translateWithinBudgetrun the call underEffect.timeoutOrElseat 15 s (aborting the provider), no retry. A timeout, an error or a refused text returns the fake's text withgeneratedBy: "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 overrunEdge.