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
- Find the owner page of the area you are changing in the table below and read its Business rules and Change impact.
- 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). - 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.
- 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. - 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. - 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
| Page | Ids | Covers |
|---|---|---|
| Short links | SL, D-SL | Short codes, URL validation, expiry, password lock, URL safety, the redirect path (edge function, Valkey, RabbitMQ, analytics worker), statistics, QR codes |
| Accounts and authentication | AU, D-AU | Sign-up, sessions, passwords, login codes, Google, Turnstile, time zone and language, data export, account deletion, client accounts |
| Bio pages | BP, D-BP | Pages, links, built-in, personal and community templates, palettes, avatars, link safety, click statistics |
| Forms | FO, D-FO | Fields, layouts, draft and published snapshot, submission pipeline, uploads, covers, events, summary, export, templates |
| Booking engine | BK, D-BK | The booking question, modes, availability and slot computation, durations, prices, resources, reservation and concurrency, monthly plans, impact |
| Appointments lifecycle | AP, D-AP | Statuses, approval, verification, cancel, reschedule, reminders, expiry, waitlist, calendar feed, agenda, tokens, health |
| Notifications and e-mail | NT, D-NT | In-app, e-mail and push notifications, preferences, delivery jobs, mail budget, catalogue of every e-mail |
| MCP and OAuth | MC, D-MC | OAuth 2.1 server, scopes, grants, the MCP tool catalogue, guards, budgets, logging |
| Admin and moderation | AD, D-AD | Roles and the permission matrix, admin screens, audits, abuse signals and their detection, template reports |
| Data model | DM, D-DM | Every table and constraint, the deletion cascade map, JSON shapes, migration history and conventions |
| Platform and operations | OP, D-OP | Topology, deploy, CI, security harness, testing, queues and the job schedule, rate limits, environment variables, observability |
| Frontend architecture | FE, D-FE | React 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 check | Why | Pages |
|---|---|---|---|
| Whether a short link works (active, safe, expired, deleted, locked), from any path | The Valkey key shortlink:<code> is written or deleted on that path | The 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 was | SL, AD, AU |
The Valkey value format ({"t":"url","v":…}, {"t":"locked"}) | edge-function/Cache.cs and its tests | An unknown t reads as not found. The edge function deploys first | SL |
The click payload or the analytics queue | edge-function/Analytics.cs, Headers.cs, AnalyticsWorker, Event column lengths | Three components share a 9-key payload | SL, OP |
URL validation (http_url) or user agent parsing | The edge function (HttpHref, Headers.cs), the bio page links, UserAgentParser | The 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 checks | Visibility of everything public is merged with User.active | AU, AD, SL |
| A new table owned by a user | ON DELETE cascade or dependent:, User#purge!, Users::DataExport | Account purge relies on cascades; the export must list every owned table | AU, DM |
users.time_zone or users.locale | Booking engine, agenda, e-mail times and language, MCP tools, useLocaleSync | Times and e-mail language are resolved from the owner and recipient | AU, 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 tools | Visitors read the snapshot; the draft and the snapshot must both stay valid (precedent 157fbd4, #255) | FO, BK, MC |
| Booking validation or slot computation | frontend/packages/booking and zone-time.ts | The frontend mirrors the backend modes and the DST handling | BK, FE |
appointment_slots (booked, held, capacity) | Reserve, Release, Reschedule, the waitlist, the check and exclusion constraints | Every availability check counts booked + held; several services share the row | BK, AP, DM |
Forms::SubmitResponse | Bookings, waitlist claims, owner notifications, Turnstile, rate limits | Bookings and claims happen inside the response transaction; losing a place rolls the response back with 409 | FO, BK, AP |
| Deleting responses, forms or accounts | Slot release, uploads, responses_count, clients who are not told | Cascades release places without e-mail; bulk deletes skip callbacks | FO, AP, DM |
| A transition of an appointment | Its notifications row and event_key, Deliver::TEMPLATES, the mailer, frontend/public/sw.js texts, notifications.json and notices.json | The unique event_key makes e-mails idempotent; push texts are duplicated in the service worker | AP, NT |
| What reads the draft vs the published form | Agenda names, capacity, reschedule targets, waitlist offers, reminders, exports | Some readers use PublicDefinition, others still read the draft fields | AP, FO |
| A rule of a domain service (forms, pages, links, appointments) | The MCP tool that calls it, and the guards that exist only in MCP | Most 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_responses | MC |
| A column of a serialized model | The MCP serializers, which are separate from the dashboard ones | A new column is never exposed (or leaked) to AI apps automatically | MC |
OAUTH_ISSUER | Every existing grant | Tokens are bound to the issuer; changing it disconnects every app | MC, OP |
MailBudget shares or a new e-mail | Login codes, data export, appointment e-mails, the health alert at 80% | One budget is shared by every sender | NT, AU |
Session revocation (sessions_revoked_at) | OAuth grants and MCP | A password change also disconnects MCP clients | AU, MC |
| A new route | .ci/security/matrix.tsv (check A00) and, for admin routes, bfla_spec.rb | The security harness fails on a route without a matrix row | OP, AD |
| A new recurring job or queue | backend/config/recurring.yml (under production:), queue.yml workers | The notifications queue needs its own worker; jobs only run in production | OP |
| A new environment variable | .ci/stack.yml / .ci/fe-stack.yml, docker-compose.yml, .env.development | A variable missing from the stack file is silently off in production | OP |
| A migration | The previous release runs against the new schema for a few seconds and after a rollback | Expand, backfill, ship the code; drop or rename in a later release | DM, OP |
| The session cookie, CORS origins or request headers | /app gating, X-Requested-With on cookie writes, backend/config/initializers/cors.rb | kurz_session is host-only on the API host, so the SSR server cannot authenticate: /app is gated on the client by GET /api/me | FE, AU |
| Rate limits | They live in Valkey through the Rails cache and are keyed by the IP the frontend SSR forwards | client_ip and SSR_FORWARD_SECRET decide the key | OP, FE |
| A theme or custom colours | Page::THEMES validates pages, forms and page templates; CustomColors is shared | One list for three models | BP, FO |
| A user-visible string | en and pt-PT, the route namespace declaration, the parity test | npm test fails on a missing key or an undeclared namespace | FE, docs/i18n.md |
Shared building blocks
Code that several domains depend on. A change here has the widest blast radius.
| Building block | Used by |
|---|---|
User.active, User#deactivate!, User#purge! | Every public surface, shortlinks cache, MCP/OAuth, data export |
Shortlink#cache_key / #cache_value | Shortlinks, admin toggles, account deactivation, forms (every form owns a short link) |
Forms::PublicDefinition, Booking::Modes.compile | Public forms, the slots endpoint, the booking engine, the agenda, MCP |
Appointments::Reserve / Release | Booking, cancellation, rescheduling, expiry, waitlist offers |
Notification rows and Notifications::Deliver | Every appointment transition, the bell, push, MCP list_notifications |
MailBudget | Login codes, data export, appointments, the health check |
Turnstile, client_ip, Rails rate_limit | Sign-in, sign-up, form submissions, uploads, unlocks, MCP |
UserAgentParser, TrafficSource | Short link analytics (mirrored in the edge function), bio page clicks, forms |
ImageUpload, imgproc | Form covers and uploads (imgproc), avatars (in-process libvips) |
GoogleLib::SafeBrowsing | Short 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
bookingfield of a form, with its mode, services, availability and rules (booking engine). - Mode: how a booking question is configured (
servicesorindividual) 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_slotsper form, resource and start time, withcapacity,bookedandheld(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
mainby.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.