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.
This commit is contained in:
codex 2026-08-15 20:08:37 +02:00
parent 6301798fab
commit 4a664533d3
24 changed files with 998 additions and 13 deletions

View file

@ -73,8 +73,7 @@ enum.
Ops-bridge is the normal path to a shielded thing.
The detailed per-family reading lives in
[`docs/exposure-posture-contract.md`](../exposure-posture-contract.md)
once `RMASTER-WP-0023-T02` writes it.
[`docs/exposure-posture-contract.md`](../exposure-posture-contract.md).
## Consequences

View file

@ -0,0 +1,58 @@
# reef-railiance exposure snapshot
Date: 2026-08-15
Status: Dated snapshot under RMASTER-WP-0023-T04. Not the living source
of truth. T05 files grants into owning declarations.
Scope: public listeners on `reef-railiance` (current member
`Railiance01`). CoulombCore is out. Provider-native APIs are out.
This is not a shutdown plan.
Grant shape matches ADR-0008: hostname or port, reason, `approved_on`,
residual-risk owner.
## Snapshot grants
These surfaces are already published. They stay up. Each row is a grant
the owning declaration should carry.
| hostname or port | Owning rapp or provider | Why it is public | Residual-risk owner |
| --- | --- | --- | --- |
| `forgejo.coulomb.social` | `rapp-forgejo` (decided, not yet materialized; today `railiance-apps` / `railiance-infra`) | Canonical git forge, OCI, and npm registry for the estate | `railiance-infra` |
| `app.coulomb.social` | Coulomb Social (`coulomb-social` / `railiance-apps`; no `rapp-*` yet) | Public web app on Railiance01 | `railiance-apps` |
| `reuse.coulomb.social` | reuse-surface (`railiance-apps`; no `rapp-*` yet) | Public federation / reuse landing | `railiance-apps` |
| port `2224` | HostEurope Nydus (`nydus-ex-api`) | Provider VPS agent; required by the platform | `railiance-infra` |
Reef-level substrate reading: `80/443` are the host ports those
hostnames need. `reef-railiance` should carry a public substrate grant
for that DNS/Ingress surface when the declaration grows `exposure`.
Host UFW on Railiance01 currently declares OpenSSH and Nydus `2224`
only (RAIL-HO-WP-0009). The names above are still published DNS; the
host-port grant is what makes 80/443 legal under this contract.
## Not grants
| Surface | Why it is not a grant here |
| --- | --- |
| `22/tcp` SSH | Operator path, not a published application listener |
| `6443/tcp` k3s API | Closed by RAIL-HO-WP-0009; not grantable as `public` |
| `bao.coulomb.social` | OpenBao public name. Target on Railiance01 is private (`public_ingress: false`). If the name still answers on this reef, that is a routed close, not a grant |
| `kc.coulomb.social` | Keycloak; last recorded DNS still targeted CoulombCore |
| `gitea.coulomb.social` | Archived Gitea on CoulombCore |
| `coulomb.social` apex | Still Bubble.io until an explicit apex cutover |
| Scaleway S3 / `reef-storage` | Provider-native API; out of this enum |
## Routed follow-ups (not shutdowns)
| Item | Route |
| --- | --- |
| File the four snapshot grants into owning declarations once `exposure` exists | T05 → `reef-railiance`, `rapp-forgejo` (or `railiance-apps` until that rapp exists) |
| Confirm `bao.coulomb.social` does not still publish on Railiance01; close DNS or keep it private | `railiance-platform` / RMASTER-WP-0020 |
| Materialize `rapp-forgejo` so the forge grant has a family home | already decided in ADR-0003 / `docs/rapp-first-wave-candidates.md` |
| Name rapps for Coulomb Social and reuse-surface, or accept layer-repo ownership until then | `railiance-apps` |
## Done-when
Every known public surface on `reef-railiance` is either a snapshot
grant above or a routed follow-up. Living updates happen in the owning
declarations, not by editing this date-stamped file.

View file

@ -0,0 +1,73 @@
# Exposure enforcement intakes
Date: 2026-08-15
Source: `RMASTER-WP-0023-T05`
Back-link: `RMASTER-WP-0023`
These are intakes for the owning-repo agents. File a workplan in the
owning repo. Do not implement the controls from `railiance-master`.
Normative reading:
- [`docs/adr/ADR-0008-private-by-default-exposure.md`](adr/ADR-0008-private-by-default-exposure.md)
- [`docs/exposure-posture-contract.md`](exposure-posture-contract.md)
- Snapshot of already-public surfaces:
[`docs/evidence/reef-railiance-exposure-snapshot-2026-08-15.md`](evidence/reef-railiance-exposure-snapshot-2026-08-15.md)
## Intake: `rail-kubernetes` (cc `railiance-cluster` until the rail owns networking)
Enforce the packet path for rapps on Kubernetes:
- default-deny NetworkPolicy
- ClusterIP as the paved Service
- no public Ingress class unless the rapp has `exposure.posture: public`
and a grant, the binding is `production-approved`, and the reef has
granted a public surface
- a rail `exposure: public` is permission to *emit* Ingress for those
granted bindings, not a blanket public default
Do not define what "production-safe" means. That stays ADR-0006.
Suggested workplan id in the owning repo: a `RAIL-K8S-WP-*` or
`RAILIANCE-WP-*` that cites `RMASTER-WP-0023`.
## Intake: `reef-railiance`
First live admission of the family rule:
- new binds stay `private` (or `operator` only for a named admin path)
- add `exposure` to `declarations/reef.yaml` with substrate grants for
the snapshot rows that belong on the reef (`80/443` DNS/Ingress
surface, Nydus `2224`)
- do not take down Forgejo, Coulomb Social, reuse-surface, or Nydus
- do not re-public `6443`
Suggested workplan id: a `REEF-RAILIANCE-WP-*` that cites
`RMASTER-WP-0023`.
## Intake: `railiance-infra`
Host surface for **new** reefs:
- do not open 80/443 until the reef declaration carries a public
substrate grant
- `6443` stays operator-only (RAIL-HO-WP-0009 / ADR-005). This contract
is not a way to reopen it
- this is not the rapp admission desk. A rapp that shares Railiance01
ingress is gated by the rail/reef grants, not by a new S1 UFW rule
per rapp
- Nydus `2224` stays a named exception, already declared
Suggested workplan id: a `RAIL-HO-WP-*` that cites `RMASTER-WP-0023`.
## Intake: `railiance-enablement`
Templates and paved paths:
- emit private Services and tunnel docs by default
- never emit a public Ingress in a new-rapp or new-rail template
- if a template mentions `exposure`, default the field to `private` or
omit it (fail-closed)
Suggested workplan id: an enablement workplan that cites
`RMASTER-WP-0023`.

View file

@ -0,0 +1,185 @@
# 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`