diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5e3911e..59e968e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -25,11 +25,14 @@ at a padel court. The device:
 ### Current development priority
 
 The platform's foundational layers (auth, multi-tenancy, device orchestration, k8s infra)
-are comparatively mature. **Right now the focus is finishing the streaming pipeline
-end-to-end** (camera/video source → MediaMTX → viewer playback in `frontend-landing`) and
-**polishing the deployment/telemetry story**. Computer-vision scoring (`neural-court`) and
-the stats pipeline (`sink`) are the next major phase once streaming e2e is solid. See
-[ROADMAP.md](ROADMAP.md) for specifics.
+are mature, and the streaming pipeline end-to-end (camera/video source → MediaMTX →
+viewer playback in `frontend-landing`) is shipped. Since then, `sink` has grown from a
+scaffold into the platform's real-time compositor/capturer/overlay/recording hub, and now
+owns MediaMTX auth (moved off `orchestrator` — see §4). `frontend-admin` is building a
+"Studio" UI on top of it (in progress). Computer-vision scoring (`neural-court`) and the
+match/stats distribution layer (still not built in `sink`, a separate concern from its
+now-mature streaming infra) remain the next major phase. See [ROADMAP.md](ROADMAP.md) for
+specifics.
 
 ## 2. Repository Map
 
@@ -42,7 +45,7 @@ the stats pipeline (`sink`) are the next major phase once streaming e2e is solid
 | `orchestrator` | Python/Django/Celery/Ansible | k8s (cloud) | Mature — device/installation/stream/component management |
 | `edge-controller` | Python/Django/Celery | k8s (cloud, **local dev only** — plays sample video) **and** edge device (RPi5, via Ansible) | Working, less battle-tested |
 | `bluespot` | Python/Django + BLE (bleak/bless) | Edge device (RPi5) only, via Ansible/rpi5 image | Working, narrow scope |
-| `sink` | Python/Django/Channels/Celery | k8s (cloud) — **not yet deployed** (no k8s manifest) | Early scaffold (~10%) |
+| `sink` | Python/Django/Channels/Celery | k8s, **local dev only** (`plevion-k8s` manifests exist on an unmerged branch, not on `plevion-k8s` main) | Mature — owns MediaMTX auth, compositor/capturer/overlay/recording; match/stats distribution not yet built |
 | `neural-court` | Python/PyTorch/Ultralytics/DeepStream | GPU edge or cloud (TBD) | Phase 2 underway — vision_module (train/validate_yolo.py), data-prep tools, Label Studio |
 | `frontend-landing` | Next.js/React/Tailwind | k8s (cloud) | Landing + login + live HLS viewer (`app/live/[installationId]/[streamId]/`, hls.js) |
 | `frontend-admin` | Next.js/React/Tailwind | k8s (cloud) | Most-built frontend — accounts, permissions, controllers CRUD |
@@ -57,19 +60,28 @@ the stats pipeline (`sink`) are the next major phase once streaming e2e is solid
 ┌─────────────────────────────────────────────────────────────────────────┐
 │ CLOUD (Kubernetes — see plevion-k8s)                                      │
 │                                                                            │
-│  ┌────────┐   ┌──────────────┐   ┌────────────┐   ┌───────────────────┐  │
-│  │ Kong   │──▶│ auth         │   │ orchestrator│──▶│ postgres / redis /│  │
-│  │ (GW)   │   │ (Django/DRF) │   │ (Django +   │   │ rabbitmq           │  │
-│  │        │   └──────────────┘   │  Celery     │   └───────────────────┘  │
-│  │        │                      │  workers)   │                          │
-│  │        │──▶ frontend-landing  └──────┬──────┘                          │
-│  │        │──▶ frontend-admin           │ Celery (per-controller queue)   │
-│  │        │                             │ + Ansible (SSH/Tailscale)        │
-│  │        │──▶ mediamtx + authmtx ◀─────┘ (auth hook on publish)          │
-│  │        │      (RTSP/RTMP/WebRTC)                                       │
-│  │        │──▶ (sink — planned, not yet deployed)                         │
-│  └────────┘                                                               │
-│  monitoring: otel-collector → prometheus / grafana / loki / tempo         │
+│  ┌────────┐   ┌──────────────┐   ┌─────────────┐   ┌──────────────────┐  │
+│  │ Kong   │──▶│ auth         │   │ orchestrator │──▶│ postgres / redis/│  │
+│  │ (GW)   │   │ (Django/DRF) │   │ (Django +    │   │ rabbitmq          │  │
+│  │        │   └──────────────┘   │  Celery      │   └──────────────────┘  │
+│  │        │                      │  workers —   │                        │
+│  │        │──▶ frontend-landing  │  zero MediaMTX│                       │
+│  │        │──▶ frontend-admin    │  awareness)   │                       │
+│  │        │                      └──────┬────────┘                       │
+│  │        │                             │ Celery (per-controller queue)  │
+│  │        │                             │ + Ansible (SSH/Tailscale)      │
+│  │        │──▶ sink                     │                                │
+│  │        │    (Django/Channels/Celery  │                                │
+│  │        │     — owns MediaMTX auth,   │                                │
+│  │        │     compositor, capturer,   │                                │
+│  │        │     overlays, recording)    │                                │
+│  │        │      ▲                      │                                │
+│  │        │──▶ mediamtx + authmtx       │                                │
+│  │        │      (RTSP/RTMP/WebRTC —    │                                │
+│  │        │       auth/ready/disconnect │                                │
+│  │        │       hooks call sink)      │                                │
+│  └────────┘                             │                                │
+│  monitoring: otel-collector → prometheus / grafana / loki / tempo        │
 └─────────────────────────────────────────────────────────────────────────┘
                      ▲                                  ▲
                      │ Tailscale VPN (SSH/Celery)       │ RTSP publish
