# Public read API — v0 (unstable)

**Status: implemented and deployed, and deliberately UNSTABLE.**

Base path: **`/api/v0/public/`**, alongside the application's own `/api/v0/`.
Unauthenticated, read-only, cacheable. There are no write endpoints and none are
planned; adding one is not a version bump, it is a different design.

> **`v0` is a promise that there is no promise yet.** The platform is on `0.x.x`
> and under active development, and this API has no external consumer. Pinning a
> stable `v1` now would mean either freezing decisions that have not been tested
> against a real page, or breaking a version that claimed not to break.
>
> So: **this document describes the shape; it does not guarantee it.** Fields may
> be removed or renamed, and endpoints may change, while the version reads `v0`.
> What is guaranteed is that changes are recorded here, in this file, rather than
> discovered.
>
> **`v1` is what changes that.** When a consumer exists and the shape has stopped
> moving, the endpoints are republished under `/api/v1/public/` and §1.1 becomes
> a real rule: additive only, removals mean `/v2`. Until then it is a working
> intention, not a contract.

This document is still written before the code where it can be, and the code is
held to it — `.github/scripts/check_api_contract.mjs` and a diff of the generated
OpenAPI spec against this file are part of the verification.

Companion documents: `docs/design/public-platform-domain.md` (the model this
exposes), `docs/design/singleton-removal-checklist.md` (what had to change
first).

---

## 1. Contract rules

These bind v1 for its whole life. They are listed first because everything below
is an application of them.

### 1.1 Additive while it can be, breaking when it must

Under `v0` the rule is an **intention**, not a guarantee:

- A new **optional** field may be added at any time. Consumers must ignore
  fields they do not recognise.
- A field may be removed or renamed, or an endpoint reshaped, **if there is a
  good reason** — and every such change is recorded in this document at the
  point it affects, marked as a change, with the reasoning. Two already are:
  `PublicEvent.image_url` (added) and `PublicAffiliate` (reshaped from one row
  per credential to one per organisation).
- Every breaking change is easier now than it will ever be again, which is the
  whole argument for staying on `v0` until a page exists.

**When this becomes `/api/v1/public/`, the rule hardens**: additive only, and a
removal or a rename means `/v2`. A field going from always-present to
sometimes-`null` counts as breaking then, because a consumer that rendered it
unconditionally starts rendering "null".

Until then, if you are building against this: pin nothing, read the changes
recorded here, and expect to adjust.

### 1.2 The public response model is the privacy policy

Every endpoint has its own Pydantic model in `app/routers/public/`, and whatever
field exists on that model is public. Consequences, all deliberate:

- **No public endpoint reuses an internal model.** Not even where the shapes
  look identical today. An internal model gains a field when somebody adds a
  column; a public model gains a field when somebody decides the world may see
  it. Those are different events and must not share a class.
- **No field-stripping at serialisation time.** No `exclude=`, no
  `response_model_exclude`, no "public view" flag on an internal model. A field
  that is not in the public model is not fetched into it.
- **No generic field-visibility engine.** Per-field configurable visibility is
  the kind of machinery that is impossible to audit: the answer to "is this
  public" becomes a runtime computation over config rather than a class you can
  read. §4 is the list, in one file, reviewable in a diff.

`response_model=` is banned repo-wide (CLAUDE.md) for a related reason, and the
public routers follow the same rule: declare with `responses={200: {"model": ...}}`.

### 1.3 Slugs, aliases and 301

- An **unknown** slug → **404**, body `{"data": null, "error": "ORGANISATION_NOT_FOUND"}`.
- A **known alias** → **301** to the canonical slug, same path and query string.
- **One hop, always.** The redirect target is read from `organisations.slug`,
  never from another alias row, so a chain cannot form. Inserting an alias equal
  to a live slug is refused at creation. A consumer may safely follow at most one
  redirect and treat a second as an error.
- Slugs are lowercase ASCII, Polish diacritics transliterated, immutable once
  published. A rename creates an alias; the old URL keeps working forever.

### 1.4 Pagination: `limit` / `offset`, everywhere

One style, applied to every collection endpoint without exception.

```
?limit=<1..100>&offset=<0..>
```

| | |
|---|---|
| `limit` default | 50 (25 on person-bearing endpoints — §4.5) |
| `limit` maximum | 100, matching `app/pagination.py::MAX_LIMIT` |
| `offset` | 0-based; beyond the end returns an empty `data`, **not** 404 |
| `meta.count` | rows in *this* response |
| `meta.total` | rows matching the query in total |

**Why offset and not a cursor.** A cursor is the right answer for a large,
rapidly-changing set where a row may be missed across pages. These sets are
small (a few hundred clubs, a dozen bodies) and change a few times a year, the
existing internal API already uses `limit`/`offset`, and — the deciding reason —
**an offset URL is completely described by itself**, so a CDN can cache page 3
as an ordinary object. A cursor makes every page a one-off. Given §5 and
`--max-instances 1`, cacheability wins.

