railiance-master/docs/exposure-posture-contract.md
codex 4a664533d3 feat: implement RMASTER-WP-0023 private-by-default exposure
Add the exposure contract, additive family schema fields, validator
checks and fixtures, the reef-railiance exception snapshot, and
routed intakes. Enforcement stays in the owning repos.
2026-08-15 20:08:37 +02:00

185 lines
6.8 KiB
Markdown

# 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.
## 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`