@@ -89,6 +101,9 @@ the stats pipeline (`sink`) are the next major phase once streaming e2e is solid
 └────────────────────────────────────────────────────────────────────────┘
 ```
 
+Note: `orchestrator` retains a Celery-based controller-health monitor that relays
+disconnect/health signals to `sink` via CQRS as a double-check fallback — see §4.
+
 **Note on local dev**: `plevion-k8s` runs `edge-controller` *inside* a local **microk8s**
 cluster (chosen over KinD as a better fit for Ubuntu hosts) as a stand-in for the edge
 device, configured to play a looped sample video instead of reading `/dev/video0`. This
@@ -110,12 +125,20 @@ Single source of truth for identity. Django app exposing `/public/v1/auth/*`:
   (`tac` claim → see [CONTRACTS.md](CONTRACTS.md)).
 - **CQRS publisher**: publishes `User`/`Account`/`Session`/`Permission*` changes to
   RabbitMQ (`auth_cqrs`) for other services to replicate.
-- Also serves as the **authorizer** Kong and authmtx call into.
+- Also serves as the **authorizer** Kong calls into to resolve/mint the JWT for every
+  `/public/v1/*` request, including the ones `authmtx` triggers (see below) — Kong is the
+  only direct caller of `auth`'s authorizer endpoint.
 
 ### authmtx (`../authmtx/CLAUDE.md`)
-Minimal Go sidecar deployed next to MediaMTX. Extracts the `tac` claim from a stream's JWT
-(query param or token field), and calls `auth`'s
-`/public/v1/auth/mediamxt/?auth-code={tac}` to allow/deny publish or play.
+Minimal Go sidecar deployed next to MediaMTX, invoked via MediaMTX's `runOnPublish`/
+`runOnRead`/`runOnReady`/`runOnNotReady` hooks. Extracts the `tac` claim from a stream's
+JWT (query param or token field) and calls through Kong (`/public/v1/sink/`) to `sink`'s
+`sources/{source_id}/{auth,ready,disconnected}/` to allow/deny publish or play and relay
+ready/disconnect state. Kong's `plevion-auth` plugin still resolves/validates the
+auth-code against `auth` on the way through (see above) — `authmtx` itself never calls
+`auth` directly. `authmtx` is namespace-dumb: it always calls the same flat `sources/`
+resource regardless of which MediaMTX path the connection came in on; `sink` resolves the
+real underlying object from the code token itself, not the URL.
 
 ### kong (`../kong/CLAUDE.md`)
 API gateway (declarative config, DB-less). All `/public/v1/*` traffic enters here. Custom
@@ -127,15 +150,18 @@ calls `auth`'s authorizer endpoint, and forwards the returned JWT as `Plevion-Au
 The control plane / "fleet management" service. Exposes `/public/v1/orchestrator/*`:
 - **Controller** = one physical Bluespot/RPi5 device (serial, model, online status).
 - **Installation** = a court (belongs to an `Account`), has one `Controller`.
-- **Stream** = a broadcast session at an installation (pending → authorized → started →
-  finished/failed), with per-user `StreamActorCapabilities` (viewer/participant
-  permissions: recording, analytics, scoreboard, etc.).
+- **Stream** = a broadcast session at an installation (`pending → starting →
+  transmitting → disconnected/finished/failed`), with per-user
+  `StreamActorCapabilities` (viewer/participant permissions: recording, analytics,
+  scoreboard, etc.).
 - **Component / ComponentVersion / ComponentDeploymentRecord** = catalog of deployable
   edge software (e.g. `edge-controller`, `bluespot`) and their versioned rollout to
   controllers via **Ansible** (`apps/components/ansible/`) over SSH/Tailscale.
 - Talks to `edge-controller` via **Celery** over per-controller queues
-  (`{controller_id}_celery`), and to **MediaMTX** via an HTTP auth hook
-  (`/public/v1/orchestrator/streams/auth/mediamtx/`).
+  (`{controller_id}_celery`). **Has zero MediaMTX awareness** — the auth hook it used
+  to expose was removed; `sink` owns MediaMTX auth now (see below). Orchestrator's own
+  controller-health monitor still relays disconnect/health signals to `sink` via CQRS
+  as a double-check fallback.
 - CQRS publisher for `Installation`, `Stream`, `StreamActorCapabilities`, `Connection`, and
   CQRS *replica* for `User`/`Account` (consumed from `auth`).
 
@@ -157,11 +183,37 @@ reachable over Tailscale and `orchestrator` deploys `edge-controller` (and itsel
 updates) via Ansible/SSH.
 
 ### sink (`../sink/CLAUDE.md`)
-Intended to be the **real-time event/stats hub**: ingest score/event data (from
-`neural-court` eventually, or manual entry in the meantime) and push live updates to
-viewers over WebSocket (Django Channels). Currently a near-empty scaffold — Django apps,
-CQRS/Channels/Celery wiring exist, but **no models, no API endpoints, no WebSocket
-handlers beyond an echo consumer**. Not yet deployed to k8s.
+Grew from an early scaffold into the platform's real-time streaming hub. Now **owns
+MediaMTX auth** (moved off `orchestrator` — the `apps.sources` `SourceViewSet` handles
+MediaMTX's `auth`/`ready`/`disconnected` hooks at `sources/{source_id}/...`, resolving
+whether the underlying object is a `Stream` (camera), `Composition` (compositor live
+output), or `CompositionSource` (interactive overlay) from the code token's own
+object-type claim — deployed via Kong's `/public/v1/sink/` route; `authmtx` calls in
+namespace-dumb). Ten Django apps: `authentication`, `common`, `replications`, `events`,
+`assets`, `compositor`, `overlays`, `sources`, `capturer`, `recording`. Owns:
+- **`compositor`** — the GStreamer composition pipeline: `Composition` /
+  `CompositionSource` / `CompositionTransition`, multi-source live compositing with
+  transitions.
+- **`capturer`** — pooled Playwright/CDP browser-source capture (`CaptureSession`) for
+  interactive overlays, replacing an earlier per-overlay Xvfb+Chromium approach.
+- **`overlays`** — the overlay template/project library (`OverlayTemplate`,
+  `OverlayProject`, `AccountOverlayPlan`).
+- **`recording`** — explicit start/stop match recording (`Recording`).
+- **`assets`** — account-owned uploaded media (`BroadcastAsset`).
+- **`replications`** — CQRS read replicas of `User`/`Account`/`Installation`/`Stream`.
+
+Three WebSocket consumers (Channels): a trivial echo/ack (`SinkEventsConsumer`), a
+`Composition` phase/state broadcaster (`BroadcastStateConsumer`), and an overlay
+render-state broadcaster for anonymous browser-source/OBS viewers
+(`OverlayStateConsumer`). Runs in the local dev k8s cluster (`services/sink/` in
+`plevion-k8s`, including a dedicated `sink-capturer-worker`) — but as of 2026-07-27 that
+manifest set only exists on `plevion-k8s`'s unmerged `feat/sink-capturer-worker` branch,
+not on `plevion-k8s` main (see Phase 4 of `PLAN_DOCS_DRIFT_UPDATE.md`).
+
+**Still not built**: the match/game-events and score/stats domain (ingesting
+`neural-court` output, deriving live score, pushing scoreboard updates) — see §5.3 and
+[ROADMAP.md](ROADMAP.md) Priority 3. This is a distinct concern from the
+streaming/compositor infrastructure above, which is the part that matured.
 
 ### neural-court (`../neural-court/CLAUDE.md`)
 The computer-vision scorer. Designed around:
@@ -228,10 +280,19 @@ pin different versions — see `node/CLAUDE.md` for details.
 3. `edge-controller` starts a stream container (Docker or k8s Pod). The container fetches
    publish auth from `orchestrator`'s `/controllers/{id}/auth/` and publishes RTSP to
    MediaMTX.
-4. MediaMTX calls `authmtx` → Kong → `orchestrator`'s
-   `/public/v1/orchestrator/installations/{id}/streams/{id}/auth/?auth-code={tac}`
-   to authorize the publish (no separate auth endpoint — Kong's `plevion-auth` plugin
-   validates the CodeToken inline). `Stream` status becomes `authorized` then `started`.
+4. MediaMTX calls `authmtx` → Kong (`/public/v1/sink/`) → `sink`'s
+   `sources/{source_id}/auth/` to authorize the publish (Kong's `plevion-auth` plugin
+   validates the CodeToken inline; `sink` resolves the underlying `Stream` from the
+   token's object-type claim, not the URL). MediaMTX's `ready`/`disconnected` hooks call
+   the sibling `sources/{source_id}/ready/` and `.../disconnected/` endpoints; `sink`
+   dispatches Celery tasks (`notify_stream_starting`/`notify_stream_transmitting`/
+   `notify_stream_disconnected`) that `orchestrator` consumes to update `Stream.status`
+   (`pending → starting → transmitting → disconnected/finished/failed`) — this is
+   `orchestrator`'s only MediaMTX-facing signal, it has no HTTP surface for it.
+   `orchestrator`'s own controller-health monitor separately relays
+   controller-offline signals to `sink` over CQRS, as a double-check fallback in the
+   other direction (if a controller drops, `sink` marks its streams disconnected even
+   if MediaMTX's `runOnNotReady` never fires).
 5. `frontend-landing`'s live viewer (`app/live/[installationId]/[streamId]/`) plays the
    HLS output via `hls.js`. See [ROADMAP.md](ROADMAP.md) for remaining items.
 
@@ -241,8 +302,9 @@ pin different versions — see `node/CLAUDE.md` for details.
 2. `scoring_service` (in `neural-court`, not yet implemented) consumes those events,
    maintains a scoring state machine, and reports match state to `sink` and/or
    `orchestrator`.
-3. `sink` (once implemented) broadcasts score/stat updates over WebSocket to subscribed
-   viewers in `frontend-landing`.
+3. `sink`'s WebSocket infrastructure already exists (Channels consumers, see §4) — once
+   its match/stats data layer is built, it would broadcast score/stat updates over
+   WebSocket to subscribed viewers in `frontend-landing`.
 
 ## 6. Cross-Cutting Concerns
 
diff --git a/CONTRACTS.md b/CONTRACTS.md
index 163ad9f..b700add 100644
--- a/CONTRACTS.md
+++ b/CONTRACTS.md
@@ -9,10 +9,15 @@ almost certainly changing a contract — check every consumer listed below.
 
 ### Django service view conventions (applies to all Python services)
 
-- Views are **dummy**: no business logic in view methods — not in `perform_create`,
-  `perform_update`, `perform_destroy`, or any method that modifies data.
-- `perform_create` / `perform_update` / `perform_destroy` must call a **service function**
-  from `apps/{app}/services/` and return a serializer wrapping the result. Example:
+- Views are **dummy**: no business logic in view methods, anywhere, ever — not in
+  `perform_create`, `perform_update`, `perform_destroy`, not in a custom `@action`, not
+  inline in `create()`/`update()`. **All business logic lives in `apps/{app}/services/`**
+  — every app in every Python service has this folder; if you're writing logic that
+  isn't there, it's in the wrong place.
+- `perform_create` / `perform_update` / `perform_destroy` (or a custom `@action`'s body)
+  must call a **service function** from `apps/{app}/services/` and return a serializer
+  wrapping the result — this is the *only* place a view is allowed to call into
+  business logic. Example:
   ```python
   def perform_create(self, serializer):
       obj = create_thing(account_id=self.request.auth.am.a.id, **serializer.validated_data)
@@ -20,11 +25,69 @@ almost certainly changing a contract — check every consumer listed below.
   ```
 - Serializers handle **validation only** — their `create()`/`update()` methods should not
   contain business logic; delegate to services.
-- This mirrors the `CreateModelMixin` in `apps/common/api/views.py` where `perform_create`
-  returns the serializer and `create()` builds the HTTP response.
-- Every ViewSet that has non-trivial permission requirements must use `ActionPermissionsMixin`
-  with `action_permission_classes = {Action.DEFAULT: (...), Action.LIST: (...), ...}`.
-  The default is `IsAccountUserContext` for most endpoints; override per action when needed.
+- **Always build ViewSets from `apps/common/api/views.py`'s shared mixins** (present,
+  byte-for-byte identical pattern, in `auth`/`orchestrator`/`sink` — verified
+  2026-07-27) rather than DRF's own mixins or hand-rolled `create()`/`update()`:
+  - **`ActionPermissionsMixin`** — `action_permission_classes = {Action.DEFAULT: (...),
+    Action.LIST: (...), ...}`, keyed by `apps.common.api.dataclasses.Action`. Default is
+    typically `IsAccountUserContext`; override per action when needed. Every ViewSet
+    with non-trivial permission requirements must use this rather than a flat
+    `permission_classes` list.
+  - **`CreateModelMixin`** — wraps DRF's, but `perform_create` **returns the
+    serializer** (rather than DRF's default of returning `None`) so `create()` can
+    build the response from it; this is the shape every app's `perform_create` above
+    must match.
+  - **`OnlyPutUpdateModelMixin`** — resource updates are **PUT-only**. `partial_update`
+    (`PATCH`) is explicitly disabled (`http_method_not_allowed`, `@extend_schema
+    (exclude=True)`) — never re-enable `PATCH` on a ViewSet that uses this mixin, and
+    never build a new update endpoint that supports `PATCH` instead of using this
+    mixin.
+  - `GetObjectWithMessageMixin` (`auth`/`orchestrator` only, not yet in `sink`) —
+    returns a real error message instead of DRF's empty-message `Http404` on a bad
+    `get_object()` lookup.
+- **User-facing API errors go through the Plevion error system, not a bare DRF
+  `ValidationError`**: every app defines its own error class in `apps/{app}/errors.py`
+  subclassing `apps.common.exceptions.base_exceptions.PlevionBaseException` (a
+  metaclass-driven registry — `PREFIX` + a numbered `ERRORS` dict), raised as
+  `SomeAppError.PREFIX_NNN(format_kwargs={...})` — e.g. `ControllerError.
+  CONTROLLER_002(format_kwargs={'controller_id': ..., 'stream_id': ...})`. This is
+  wired to its own exception handler (`PlevionAPIErrorHandler`) that surfaces the
+  specific `error_code` in the response; a raw DRF `serializers.ValidationError` gets
+  bucketed into the generic `VAL_001` code instead, which loses the specific,
+  documented error identity callers need to handle it programmatically. **Exception**:
+  DRF `ValidationError` remains correct for what it's actually for — pure serializer
+  field-level validation (wrong type, missing required field) — the rule is about
+  *business-rule* violations surfaced from the service layer, which must use a real
+  `PlevionBaseException` subclass instead of `raise ValidationError('some string')`.
+  This shared `apps/common/exceptions/` module (`base_exceptions.py`, `handlers.py`,
+  `router.py`) is itself identical across `auth`/`orchestrator`/`sink` — verified
+  2026-07-27.
+- **Webhook/auth endpoints are a real ViewSet with named `@action`s on the actual
+  resource — never a bare `APIView`, and never a fake queryset-less viewset keyed by a
+  generic string id.** Learned the hard way during source-auth-unification; verified
+  2026-07-27 with no violations across `auth`/`orchestrator`/`sink`
+  (`git grep "APIView" -- '*/api/views.py'` is empty in all three). Examples:
+  `sink`'s `SourceViewSet.auth/ready/disconnected` (§2), `orchestrator`'s
+  `StreamViewSet` actions, `sink`'s `CompositionViewSet.auth`.
+- **`TextChoices` members are always a parenthesized `(value, label)` tuple** —
+  `name = ('value', 'Label')`, never a bare `name = 'value'` or an unparenthesized
+  comma-tuple. Verified 2026-07-27, no violations found across `auth`/`orchestrator`/
+  `sink`'s `choices.py` files.
+- **Quote style**: user-facing strings (error messages, UI copy) double-quoted and
+  capitalized; internal strings (dict keys, log messages, enum values) single-quoted.
+  Ruff's `quote-style = "preserve"` does **not** enforce this — it's a convention, not
+  a lint gate, so drift is possible and not auto-caught.
+- **No meta-comments in code**: never reference PR numbers, spec section numbers, or
+  internal task codes (e.g. `SRC-AUTH.5`) in code comments — only durable "why"
+  documentation that still makes sense after the PR/task is forgotten. **Violation
+  found 2026-07-27**: `auth/src/apps/tokens/choices.py:17` and
+  `tests/tokens/api/test_views.py:68` reference `SRC-AUTH.5/.7` task codes directly.
+  Not fixed as part of this docs pass (app-code change, needs its own PR) — flagged
+  here as a small cleanup follow-up.
+- **Migrations (pre-launch services only)**: squash to a single `0001_initial.py` per
+  app rather than accumulating numbered migrations — verified current practice in
+  `auth`/`orchestrator`/`sink` (every app has exactly one migration file). Stop doing
+  this once a service is actually deployed to production with real data.
 
 - All public traffic enters through **Kong** (`kong/`) and is namespaced
   `/public/v1/{service}/...` (e.g. `/public/v1/auth/...`,
@@ -46,16 +109,20 @@ almost certainly changing a contract — check every consumer listed below.
 
 ## 2. JWT & Auth-Code (`tac`) Flow
 
+**This section describes the state as of 2026-07-27, post "source auth unification":
+`sink` — not `orchestrator` — is the service `authmtx` calls for every MediaMTX
+auth/ready/disconnect decision. `orchestrator` has zero MediaMTX awareness now; it
+learns stream state only via Celery tasks `sink` dispatches (§3).**
+
 ### JWT structure (issued by `auth`, `apps.authentication.context.JWTAuthContext.build_jwt()`)
 
 Standard claims: `jti`, `iss`, `aud`, `iat`, `exp`.
 
 Custom compact claims:
-- `t` — token type. Three values from `apps.authentication.dataclasses.AuthType`
-  (verified in source): `m` = module/service-to-service, `u` = user session, `w` = code
-  token (used for streaming and invitation flows). **Note**: the streaming JWT issued by
-  `generate_streaming_service_jwt_token()` does **not** use these claims — it is a
-  separate payload with `tid`, `tac`, `oid`, `ot`, `p` (see below).
+- `t` — token type. Three values from `apps.authentication.dataclasses.AuthType`: `m` =
+  module/service-to-service, `u` = user session, `w` = code token (used for streaming
+  and invitation flows). **Note**: the streaming JWT issued by
+  `generate_streaming_service_jwt_token()` does **not** use these claims — see below.
 - `u` — actor object, includes `u.o` (the acting user/org id).
 - `pg` — always `[]` in the propagated JWT. Permission validation happens at Kong (via the
   auth check endpoint) before the request reaches any downstream service; downstream
@@ -64,104 +131,148 @@ Custom compact claims:
 - `tac` — **auth-code** claim, present only on **streaming tokens**. Its value is a
   `CodeToken.id`. This is the claim `authmtx` extracts.
 
+**Streaming JWT payload** (`generate_streaming_service_jwt_token()`): only `tac`
+(`CodeToken.code`) and `p` (`CodeToken.purpose`) — `jti`/`tid`/`oid`/`ot` are
+deliberately dropped (SRT has a hard 512-char streamid limit, and none of those claims
+are read downstream). No `t`/`u`/`pg`/`am` claims either.
+
+### CodeToken vocabulary (`auth/src/apps/tokens/choices.py`)
+
+`object_type` + `purpose` together determine what a code token authorizes — see the
+namespace/purpose matrix below. Current values relevant to streaming:
+
+| `object_type` | `purpose` | Who mints it, for what |
+|---|---|---|
+| `stream` | `orchestrator_stream_publish` | `orchestrator`, for the controller's own camera publish |
+| `stream` | `orchestrator_stream_read` | `sink`'s compositor, to pull a camera feed for compositing |
+| `stream` | `sink_recording_read` | `sink`'s recording app, to pull the same feed for FFmpeg |
+| `overlay_source` | `sink_overlay_publish` | `sink`'s capturer, for a browser-rendered overlay's own publish |
+| `overlay_source` | `sink_overlay_read` | `sink`'s compositor, to pull an overlay feed for compositing |
+| `composition` | `sink_compositor_read` | `sink`'s compositor's own `live/{composition_id}` publish read-back |
+| `composition` | `sink_recording_read` | `sink`'s recording app, to pull the composed output |
+| `installation` | *(viewer read, see below)* | issued for viewer access — minting path not re-traced in this pass, verify with `orchestrator`/`frontend-landing` before relying on specifics |
+
+**New pattern worth knowing**: `sink` is not just an authorizer now — it also mints its
+own code tokens as a *client*, via `apps.common.services.auth.get_object_jwt(object_id,
+object_type, purpose)`, whenever its own GStreamer pipeline needs to pull a
+camera/overlay/composition feed from MediaMTX. This is the same
+`POST auth/tokens/codes/` → `POST auth/tokens/streaming/` two-step every other minter
+uses, just called from `sink` itself rather than `orchestrator`/`edge-controller`.
+
+### MediaMTX path namespaces (`sink/src/apps/sources/services/mediamtx.py`)
+
+Every MediaMTX path is exactly `{namespace}/{object_id}` — **no `installation_id`
+segment** (removed as redundant; `sink` resolves the owning installation from the
+object itself when needed). `authmtx` is namespace-dumb: it extracts only the last path
+segment as `object_id` (`pathObjectID()`) and never inspects the namespace itself —
+`sink`'s `authorize_mediamtx_connection()` is where namespace + `object_type` + `purpose`
+actually get cross-checked:
+
+| Namespace | `object_id` is | Publish/read authorized against |
+|---|---|---|
+| `streams/{stream_id}` | `Stream.id` | `Stream` directly (no `CompositionSource` involved — a `Stream`'s MediaMTX identity is independent of any composition referencing it) |
+| `overlays/{source_id}` | `CompositionSource.id` | `CompositionSource` where `source_type=INTERACTIVE` |
+| `live/{composition_id}` | `Composition.id` | `Composition` (the compositor's own published output — shared by PREVIEW and LIVE phases, one path for both) |
+
 ### Streaming auth-code flow — publisher (implemented, working)
 
-The edge device (`edge-controller`) is the current working consumer of this flow.
-
-1. `orchestrator` creates a `CodeToken` on behalf of the controller
-   (`apps.controllers.services.auth.get_auth_code_for_controller`) and sends it to the
-   edge-controller in the `STREAM_START` Celery command message as `code`.
-2. `edge-controller` calls `orchestrator POST /controllers/{id}/auth/?auth-code={code}`
-   with body `{stream: {id: stream_id}}` (`apps.controller.services.auth.get_stream_data`).
-   Orchestrator resolves the stream's `protocol` field and returns stream connection data:
-   `{url, jwt, protocol}` (`apps.controllers.services.auth.get_stream_connection_data`).
-   - `url`: protocol-specific MediaMTX endpoint (`whip` path for WebRTC,
-     `rtsp://host:8554/path` for RTSP, `srt://host:8890?streamid=/path` for SRT).
-   - `protocol`: `webrtc` | `rtsp` | `srt` — set on the `Stream` model at creation time.
-3. `POST /public/v1/auth/tokens/streaming/` (requires `IsCodeContext` — i.e. a
-   CodeToken credential) → `auth` looks up the `CodeToken` by code, generates a
-   **streaming JWT** with: `{ tac: CodeToken.code, p: CodeToken.purpose }`.
-   `tid`/`oid`/`ot` were explicitly removed — `authmtx` only needs `tac`.
-   Note: this JWT does **not** have `t`/`u`/`pg`/`am` claims.
-4. `edge-controller` appends the JWT to the URL and launches the stream pod with
-   `STREAM_CONTAINER_RECEIVER_URL` and `STREAM_PROTOCOL` env vars. The stream pod
-   (`apps.streams.services.protocols`) publishes via the chosen protocol:
-   - `webrtc`: aiortc WHIP offer to `{url}?jwt={jwt}`.
-   - `rtsp`: ffmpeg subprocess with `-f rtsp -rtsp_transport tcp` to `{url}?jwt={jwt}`.
-   - `srt`: ffmpeg subprocess with `-f mpegts` to
-     `srt://host:8890?streamid=publish:live/{installation_id}/{stream_id}:user:{jwt}`.
-     The JWT travels in the streamid's **pass field** (`:user:JWT` suffix per the
-     MediaMTX SRT streamid format), not as a query-string param. MediaMTX exposes this
-     as `Credentials.Pass` in the auth request and as `token` in the HTTP auth JSON body,
-     but does **not** set `$MTX_QUERY` from it — see "SRT tac cache" note below.
-5. **MediaMTX → authmtx**: MediaMTX calls authmtx (POST `/auth`). `authmtx` decodes
-   the JWT **without verifying the signature** (`internal/token/token.go`) to extract
-   `tac`. It also extracts `installation_id` and `stream_id` from the last two non-empty
-   segments of the stream `path` (convention: `live/{installation_id}/{stream_id}`).
-   Paths with fewer than two non-empty segments are rejected immediately (401).
-   For SRT publish, authmtx also writes `(path → tac)` into its in-memory tac store
-   (`internal/tacstore`) for use by the runOnReady hook (see step 7a).
-6. **authmtx → Kong → orchestrator**: authmtx calls Kong at
-   `POST {base_url}/public/v1/orchestrator/installations/{installation_id}/streams/{stream_id}/auth/?auth-code={tac}`
-   with body `{ip, path, action, protocol}`. Kong's `plevion-auth` plugin transforms the
-   `?auth-code` query param into `Authorization: Apikey w:{tac}`, calls auth to validate
-   the CodeToken, and injects `Plevion-Auth: Bearer {jwt}` before forwarding to
-   orchestrator. Orchestrator's `StreamViewSet.auth` action checks stream exists in the
-   given installation (`get_object()`) then delegates to `authorize_mediamtx_connection()`
-   (publish: CodeToken type = controller, play: CodeToken type = installation).
-   Returns `200` allow, `401` deny.
-7. **MediaMTX → authmtx (runOnReady / runOnNotReady hooks)**:
-   - **7a. runOnReady** — MediaMTX fires `POST /notify/ready?path=$MTX_PATH&query=$MTX_QUERY&source_type=$MTX_SOURCE_TYPE`
-     once the publisher's stream is live. authmtx extracts the tac and calls
-     `POST orchestrator/.../start/?auth-code={tac}` to mark the stream started.
-     - For **WebRTC** (`source_type=webrtcSession`) and **RTSP** (`source_type=rtspSession`):
-       `$MTX_QUERY` carries `jwt=<JWT>` (URL-escaped); authmtx decodes the JWT inline.
-     - For **SRT** (`source_type=srtConn`): `$MTX_QUERY` is always **empty** because the
-       JWT lives in the streamid pass field, not the query segment. authmtx reads the tac
-       from the in-memory tac store written at step 5 instead.
-   - **7b. runOnNotReady** — MediaMTX fires `POST /notify/stop?path=$MTX_PATH` when the
-     stream ends. authmtx evicts the tac store entry for that path.
-     The store also applies a `DefaultTTL` (4 hours) so stale entries are evicted even
-     if the hook is missed (e.g. pod kill).
-
-### SRT tac cache — design note
+The edge device (`edge-controller`) is the current working consumer for camera publish.
+
+1. `orchestrator` mints a **stream-scoped** CodeToken (`apps.controllers.services.
+   auth.get_auth_code_for_stream_publish`, `object_type=stream`,
+   `purpose=orchestrator_stream_publish`, scoped to the specific `stream_id` — not the
+   controller, so it can't be replayed against a different stream) and sends it to
+   `edge-controller` in the `STREAM_START` Celery command message as `code`.
+2. `edge-controller` calls `orchestrator POST /controllers/{id}/auth/?auth-code={code}`.
+   Orchestrator resolves the stream's `protocol` and returns `{url, jwt, protocol}`
+   (`get_stream_connection_data`) — `jwt` here is already the streaming JWT (fetched via
+   `get_jwt_for_controller`), and `url` is `streams/{stream_id}` under the
+   protocol-appropriate MediaMTX endpoint (WHIP for `webrtc`, RTSP, or SRT).
+3. `edge-controller`'s stream container publishes via the chosen protocol
+   (`apps.streams.services.protocols`) — WebRTC: aiortc WHIP offer to `{url}?jwt={jwt}`;
+   RTSP: ffmpeg to `{url}?jwt={jwt}`; SRT: ffmpeg to
+   `srt://host:8890?streamid=publish:streams/{stream_id}:user:{jwt}` (JWT in the
+   streamid's **pass field**, not a query param — MediaMTX does **not** set `$MTX_QUERY`
+   from it, see "tac cache" note below).
+4. **MediaMTX → authmtx (`POST /auth`)**: `authmtx` extracts the JWT — `token` field
+   directly for most protocols, or falls back to parsing `?jwt=` out of the `query`
+   field for RTSP (GStreamer's Basic-auth `user-pw` arrives in the `token` field too).
+   Decodes it **without verifying the signature** (that's `auth`'s job) to get `tac`,
+   and extracts `object_id` as the **last path segment** (`pathObjectID()` — no
+   namespace inspection, no installation_id). For SRT/RTSP publish, also writes
+   `(path → tac)` into the in-memory tac store (`internal/tacstore`) for the
+   `runOnReady` hook to use (step 6).
+5. **authmtx → Kong → `sink`**: `authmtx` calls
+   `POST {base_url}{authPath}sources/{object_id}/auth/?auth-code={tac}` with body
+   `{ip, path, action, protocol}`. Kong's `plevion-auth` plugin resolves/validates the
+   auth-code against `auth` and injects `Plevion-Auth: Bearer {jwt}` before forwarding.
+   `sink`'s `SourceViewSet.auth` cross-checks the URL's `object_id` against the same id
+   embedded in the request body's `path`, then calls `authorize_mediamtx_connection()`,
+   which matches on `(namespace, object_type, purpose)` per the table above — e.g.
+   `streams/{stream_id}` + `object_type=stream` + `purpose=orchestrator_stream_publish`
+   authorizes if the `Stream` exists and its `status` is `pending` or one of the active
+   statuses. On a successful **publish** auth (not read), `sink` also dispatches
+   `notify_stream_starting` to `orchestrator` over Celery (see §3) — the first of three
+   status signals `sink` sends `orchestrator`, gated so it only fires once (`Stream`
+   must still be `pending`, since every reconnect re-runs this same webhook). Returns
+   `200` allow, `401` deny.
+6. **MediaMTX → authmtx (`runOnReady`/`runOnNotReady` hooks)**:
+   - **runOnReady** (`POST /notify/ready?path=$MTX_PATH&query=$MTX_QUERY&source_type=$MTX_SOURCE_TYPE`),
+     once the publisher is live. `authmtx` resolves the tac two ways depending on
+     `source_type`: for `srtConn`/`rtspSession` (Basic-auth-style credentials that
+     never reach `$MTX_QUERY`), reads from the tac store written in step 4; for
+     anything else (WebRTC), decodes `jwt=<JWT>` out of `$MTX_QUERY` inline. Either
+     way, calls `sink`'s `sources/{object_id}/ready/?auth-code={tac}`, which dispatches
+     `notify_stream_transmitting` to `orchestrator` (unconditionally — idempotent
+     orchestrator-side against repeats).
+   - **runOnNotReady** (`POST /notify/stop?path=$MTX_PATH`), when the connection drops.
+     `authmtx` evicts the tac store entry for that path (TTL 4h, as a backstop if this
+     hook is missed) and calls `sink`'s `sources/{object_id}/disconnected/`, which
+     dispatches `notify_stream_disconnected` to `orchestrator` — but **only** as a
+     double-check fallback: the primary disconnect path is `orchestrator`'s own
+     controller-health monitor, relayed to `sink` over CQRS; `sink` skips the Celery
+     dispatch if its own CQRS-replicated `Stream` already shows `disconnected`.
+
+### tac cache — design note
 
 The tac store (`authmtx/internal/tacstore`) bridges a gap in what MediaMTX exposes to
-hooks for SRT:
+hooks for **both SRT and RTSP** publish (not SRT-only — RTSP's Basic-auth `user-pw`
+credential has the same problem: it never reaches `$MTX_QUERY` either):
 
 | Protocol | JWT in `/auth` body | `$MTX_QUERY` for `runOnReady` |
 |---|---|---|
-| WebRTC | `token` field (URL query param) | `jwt=<JWT>` (URL-escaped) |
-| RTSP | `query` field (`?jwt=<JWT>` URL suffix) | `jwt=<JWT>` (URL-escaped) |
+| WebRTC | `token` field | `jwt=<JWT>` (URL-escaped) |
+| RTSP | `token` field (Basic-auth `user-pw` → `token`) | **empty** |
 | SRT | `token`/`password` fields (streamid pass) | **empty** |
 
-The cache is keyed by `$MTX_PATH` (`live/{installation_id}/{stream_id}`), which is
-unique per stream (stream IDs are UUIDs). Entry lifecycle:
-- **Written**: on successful SRT publish auth in `/auth`.
-- **Read**: in `/notify/ready` when `source_type=srtConn`.
-- **Deleted**: explicitly in `/notify/stop` (runOnNotReady); or lazily on expiry via TTL.
-
-Failure modes handled:
-- **runOnNotReady not fired** (pod kill, crash): TTL evicts the entry within 4 hours.
-- **runOnReady fires before auth**: impossible — MediaMTX fires runOnReady only after
-  auth succeeds, and the cache write happens synchronously in the auth handler.
-- **authmtx restart between auth and runOnReady**: extremely unlikely (gap is milliseconds);
-  if it occurs, `runOnReady` gets a 401 and the stream will not be marked started in
-  orchestrator (stream remains in `authorized` status until manually resolved or the
-  stream container stops).
-- **Same path reused**: a new auth always overwrites the cache entry, so the fresh tac
-  is always present by the time runOnReady fires.
+The cache is keyed by `$MTX_PATH` (`{namespace}/{object_id}`, unique since object IDs
+are UUIDs). Entry lifecycle: **written** on successful SRT/RTSP publish auth; **read**
+in `/notify/ready` when `source_type` is `srtConn` or `rtspSession`; **deleted**
+explicitly in `/notify/stop`, or lazily via the 4-hour TTL if that hook is missed (pod
+kill/crash). A new auth always overwrites the entry, so the fresh tac is present by the
+time `runOnReady` fires, even across reconnects.
 
 ### Streaming auth-code flow — viewer (implemented)
 
 - **Protocol**: HLS. `plevion-k8s/services/mediamtx/base/mediamtx.yml` has
   `hls: yes`, `hlsVariant: lowLatency`, `hlsSegmentDuration: 1s`, `hlsPartDuration: 200ms`.
   `hls.js` in `frontend-landing`.
-- **Implementation** (`frontend-landing/app/live/[installationId]/[streamId]/`):
-  - Authenticated: viewer calls orchestrator with `Authorization: Apikey u:{sid}` to
-    obtain the HLS URL (built from `MEDIAMTX_HLS_URL` env var).
-  - Public: `?is_public=true` in the URL bypasses user auth — access is checked against
-    the `is_public` flag on the stream/installation, not a token.
+- **Implementation** (`frontend-landing/app/live/[installationId]/[streamId]/`): both
+  modes require a `?jwt=` query param (checked for expiry client-side in `actions.ts`
+  before use, no signature check). **Authenticated** mode (no `?is_public=true`)
+  additionally requires a `sid` cookie and an `orchestrator` installation-access check
+  (`Authorization: Apikey u:{sid}`) before the JWT is even looked at. **Public** mode
+  (`?is_public=true`) skips the cookie/installation check entirely — the JWT itself
+  (and what `sink` authorizes it against, below) is the only gate. See
+  `frontend-landing/CLAUDE.md` for the full page-level flow.
+- **`sink`-side authorization** (viewer read, `authorize_mediamtx_connection`):
+  `object_type=installation`-scoped tokens authorize a `read` action against either
+  `live/{composition_id}` (must be `ACTIVE` + `phase=LIVE` — a composition still in
+  `PREVIEW` is not visible to installation-scoped viewer tokens) or
+  `streams/{stream_id}` directly (a plain camera with no composition — must belong to
+  the same `installation_id` and have an active status). Neither path is ever public at
+  the `sink` layer — `frontend-landing`'s `?is_public=true` bypass happens one layer up,
+  before the JWT/installation-scoped token is even involved.
 
 ### Kong `plevion-auth` plugin (`kong/plugins/plevion-auth/`)
 
@@ -198,11 +309,14 @@ Fields: `controller_id`, `action`, `code`, `container_id`, `context`.
 - `edge-controller` processes the command (e.g. starts/stops a stream container via
   Docker/Kubernetes SDK) and returns a `CommandResult`.
 - `orchestrator` records this against the corresponding `ControllerCommand`
-  (`delivery_status`, `result_status`) via link processors:
-  - **SRP** (success_result_processor)
-  - **SCP** (streaming_container_status_processor)
-- These processors are the place to look when a `Stream`'s status isn't transitioning as
-  expected (e.g. stuck in `authorized`, never reaching `started`).
+  (`delivery_status`, `result_status`) via link processors in
+  `apps/controllers/services/processors.py` — these handle **container/command**
+  status only (`ControllerContainer`, `ControllerCommand`), not `Stream.status`.
+- `Stream.status` transitions live in **`apps/streams/services/lifecycle.py`**
+  (`mark_stream_starting`/`mark_stream_transmitting`/`mark_stream_disconnected`/
+  `fail_stream`/`stop_stream`), driven by the Celery tasks in `apps/streams/tasks.py`
+  that `sink` dispatches (§2) — **this**, not the container-result processors, is the
+  place to look when a `Stream` gets stuck.
 
 ### Stream-start sequence (concrete)
 1. `orchestrator`: `Stream.status = pending`, send `CommandMessage(action=STREAM_START,
@@ -213,9 +327,12 @@ Fields: `controller_id`, `action`, `code`, `container_id`, `context`.
    `python manage.py stream`).
 3. Stream container fetches publish credentials from `orchestrator`
    `GET /api/orchestrator/controllers/{id}/auth/`, then publishes RTSP to MediaMTX.
-4. MediaMTX → authmtx → Kong → orchestrator auth-hook flow (§2) authorizes the publish;
-   `orchestrator` flips `Stream.status` to `authorized` then `started` via the SCP link
-   processor.
+4. MediaMTX → `authmtx` → Kong → **`sink`** auth-hook flow (§2) authorizes the publish;
+   `sink` dispatches `notify_stream_starting` (auth passed) then `notify_stream_
+   transmitting` (MediaMTX confirms live) to `orchestrator` via Celery, moving
+   `Stream.status` `pending → starting → transmitting`. `orchestrator` itself has no
+   part in this beyond consuming those two tasks — no HTTP call happens in its
+   direction at all.
 
 ## 4. CQRS / RabbitMQ Events (`django-cqrs` / `dj_cqrs`)
 
@@ -226,12 +343,16 @@ Fields: `controller_id`, `action`, `code`, `container_id`, `context`.
   likely `PermissionAssignment` — verify).
 - `orchestrator`:
   - **Consumes** `User`/`Account` replicas from `auth_cqrs`.
-  - **Publishes** `Installation`, `Stream`, `StreamActorCapabilities`, `Connection` (no
-    known consumer yet).
-- `sink` **consumes `User` events from `auth_cqrs`** (`apps.replications.User`,
-  `CQRS_ID='user'`, `ReplicaMixin` — same pattern as `orchestrator`). When built out it
-  will also need `Stream`/`Installation` replicas from `orchestrator_cqrs` to correlate
-  live events with a match/court.
+  - **Publishes** `Installation`, `Stream`, `StreamActorCapabilities`, `Connection` —
+    **`Stream`/`Installation` now have a real, load-bearing consumer: `sink`** (below),
+    which needs them to resolve MediaMTX auth decisions. `StreamActorCapabilities`/
+    `Connection` still have no known consumer.
+- `sink` **consumes `User`, `Account`, `Installation`, and `Stream`**
+  (`apps.replications.{User,Account,Installation,Stream}`, `ReplicaMixin` — `User`/
+  `Account` from `auth_cqrs`, `Installation`/`Stream` from `orchestrator_cqrs`). The
+  `Installation`/`Stream` replicas aren't speculative anymore — `sink`'s own MediaMTX
+  auth logic (§2) reads them directly to authorize `streams/{stream_id}` connections
+  and resolve viewer-read installation scoping.
 - Service registry UUIDs (from `auth/apps/permissions/registry.py`) — these UUIDs
   identify *services* as actors (e.g. for `SERVICE_TOKEN` auth, and CQRS actor
   attribution), not CQRS routing keys per se:
@@ -241,19 +362,33 @@ Fields: `controller_id`, `action`, `code`, `container_id`, `context`.
   - `ui-admin` (frontend-admin): `108246e1-83b8-4af5-85ae-329e4bd0fb46`
     (`UI_ADMIN_SERVICE_TOKEN`, **excluded from CQRS** — i.e. changes attributed to this
     actor don't trigger replication loops)
+  - `sink`: `d4a1e8f2-3c57-4b90-a612-7e5f9d083c1a` (`SINK_SERVICE_TOKEN`) — registered
+    with 6 permission collections (`assets`, `events`, `composition`, `sources`,
+    `recording`, `overlays`); see `auth/CLAUDE.md` for why the last three were added
+    late (their absence caused silent 403s).
 
 ## 5. WebSocket Conventions
 
-- `sink` exposes a Channels consumer (currently `SinkEventsConsumer`, echo-only):
-  ```python
-  class SinkEventsConsumer(AsyncJsonWebsocketConsumer):
-      async def connect(self):
-          await self.accept()
-      async def receive_json(self, content, **kwargs):
-          await self.send_json({'type': 'ack', 'payload': content})
-  ```
-  Future real implementation should define a message-type envelope (`{"type": ...,
-  "payload": ...}` pattern already implied by the echo) for score/event updates.
+- `sink` exposes three Channels consumers today, for two different concerns:
+  - `apps.events.consumers.SinkEventsConsumer` — still the original echo/ack (no real
+    auth check), unused by anything real yet:
+    ```python
+    class SinkEventsConsumer(AsyncJsonWebsocketConsumer):
+        async def connect(self):
+            await self.accept()
+        async def receive_json(self, content, **kwargs):
+            await self.send_json({'type': 'ack', 'payload': content})
+    ```
+  - `apps.events.broadcast.consumers.BroadcastStateConsumer` — real, in use: pushes a
+    `Composition`'s live phase/state to viewers, scoped by `composition_id`,
+    `IsAccountUserContext | IsCodeContext`.
+  - `apps.events.overlays.consumers.OverlayStateConsumer` — real, in use: pushes an
+    `OverlayProject`'s render state to anonymous browser-source/OBS viewers via a
+    short-lived project-scoped CodeToken, scoped by `project_id`.
+  - **None of these three are the match/score/event distribution channel** —
+    `sink`'s match-events domain (DOMAIN_MODEL.md §5) is still unbuilt. When it lands,
+    it needs its own consumer; the two real consumers above are for
+    composition/overlay broadcast state, a different concern that matured first.
 - `auth` exposes a websocket events endpoint consumed by `frontend-admin`:
   `/auth/events/authentication/` (see `frontend-admin/interfaces/websocket.interface.ts`
   — `NotificationEvent`/`GameEvent`/`UserEvent`/`GenericEvent` types). `GameEvent` here
@@ -269,8 +404,9 @@ Common env var patterns across Python services (verify exact names per-service):
 - `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_PORT` — Postgres.
 - `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`.
 - `RABBITMQ_HOST`, `RABBITMQ_PORT`, `RABBITMQ_DEFAULT_USER`, `RABBITMQ_DEFAULT_PASS`.
-- `SERVICE_TOKEN` / `ORCHESTRATOR_SERVICE_TOKEN` / `UI_ADMIN_SERVICE_TOKEN` — match the
-  registry UUIDs above; used for service-to-service `Apikey m:{token}` auth.
+- `SERVICE_TOKEN` / `ORCHESTRATOR_SERVICE_TOKEN` / `UI_ADMIN_SERVICE_TOKEN` /
+  `SINK_SERVICE_TOKEN` — match the registry UUIDs above; used for service-to-service
+  `Apikey m:{token}` auth.
 - `AUTH_JWT_*`, `AUTH_ROOT_*` — JWT signing/issuer config, shared secret/keys must match
   between `auth` (issuer) and any service that *verifies* signatures (note: `authmtx`
   deliberately does **not** verify signatures, relying on the network boundary +
@@ -286,23 +422,44 @@ Common env var patterns across Python services (verify exact names per-service):
   `m` (module), `u` (user session), `w` (code token).
 - **orchestrator CQRS queue** — confirmed `orchestrator_cqrs`
   (`SERVICE_NAME='orchestrator'`, `queue=f'{SERVICE_NAME}_cqrs'` in settings).
-- **sink CQRS** — sink now consumes `User` from `auth_cqrs` (see §4, DOMAIN_MODEL.md).
-
-### Verified: streaming auth-hook (no longer open)
-
-The auth hop from `authmtx` goes directly through Kong to orchestrator — **no separate
-`/mediamxt/` endpoint in auth is needed**. Kong's `plevion-auth` plugin handles CodeToken
-validation (transforms `?auth-code={tac}` → `Authorization: Apikey w:{tac}`, calls auth,
-injects `Plevion-Auth: Bearer {jwt}`). Orchestrator receives the validated identity and
-owns the stream authorization decision.
-
-Orchestrator endpoint: `POST /api/orchestrator/installations/{installation_id}/streams/{stream_id}/auth/`
-— a `@action(detail=True)` on `StreamViewSet`, protected by `IsCodeContext` permission.
-Stream path convention: `live/{installation_id}/{stream_id}` (authmtx extracts both IDs
-from the last two non-empty path segments).
-
-### Verified: streaming viewer (no longer open)
-
-HLS viewer is implemented in `frontend-landing`. Public access uses `?is_public=true`
-(checked against the stream/installation flag); authenticated access uses user session
-`Apikey u:{sid}`. Both paths call orchestrator to resolve the HLS URL via `MEDIAMTX_HLS_URL`.
+- **sink CQRS** — sink now consumes `User`/`Account` (from `auth_cqrs`) and
+  `Installation`/`Stream` (from `orchestrator_cqrs`) — see §4, DOMAIN_MODEL.md.
+
+### Verified: streaming auth-hook — SUPERSEDED, now owned by `sink` (no longer open)
+
+**This entry originally described `orchestrator` owning the MediaMTX auth hook. That
+changed in the "source auth unification" effort (`orchestrator` PR #31, `3e63d4f`,
+merged 2026-07-24) — `orchestrator` has zero MediaMTX awareness now.** The auth hop
+from `authmtx` goes through Kong to **`sink`**: Kong's `plevion-auth` plugin handles
+CodeToken validation (transforms `?auth-code={tac}` → `Authorization: Apikey w:{tac}`,
+calls `auth`, injects `Plevion-Auth: Bearer {jwt}`) exactly as before — only the
+upstream target changed. See §2 for the full current flow, including the namespace/
+object_type/purpose matrix and `sink`'s new pattern of minting its own auth-codes as a
+client (not just an authorizer).
+
+`sink` endpoint: `POST /public/v1/sink/sources/{object_id}/{auth,ready,disconnected}/`
+— actions on `SourceViewSet`, protected by `IsCodeContext`. Path convention:
+`{namespace}/{object_id}` where `namespace` is `streams`/`overlays`/`live` — **no
+`installation_id` segment** (removed as redundant once `sink` could resolve it from the
+object itself).
+
+### Verified: streaming viewer (no longer open, re-verified 2026-07-27)
+
+HLS viewer is implemented in `frontend-landing` (`app/live/[installationId]/
+[streamId]/`), shipped 2026-06-20. Public access (`?is_public=true`) bypasses the
+`sid`-cookie/orchestrator-installation check entirely — it is **not** checked against
+an `is_public` flag on the stream/installation record (that framing in an earlier
+version of this doc was never accurate); the JWT and what `sink` authorizes it against
+(§2, viewer-read rules) is the only gate in public mode. Authenticated access uses a
+`sid` cookie + `Authorization: Apikey u:{sid}` against `orchestrator`'s installation
+endpoint, on top of the same JWT requirement. Neither path calls `orchestrator` to
+resolve the HLS URL — `frontend-landing` builds it directly from `MEDIAMTX_HLS_URL` +
+the JWT already present in the page's own `?jwt=` query param.
+
+### Open: how the viewer's `?jwt=` (installation-scoped CodeToken) is actually minted
+
+`sink`'s `authorize_mediamtx_connection()` expects an `object_type=installation`-scoped
+token for viewer reads (§2), and `frontend-landing`'s live page expects that JWT to
+already be present in its own URL — but the exact code path that mints this
+installation-scoped `CodeToken` (which service, which endpoint, when) was not traced in
+this pass. Verify with `orchestrator`/`auth` before relying on specifics here.
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 6f8bf2a..5c24607 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -43,15 +43,16 @@ build/CI details (how each *image* is built and tested), see that repo's `CLAUDE
 | `edge-controller` | Has RBAC granting permission to create/delete Pods (it manages stream-container pods directly via the Kubernetes SDK in k8s-backend mode). In `local`, configured to play a sample video. |
 | `frontend-landing` | Standard Deployment + Service, fronted by Kong. |
 | `frontend-admin` | Standard Deployment + Service, fronted by Kong. |
-| `mediamtx` | **Custom image** `ghcr.io/plevion-dev/mediamtx` (repo `plevion-dev/mediamtx`) — wraps `bluenviron/mediamtx` at a pinned version with Alpine shell tooling for `runOnReady`/`runOnNotReady` hooks. Deployed with the `authmtx` Go binary as a **sidecar container** (auth hook). Has **3 networking modes** — Direct NodePort, Kong HostNetwork, Kong NodePort (see `mediamtx/` subdirectories/overlays for which is active in `local`). |
-| `postgres` | Shared/separate instances — verify whether each Django service gets its own DB or shares one instance with separate schemas/databases (`DB_NAME` per service suggests separate logical databases). |
-| `redis` | Used by `auth` (sessions/cache), `orchestrator` (Celery broker/result backend — verify). `sink` uses RabbitMQ (loudhailer) for its Channels layer, not Redis. |
+| `mediamtx` | **Custom image** `ghcr.io/plevion-dev/mediamtx` (repo `plevion-dev/mediamtx`) — wraps `bluenviron/mediamtx` at a pinned version, drops the old Alpine busybox-copy initContainer workaround. Deployed with the `authmtx` Go binary as a **sidecar container** (auth hook, now pointed at `sink` — see ARCHITECTURE.md §4). Has **3 networking modes** — Direct NodePort, Kong HostNetwork, Kong NodePort. **Only true on `plevion-k8s`'s `feat/sink-capturer-worker` branch** — `main` still has the old image + initContainer as of 2026-07-27, see `plevion-k8s/CLAUDE.md` "Known limitations". |
+| `postgres` | Shared/separate instances per service (`DB_NAME` per service). **No PersistentVolumeClaim** — only a ConfigMap mount for init scripts. Any pod restart wipes every database; re-run migrate+fixturing per service afterward. |
+| `redis` | Used by `auth` (sessions/cache), `orchestrator` (Celery broker/result backend — verify). `sink` uses RabbitMQ for its Channels layer, not Redis. |
 | `rabbitmq` | CQRS transport + Celery broker for some services — verify per-service which broker (Redis vs RabbitMQ) is actually used for Celery vs CQRS. |
-| `kong` | Deployed via Helm chart v2.36.0, **custom image** `ghcr.io/plevion-dev/kong:v0.0.3-6f23574` (bundles the `plevion-auth` plugin — see `kong/CLAUDE.md` for how this image is built). |
+| `kong` | Deployed via Helm chart v2.36.0, custom image (bundles the `plevion-auth` plugin — see `kong/CLAUDE.md`); the local overlay currently pins `ghcr.io/plevion-dev/kong:latest`, not a fixed version tag. |
+| `sink` | Owns MediaMTX auth + the compositor/capturer/overlay/recording stack (see ARCHITECTURE.md §4) — a mature, actively-deployed service in the author's **local** cluster. Its manifests (`services/sink/`, including a dedicated `sink-capturer-worker` Deployment) exist **only on `plevion-k8s`'s unmerged branch `feat/sink-capturer-worker`** (open PRs #17, #18) — not on `plevion-k8s` `main`. Don't call it "deployed" without that qualifier. |
 
-`sink`, `neural-court`, and `bluespot` have **no k8s manifests** — `sink`/`neural-court`
-aren't deployed anywhere yet; `bluespot` runs only on physical edge devices (via `rpi5`
-image + Ansible), never in k8s.
+`neural-court` and `bluespot` have **no k8s manifests** — `neural-court` isn't deployed
+anywhere yet; `bluespot` runs only on physical edge devices (via `rpi5` image +
+Ansible), never in k8s.
 
 ## 4. Edge Device Deployment (real hardware, not k8s)
 
@@ -98,7 +99,8 @@ logging being too chatty).
    each service's logging config (Python: `LOGGING` dict / `opentelemetry-instrument`
    wrapping; Next.js: pino transport config) for redundant fields.
 2. **Trace coverage**: confirm **every service actually emits traces** (not just has the
-   SDK installed) — auth, orchestrator, edge-controller, sink (if running), bluespot (if
+   SDK installed) — auth, orchestrator, edge-controller, sink (running in local dev as
+   of 2026-07-27, see §3), bluespot (if
    relevant), frontend-landing, frontend-admin, authmtx, kong/mediamtx if applicable.
 3. **Trace correlation**: confirm a single request's `trace_id` is **propagated and
    visible across service boundaries** — e.g. a request that goes
diff --git a/README.md b/README.md
index 0d554a3..5d376ea 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ they share, and what the current priorities are. It does **not** replace each re
 | [CONTRACTS.md](CONTRACTS.md) | Cross-service contracts: JWT/auth claims, Kong auth plugin flow, CQRS/RabbitMQ events, Celery command messages, REST API conventions, WebSocket conventions |
 | [DEPLOYMENT.md](DEPLOYMENT.md) | Kubernetes topology, local dev (microk8s), edge-device provisioning & deployment, observability stack |
 | [ROADMAP.md](ROADMAP.md) | Current priorities, known gaps, active work areas, next phases, open questions |
+| [WORKFLOW.md](WORKFLOW.md) | Standard process for building non-trivial features: Spec → Spec Revision → Plan → Build → Code Revision, with per-phase skills and independent-model revision steps |
 
 ## Repo Inventory (quick reference)
 
@@ -38,7 +39,7 @@ parent), each as its own independent git repository.
 | [orchestrator](../orchestrator/CLAUDE.md) | Django/Celery | Control plane: devices, installations, streams, component deployment |
 | [edge-controller](../edge-controller/CLAUDE.md) | Django/Celery | Runs on/near the court device; manages stream containers |
 | [bluespot](../bluespot/CLAUDE.md) | Django + BLE | First-boot provisioning service on the RPi5 (WiFi + Tailscale via BLE) |
-| [sink](../sink/CLAUDE.md) | Django/Channels/Celery | Real-time match events & stats distribution (early scaffold) |
+| [sink](../sink/CLAUDE.md) | Django/Channels/Celery | Real-time streaming hub — owns MediaMTX auth, compositor, browser-overlay capture, recording; match/stats distribution not yet built |
 | [neural-court](../neural-court/CLAUDE.md) | PyTorch/Ultralytics | Computer-vision automatic scorer (phase-1 stubs only) |
 | [frontend-landing](../frontend-landing/CLAUDE.md) | Next.js | Public marketing site + viewer-facing app (login built, live view pending) |
 | [frontend-admin](../frontend-admin/CLAUDE.md) | Next.js | Internal admin console (accounts, permissions, controllers, deployments) |
@@ -48,7 +49,7 @@ parent), each as its own independent git repository.
 
 ## Maintenance
 
