Skip to content
Docs

Command palette

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

Changelog

The academy side and Public Academy Records

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-11-13 · Implements: US-16, US-17, US-31…US-34, US-36, RN08, RN09, RN11, RNF12, C4, C8 · Related: ADR-0009, ADR-0018, ADR-0019, ADR-0021, ADR-0022

Enforcement: [test] academy.rules.test.ts, prospect.rules.test.ts, academy.procedures.test.ts (C8, RN09, RNF12, email hidden until acceptance, RLS) and prospect.procedures.test.ts (C4, objection job, blocklist, claims) · [lint] scripts/check-system-call-sites.mjs (four new SystemReasons) · [test] packages/db/src/rls-catalog.test.ts and db:verify-rls (grants).

An Academy must learn that a black belt passes nearby (F5) and an Instructor must see which academies lie along the route (F7), yet the two sides are different Organizations under row-level security, and German law forbids cold promotional email (UWG § 7).

Decision

Where an Instructor is. An Academy sees a City and a published window, never a location. A Stay is derived, by the leaf academy.rules.ts, from what the Instructor already planned:

  • each Stop of a planned or active Tour is a Stay in its City from its own date (the Tour's start for the first Stop) to the day before the next Stop (the Tour's end for the last), so Stays never overlap and Caio is "in Berlin 12–14 Mar";
  • a Travel Window with no Tour is a Stay at the Instructor's Europe base City;
  • fuzzing widens each Stay by a day on each side (US-35); a Stay leaves the map the day after its real end (RN09).

Who is read. Only instructor_profile.visibility_audience = 'academies' and tour_hidden = false, filtered in the query on every read. Turning invisible therefore drops the Instructor from alerts, the map, the live count and new requests on the next read (C8), and instructor.updateVisibility appends a cutoff Itinerary View.

Matching (F5). A Stay matches an alert when the Academy's City is within the radius (50–600 km in steps of 50, City centroids, distanceKm), the formats intersect and the Stay touches one of the months. The alert shows the earliest matching Stay per Instructor.

Crossing tenants. Four new SystemReasons, each with one job:

ReasonWho crossesWhat it reads or writes
academy.itinerary-readan Academyvisible Instructors' profiles, Tours, Stops and Travel Windows; writes Itinerary Views
academy.seminar-requestan InstructorSeminar Requests addressed to them, the requester's email once accepted; the answer
prospect.registered-academiesan Instructorregistered Academies (contact consent) and their alert along the route
prospect.public-recordthe public, no sessionobjection and claim of a Public Academy Record

Tables.

  • academy_profile (Organization PK, CHECK contact_consent = true: no consent, no row), alert_subscription (Organization PK, CHECK on the radius): the Academy's tenant reads and writes them.
  • seminar_request belongs to the Academy (tenant SELECT/INSERT, no UPDATE grant); the Instructor reads and answers it through academy.seminar-request. It has no email column: the requester's address is read from user only when the status is accepted (US-33).
  • itinerary_view belongs to the Instructor (their tenant reads it for the Art. 15 export); academy views (view/request, surface alerts/travelers/request) are written through academy.itinerary-read, and the tenant may insert only cutoff rows. Insert-only. This is the RNF12 audit log; the generic audit_log keeps the security events (academy.registered, academy.seminar_requested, academy.seminar_request_answered).
  • public_academy_record is global, not a tenant row: app_tenant may SELECT rows that are active with no removal requested (and app.session_trusted()), nothing else. It has no email column, by design (C4). public_record_blocklist and public_record_claim have RLS with no policy and no grant (system path only).

Public endpoints. publicRecords.object (Art. 21) and publicRecords.claim (RN11) need no session: the academy behind a record has no account. An objection hides the record at once (the read policy) and the publicRecords job of the tick removes it, drops its website and blocklists its key (normalised host, or name@city) within the minute — well inside 24 h. A claim stores a pending proof (a DNS TXT token for domain; nothing for website_email); verification and any email are not built.

Data source. prospect.source.ts is the ADR-0022 adapter: PUBLIC_ACADEMY_SOURCE=off (default) imports nothing, fake imports fictional .example academies. The same job imports at most once a day per source and skips blocklisted keys. Source and licence stay [TBD].

Consequences

  • Every academy read of an itinerary costs one insert per Instructor shown; the reads are capped (500 Instructors, 100 shown) and fine for v1. A materialised presence table is the next step if this grows.
  • Straight legs between City centroids under-estimate the real detour ("44 km off the route" for Dresden, where rail says 8 km); the 25/75 km radius is a coarse filter, as in ADR-0017.
  • The public objection endpoint can be called by anyone for any record. Removal only shrinks prospect data, so v1 accepts it; rate limiting belongs with the public routes.
  • Seminars already on sale stay public after an Instructor turns invisible; that rule lands with the Seminar feature (S6).
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.