Every collection has a **stable total order** (stated per endpoint), so pages do
not overlap or skip while nothing is being written.

### 1.5 The envelope

Every response, success or error, is `{ "data", "error", "meta" }` — the
repo-wide shape (`app/models/envelope.py`).

```json
{
  "data": { "...": "..." },
  "error": null,
  "meta": { "locale": "pl", "count": 12, "total": 12 }
}
```

On an error, `data` is `null` and `error` is a **stable machine-readable code**
(`ORGANISATION_NOT_FOUND`, `RATE_LIMITED`, …). The human-readable message lives
in `meta.message` and may be reworded at any time; the code may not.

### 1.6 Language

Storage is single-locale (`docs/design/public-platform-domain.md` §3); the
**contract** is locale-aware from the first request, because retrofitting
negotiation into a published contract is the expensive half.

- **`?lang=pl|en`** wins when present. It is explicit, shareable and part of the
  cache key.
- **`Accept-Language`** is the fallback when `?lang=` is absent.
- **`Vary: Accept-Language`** on every response. Omitting it means a CDN serves
  the first requester's language to everyone — a correctness bug, not a nicety.
- **`meta.locale`** states the locale actually served, which is not necessarily
  the one asked for.
- **Fallback rule, one rule everywhere:** requested locale → the organisation's
  `default_locale` → the stored value. A translatable field is **never** `null`
  or `""` because a translation was missing; it falls back to text in another
  language and `meta.locale` says which.

Today every request resolves to the organisation's default, so responses are
byte-identical to a single-locale API. When translations arrive, no field is
renamed and no URL changes meaning — purely additive, no v2.

The accepted values (`pl`, `en`) and the `pl` fallback match what the frontend
already does (`frontend/src/i18n.ts`), deliberately.

### 1.7 Status codes

| Code | When |
|---|---|
| 200 | Success. |
| 301 | The slug is a known alias. `Location` carries the canonical URL. |
| 304 | `If-None-Match` matched the current `ETag`. |
| 400 | A malformed parameter (`limit=abc`, a bad date). `error: "BAD_REQUEST"`. |
| 404 | Unknown slug, or a known organisation that is not publicly visible. |
| 422 | A well-formed parameter with an unacceptable value (`limit=500`, `role=owner`). |
| 429 | Rate limited. `Retry-After` in seconds. |
| 503 | Upstream unavailable. `Retry-After`. Never leaks a stack trace. |

**404 for a DRAFT organisation, not 403.** A public API that distinguishes "does
not exist" from "exists but is hidden" leaks the existence of unpublished
records. Both are 404, with the same body.

---

## 2. Endpoints

### 2.1 `GET /api/v0/public/orgs`

The aggregate directory — every publicly visible organisation across the
platform. This is `surfpoland.com`'s list, and the one endpoint that is **not**
tenant-scoped, because organisations are global identities
(`docs/design/public-platform-domain.md` §2.3).

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `type` | repeatable enum | — | `CLUB` \| `SCHOOL` \| `ASSOCIATION` \| `FEDERATION`. Derived from active qualifications. |
| `discipline` | repeatable string | — | Discipline code, exact. No implicit parent/child expansion. |
| `affiliated_to` | slug | — | Only organisations holding an active qualification **issued by** that federation. |
| `q` | string | — | Case-insensitive substring of the name. Minimum 2 characters. |
| `limit`, `offset` | | 50 | §1.4 |

Order: `name ASC, id ASC`. Response: `PublicOrganisationSummary[]` (§4.1).

### 2.2 `GET /api/v0/public/orgs/{slug}`

One organisation. Response: `PublicOrganisationDetail` (§4.2).

404 when the slug is unknown or the organisation is `DRAFT`. 301 for an alias.

### 2.3 `GET /api/v0/public/orgs/{slug}/affiliates`

The organisations affiliated to this one — i.e. holding an active qualification
**issued by** it. This is the endpoint that makes affiliation-derives-from-
qualifications visible: there is no membership table behind it.

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `type` | repeatable enum | — | `CLUB` \| `SCHOOL` \| `ASSOCIATION` |
| `discipline` | repeatable string | — | |
| `limit`, `offset` | | 50 | |

Order: `name ASC, id ASC`. Response: `PublicAffiliate[]` (§4.3).

"Active" means `status = APPROVED` **and** `valid_from <= today <= valid_to`,
evaluated server-side in Europe/Warsaw — the same predicate the rest of the
application uses (`app/qualifications.py::is_active_qualification`). A lapsed
club disappears from this list on the day it lapses, which is the point.

**A self-issued qualification is excluded.** A federation's own `FEDERATION`
qualification is self-issued by design (`docs/design/public-platform-domain.md`
§2.1), and an association with no governing body above it holds a self-issued
`ASSOCIATION` one. Without this exclusion each would appear in its own list of
members. **Affiliation is a relationship between two organisations**, so an
organisation is never its own affiliate — in this endpoint, in the directory's
`affiliated_to` filter, or in `PublicOrganisationDetail.affiliated_to`.