-This documentation describes the system as of **2026-06-15**. Each service repo evolves
+This documentation describes the system as of **2026-07-27**. Each service repo evolves
 independently — before relying on a specific claim here (a file path, endpoint, env var,
 model field), verify it against the current code. If you find this doc is stale, update it
 as part of your change rather than working around the discrepancy silently.
diff --git a/ROADMAP.md b/ROADMAP.md
index 819f4dc..cca1c88 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -3,8 +3,12 @@
 This is the **living** doc for "what should I work on / what's the state of things right
 now." Update it as priorities shift — it decays faster than the other docs.
 
-Status as of **2026-06-20**: ongoing development, no hard deadlines. Priority 1 live
-viewer is shipped (HLS + `?is_public=true`). Priority 2 infra work continues in parallel.
+Status as of **2026-07-27**: ongoing development, no hard deadlines. Priority 1 live
+viewer is shipped and stable (HLS + `?is_public=true`). Since Priority 1 shipped, a
+"source auth unification" effort (moving MediaMTX auth ownership from `orchestrator` to
+`sink`) has landed, and `sink` has grown from a scaffold into the platform's real-time
+compositor/capturer/overlay/recording hub — `frontend-admin` is building a "Studio" UI on
+top of it (in progress, unmerged). Priority 2 infra work continues in parallel.
 `neural-court` Phase 2 is underway.
 
 ## Priority 1: Streaming pipeline end-to-end
