# Satellite registry API

**Status:** in production (v0.11.0)
**Lives at:** `/api/registry/*` (router `app/routers/registry.py`, service
`app/services/registry.py`; session-gate bypass in
`app/services/auth/middleware.py`)
**Spec:** new (R2 suite-integration slice) — no founding-spec parity bar.
Contract: [`../SATELLITE-CONTRACT.md`](../SATELLITE-CONTRACT.md); rationale:
[`../decisions.md` ADR #014](../decisions.md).

## Summary

The public edge by which other Bowden Works suite apps ("satellites" —
`hosting` first) resolve, search, and create the People/Organizations and
Projects that `with` owns. A satellite stores an opaque `party_id` and
reads its display label / reference facts here, and creates new
organizations through the ONE write endpoint. Server-to-server only,
guarded by a Bearer service token.

## Why

2026-07-10 — Standing up `hosting.bowden.works` (the sync rebuild) forced
the question "what does a satellite link to — a client or a project?" The
answer (SATELLITE-CONTRACT.md): the owner **party** (`party_id`), resolved
through a small registry API on the hub. ADR #012/#013 shipped the entity
model that makes party the stable cross-suite anchor; this feature is the
API over it that satellites actually call.

## Behavior

Five endpoints (all under `/api/registry/`, all requiring the service
token). The API vocabulary is **person / organization**; the DB
discriminator `parties.kind` is **person / org** and is mapped at every
boundary so it never leaks.

- **`GET /parties/{id}`** — resolve an opaque party id →
  `{id, kind, name, is_active}`; `404 PARTY_NOT_FOUND` if unknown.
  `is_active:false` = archived/merged in `with` (keep the link, flag it).
- **`GET /parties?query=&kind=&limit=`** — typeahead. Case-insensitive
  substring on name; `kind` optional (`organization`|`person`); results
  order exact-lower-name-match first, then name asc; `limit` default 20,
  hard cap 50. `{results:[…]}`.
- **`POST /organizations`** — the ONE write. `body {name, allow_duplicate?}`
  → `201 {id, kind:"organization", name, is_active:true, near_matches:[…]}`.
  Creates a party anchor + its `organizations` detail row atomically via
  the `create_org_party` choke point (slug from the shared allocator).
- **`GET /projects/{id}`** — `{id, name, client_party_id, tenant_id}`;
  `404 PROJECT_NOT_FOUND`. For the rare genuinely project-scoped record.
- **`GET /organizations/{id}/reference`** — the shared client reference
  facts off the org detail row: `{party_id, drive_folder_url,
  mosiah_folder}`; `404 ORG_NOT_FOUND` if the id is not an org party.

## Constraints & edge cases

- **Auth (ADR #014).** `Authorization: Bearer <token>` vs
  `settings.registry_service_token`, timing-safe (`hmac.compare_digest`),
  never logged/echoed. Unconfigured ⇒ `503 REGISTRY_DISABLED` (fail
  closed); missing/malformed ⇒ `401 REGISTRY_AUTH_REQUIRED`; wrong ⇒
  `401 REGISTRY_AUTH_INVALID`. A `with` app-session cookie is deliberately
  NOT accepted — the token is the sole credential.
- **Session-gate bypass.** `is_public_path` lets `REGISTRY_PREFIX` past the
  default-deny SESSION middleware so the router-level token check is the
  only gate; kept out of `ALLOWLIST_EXACT` (the browser public set) on
  purpose. The route-auth sweep asserts every registry route stays
  token-gated.
- **Create dedup.** ACTIVE same-name (case-insensitive) org ⇒ `409
  ORG_EXISTS` carrying `existing`; overridable with `allow_duplicate:true`.
  INACTIVE same-name orgs do NOT block (archived names free up). A `201`
  carries `near_matches` (≤5 similar ACTIVE orgs, substring either
  direction, excluding self) as a soft link-don't-duplicate hint.
- **Name validation.** Trimmed, non-empty (`422 ORG_NAME_REQUIRED`), ≤200
  chars (`422 ORG_NAME_TOO_LONG`).
- **Search safety.** LIKE metacharacters (`% _ \`) in `query` are escaped,
  so a query of `%` matches the literal char, not every row. `limit` is
  clamped to `[1, 50]`; a non-integer `limit` ⇒ `422 BAD_LIMIT`; an
  unknown `kind` ⇒ `422 BAD_KIND`.
- **Opaque ids.** A malformed UUID in a path is treated as "unknown" — a
  clean `404` in that endpoint's vocabulary, never a `500` or a parser
  leak (ids are opaque to satellites).
- **Suite-global, audited write.** Entities are not tenant-scoped (no
  "whose books?"). The create writes an audit row `registry.create_org`
  with NULL tenant + NULL actor (a registry call has neither), riding the
  same transaction as the party/detail insert (atomic).

## Permissions

Not part of the app's user-capability grid — there is no `Actor`. Access is
all-or-nothing on the shared service token. Reads and the one write are
equally gated; the write is additionally audited. A satellite is trusted as
a whole once it holds the token; per-satellite scoping is a future
refinement (ADR #014).

## Open considerations

- **One token for all satellites.** Revocation is token rotation (cuts off
  everyone at once). Per-satellite credentials / scopes are deferred until
  there is more than one consumer or a need to revoke one independently.
- **No timed auto-disable.** Unlike the id-auth site gate, there is no
  break-glass timer — the token is simply present or rotated.
- **Search returns archived orgs too** (flagged `is_active:false`) so a
  satellite can still resolve/inspect a merged entity; a future `active
  only` flag could trim typeahead noise if asked for.
- **Reference facts accrete.** `drive_folder_url` / `mosiah_folder` are the
  two shipped org-detail facts; new shared facts are additive fields on the
  reference payload (absent = not set).

## Tests

`tests/registry/` (37 tests). Accreted IDs (new feature — recorded here):

- T-REG-001 — 503 when the token is unset (fail closed).
- T-REG-002 — 401 on missing token, wrong token, and non-Bearer scheme.
- T-REG-003 — a valid app session with NO bearer still gets 401 (session
  never substitutes for the token); error body never echoes the token.
- T-REG-004 — resolve 200 with org→"organization" / person→"person" kind
  mapping; `is_active:false` for an archived party.
- T-REG-005 — resolve 404 (unknown) and clean 404 (invalid UUID).
- T-REG-006 — search substring (case-insensitive), exact-match-first order,
  kind filter (person / organization), invalid kind → 422.
- T-REG-007 — search escapes LIKE wildcards (`%`, `_` match nothing);
  limit cap at 50 and limit respected.
- T-REG-008 — create 201 via the choke point (party + `organizations`
  detail row + slug), audit row `registry.create_org` (NULL tenant/actor),
  slug uniqueness on duplicate names.
- T-REG-009 — create 409 `ORG_EXISTS` with `existing` payload (exact +
  case-insensitive); `allow_duplicate:true` → 201; inactive same-name does
  not block; `near_matches` populated for similar names.
- T-REG-010 — create name validation (blank → 422, >200 → 422) and token
  required on the write.
- T-REG-011 — project 200 / 404 / invalid-uuid.
- T-REG-012 — org reference 200 (folders), null fields when unset, 404 for
  a person id, 404 for unknown.
- Security sweep (`tests/security/test_route_auth_sweep.py`) extended to
  assert every `/api/registry/*` route is token-gated, not session-gated.

## Changelog

- 2026-07-10 (v0.11.0) — Feature shipped: five registry endpoints, Bearer
  service-token auth (fail-closed), the create dedup/near-matches
  refinements, session-gate bypass. ADR #014.