This endpoint is also **not federation-specific**. An association that licenses
nobody gets an empty list and a 200, which is the normal state for an
organisation that is not a governing body — not a 404 and not an error.

### 2.4 `GET /api/v0/public/orgs/{slug}/events`

Events this organisation is connected to, **with the nature of the connection**.

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `role` | repeatable enum | all | `organiser` \| `co_organiser` \| `sanctioning` \| `patronage` \| `host` |
| `from`, `to` | date (`YYYY-MM-DD`) | — | Inclusive, on `start_time` in Europe/Warsaw. |
| `type` | repeatable enum | — | `competition` \| `camp` \| `course` |
| `limit`, `offset` | | 50 | |

Order: `start_time DESC, id ASC` — upcoming and recent first, which is what a
website renders.

Response: `PublicEvent[]` (§4.4), each carrying **`roles: string[]`** — this
organisation's roles on that event.

**This is the endpoint the "Baltica problem" exists for.** PZSurf is patron of
events organised by Polskie Stowarzyszenie Surfingu. Both appear here, and
`roles` is what lets B3 render `["patronage"]` differently from `["organiser"]`
instead of implying the federation ran it.

Two details worth stating:

- An event where the organisation is the **tenant** but holds no role still
  appears, with `roles: []`. Suppressing it would hide a federation's own event
  because nobody filled in the junction.
- `roles` is a **list**, because one organisation can be host *and*
  co-organiser. A scalar `role` would have forced a choice.

### 2.5 `GET /api/v0/public/orgs/{slug}/bodies`

Governance bodies with their **current** members. Order: `name ASC`. Members
within a body: `display_order`, then surname.

"Current" means `valid_to IS NULL OR valid_to > today`. Historical membership is
not exposed in v1.

Response: `PublicGovernanceBody[]` (§4.6). **Members are subject to §4.5 and the
consent gate in §3.**

### 2.6 `GET /api/v0/public/orgs/{slug}/national-teams`

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `year` | integer | current season | The season's year. |
| `discipline` | repeatable string | — | |
| `limit`, `offset` | | 50 | |

Order: `discipline_code ASC, name ASC`. Response: `PublicNationalTeam[]` (§4.7).

**Rosters contain minors by definition** — U10 through U18 are the point of a
junior team. §4.5 applies with no exceptions.

### 2.7 `GET /api/v0/public/orgs/{slug}/documents`

Not in the brief's proposed surface; added because the existing public site
already renders this list from `/documents/public`, and B3 replaces that page.
Without it the WordPress plugin cannot show what pzsurf.pl shows today, which
would be a regression dressed as a migration.

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `include_minutes` | boolean | `false` | Meeting minutes are published in the context of their meeting. |
| `limit`, `offset` | | 50 | |

Order: `doc_date DESC NULLS LAST, title ASC`. Response: `PublicDocument[]` (§4.8).

**`q` is deliberately not offered here.** The internal endpoint searches
`documents.extracted_text`; exposing full-text search over document contents to
an unauthenticated caller is a different feature with different risks, and B3
does not need it.

### 2.8 `GET /api/v0/public/orgs/{slug}/statute`

The association's *statut*, as Markdown. **404** when there is none — an
association without a statute is not an error, but there is no resource to
return and an empty body would be indistinguishable from a statute of no words.

Response: `PublicStatute` (§4.9).

### 2.9 `GET /api/v0/public/orgs/{slug}/meetings`

The association's meetings, newest first. `limit`/`offset` as §1.4.

Response: `PublicMeeting[]` (§4.10) — **cards only**. Who attended is a list of
people, and a list of people belongs behind §4.5 rather than in a summary, so
this carries `participant_count` and the meeting's id.

### 2.10 `GET /api/v0/public/orgs/{slug}/tasks`

The association's published task market.

**404 when the board is not published** — not 403, and not an empty list. An
unpublished board must be indistinguishable from an association that has none,
exactly as a DRAFT organisation is indistinguishable from a missing one. The
gate is the existing `organisations.task_board_public`: a work board is internal
until somebody decides otherwise.

Only the three board columns (`TODO`, `IN_PROGRESS`, `DONE`) are ever published.
The backlog and the archive are not a public work board.

Response: `PublicTask[]` (§4.11). Rate-limited with the person-bearing
endpoints, because a task can name its assignee.

### 2.11 Deliberately not in v0