@@ -17,8 +21,10 @@ stream from a court's Bluespot device, start to finish, with proper auth.
 - `orchestrator` ↔ `edge-controller` command flow (Celery) for `STREAM_START`/`STOP`.
 - `edge-controller` starting a stream container (Docker or k8s) that publishes RTSP to
   MediaMTX.
-- MediaMTX → `authmtx` → `auth` → `orchestrator` auth-hook chain for publish
-  authorization (see CONTRACTS.md §2).
+- MediaMTX → `authmtx` → Kong → `sink` auth-hook chain for publish authorization, with
+  `sink` relaying state to `orchestrator` via Celery tasks (see ARCHITECTURE.md §4 and
+  CONTRACTS.md §2 — CONTRACTS.md's auth-code flow description may still reference the
+  old orchestrator-owned hook and needs its own pass, see PLAN_DOCS_DRIFT_UPDATE.md).
 - Local dev: `edge-controller` runs in a local microk8s cluster playing a looped sample
   video, so the whole pipeline above can be exercised without hardware.
 
@@ -34,6 +40,17 @@ stream from a court's Bluespot device, start to finish, with proper auth.
 
 3. ✅ **Auth-chain verification** — §2 auth-code flow (publish + viewer) verified end-to-end.
 
+4. ✅ **MediaMTX auth ownership moved to `sink`** — the "source auth unification" effort
+   collapsed `Stream.status` to `pending/starting/transmitting/disconnected/finished/
+   failed`, removed all MediaMTX awareness from `orchestrator`, and made `sink` the
+   service `authmtx` calls for publish/ready/disconnect auth (via Kong's
+   `/public/v1/sink/` route). `sink` relays state back to `orchestrator` via Celery
+   tasks (`notify_stream_starting`/`transmitting`/`disconnected`) — `orchestrator` has
+   no MediaMTX-facing HTTP surface at all now. See ARCHITECTURE.md §4. `authmtx`'s own
+   re-point (`feat/sink-only-upstream`, including dropping capturer session-scoped auth
+   routing in favor of resolving by code-token object-type) is confirmed **merged**
+   (PR #16, 2026-07-25) and intentional, not a pending question.
+
 ## Priority 2: Infra / deployment polish + telemetry cleanup
 
 **Goal**: deployment process is well understood/documented, and observability is
@@ -74,15 +91,19 @@ downstream pipeline is **designed but not yet implemented**:
 `vision_module` (YOLOv8 detection + pose) → `data_bridge` (2D→3D coordinate ETL via
 homography) → `sequence_module` (BiLSTM event classification) → `deployment/deepstream`
 (NVIDIA DeepStream pipeline on the RTSP stream) → RabbitMQ → `scoring_service` (scoring
-state machine, not yet implemented) → `sink` (real-time distribution, ~10% scaffold) →
-`frontend-landing`/`frontend-admin` (live scoreboard/stats UI).
+state machine, not yet implemented) → `sink` (real-time distribution — its
+streaming/compositor infra is now mature, but the match/score data models and events
+don't exist yet, see DOMAIN_MODEL.md §5) → `frontend-landing`/`frontend-admin` (live
+scoreboard/stats UI).
 
 This phase depends on Priority 1 (needs a working RTSP stream to consume) and benefits
 from Priority 2 (will generate a lot of new telemetry that needs to be readable).
 
 When this phase starts, expect substantial new work in:
 - `neural-court` (everything beyond configs).
