Skip to content

Kurz wiki ​

The business rules and the decisions of the Kurz application as they are at commit bc7f65c (2026-10-17). The code is the source of truth: every rule cites the file and line that enforces it, and the test that pins it when there is one.

Use it before implementing a feature, to know what the app does today, which rule enforces it, why it was decided that way, and what else breaks if it changes.

How to use it ​

  1. Find the owner page of the area you are changing in the table below and read its Business rules and Change impact.
  2. Check the impact map below for couplings with other domains. Most impact misses in this codebase come from a rule that exists in two places (Rails and the edge function, the dashboard and the MCP tools, the backend and @internal/booking).
  3. Cite rule ids in PRs and plans ("changes BK-12, adds FO-63"). Ids are stable: a removed rule keeps its id, marked as removed.
  4. Update the page in the same PR that changes the behaviour, and move the state line (State as of commit ...) of the pages you touched.
  5. Security findings are not listed here. A weakness found while reading the code is reported privately, as described in SECURITY.md, and stays out of these pages until it is fixed.
  6. Rationale comes from code comments, docs/, commit messages and PRs. Rationale: not recorded. means none was found; (inferred) marks a reading of the code, not a recorded decision.

Each page has the same sections: Summary, Glossary, Data, Business rules, Flows, States and transitions, Limits and configuration, Authorization and privacy, API and UI surface, Decisions, Change impact, Tests that pin this behaviour, Drift and open questions.

Pages ​

PageIdsCovers
Short linksSL, D-SLShort codes, URL validation, expiry, password lock, URL safety, the redirect path (edge function, Valkey, RabbitMQ, analytics worker), statistics, QR codes
Accounts and authenticationAU, D-AUSign-up, sessions, passwords, login codes, Google, Turnstile, time zone and language, data export, account deletion, client accounts
Bio pagesBP, D-BPPages, links, built-in, personal and community templates, palettes, avatars, link safety, click statistics
FormsFO, D-FOFields, layouts, draft and published snapshot, submission pipeline, uploads, covers, events, summary, export, templates
Booking engineBK, D-BKThe booking question, modes, availability and slot computation, durations, prices, resources, reservation and concurrency, monthly plans, impact
Appointments lifecycleAP, D-APStatuses, approval, verification, cancel, reschedule, reminders, expiry, waitlist, calendar feed, agenda, tokens, health
Notifications and e-mailNT, D-NTIn-app, e-mail and push notifications, preferences, delivery jobs, mail budget, catalogue of every e-mail
MCP and OAuthMC, D-MCOAuth 2.1 server, scopes, grants, the MCP tool catalogue, guards, budgets, logging
Admin and moderationAD, D-ADRoles and the permission matrix, admin screens, audits, abuse signals and their detection, template reports
Data modelDM, D-DMEvery table and constraint, the deletion cascade map, JSON shapes, migration history and conventions
Platform and operationsOP, D-OPTopology, deploy, CI, security harness, testing, queues and the job schedule, rate limits, environment variables, observability
Frontend architectureFE, D-FEReact Router app, SSR and prerender, route map, auth guards, API client conventions, i18n, CSP, packages, service worker

Existing decision records stay in docs/: appointments, i18n, MCP, Postgres roles. The wiki pages link to them and list where they disagree with the code.

Architecture at a glance ​

flowchart LR
  visitor((Visitor)) -->|short link| edge[Edge function<br/>edge-function/]
  edge -->|GET shortlink:code| valkey[(Valkey)]
  edge -->|publish click| rabbit[[RabbitMQ<br/>analytics queue]]
  rabbit --> worker[Sneakers<br/>AnalyticsWorker]
  worker --> pg[(PostgreSQL)]
  visitor -->|pages, forms, bookings| fe[Frontend SSR<br/>React Router]
  owner((Owner)) --> fe
  ai((AI app)) -->|OAuth + MCP| web
  fe -->|/api, visitor IP via SSR_FORWARD_SECRET| web[Rails API<br/>backend/]
  web --> pg
  web -->|cache, rate limits, link keys, mail budget| valkey
  web --> jobs[Solid Queue jobs<br/>recurring.yml]
  jobs --> pg
  jobs -->|e-mail, Web Push| out[SMTP / push services]
  web -->|form uploads, covers| imgproc[imgproc sandbox]
  web --> s3[(Object storage)]
  • Rails writes shortlink:<code> in Valkey and the edge function only reads it. Valkey must never be flushed (shortlinks, platform).
  • Clicks on short links go through the edge function and RabbitMQ. Clicks on bio page links are written synchronously by Rails (bio pages).
  • Bookings have no endpoint of their own: they are made inside the form submission transaction (forms, booking engine).
  • Notifications are rows written in the same transaction as the event and delivered by a job every minute (notifications).