| Candidate | Why not |
|---|---|
| `GET /orgs/{slug}/people` | There is no public roster of a federation's people, by design (§4.5). People appear only inside a named body or team. |
| `GET /orgs/{slug}/featured` | The spotlight is **presentation, not data**. A consuming page controls its own hero image and copy; serving it from here would make the API responsible for how a site looks. Asked for and declined. |
| `GET /orgs/{slug}/meetings/{id}` | The meeting cards carry an id, and the full record is already public at `/api/v0/meetings/public/{id}`. Worth folding into this namespace when the page needs it. |
| `GET /events/{id}` | B3 renders lists and links out. A detail endpoint is additive later. |
| Anything about money | Transactions, prices and affiliation tiers are not public. |
| Any write endpoint | Out of scope for the whole track. |

---

## 3. Consent is a query, not a filter

**Non-negotiable, and the single most important implementation rule in this
document.**

### 3.1 What consent actually governs here

> **Corrected after this section was first written.** The original draft proposed
> gating *every* person's appearance on publication consent, and that was
> approved. Implementing it turned up `frontend/src/legal/privacy-2026-08-29.ts`
> — the privacy policy **currently in force, which users have accepted** — which
> already assigns the legal bases, and assigns them differently. The policy wins:
> deviating from a published basis is a legal problem, not a design preference.

The policy's own table (§4, *Cele i podstawy prawne przetwarzania*):

| Purpose | Basis in the published policy |
|---|---|
| Register of the federation's members and organisations | Art. 6(1)(f) + 6(1)(e) — legitimate interest / public task |
| Historical register of people holding **office in the governing bodies** | Art. 6(1)(e) — public task / accountability |
| **Publication of a person's image** (*publikacja wizerunku*) and marketing | **Art. 6(1)(a) — consent, withdrawable at any time** |

So the split is not person-by-person, it is **field-by-field**:

- A **name and an office**, inside a named governance body or national-team
  roster, is published on the public-task basis. Not consent-gated. This matches
  what the code already does — `app/consent.py::consented_person_ids` carries the
  note "except in the National Teams and Governance contexts (public-interest
  basis), which don't call this".
- A **photograph is consent, always**, everywhere, for everybody. Absence of a
  consent record is not consent.

This is narrower than "gate everything" for names and **stricter** than the
status quo for images, which are currently shown on those pages without checking.

### 3.2 The rule, as implemented

| Field | Gate |
|---|---|
| `display_name` | none, inside a body or team (§3.1). There is no other public context. |
| `role`, `categories` | none, same basis |
| `image_url` | **publication consent, enforced in SQL** |
| anything else | not exposed at all (§4.5) |

The image is not blanked after fetching. It is reached through a join that is
conditional on consent, so a URL for a person without consent **never enters the
result set**. That is what "at the query layer" has to mean for a field-level
gate — the alternative, selecting it and then setting it to `None`, is exactly
the post-filter this rule exists to forbid.

`app/consent.py::publication_granted_person_ids` is that subquery. Its semantics
match `person_publication_granted` exactly: the **most recent** publication event
decides, and no event means no consent.

### 3.3 Why the query layer, and not a filter

Two reasons, and the second is the one that matters:

1. A row that was fetched can be leaked by the next person who adds a code path
   — a count, a log line, an `id` used for a lookup, a debug dump.
2. **`meta.total` must agree with `data`.** A post-filter produces a total that
   counts people the response does not contain. Where a gate ever removes whole
   people, that difference tells a caller how many withheld consent — a
   disclosure about those individuals, produced by the mechanism meant to
   protect them.

The second is why `member_count` is `len(members)` and never the true size of
the body (§4.6).

### 3.4 Withdrawal must invalidate caches

Consent is withdrawn by appending a `granted=false` row. The person's photo then
disappears from every public response — but a cached response still contains it,
and a CDN will serve it until it expires.

So a withdrawal **purges by person**: every cache key that could contain that
person is invalidated at write time. §5.3 defines the keys. A TTL alone is not an
acceptable answer to a withdrawal.

### 3.5 What this does on day one

Under the corrected rule, **no roster empties**: names and offices keep
publishing on the basis the policy already states. What changes is that
**photographs disappear** for everyone without a publication consent record, and
that is the intended effect — the policy says an image needs consent, and until
now nothing checked.

Worth counting before it goes live, so the change is expected rather than
noticed:

```sql
SELECT count(*) FILTER (WHERE c.granted) AS may_show_photo,
       count(*)                          AS people_on_public_pages
  FROM persons p
  LEFT JOIN LATERAL (
       SELECT ce.granted
         FROM consent_events ce
        WHERE ce.consent_type = 'publication' AND ce.subject_person_id = p.id
        ORDER BY ce.created_at DESC LIMIT 1
  ) c ON true
 WHERE p.status = 'ACTIVE' AND p.anonymized_at IS NULL;
```

If that first number is small, the answer is a consent drive before B3 goes live
on pzsurf.pl — not loosening the gate.

## 4. The fields — and nothing else

This is the B2.2 approval list. Everything here is public; everything not here is
not.

### 4.1 `PublicOrganisationSummary`