-- `sink` (data models, API, real WebSocket consumer/producer).
+- `sink` (new app + models for match/score/events — can reuse the Channels
+  consumer/CQRS/API patterns already established by `compositor`/`overlays`/`recording`
+  rather than building that plumbing from scratch).
 - `frontend-landing` (live scoreboard UI — note the existing hardcoded `Scoreboard` demo
   component is presumably the visual target/prototype for this).
 - Possibly `orchestrator` (if match/score state needs to be associated with
diff --git a/DOMAIN_MODEL.md b/DOMAIN_MODEL.md
index 670be86..cda9df0 100644
--- a/DOMAIN_MODEL.md
+++ b/DOMAIN_MODEL.md
@@ -65,10 +65,16 @@ of the CQRS events themselves, see [CONTRACTS.md](CONTRACTS.md).
 
 ### Stream
 - A broadcast/recording session belonging to an `Installation`.
-- `status`: `pending` → `authorized` → `started` → `finished` / `failed`.
+- `status`: `pending` → `starting` → `transmitting` → `disconnected` / `finished` /
+  `failed` (redesigned — the earlier `pending/authorized/started/finished/failed`
+  machine was retired when MediaMTX auth ownership moved to `sink`, see §4 below).
 - `external_id` — the MediaMTX path/stream identifier.
 - `initialized_by` — FK to the `User` (replica) who started it.
 
+(The relationship to `sink`'s `CompositionSource` is one-directional and lives on the
+other side: `CompositionSource.stream_id` is a plain field, not a FK, referencing this
+`Stream` — `Stream` itself carries no reference back.)
+
 ### StreamActorCapabilities
 - Per-`Stream`, per actor (`actor_type`: `participant` / `viewer`), boolean capability
   flags: `live_streaming`, `record_full`, `record_highlights`, `analytics_realtime`,
@@ -105,15 +111,24 @@ of the CQRS events themselves, see [CONTRACTS.md](CONTRACTS.md).
 ## 4. Streaming Infrastructure (no DB-backed model — runtime state in MediaMTX)
 
 MediaMTX itself is the source of truth for "is this stream currently live / what paths
-exist." `orchestrator`'s `Stream.status` and MediaMTX's runtime state should stay in sync
-via the auth-hook flow (§5.2 in ARCHITECTURE.md) but are **not** the same thing — if
-building the viewer player, treat MediaMTX (via its WebRTC/HLS endpoints, fronted by Kong)
-as the playback source, and `orchestrator`'s `Stream` as the authorization/metadata
-source.
+exist." As of the source-auth-unification effort, `sink` — not `orchestrator` — is the
+service MediaMTX/`authmtx` actually call for auth/ready/disconnect decisions (§5.2 in
+ARCHITECTURE.md); `sink` relays that state to `orchestrator` via Celery tasks
+(`notify_stream_starting`/`notify_stream_transmitting`/`notify_stream_disconnected`) to
+keep `Stream.status` in sync — `orchestrator` has no MediaMTX-facing HTTP surface at all,
+this Celery consumer is its only signal. (CQRS is used the other way: `orchestrator`'s
+controller-health monitor relays controller-offline signals to `sink` as a double-check
+fallback.) `orchestrator`'s `Stream.status` and MediaMTX's runtime state are still **not**
+the same thing — if building the viewer player, treat MediaMTX (via its WebRTC/HLS
+endpoints, fronted by Kong) as the playback source, and `orchestrator`'s `Stream` as the
+authorization/metadata source.
 
 ## 5. Match Events & Stats (intended owner: `sink`, **not yet implemented**)
 
-`sink` is scaffolded to eventually own:
+`sink` has grown into a mature service for streaming/compositor infrastructure — MediaMTX
+auth, live composition, browser-overlay capture, recording (see ARCHITECTURE.md §4) — but
+the match/score/stats domain specifically remains unbuilt. `sink` is expected to
+eventually own:
 - Match/game state (score, set/game progression).
 - Discrete game events (rally start/end, bounces, hits, faults — mirroring
   `neural-court`'s 8 event classes).
@@ -143,15 +158,18 @@ carries a timestamp, 3D position (from the 26-keypoint court model in
 | Entity | Owner | Replicated to (via CQRS) |
 |---|---|---|
 | User | auth | orchestrator, sink |
-| Account | auth | orchestrator |
+| Account | auth | orchestrator, sink |
 | Session | auth | — |
 | Permission / PermissionGroup / PermissionAssignment | auth | — |
-| Installation | orchestrator | (publishes to `orchestrator_cqrs`; no known consumer yet) |
-| Stream | orchestrator | (publishes; no known consumer yet — future: `sink`) |
+| Installation | orchestrator | sink |
+| Stream | orchestrator | sink (needed for the MediaMTX-auth handoff — see ARCHITECTURE.md §4) |
 | StreamActorCapabilities | orchestrator | (publishes; no known consumer yet) |
 | Connection | orchestrator | (publishes; no known consumer yet) |
 
-`sink`'s CQRS transport (`sink_cqrs` queue) is configured and **consumes `User` events
-from auth** (`apps.replications.User`, `CQRS_ID = 'user'`, `ReplicaMixin`) — same
-pattern as `orchestrator`. It's also the obvious future home for `Stream`/`Installation`
-replicas once it needs them to correlate live events with a match.
+`sink`'s CQRS transport (`sink_cqrs` queue) now consumes `User`, `Account`,
+`Installation`, and `Stream` replicas (`apps.replications.{User,Account,Installation,
+Stream}`, `ReplicaMixin`) — the `Installation`/`Stream` replicas exist specifically to
+support `sink`'s MediaMTX-auth ownership (resolving which `Stream` a given
+`sources/{source_id}/auth/` call is for). This grew beyond the original "future home for
+match-event correlation" framing — it's now load-bearing for streaming auth, not just a
+speculative future need.
