Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

External services are adapters with fakes; money rules are config

The repository's architectural decision, rationale and consequences.

Markdown

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

  • 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

  • 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.
Edit on GitHubDocumentation is in English.
Cookie settings
We use essential cookies to make our app work. With your consent, we also use optional cookies to improve your experience.

Read our Cookie Policy and Privacy Policy.