| Field | Type | Source | Note |
|---|---|---|---|
| `slug` | string | `organisations.slug` | The stable identifier. |
| `name` | string | `organisations.name` | Translatable. |
| `types` | string[] | derived | Active qualification types. |
| `image_url` | string? | `images` primary | |
| `city` | string? | primary `organisation_addresses.address` | **Locality only**, not the full street address. |
| `disciplines` | string[] | `organisation_disciplines` | Codes. |

**Not included:** `id` (the UUID is an internal handle; `slug` is the public
one), `status`, `notes`, `email`, `phone`, `member_count`, timestamps.

> `member_count` is on the *internal* public list today. It is left out here
> deliberately: an aggregate count of natural persons per club, across every
> club, is a dataset about people even though no individual is named.

### 4.2 `PublicOrganisationDetail`

Everything in §4.1, plus:

| Field | Type | Source | Note |
|---|---|---|---|
| `description` | string? | `organisations.description` | The public blurb. Translatable. Never `notes`. |
| `website_url` | string? | `organisations.website_url` | |
| `email` | string? | `organisations.email` | **Organisational** address only — §4.9. |
| `phone` | string? | `organisations.phone` | Same. |
| `addresses` | object[] | `organisation_addresses` | `{address, latitude, longitude, is_primary}` — full address here, since an organisation's seat is a public registry fact. |
| `socials` | object[] | `socials` | `{platform, url}`. |
| `affiliated_to` | object[] | `qualifications` | `[{slug, name, type, valid_to}]` — the federations that license it. |

**Not included:** `notes`, `task_board_public`, `default_locale` (it is in
`meta.locale`), `id`, timestamps, anything about AI quota.

### 4.3 `PublicAffiliate`

**One row per organisation**, not per credential. `PublicOrganisationSummary`,
plus:

| Field | Type | Note |
|---|---|---|
| `qualifications` | object[] | `[{type, valid_to}]` — what this organisation holds **from the federation being asked about**. |

> **Changed after the first deploy, and deliberately a breaking change.** It was
> one row per QUALIFICATION (`qualification_type` + `valid_to` on the affiliate
> itself), so a club holding `CLUB`, `SCHOOL` and `ASSOCIATION` from the same
> federation appeared **three times** in its list of members — which showed up on
> real production data the hour it went live. On a public page that reads as a
> bug, and every consumer would have had to group the rows itself.
>
> Made in place rather than as a new version, because the endpoint had **no
> consumers at all** — `check_api_contract.mjs` reported every
> `/api/v0/public/` path as never called. This is exactly the kind of change
> `v0` exists to allow: found on real data, fixed where it was, recorded here.
> Under `/api/v1/public/` it would have meant `/v2`.

`qualifications` is deliberately **not** the same thing as the inherited
`types`. `types` is what the organisation **is** — derived from every active
qualification it holds, whoever issued it. `qualifications` is what it holds
**from this federation**, which with two federations is a strictly smaller set.
Collapsing them would be correct only while a single federation exists, which is
the assumption this whole track removes.

**Not included:** `valid_from`, `tier`, `status`, `stripe_payment_id`, the
qualification's `id`. The tier is a commercial relationship between the club and
the federation; the amount paid is nobody else's business, and `tier` is a short
step from it.

### 4.4 `PublicEvent`

| Field | Type | Source |
|---|---|---|
| `id` | uuid | `events.id` — events have no slug, so the UUID is the identifier. |
| `name` | string | Translatable. |
| `event_type` | string | `competition` \| `camp` \| `course` |
| `description` | string? | Translatable. |
| `start_time`, `end_time` | datetime? | ISO 8601, Europe/Warsaw. |
| `all_day` | boolean | |
| `location_name` | string? | |
| `latitude`, `longitude` | float? | |
| `external_url` | string? | |
| `image_url` | string? | The event's own photograph. |
| `roles` | string[] | This organisation's roles. May be `[]` — §2.4. |
| `organisers` | object[] | `[{slug, name, role}]` — everyone on the junction. |

**Not included:** `price_amount`, `price_currency`, `registration_url`,
`status`, `location_address`, the forecast columns, `organiser_id`. Price and
registration are commerce; a v1.1 may add them once somebody wants them.

> `image_url` was added after the field list was first approved — an additive
> change, which §1.1 permits. It is load-bearing for one thing: a link shared to
> Facebook or WhatsApp previews with the event's own photograph rather than the
> site's generic image, and those crawlers read it from the prerendered HTML
> (`.github/scripts/prerender.mjs`) rather than from a live call.

### 4.5 `PublicPerson` — the strict one

The only shape in which a natural person appears, anywhere in v1. It is nested
inside a body or a team; **it is never the top-level resource of any endpoint.**

| Field | Type | Adult | Minor |
|---|---|---|---|
| `display_name` | string | `"Anna Nowak"` | **`"Anna N."`** |
| `image_url` | string? | photo **if publication consent is recorded** (§3.2), else `null` | **always `null`** |
| `role` | string | the office or squad role | same |
| `categories` | string[] | `[]` | `["U16"]` — assigned, never computed |

