From 4a664533d3d935ea3d066f34d6425fa6c2057a09 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 15 Aug 2026 20:08:37 +0200 Subject: [PATCH] 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. --- README.md | 1 + .../ADR-0008-private-by-default-exposure.md | 3 +- ...-railiance-exposure-snapshot-2026-08-15.md | 58 ++++++ docs/exposure-enforcement-intakes.md | 73 +++++++ docs/exposure-posture-contract.md | 185 ++++++++++++++++++ schemas/README.md | 10 + schemas/rail.schema.json | 60 ++++++ schemas/rapp.schema.json | 64 ++++++ schemas/reef.schema.json | 67 +++++++ .../rail-kubernetes/declarations/rail.yaml | 12 ++ .../rapp-nogrant/declarations/rapp.yaml | 37 ++++ .../rapp-unapproved/declarations/rapp.yaml | 42 ++++ .../rapp-wrongreef/declarations/rapp.yaml | 42 ++++ .../reef-closed/declarations/reef.yaml | 12 ++ .../reef-example/declarations/reef.yaml | 20 ++ .../reef-k3s/declarations/reef.yaml | 19 ++ .../declarations/reef.yaml | 15 ++ .../good-exposure/inventory.json | 8 + .../rail-knative/declarations/rail.yaml | 21 ++ .../rail-kubernetes/declarations/rail.yaml | 12 ++ .../rapp-example/declarations/rapp.yaml | 42 ++++ .../reef-example/declarations/reef.yaml | 21 ++ tools/validate-family-declarations.py | 148 +++++++++++++- ...TER-WP-0023-private-by-default-exposure.md | 39 +++- 24 files changed, 998 insertions(+), 13 deletions(-) create mode 100644 docs/evidence/reef-railiance-exposure-snapshot-2026-08-15.md create mode 100644 docs/exposure-enforcement-intakes.md create mode 100644 docs/exposure-posture-contract.md create mode 100644 tools/testdata/family-declarations/bad-exposure/rail-kubernetes/declarations/rail.yaml create mode 100644 tools/testdata/family-declarations/bad-exposure/rapp-nogrant/declarations/rapp.yaml create mode 100644 tools/testdata/family-declarations/bad-exposure/rapp-unapproved/declarations/rapp.yaml create mode 100644 tools/testdata/family-declarations/bad-exposure/rapp-wrongreef/declarations/rapp.yaml create mode 100644 tools/testdata/family-declarations/bad-exposure/reef-closed/declarations/reef.yaml create mode 100644 tools/testdata/family-declarations/bad-exposure/reef-example/declarations/reef.yaml create mode 100644 tools/testdata/family-declarations/bad-exposure/reef-k3s/declarations/reef.yaml create mode 100644 tools/testdata/family-declarations/bad-exposure/reef-storage-example/declarations/reef.yaml create mode 100644 tools/testdata/family-declarations/good-exposure/inventory.json create mode 100644 tools/testdata/family-declarations/good-exposure/rail-knative/declarations/rail.yaml create mode 100644 tools/testdata/family-declarations/good-exposure/rail-kubernetes/declarations/rail.yaml create mode 100644 tools/testdata/family-declarations/good-exposure/rapp-example/declarations/rapp.yaml create mode 100644 tools/testdata/family-declarations/good-exposure/reef-example/declarations/reef.yaml diff --git a/README.md b/README.md index 9a34934..6947e61 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ recorded before they are spread across implementation repos. - [docs/repo-family-bootstrap-contract.md](docs/repo-family-bootstrap-contract.md) - [docs/rail-composition-contract.md](docs/rail-composition-contract.md) - [docs/reef-production-readiness-contract.md](docs/reef-production-readiness-contract.md) +- [docs/exposure-posture-contract.md](docs/exposure-posture-contract.md) - [docs/qonto-knative-runtime-contract.md](docs/qonto-knative-runtime-contract.md) - [docs/adr/ADR-0001-repository-prefix-architecture.md](docs/adr/ADR-0001-repository-prefix-architecture.md) - [docs/adr/ADR-0002-rail-kubernetes-wave-1-boundary.md](docs/adr/ADR-0002-rail-kubernetes-wave-1-boundary.md) diff --git a/docs/adr/ADR-0008-private-by-default-exposure.md b/docs/adr/ADR-0008-private-by-default-exposure.md index 246fef9..0de4bb7 100644 --- a/docs/adr/ADR-0008-private-by-default-exposure.md +++ b/docs/adr/ADR-0008-private-by-default-exposure.md @@ -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 diff --git a/docs/evidence/reef-railiance-exposure-snapshot-2026-08-15.md b/docs/evidence/reef-railiance-exposure-snapshot-2026-08-15.md new file mode 100644 index 0000000..bca14e8 --- /dev/null +++ b/docs/evidence/reef-railiance-exposure-snapshot-2026-08-15.md @@ -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. diff --git a/docs/exposure-enforcement-intakes.md b/docs/exposure-enforcement-intakes.md new file mode 100644 index 0000000..67bd7b0 --- /dev/null +++ b/docs/exposure-enforcement-intakes.md @@ -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`. diff --git a/docs/exposure-posture-contract.md b/docs/exposure-posture-contract.md new file mode 100644 index 0000000..2317771 --- /dev/null +++ b/docs/exposure-posture-contract.md @@ -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` diff --git a/schemas/README.md b/schemas/README.md index 01840b1..5abe184 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -98,6 +98,16 @@ JSON Schema cannot express these; `tools/validate-family-declarations.py` does: - a rapp `ownership_repo` must not be the rapp repo itself - declared member repos, named rails, and named reefs must resolve on disk - a deployable name may belong to at most one rapp +- `exposure.posture: public` on a rapp requires `exposure.grant`, + `binding_admission: production-approved`, and a bound reef that has + granted a public surface (ADR-0008) +- `exposure.posture: public` on a rail requires `exposure.grant` and + `readiness_state: production-approved` +- `exposure.posture: public` on a reef requires `exposure.grants`; a + provider-delegated reef (no rail) cannot be public +- grant port `6443` is never legal +- omitted `exposure` means `private`; the field is additive and must + not be confused with rapp `data_classification: public` ## Rail schema (T04) diff --git a/schemas/rail.schema.json b/schemas/rail.schema.json index 754dac4..6d63b7e 100644 --- a/schemas/rail.schema.json +++ b/schemas/rail.schema.json @@ -108,6 +108,20 @@ "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/sourceDocument" } + }, + "exposure": { + "type": "object", + "additionalProperties": false, + "required": ["posture"], + "description": "Which listener classes this rail may emit. Optional; omitted means private (ADR-0008). public is permission to emit Ingress for a granted binding, not a blanket public default.", + "properties": { + "posture": { + "enum": ["private", "operator", "public"], + "description": "Default if exposure is omitted: private." + }, + "grant": { "$ref": "#/$defs/exposureGrant" }, + "tunnel": { "$ref": "#/$defs/exposureTunnel" } + } } }, "allOf": [ @@ -144,6 +158,23 @@ ] } } + }, + { + "description": "public rail exposure requires a grant object.", + "if": { + "required": ["exposure"], + "properties": { + "exposure": { + "required": ["posture"], + "properties": { "posture": { "const": "public" } } + } + } + }, + "then": { + "properties": { + "exposure": { "required": ["grant"] } + } + } } ], "$defs": { @@ -173,6 +204,35 @@ "repo": { "$ref": "#/$defs/repoSlug" }, "path": { "type": "string", "minLength": 1 } } + }, + + "exposureGrant": { + "type": "object", + "additionalProperties": false, + "required": ["reason", "approved_on", "residual_risk_owner"], + "description": "Accepted residual risk for a public rail listener. Must name a hostname or a port. Port 6443 is rejected by the validator.", + "properties": { + "hostname": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "reason": { "type": "string", "minLength": 1 }, + "approved_on": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "residual_risk_owner": { "type": "string", "minLength": 1 } + }, + "anyOf": [ + { "required": ["hostname"] }, + { "required": ["port"] } + ] + }, + + "exposureTunnel": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "Named ops-bridge / SSH tunnel to the rail API or admin path. Does not change packet posture.", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "local_port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } } } } diff --git a/schemas/rapp.schema.json b/schemas/rapp.schema.json index 6f34da0..7c4da56 100644 --- a/schemas/rapp.schema.json +++ b/schemas/rapp.schema.json @@ -108,6 +108,24 @@ "minItems": 1, "items": { "$ref": "#/$defs/sourceDocument" }, "description": "Documents this declaration was derived from. All three live rapps already carry this consistently." + }, + "exposure": { + "type": "object", + "additionalProperties": false, + "required": ["posture"], + "description": "Who may reach this rapp's listener. Optional; omitted means private (ADR-0008). Distinct from data_classification.", + "properties": { + "posture": { + "enum": ["private", "operator", "public"], + "description": "Intended consumer-facing listener. Default if exposure is omitted: private." + }, + "grant": { "$ref": "#/$defs/exposureGrant" }, + "tunnel": { "$ref": "#/$defs/exposureTunnel" }, + "binding_admission": { + "enum": ["declared", "installed", "verified", "production-approved", "deprecated"], + "description": "ADR-0006 admission state of the reef binding this public listener sits on. Not rapp readiness_state. Required by the validator when posture is public." + } + } } }, "allOf": [ @@ -119,6 +137,23 @@ "description": "consumers requires consumer_contract.", "if": { "required": ["consumers"] }, "then": { "required": ["consumer_contract"] } + }, + { + "description": "public rapp exposure requires a grant object.", + "if": { + "required": ["exposure"], + "properties": { + "exposure": { + "required": ["posture"], + "properties": { "posture": { "const": "public" } } + } + } + }, + "then": { + "properties": { + "exposure": { "required": ["grant"] } + } + } } ], "$defs": { @@ -356,6 +391,35 @@ "repo": { "$ref": "#/$defs/repoSlug" }, "path": { "type": "string", "minLength": 1 } } + }, + + "exposureGrant": { + "type": "object", + "additionalProperties": false, + "required": ["reason", "approved_on", "residual_risk_owner"], + "description": "Accepted residual risk for a public listener. Must name a hostname or a port. Port 6443 is rejected by the validator.", + "properties": { + "hostname": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "reason": { "type": "string", "minLength": 1 }, + "approved_on": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "residual_risk_owner": { "type": "string", "minLength": 1 } + }, + "anyOf": [ + { "required": ["hostname"] }, + { "required": ["port"] } + ] + }, + + "exposureTunnel": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "Named ops-bridge / SSH tunnel. Does not change packet posture.", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "local_port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } } } } diff --git a/schemas/reef.schema.json b/schemas/reef.schema.json index d7dabfd..147af11 100644 --- a/schemas/reef.schema.json +++ b/schemas/reef.schema.json @@ -78,8 +78,46 @@ "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/sourceDocument" } + }, + "exposure": { + "type": "object", + "additionalProperties": false, + "required": ["posture"], + "description": "Host-port and public-DNS surface this reef publishes. Optional; omitted means private (ADR-0008). public is a substrate grant, not reef lifecycle_state. Provider-delegated reefs must not set public.", + "properties": { + "posture": { + "enum": ["private", "operator", "public"], + "description": "Default if exposure is omitted: private." + }, + "grants": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/exposureGrant" }, + "description": "Substrate grants. Required by schema when posture is public." + }, + "tunnel": { "$ref": "#/$defs/exposureTunnel" } + } } }, + "allOf": [ + { + "description": "public reef exposure requires at least one substrate grant.", + "if": { + "required": ["exposure"], + "properties": { + "exposure": { + "required": ["posture"], + "properties": { "posture": { "const": "public" } } + } + } + }, + "then": { + "properties": { + "exposure": { "required": ["grants"] } + } + } + } + ], "$defs": { "repoSlug": { "type": "string", @@ -111,6 +149,35 @@ "repo": { "$ref": "#/$defs/repoSlug" }, "path": { "type": "string", "minLength": 1 } } + }, + + "exposureGrant": { + "type": "object", + "additionalProperties": false, + "required": ["reason", "approved_on", "residual_risk_owner"], + "description": "Accepted residual risk for a reef host port or public DNS name we publish. Must name a hostname or a port. Port 6443 is rejected by the validator.", + "properties": { + "hostname": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "reason": { "type": "string", "minLength": 1 }, + "approved_on": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "residual_risk_owner": { "type": "string", "minLength": 1 } + }, + "anyOf": [ + { "required": ["hostname"] }, + { "required": ["port"] } + ] + }, + + "exposureTunnel": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "description": "Named ops-bridge / SSH path to this reef. Does not change packet posture.", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "local_port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } } } } diff --git a/tools/testdata/family-declarations/bad-exposure/rail-kubernetes/declarations/rail.yaml b/tools/testdata/family-declarations/bad-exposure/rail-kubernetes/declarations/rail.yaml new file mode 100644 index 0000000..fc2d33e --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/rail-kubernetes/declarations/rail.yaml @@ -0,0 +1,12 @@ +kind: execution-rail +rail_id: rail-kubernetes +repo: rail-kubernetes +ownership_repo: railiance-cluster +contract_version: 1.0.0 +composition_kind: base +execution_architecture: kubernetes +readiness_state: verified +required_substrate_capabilities: + - kubernetes-api +supported_rollout_modes: + - stage1-run diff --git a/tools/testdata/family-declarations/bad-exposure/rapp-nogrant/declarations/rapp.yaml b/tools/testdata/family-declarations/bad-exposure/rapp-nogrant/declarations/rapp.yaml new file mode 100644 index 0000000..5fdf069 --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/rapp-nogrant/declarations/rapp.yaml @@ -0,0 +1,37 @@ +kind: managed-workload-package +repo_family: rapp +rapp_id: rapp-nogrant +repo: rapp-nogrant +ownership_repo: railiance-platform +contract_version: 1.0.0 +readiness_state: declared +workload_identity: + name: nogrant + package_type: helm-managed-platform-service +data_classification: internal +criticality: medium +primary_rail: rail-kubernetes +supported_rails: + - rail-kubernetes +bound_reefs: + - reef-example +runtime_dependencies: + - kubernetes-api +composition: + purpose: Fixture rapp used to exercise exposure validation. + member_repos: + - repo: rapp-nogrant + role: package + deployables: + - nogrant +rollout_contract: + default_mode: helm-upgrade-install +smoke_contract: + required: + - example-status-ok +rollback_contract: + order: + - helm-rollback +exposure: + posture: public + binding_admission: production-approved diff --git a/tools/testdata/family-declarations/bad-exposure/rapp-unapproved/declarations/rapp.yaml b/tools/testdata/family-declarations/bad-exposure/rapp-unapproved/declarations/rapp.yaml new file mode 100644 index 0000000..00a5e1f --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/rapp-unapproved/declarations/rapp.yaml @@ -0,0 +1,42 @@ +kind: managed-workload-package +repo_family: rapp +rapp_id: rapp-unapproved +repo: rapp-unapproved +ownership_repo: railiance-platform +contract_version: 1.0.0 +readiness_state: declared +workload_identity: + name: unapproved + package_type: helm-managed-platform-service +data_classification: internal +criticality: medium +primary_rail: rail-kubernetes +supported_rails: + - rail-kubernetes +bound_reefs: + - reef-example +runtime_dependencies: + - kubernetes-api +composition: + purpose: Fixture rapp used to exercise exposure validation. + member_repos: + - repo: rapp-unapproved + role: package + deployables: + - unapproved +rollout_contract: + default_mode: helm-upgrade-install +smoke_contract: + required: + - example-status-ok +rollback_contract: + order: + - helm-rollback +exposure: + posture: public + binding_admission: verified + grant: + hostname: unapproved.example + reason: should fail + approved_on: "2026-08-15" + residual_risk_owner: railiance-platform diff --git a/tools/testdata/family-declarations/bad-exposure/rapp-wrongreef/declarations/rapp.yaml b/tools/testdata/family-declarations/bad-exposure/rapp-wrongreef/declarations/rapp.yaml new file mode 100644 index 0000000..6c916d0 --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/rapp-wrongreef/declarations/rapp.yaml @@ -0,0 +1,42 @@ +kind: managed-workload-package +repo_family: rapp +rapp_id: rapp-wrongreef +repo: rapp-wrongreef +ownership_repo: railiance-platform +contract_version: 1.0.0 +readiness_state: declared +workload_identity: + name: wrongreef + package_type: helm-managed-platform-service +data_classification: internal +criticality: medium +primary_rail: rail-kubernetes +supported_rails: + - rail-kubernetes +bound_reefs: + - reef-closed +runtime_dependencies: + - kubernetes-api +composition: + purpose: Fixture rapp used to exercise exposure validation. + member_repos: + - repo: rapp-wrongreef + role: package + deployables: + - wrongreef +rollout_contract: + default_mode: helm-upgrade-install +smoke_contract: + required: + - example-status-ok +rollback_contract: + order: + - helm-rollback +exposure: + posture: public + binding_admission: production-approved + grant: + hostname: closed.example + reason: reef has no public surface + approved_on: "2026-08-15" + residual_risk_owner: railiance-platform diff --git a/tools/testdata/family-declarations/bad-exposure/reef-closed/declarations/reef.yaml b/tools/testdata/family-declarations/bad-exposure/reef-closed/declarations/reef.yaml new file mode 100644 index 0000000..03f3bca --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/reef-closed/declarations/reef.yaml @@ -0,0 +1,12 @@ +kind: substrate-reef +reef_id: reef-closed +repo: reef-closed +ownership_repo: railiance-infra +substrate_kind: server-group +lifecycle_state: active +criticality: high +primary_rail: rail-kubernetes +hosted_rails: + - rail-kubernetes +current_members: + - Closed01 diff --git a/tools/testdata/family-declarations/bad-exposure/reef-example/declarations/reef.yaml b/tools/testdata/family-declarations/bad-exposure/reef-example/declarations/reef.yaml new file mode 100644 index 0000000..1d7c0b9 --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/reef-example/declarations/reef.yaml @@ -0,0 +1,20 @@ +kind: substrate-reef +reef_id: reef-example +repo: reef-example +ownership_repo: railiance-infra +substrate_kind: server-group +lifecycle_state: active +criticality: high +primary_rail: rail-kubernetes +hosted_rails: + - rail-kubernetes +current_members: + - Example01 +exposure: + posture: public + grants: + - hostname: example.coulomb.social + port: 443 + reason: fixture public DNS surface + approved_on: "2026-08-15" + residual_risk_owner: railiance-infra diff --git a/tools/testdata/family-declarations/bad-exposure/reef-k3s/declarations/reef.yaml b/tools/testdata/family-declarations/bad-exposure/reef-k3s/declarations/reef.yaml new file mode 100644 index 0000000..d8be932 --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/reef-k3s/declarations/reef.yaml @@ -0,0 +1,19 @@ +kind: substrate-reef +reef_id: reef-k3s +repo: reef-k3s +ownership_repo: railiance-infra +substrate_kind: server-group +lifecycle_state: active +criticality: high +primary_rail: rail-kubernetes +hosted_rails: + - rail-kubernetes +current_members: + - K3s01 +exposure: + posture: public + grants: + - port: 6443 + reason: must be rejected + approved_on: "2026-08-15" + residual_risk_owner: railiance-infra diff --git a/tools/testdata/family-declarations/bad-exposure/reef-storage-example/declarations/reef.yaml b/tools/testdata/family-declarations/bad-exposure/reef-storage-example/declarations/reef.yaml new file mode 100644 index 0000000..1cd9c15 --- /dev/null +++ b/tools/testdata/family-declarations/bad-exposure/reef-storage-example/declarations/reef.yaml @@ -0,0 +1,15 @@ +kind: substrate-reef +reef_id: reef-storage-example +repo: reef-storage-example +ownership_repo: railiance-platform +substrate_kind: object-storage +lifecycle_state: planned +criticality: high +current_members: [] +exposure: + posture: public + grants: + - hostname: s3.example + reason: provider API is not a listener we open + approved_on: "2026-08-15" + residual_risk_owner: railiance-platform diff --git a/tools/testdata/family-declarations/good-exposure/inventory.json b/tools/testdata/family-declarations/good-exposure/inventory.json new file mode 100644 index 0000000..3aa5dfe --- /dev/null +++ b/tools/testdata/family-declarations/good-exposure/inventory.json @@ -0,0 +1,8 @@ +{ + "source": "fixture", + "captured_at": "2026-08-15T00:00:00Z", + "reef": "reef-example", + "deployables": [ + {"name": "example", "namespace": "example", "kind": "helm-release"} + ] +} diff --git a/tools/testdata/family-declarations/good-exposure/rail-knative/declarations/rail.yaml b/tools/testdata/family-declarations/good-exposure/rail-knative/declarations/rail.yaml new file mode 100644 index 0000000..ac65358 --- /dev/null +++ b/tools/testdata/family-declarations/good-exposure/rail-knative/declarations/rail.yaml @@ -0,0 +1,21 @@ +kind: execution-rail +rail_id: rail-knative +repo: rail-knative +ownership_repo: railiance-cluster +contract_version: 1.0.0 +composition_kind: derived +base_rail: rail-kubernetes +base_rail_contract: 1.0.0 +execution_architecture: knative-serving-on-kubernetes +readiness_state: verified +required_substrate_capabilities: + - kubernetes-api + - knative-serving +supported_rollout_modes: + - revision-canary +inherited_semantics: + - workload-identity +overridden_semantics: + - request-activation +compatibility_constraints: + - rail-kubernetes contract must equal 1.0.0 diff --git a/tools/testdata/family-declarations/good-exposure/rail-kubernetes/declarations/rail.yaml b/tools/testdata/family-declarations/good-exposure/rail-kubernetes/declarations/rail.yaml new file mode 100644 index 0000000..fc2d33e --- /dev/null +++ b/tools/testdata/family-declarations/good-exposure/rail-kubernetes/declarations/rail.yaml @@ -0,0 +1,12 @@ +kind: execution-rail +rail_id: rail-kubernetes +repo: rail-kubernetes +ownership_repo: railiance-cluster +contract_version: 1.0.0 +composition_kind: base +execution_architecture: kubernetes +readiness_state: verified +required_substrate_capabilities: + - kubernetes-api +supported_rollout_modes: + - stage1-run diff --git a/tools/testdata/family-declarations/good-exposure/rapp-example/declarations/rapp.yaml b/tools/testdata/family-declarations/good-exposure/rapp-example/declarations/rapp.yaml new file mode 100644 index 0000000..65f1f24 --- /dev/null +++ b/tools/testdata/family-declarations/good-exposure/rapp-example/declarations/rapp.yaml @@ -0,0 +1,42 @@ +kind: managed-workload-package +repo_family: rapp +rapp_id: rapp-example +repo: rapp-example +ownership_repo: railiance-platform +contract_version: 1.0.0 +readiness_state: declared +workload_identity: + name: example + package_type: helm-managed-platform-service +data_classification: internal +criticality: medium +primary_rail: rail-kubernetes +supported_rails: + - rail-kubernetes +bound_reefs: + - reef-example +runtime_dependencies: + - kubernetes-api +composition: + purpose: Fixture rapp used to exercise exposure validation. + member_repos: + - repo: rapp-example + role: package + deployables: + - example +rollout_contract: + default_mode: helm-upgrade-install +smoke_contract: + required: + - example-status-ok +rollback_contract: + order: + - helm-rollback +exposure: + posture: public + binding_admission: production-approved + grant: + hostname: example.coulomb.social + reason: fixture public rapp + approved_on: "2026-08-15" + residual_risk_owner: railiance-platform diff --git a/tools/testdata/family-declarations/good-exposure/reef-example/declarations/reef.yaml b/tools/testdata/family-declarations/good-exposure/reef-example/declarations/reef.yaml new file mode 100644 index 0000000..4b5549e --- /dev/null +++ b/tools/testdata/family-declarations/good-exposure/reef-example/declarations/reef.yaml @@ -0,0 +1,21 @@ +kind: substrate-reef +reef_id: reef-example +repo: reef-example +ownership_repo: railiance-infra +substrate_kind: server-group +lifecycle_state: active +criticality: high +primary_rail: rail-kubernetes +hosted_rails: + - rail-kubernetes + - rail-knative +current_members: + - Example01 +exposure: + posture: public + grants: + - hostname: example.coulomb.social + port: 443 + reason: fixture public DNS surface + approved_on: "2026-08-15" + residual_risk_owner: railiance-infra diff --git a/tools/validate-family-declarations.py b/tools/validate-family-declarations.py index 7c0c52c..582072b 100755 --- a/tools/validate-family-declarations.py +++ b/tools/validate-family-declarations.py @@ -11,8 +11,11 @@ checks, in this order: 2. sibling-field constraints JSON Schema cannot express 3. declared member repos, named rails, and named reefs resolve on disk 4. reef bound_rapps matches the projection of rapp.bound_reefs -5. a deployable name belongs to at most one rapp -6. if --inventory is given, every live deployable belongs to exactly one rapp +5. exposure posture rules from ADR-0008 (public needs a grant and + approved binding; rapp public needs a public reef surface; 6443 is + never grantable; provider-delegated reefs cannot be public) +6. a deployable name belongs to at most one rapp +7. if --inventory is given, every live deployable belongs to exactly one rapp This repo does not query a cluster. Live coverage consumes an inventory file produced by an implementation repo (RMASTER-WP-0021-T06). @@ -53,6 +56,7 @@ DECL_BY_PREFIX = { } FLOATING_PIN = re.compile(r"^(latest|[\^~*]|.*\*|.*x$)", re.IGNORECASE) SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +FORBIDDEN_PUBLIC_PORTS = {6443} @dataclass @@ -260,6 +264,119 @@ def check_resolution(loaded: Loaded, present: set[str], root: Path, report: Repo need(f"hosted_rails[{i}]", rail) +def exposure_block(doc: dict[str, Any]) -> dict[str, Any] | None: + block = doc.get("exposure") + return block if isinstance(block, dict) else None + + +def posture_of(doc: dict[str, Any]) -> str: + block = exposure_block(doc) + if block is None: + return "private" + posture = block.get("posture") + return posture if isinstance(posture, str) else "private" + + +def grants_of(loaded: Loaded) -> list[dict[str, Any]]: + block = exposure_block(loaded.doc) if isinstance(loaded.doc, dict) else None + if block is None: + return [] + if loaded.family == "reef": + return [item for item in as_list(block.get("grants")) if isinstance(item, dict)] + grant = block.get("grant") + return [grant] if isinstance(grant, dict) else [] + + +def reef_has_public_surface(loaded: Loaded) -> bool: + return ( + loaded.family == "reef" + and isinstance(loaded.doc, dict) + and posture_of(loaded.doc) == "public" + and bool(grants_of(loaded)) + ) + + +def reject_forbidden_ports(loaded: Loaded, report: Report) -> None: + field_prefix = "exposure.grants" if loaded.family == "reef" else "exposure.grant" + for i, grant in enumerate(grants_of(loaded)): + port = grant.get("port") + if port in FORBIDDEN_PUBLIC_PORTS: + loc = field_prefix if loaded.family != "reef" else f"{field_prefix}[{i}].port" + if loaded.family != "reef": + loc = f"{field_prefix}.port" + report.add( + loaded.rel, + loc, + f"port {port} is not a grantable public surface", + ) + + +def check_exposure(loaded_docs: list[Loaded], report: Report) -> None: + reefs: dict[str, Loaded] = {} + for loaded in loaded_docs: + if loaded.family != "reef" or not isinstance(loaded.doc, dict): + continue + reef_id = loaded.doc.get("reef_id") + if isinstance(reef_id, str): + reefs[reef_id] = loaded + + for loaded in loaded_docs: + if not isinstance(loaded.doc, dict): + continue + reject_forbidden_ports(loaded, report) + posture = posture_of(loaded.doc) + block = exposure_block(loaded.doc) or {} + if loaded.family == "reef": + hosted = as_list(loaded.doc.get("hosted_rails")) + primary = loaded.doc.get("primary_rail") + provider_delegated = not primary and not hosted + if posture == "public" and provider_delegated: + report.add( + loaded.rel, + "exposure.posture", + "provider-delegated reef cannot use exposure.public", + ) + if posture == "public" and not grants_of(loaded): + report.add( + loaded.rel, + "exposure.grants", + "public reef requires at least one substrate grant", + ) + continue + if posture != "public": + continue + if not grants_of(loaded): + report.add( + loaded.rel, + "exposure.grant", + "public posture requires a grant object", + ) + if loaded.family == "rail" and loaded.doc.get("readiness_state") != "production-approved": + report.add( + loaded.rel, + "readiness_state", + "public rail requires readiness_state production-approved", + ) + if loaded.family != "rapp": + continue + admission = block.get("binding_admission") + if admission != "production-approved": + got = f"; got {admission!r}" if admission is not None else "" + report.add( + loaded.rel, + "exposure.binding_admission", + "public requires binding_admission production-approved " + f"(not rapp readiness_state){got}", + ) + bound = [item for item in as_list(loaded.doc.get("bound_reefs")) if isinstance(item, str)] + if not any(reef_id in reefs and reef_has_public_surface(reefs[reef_id]) for reef_id in bound): + report.add( + loaded.rel, + "exposure.posture", + "public rapp is not bound to a reef that has granted a public surface", + ) + + def check_bound_rapps(loaded_docs: list[Loaded], report: Report) -> None: derived: dict[str, set[str]] = defaultdict(set) rapp_ids: set[str] = set() @@ -466,6 +583,7 @@ def validate( check_reef(loaded, report) check_resolution(loaded, present, root, report) check_bound_rapps(loaded_docs, report) + check_exposure(loaded_docs, report) check_deployable_uniqueness(loaded_docs, report) check_undeclared(repos, loaded_docs, root, report) if inventory_path is not None: @@ -521,6 +639,30 @@ def self_test() -> int: if not bad.errors: failures.append("bad-stale-bound produced no errors") + exposed_root = TESTDATA / "good-exposure" + exposed = validate(exposed_root, discover_repos(exposed_root), validators) + if exposed.errors: + failures.append( + "good-exposure fixture produced errors:\n " + + "\n ".join(str(f) for f in exposed.errors) + ) + + bad_exp_root = TESTDATA / "bad-exposure" + bad_exp = validate(bad_exp_root, discover_repos(bad_exp_root), validators) + exp_messages = "\n".join(str(f) for f in bad_exp.errors) + expected_exposure = [ + ("binding_admission production-approved", "unapproved public rapp"), + ("requires a grant object", "public rapp without grant"), + ("not bound to a reef that has granted a public surface", "public rapp on a private reef"), + ("port 6443 is not a grantable public surface", "6443 grant"), + ("provider-delegated reef cannot use exposure.public", "public storage reef"), + ] + for needle, label in expected_exposure: + if not any(needle in f.message for f in bad_exp.errors): + failures.append(f"bad-exposure did not flag {label}:\n{exp_messages}") + if not bad_exp.errors: + failures.append("bad-exposure produced no errors") + live_rails = [ Path("/home/worsch/rail-kubernetes"), Path("/home/worsch/rail-knative"), @@ -541,7 +683,9 @@ def self_test() -> int: return 1 print("self-test ok") print(f" good: {good.files} file(s), {len(good.errors)} error(s)") + print(f" good-exposure: {exposed.files} file(s), {len(exposed.errors)} error(s)") print(f" bad-stale-bound: {bad.files} file(s), {len(bad.errors)} error(s) (expected)") + print(f" bad-exposure: {bad_exp.files} file(s), {len(bad_exp.errors)} error(s) (expected)") return 0 diff --git a/workplans/RMASTER-WP-0023-private-by-default-exposure.md b/workplans/RMASTER-WP-0023-private-by-default-exposure.md index bee6cbb..e83ebd7 100644 --- a/workplans/RMASTER-WP-0023-private-by-default-exposure.md +++ b/workplans/RMASTER-WP-0023-private-by-default-exposure.md @@ -4,8 +4,8 @@ type: workplan title: "Private-by-default exposure until production admission" domain: financials repo: railiance-master -status: active -owner: bernd +status: finished +owner: grok topic_slug: railiance planning_priority: high planning_order: 23 @@ -212,7 +212,7 @@ distinct. ```task id: RMASTER-WP-0023-T02 -status: todo +status: done priority: high state_hub_task_id: "96db2a2d-670c-4b98-b6f3-860f9b3009b3" ``` @@ -236,11 +236,15 @@ substrate grant is invalid. **Done when:** implementation repos can implement without inventing vocabulary. +Completed 2026-08-15: `docs/exposure-posture-contract.md` names the +per-family reading, grant shape, fail-closed default, conflict rule, +6443 exclusion, and provider-delegated carve-out. + ### T03 — Schema and validator ```task id: RMASTER-WP-0023-T03 -status: wait +status: done priority: high state_hub_task_id: "03070be7-925e-4aee-9938-85b03b8cbe37" ``` @@ -268,11 +272,17 @@ Name the field so it cannot be confused with rapp rapp that is not production-approved, and the testdata tree has good and bad fixtures. Live sibling repos are unchanged. +Completed 2026-08-15: additive `exposure` on all three family schemas. +Validator rejects unapproved public rapps, missing grants, public rapps +on a private reef, port 6443, and public provider-delegated reefs. +Fixtures: `good-exposure` and `bad-exposure`. Live declarations omitted +the field and still validate. + ### T04 — Exception snapshot for already-public surfaces ```task id: RMASTER-WP-0023-T04 -status: todo +status: done priority: medium state_hub_task_id: "6576cc7c-6c85-4d3b-b0cc-dfba4e9b4774" ``` @@ -293,11 +303,16 @@ Out of scope: CoulombCore, shutdown plans, provider-native APIs. **Done when:** every known public surface on `reef-railiance` is either a snapshot grant or a routed task to close it. +Completed 2026-08-15: +`docs/evidence/reef-railiance-exposure-snapshot-2026-08-15.md`. +Grants: Forgejo, Coulomb Social, reuse-surface, Nydus 2224. Routed: +`bao.coulomb.social` verify/close; CoulombCore names stay out. + ### T05 — Route enforcement work ```task id: RMASTER-WP-0023-T05 -status: wait +status: done priority: high state_hub_task_id: "d780df59-c67d-486e-8e8a-15e5043cd513" ``` @@ -318,6 +333,12 @@ there from this repo. **Done when:** each owning repo has a workplan or an accepted intake, with a back-link to this ID. +Completed 2026-08-15: intakes in +`docs/exposure-enforcement-intakes.md`. State Hub messages sent to +`rail-kubernetes`, `railiance-cluster`, `reef-railiance`, +`railiance-infra`, and `railiance-enablement`. Owning-repo agents file +the workplans. + ## Suggested review order 1. T01 (human) — accept or change the recommended table. @@ -328,6 +349,6 @@ with a back-link to this ID. ## Outcome -T01 accepted 2026-08-15 (`status: active`). ADR-0008 is the record. -Next: T02 contract and T04 exception snapshot. Implementation stays out -of this repo. +Finished 2026-08-15. ADR-0008, the exposure contract, additive schemas, +validator fixtures, the reef-railiance snapshot, and routed intakes are +in this repo. Packet-level enforcement stays with the owning repos.