# The academy side and Public Academy Records

The repository's architectural decision, rationale and consequences.

# The academy side and Public Academy Records

**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](0009-row-level-security.md), [ADR-0018](0018-location-is-city-and-window.md), [ADR-0019](0019-instructor-and-academy-are-organizations.md), [ADR-0021](0021-jobs-on-the-cron-tick-and-leased-queue.md), [ADR-0022](0022-adapters-with-fakes-and-money-in-config.md)

**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 `SystemReason`s) · `[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 [#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 `SystemReason`s, 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, 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 [#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).