That is four fields. **Not included, for anybody:** `id`, `date_of_birth`, `age`,
`email`, `phone`, `first_name`/`last_name` separately, club membership,
disciplines, socials, qualifications held, `status`, `anonymized_at`.

- **No date of birth and no age, ever** — a brief constraint, and easy to honour
  because `categories` are *assigned* values on the roster
  (`national_team_members.categories`), not derived from a birth date. The
  public API never reads `persons.date_of_birth`.
- **No `id` field.** A stable per-person identifier across every public response
  is the join key that turns two endpoints into a profile. Bodies and teams are
  small and ordered; a consumer that needs to address a person does not have that
  right in v1.

> **Known limitation, found while implementing and recorded rather than hidden.**
> `image_url` points at GCS, and `app/gcs.py:47` names objects
> `persons/{person_id}/{uuid}_{filename}`. **So a photo URL contains the person's
> UUID**, and a caller who reads it gets the identifier this field list
> deliberately withholds.
>
> The practical harm is bounded: the UUID is an opaque handle, not personal data
> in itself, and what it enables is joining the two person-bearing endpoints —
> which already show the same name and role, so the join tells a reader little
> the names do not. It applies only to people who consented to a photograph.
>
> It is still a stated protection being defeated by an implementation detail, so
> it is named here rather than left for somebody to discover. Two ways to close
> it, both **v1.1** and neither worth building speculatively now: serve public
> images through an opaque path (a keyed digest of the object name, no mapping
> table), or name new GCS objects by a random id instead of the entity's. The
> second is cheaper and fixes it only for photos uploaded afterwards.

#### Who counts as a minor

> **A person is treated as a minor unless they are known to be an adult** —
> `date_of_birth` present and 18+ years ago. **Unknown date of birth means
> minor.**

Failing safe is the only defensible default: the alternative treats every person
with incomplete data as an adult, which is exactly backwards for a federation
whose rosters are full of juniors.

**The consequence has to be stated plainly, because it is visible.**
`persons.date_of_birth` is nullable and widely unset, so on day one a substantial
share of adults will render as `"Anna N."` with no photo. That is a data-quality
problem showing through, not a bug — but it is the kind of thing that looks like
a bug on a live website. Count it first:

```sql
SELECT count(*) FILTER (WHERE date_of_birth IS NULL) AS unknown_dob,
       count(*) AS total
  FROM persons WHERE status = 'ACTIVE' AND anonymized_at IS NULL;
```

If the number is large, the fix is to fill in dates of birth, not to loosen the
rule.

#### Anti-scraping

The brief requires that no endpoint return bulk personal data in a form
convenient for scraping. Concretely:

- **No person-collection endpoint exists.** People are reachable only inside a
  named governance body or a national team — small, published groups with a
  reason to be public.
- **No name search, no sort, no filter on person fields.** Nothing lets a caller
  enumerate or slice people.
- **`limit` maxes at 25** on person-bearing endpoints, against 100 elsewhere.
- **No stable person identifier** across responses (above), so results cannot be
  joined into profiles.
- **Rate limits are stricter** on these two endpoints (§5.4).

None of this defeats a determined scraper. It does mean the API is not *itself*
the convenient form, which is the actual requirement.

### 4.6 `PublicGovernanceBody`

| Field | Type | Note |
|---|---|---|
| `name` | string | Translatable. |
| `body_type` | string | `BOARD` \| `AUDIT` \| … |
| `description` | string? | Translatable. |
| `members` | `PublicPerson[]` | §4.5 and §3. |
| `member_count` | integer | **Counts only the members in `members`.** |

`member_count` must never be the true size of the body. A body of nine listing
four people, with `member_count: 9`, discloses that five members withheld
consent. It is `len(members)`, computed from the same consent-gated query.

**Not included:** `id`, `organisation_id`, timestamps, membership windows,
`notes`.

### 4.7 `PublicNationalTeam`

| Field | Type | Note |
|---|---|---|
| `name` | string? | e.g. "Kadra 2026". Translatable. |
| `year` | integer | From the season. |
| `discipline` | string | Code. |
| `members` | `PublicPerson[]` | §4.5 and §3. |
| `member_count` | integer | As §4.6 — `len(members)`. |

**Not included:** `id`, `season_id`, `organisation_id`, `status`, timestamps.

### 4.8 `PublicDocument`

| Field | Type |
|---|---|
| `title` | string |
| `doc_date` | date? |
| `file_url` | string |
| `file_name` | string? |

**Not included:** `extracted_text` (never), `doc_type`, `organisation_id`, `id`,
timestamps.

> `extracted_text` is the whole content of every governance PDF. It exists so
> the AI assistant and the search filter need not reparse a file. It is not a
> public field and there is no public endpoint that returns it.

### 4.9 `PublicStatute`

| Field | Type |
|---|---|
| `title` | string |
| `body_md` | string |
| `adopted_on` | date? |
| `source_file_url` | string? |
| `source_url` | string? |

