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:
| Reason | Who crosses | What it reads or writes |
|---|---|---|
academy.itinerary-read | an Academy | visible Instructors' profiles, Tours, Stops and Travel Windows; writes Itinerary Views |
academy.seminar-request | an Instructor | Seminar Requests addressed to them, the requester's email once accepted; the answer |
prospect.registered-academies | an Instructor | registered Academies (contact consent) and their alert along the route |
prospect.public-record | the public, no session | objection and claim of a Public Academy Record |
Tables.
academy_profile(Organization PK, CHECKcontact_consent = true: no consent, no row),alert_subscription(Organization PK, CHECK on the radius): the Academy's tenant reads and writes them.seminar_requestbelongs to the Academy (tenant SELECT/INSERT, no UPDATE grant); the Instructor reads and answers it throughacademy.seminar-request. It has no email column: the requester's address is read fromuseronly when the status isaccepted(US-33).itinerary_viewbelongs to the Instructor (their tenant reads it for the Art. 15 export); academy views (view/request, surfacealerts/travelers/request) are written throughacademy.itinerary-read, and the tenant may insert onlycutoffrows. Insert-only. This is the RNF12 audit log; the genericaudit_logkeeps the security events (academy.registered,academy.seminar_requested,academy.seminar_request_answered).public_academy_recordis global, not a tenant row: app_tenant may SELECT rows that areactivewith no removal requested (andapp.session_trusted()), nothing else. It has no email column, by design (C4).public_record_blocklistandpublic_record_claimhave 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).