Cross-domain impact map ​

If you change…Also checkWhyPages
Whether a short link works (active, safe, expired, deleted, locked), from any pathThe Valkey key shortlink:<code> is written or deleted on that pathThe edge function reads only Valkey. A path that changes the row and not the key (an update_all, a new job) leaves the redirect as it wasSL, AD, AU
The Valkey value format ({"t":"url","v":…}, {"t":"locked"})edge-function/Cache.cs and its testsAn unknown t reads as not found. The edge function deploys firstSL
The click payload or the analytics queueedge-function/Analytics.cs, Headers.cs, AnalyticsWorker, Event column lengthsThree components share a 9-key payloadSL, OP
URL validation (http_url) or user agent parsingThe edge function (HttpHref, Headers.cs), the bio page links, UserAgentParserThe same rule exists in Ruby and C#SL, BP
User.active, User#deactivate!, reactivate!, cancel_deletion!Link cache, public pages, public forms, calendar feed, MCP and OAuth token checksVisibility of everything public is merged with User.activeAU, AD, SL
A new table owned by a userON DELETE cascade or dependent:, User#purge!, Users::DataExportAccount purge relies on cascades; the export must list every owned tableAU, DM
users.time_zone or users.localeBooking engine, agenda, e-mail times and language, MCP tools, useLocaleSyncTimes and e-mail language are resolved from the owner and recipientAU, NT, BK
The shape of forms.fields (any field type, the booking question)A data migration on both fields and published_snapshot, the frontend package @internal/booking, the MCP form and booking toolsVisitors read the snapshot; the draft and the snapshot must both stay valid (precedent 157fbd4, #255)FO, BK, MC
Booking validation or slot computationfrontend/packages/booking and zone-time.tsThe frontend mirrors the backend modes and the DST handlingBK, FE
appointment_slots (booked, held, capacity)Reserve, Release, Reschedule, the waitlist, the check and exclusion constraintsEvery availability check counts booked + held; several services share the rowBK, AP, DM
Forms::SubmitResponseBookings, waitlist claims, owner notifications, Turnstile, rate limitsBookings and claims happen inside the response transaction; losing a place rolls the response back with 409FO, BK, AP
Deleting responses, forms or accountsSlot release, uploads, responses_count, clients who are not toldCascades release places without e-mail; bulk deletes skip callbacksFO, AP, DM
A transition of an appointmentIts notifications row and event_key, Deliver::TEMPLATES, the mailer, frontend/public/sw.js texts, notifications.json and notices.jsonThe unique event_key makes e-mails idempotent; push texts are duplicated in the service workerAP, NT
What reads the draft vs the published formAgenda names, capacity, reschedule targets, waitlist offers, reminders, exportsSome readers use PublicDefinition, others still read the draft fieldsAP, FO
A rule of a domain service (forms, pages, links, appointments)The MCP tool that calls it, and the guards that exist only in MCPMost MCP tools reuse the services but add their own scopes, rate limits and guards. Some copy controller code instead: update_shortlink (cache refresh, safety job), create_page, page link reorder, delete_all_responsesMC
A column of a serialized modelThe MCP serializers, which are separate from the dashboard onesA new column is never exposed (or leaked) to AI apps automaticallyMC
OAUTH_ISSUEREvery existing grantTokens are bound to the issuer; changing it disconnects every appMC, OP
MailBudget shares or a new e-mailLogin codes, data export, appointment e-mails, the health alert at 80%One budget is shared by every senderNT, AU
Session revocation (sessions_revoked_at)OAuth grants and MCPA password change also disconnects MCP clientsAU, MC
A new route.ci/security/matrix.tsv (check A00) and, for admin routes, bfla_spec.rbThe security harness fails on a route without a matrix rowOP, AD
A new recurring job or queuebackend/config/recurring.yml (under production:), queue.yml workersThe notifications queue needs its own worker; jobs only run in productionOP
A new environment variable.ci/stack.yml / .ci/fe-stack.yml, docker-compose.yml, .env.developmentA variable missing from the stack file is silently off in productionOP
A migrationThe previous release runs against the new schema for a few seconds and after a rollbackExpand, backfill, ship the code; drop or rename in a later releaseDM, OP
The session cookie, CORS origins or request headers/app gating, X-Requested-With on cookie writes, backend/config/initializers/cors.rbkurz_session is host-only on the API host, so the SSR server cannot authenticate: /app is gated on the client by GET /api/meFE, AU
Rate limitsThey live in Valkey through the Rails cache and are keyed by the IP the frontend SSR forwardsclient_ip and SSR_FORWARD_SECRET decide the keyOP, FE
A theme or custom coloursPage::THEMES validates pages, forms and page templates; CustomColors is sharedOne list for three modelsBP, FO
A user-visible stringen and pt-PT, the route namespace declaration, the parity testnpm test fails on a missing key or an undeclared namespaceFE, docs/i18n.md

Shared building blocks ​

Code that several domains depend on. A change here has the widest blast radius.

Building blockUsed by
User.active, User#deactivate!, User#purge!Every public surface, shortlinks cache, MCP/OAuth, data export
Shortlink#cache_key / #cache_valueShortlinks, admin toggles, account deactivation, forms (every form owns a short link)
Forms::PublicDefinition, Booking::Modes.compilePublic forms, the slots endpoint, the booking engine, the agenda, MCP
Appointments::Reserve / ReleaseBooking, cancellation, rescheduling, expiry, waitlist offers
Notification rows and Notifications::DeliverEvery appointment transition, the bell, push, MCP list_notifications
MailBudgetLogin codes, data export, appointments, the health check
Turnstile, client_ip, Rails rate_limitSign-in, sign-up, form submissions, uploads, unlocks, MCP
UserAgentParser, TrafficSourceShort link analytics (mirrored in the edge function), bio page clicks, forms
ImageUpload, imgprocForm covers and uploads (imgproc), avatars (in-process libvips)
GoogleLib::SafeBrowsingShort links (SafetyUrlJob), bio page links (PageLinkSafetyJob)

Glossary ​

Terms used across pages. Each page has its own glossary for its domain.

  • Owner: a signed-in user who creates links, pages and forms.
  • Visitor: anyone using a public link, page or form, without an account.
  • Client account: a verified, active user whose e-mail equals an appointment's client_email. It is matched by e-mail every time, never linked by id (accounts).
  • Short code: the key of a short link, read by the edge function from Valkey (shortlinks).
  • Servable: a short link that redirects: not inactive, safe and not expired (shortlinks).
  • Draft / published snapshot: a form is edited as a draft; visitors read published_snapshot, a copy taken at publish time with a version and digest (forms).
  • Booking question: the booking field of a form, with its mode, services, availability and rules (booking engine).
  • Mode: how a booking question is configured (services or individual) and compiled for the engine (booking engine).
  • Resource: whose time a slot is; services that name the same resource share its hours (booking engine).
  • Slot: one row of appointment_slots per form, resource and start time, with capacity, booked and held (booking engine).
  • Held: places of a slot kept for a waitlist offer; they count like booked places (appointments).
  • Grant: a user's consent to an OAuth client with a set of scopes (MCP and OAuth).
  • Abuse signal: a row opened by a detection job for an admin to review (admin).

Keeping it current ​

  • A PR that changes a rule updates the rule on its page, keeps its id, and moves the page's state line to the new commit.
  • A new domain gets its own page with the same sections and a new two-letter prefix, and a row in the tables above.
  • The site is built with VitePress and published to GitHub Pages from main by .github/workflows/wiki.yml. To preview it: cd wiki && npm ci && npm run dev. The build fails on a dead link between pages.
  • References are path:LINE. Lines move: when you touch a file, fix the references to it in the pages that cite it.