The Markdown body is the point: a statute is **read on the page**, not
downloaded, which is why it is not a `documents` row in the first place. The two
source fields are provenance — a signed PDF we hold in GCS, and a page on the
open web it was transcribed from — and either may be absent.

**Not included:** `id`, `organisation_id`. There is exactly one statute per
association, and the caller already named the association in the URL.

### 4.10 `PublicMeeting`

| Field | Type |
|---|---|
| `id` | uuid |
| `title` | string |
| `starts_at`, `ends_at` | datetime? |
| `status` | string |
| `location_mode`, `location_address` | string? |
| `participant_count`, `document_count` | integer |

**Not included:** the participants themselves. Counts rather than lists, because
a list of people belongs behind §4.5.

### 4.11 `PublicTask`

| Field | Type |
|---|---|
| `id` | uuid |
| `title` | string |
| `description` | string? |
| `status` | `TODO` \| `IN_PROGRESS` \| `DONE` |
| `due_date` | date? |
| `estimate` | string? |
| `is_assigned` | boolean |
| `assignee` | `PublicPerson`? — §4.5 |

`is_assigned` is separate from `assignee` on purpose. A task whose assignee
withheld consent comes back `assignee: null` with `is_assigned: true` — the work
is public, the person is not. Without the flag the board would suggest nobody had
picked the work up, which is a different and wrong statement.

The assignee goes through §4.5 unchanged: a task assigned to a junior shows an
abbreviated name and no photograph.

**Not included:** `assignee_person_id`, `position`, `organisation_id`,
`source_action_item_id`, attachments, timestamps.

### 4.12 A note on organisational contact details

`organisations.email` and `.phone` are published (§4.2) because they are the
organisation's own published contact details — the ones already on club
websites and in the KRS.

They are also, often, a named person's address. Nothing in the schema
distinguishes `kontakt@klub.pl` from `anna.nowak@gmail.com`, and the second is
personal data being published because it sits in an organisational column.

Not solvable in v1 without a schema change. Recorded here so it is a known
limitation rather than an oversight, and proposed for v1.1: a
`contact_is_personal` flag, defaulting to *suppress*, on organisations whose
address is a personal one.

---

## 5. Caching, purging and limits

**Prerequisite, not an optimisation.** The backend runs `--max-instances 1`
(CLAUDE.md). One warm container behind an unauthenticated public API with no CDN
is a self-inflicted outage, and the first consumer is a WordPress site whose
traffic we do not control.

### 5.1 `Cache-Control` per endpoint

Every response carries `Cache-Control` and a strong `ETag` over the serialised
body. `If-None-Match` returns 304.

| Endpoint | `max-age` | `s-maxage` | `swr` |
|---|---|---|---|
| `/orgs`, `/orgs/{slug}`, `/affiliates`, `/documents`, `/meetings` | 0 | 60 | — |
| `/orgs/{slug}/events` | 0 | 30 | — |
| `/orgs/{slug}/bodies`, `/national-teams`, `/tasks` | 0 | 30 | — |
| `/orgs/{slug}/statute` | 0 | 300 | 300 |

`/statute` is the outlier: it changes at an AGM and it is by far the largest
body in the API, so it is the one endpoint where a longer window costs nothing
anybody would notice.

**Short everywhere, because the consuming page renders from this live.** An
editor who adds an event and does not see it for minutes concludes the save
failed, and that is a worse failure than an origin request.

> **Revised twice, and the second revision is the instructive one.** The first
> draft had `stale-while-revalidate` up to an hour, sized to protect a single
> Cloud Run instance from a WordPress plugin's traffic — `/events` could be
> served ~65 minutes old. The correction is that **`s-maxage=30` protects the
> instance just as well**: thirty seconds of traffic, however much of it there
> is, collapses to ONE origin request. Burst absorption is what a CDN does here,
> and the long windows were never buying more of it — only more staleness.

`max-age=0, must-revalidate` puts the browser on the ETag: every view asks, and
an unchanged response returns **304 with no body**. That is what makes an
editor's own reload show their change rather than serving them their own stale
copy, and it costs a round trip rather than a payload.

**`/bodies` and `/national-teams` carry no `stale-while-revalidate` at all.**
They are the two endpoints that contain people; Firebase Hosting has no
purge-by-key API, so a withdrawn photograph has to expire on its own. The
`Surrogate-Key` headers stay — they cost nothing and become useful the day
something can consume them.

**`Vary: Accept-Language`** on every response (§1.6). Forgetting it is the
subtlest bug available here.

**Nothing in the app's own API (`/api/v0/**`) is cached**, which is why an admin
sees an edit immediately in the console. Only this public contract carries TTLs.

### 5.1a CORS — open here, and only here

