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.
6.8 KiB
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:
exposure.grantis presentexposure.binding_admissionisproduction-approved- at least one bound reef has
exposure.posture: publicand 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 ownexposure.grant, andreadiness_statemust beproduction-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 inexposure.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:
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.
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
publicandbinding_admissionis notproduction-approved - a rail is
publicandreadiness_stateis notproduction-approved - posture is
publicand the grant object is missing - a rapp is
publicand no bound reef has granted a public surface - a grant names port
6443 - a provider-delegated reef sets
posture: public