# Exposure Posture Contract Date: 2026-08-15 Status: Adopted under ADR-0008 / RMASTER-WP-0023-T02 ## Purpose Say what `private`, `operator`, and `public` mean for each family, so implementation repos can enforce one rule without inventing vocabulary. ADR-0008 is the decision. This file is the per-family reading. Schema shapes live in `schemas/{rapp,rail,reef}.schema.json`. Do not restate those fields here except to name the objects the validator checks. Admission (ADR-0006) answers "may this binding run in production?" Exposure answers "who may reach the listener?" A `production-approved` binding is still private until a grant exists. Neither is an authorization decision. Whether an actor may perform an action is `access-engine` (ADR-0009). ## Vocabulary | Posture | Meaning | Who may reach it | | --- | --- | --- | | `private` | No extra listener we control | in-cluster DNS, same-reef services | | `operator` | Same listener as `private`, plus a named ops-bridge / SSH tunnel | operator and approved agents | | `public` | Internet listener we publish | anyone the Ingress / DNS / UFW surface allows | The field name is `exposure`. Rapp `data_classification: public` is a different field. Missing `exposure` means `private`. That is fail-closed. `operator` does not open a host port or a public Ingress. A ClusterIP Service plus a named tunnel is still `private` at the packet layer. Default for a new reef, rail, or rapp is `private`. Use `operator` only for a named admin or API surface that must be reachable before admission. ## What this contract covers Listeners we control: - host ports on a compute reef - Ingress and Service types a rail may emit - public DNS names we publish Out of this enum: - provider-native internet APIs (Scaleway S3 and other provider-delegated endpoints) - CoulombCore host inventory until it is a declared reef - Q7 / Goss declared-vs-live reaction `6443` / the k3s API is not a grantable public surface. ## Grant object A grant is accepted residual risk, not a self-serve wish. Required fields: | Field | Meaning | | --- | --- | | `hostname` or `port` | at least one; the listener being granted | | `reason` | why this must be public | | `approved_on` | ISO date `YYYY-MM-DD` | | `residual_risk_owner` | who accepts the residual risk | A working deploy, a `hosts_rail` / `binds_rapp` line, or an Ingress object is not a grant. Rapp and rail carry a single `exposure.grant` when `posture` is `public`. A compute reef carries `exposure.grants` (a list) because one substrate can publish more than one host or DNS surface. ## Per-family meaning | Family | `private` | `operator` | `public` | | --- | --- | --- | --- | | `reef-*` compute | no extra host ports; no public DNS for new members | ops-bridge / SSH only | declared host/DNS surface plus substrate grant | | `reef-*` provider-delegated | we publish no extra name | n/a | out of this enum | | `rail-*` | no public Ingress class; default-deny NetworkPolicy | tunnel to the rail's API or admin path | rail may emit public Ingress for a granted binding | | `rapp-*` | ClusterIP / in-cluster DNS only | named tunnel (local port) on the same ClusterIP | named public hostname plus binding grant | ### Rapp `exposure.posture` is the intended consumer-facing listener. `public` is legal only when all of these hold: 1. `exposure.grant` is present 2. `exposure.binding_admission` is `production-approved` 3. at least one bound reef has `exposure.posture: public` and a substrate grant `binding_admission` is the ADR-0006 state of the reef binding being published. It is not `readiness_state` on `rapp.yaml`. Do not add `production-approved` to the rapp readiness enum for this purpose. A rapp cannot be `public` on a reef that has not granted a public surface. Conflicting declarations fail closed. ### Rail `exposure.posture` is which listener classes the rail may emit. - `private` (default): the rail must not emit a public Ingress class. ClusterIP is the paved Service. - `operator`: a named tunnel to the rail's own API or admin path. - `public`: the rail may emit public Ingress **for a granted binding**. The rail still needs its own `exposure.grant`, and `readiness_state` must be `production-approved`. A rapp grant is still required before a specific hostname is published. A rail that is `private` may still *host* a public rapp if the reef has granted a public surface and the rapp has a grant. The rail's posture is about what the rail itself publishes (its API, default Ingress class), not a veto on every granted rapp. Implementation repos enforce "no public Ingress unless a grant exists" at the rapp/binding, not by forcing every rail to `public`. ### Reef `exposure.posture` is the host-port and public-DNS surface we publish. - `private` (default): no extra host ports; no public DNS for new members. SSH for operator login is the ops path, not a public grant. - `operator`: ops-bridge / SSH only. No 80/443. - `public`: one or more substrate grants in `exposure.grants`. A reef has no `production-approved` lifecycle. `public` on a reef is the substrate grant, not `lifecycle_state`. A provider-delegated reef (no `primary_rail`, no `hosted_rails`) must not set `exposure.posture: public`. The provider API is not a listener we open. ## Operator tunnel When `posture` is `operator`, name the tunnel: ```yaml exposure: posture: operator tunnel: name: k3s-api-railiance01 local_port: 16444 ``` The tunnel is documentation of the paved path. It does not change packet reachability from the internet. ## Enforcement split | Owner | What to enforce | | --- | --- | | `rail-kubernetes` / `railiance-cluster` | default-deny NetworkPolicy; no public Ingress unless the grant exists; ClusterIP as the paved Service | | `reef-railiance` | new binds stay `private` (or `operator` only for a named admin path); existing public binds receive named grants | | `railiance-infra` | new reefs do not get 80/443 until a reef substrate grant exists; 6443 stays operator-only | | `railiance-enablement` | templates emit private Services + tunnel docs, never a public Ingress by default | This contract does not install those controls. Routing is `RMASTER-WP-0023-T05`. ## Existing public surfaces A dated snapshot of already-public listeners on `reef-railiance` lives in [`docs/evidence/reef-railiance-exposure-snapshot-2026-08-15.md`](evidence/reef-railiance-exposure-snapshot-2026-08-15.md). That snapshot is not the living source of truth. Grants move into the owning declarations. ## Schema and validator `exposure` is additive. Existing declarations that omit it remain valid and mean `private`. `tools/validate-family-declarations.py` fails when: - a rapp is `public` and `binding_admission` is not `production-approved` - a rail is `public` and `readiness_state` is not `production-approved` - posture is `public` and the grant object is missing - a rapp is `public` and no bound reef has granted a public surface - a grant names port `6443` - a provider-delegated reef sets `posture: public`