Every response under `/api/v0/public/` carries:

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
Access-Control-Allow-Headers: Accept-Language, If-None-Match, Content-Type
Access-Control-Expose-Headers: ETag, Cache-Control, Vary
Access-Control-Max-Age: 86400
```

Safe to open to every origin because these endpoints are **unauthenticated,
read-only and carry no cookies** — there is no credential for a hostile page to
ride on. The application's own `/api/v0/**` keeps its `CORS_ORIGINS` allowlist,
which is why this is a separate middleware rather than a widened list: somebody
building their organisation's site on their own domain must be able to call the
public API, and must still not be able to call the admin one.

Two details that are easy to get wrong:

- **A literal `*`, not the caller's origin echoed back.** Echoing makes the
  response vary by `Origin`, so a CDN caches one copy *per calling site*. A
  constant keeps one copy for everyone, which matters rather a lot in front of a
  single Cloud Run instance.
- **`If-None-Match` is listed, and `ETag` is exposed.** Neither is
  CORS-safelisted. Without the first a cross-origin caller cannot revalidate;
  without the second its JavaScript cannot read the ETag it is meant to send
  back. Miss either and every request re-sends the whole body while looking like
  it works.

Preflights are answered by the public middleware itself, because a third-party
origin is not on the app's allowlist and would otherwise be rejected before
reaching a route.

### 5.2 What is never cached

A 429 and a 503 carry `Cache-Control: no-store`. Caching a rate-limit response
would extend one caller's limit to everyone sharing the CDN node.

### 5.3 Purge keys

Every response is tagged with the keys that could invalidate it (a `Cache-Tag` /
`Surrogate-Key` header, depending on the CDN chosen in §5.5):

| Key | Tagged on | Purged when |
|---|---|---|
| `org:{slug}` | every response about that organisation | the organisation changes |
| `org:{slug}:affiliates` | `/affiliates` | any qualification it issued changes |
| `org:{slug}:events` | `/events` | an event or an `event_organisations` row changes |
| `org:{slug}:bodies` | `/bodies` | a body or a current membership changes |
| `org:{slug}:teams` | `/national-teams` | a team or its roster changes |
| `org:{slug}:documents` | `/documents` | a document changes |
| **`person:{uuid}`** | **every response containing that person** | **any `consent_events` row for them** |

`person:{uuid}` is the one that matters and the one a TTL cannot replace. It is
tagged using the person's UUID even though that UUID is **never in the response
body** (§4.5) — the key is a header for the CDN, not data for the consumer.

**A withdrawal purges synchronously, before `POST /auth/consent` returns.** A
consent withdrawal that is eventually consistent is a consent withdrawal that
has not happened yet.

### 5.4 Rate limits

| Scope | Limit |
|---|---|
| Default, per IP | 60 requests / minute |
| Person-bearing (`/bodies`, `/national-teams`), per IP | 20 requests / minute |
| Burst | 10 |

429 carries `Retry-After` in seconds and `Cache-Control: no-store`.

Counted at the edge where possible. An application-level limiter behind a single
Cloud Run instance still spends that instance's capacity deciding to refuse, so
it is a backstop rather than the mechanism.

### 5.5 Infrastructure — proposed, not applied

Infra is a hard stop. **This is a proposal for a separate approval**, and nothing
in B2 applies it:

- A CDN in front of `api.surfpoland.com`. The obvious candidate is **Cloud CDN
  behind a global external HTTPS load balancer**, since the project is already on
  GCP and Cloud Run is a supported backend. Cloudflare in front of the existing
  Cloud Run URL is the cheaper alternative and would also give edge rate
  limiting.
- A DNS record for `api.surfpoland.com`.
- Terraform for both, in `infra/app/`.
- Whatever the chosen CDN calls a surrogate-key purge, plus a service account
  permitted to call it.

Until that exists, the `Cache-Control` headers are still correct and still
honoured by the WordPress plugin's transient cache (B3), which is a second line
of defence and part of why B3 fetches server-side.

---

## 6. Verification

From the brief's own list, as executable checks:

```bash
curl -i "$API/api/v0/public/orgs/polski-zwiazek-surfingu"      # 200, Cache-Control, ETag, Vary
curl -i "$API/api/v0/public/orgs/nie-ma-takiego"               # 404, error: ORGANISATION_NOT_FOUND
curl -i "$API/api/v0/public/orgs/<old-slug>"                   # 301 -> canonical
curl -i -L "$API/api/v0/public/orgs/<old-slug>"                # exactly one hop
curl -i -H "If-None-Match: <etag>" "$API/api/v0/public/orgs/x" # 304
for i in $(seq 1 80); do curl -s -o /dev/null -w '%{http_code} ' "$API/..."; done  # 429 + Retry-After
```

Plus two checks that are not curl:

- **Diff the generated OpenAPI spec against §2 and §4.** Any drift is a bug in
  one of the two, and which one is a decision, not an assumption.
- **Read `app/routers/public/models.py` against §4.** No field may appear there
  that is not in this document. This is the review that makes §1.2 true.
