Finish RAILIANCE-WP-0021: family schemas, validator, and ADR-0007

Add rail and reef schemas, derive reef bound_rapps from rapp.bound_reefs,
and ship a standalone family-declaration validator with an inventory-fed
coverage check. Point the bootstrap contract at the schemas, record the
dimension and cardinality decisions in ADR-0007, correct the first-wave
candidates document, and release the shape to downstream repos.
This commit is contained in:
codex 2026-08-13 15:29:07 +02:00
parent d3b3512a60
commit 3cc0dc31d6
20 changed files with 1509 additions and 187 deletions

View file

@ -13,7 +13,7 @@
| workplan | RAILIANCE-WP-0018 | finished | — | workplans/RAILIANCE-WP-0018-first-wave-repo-family-materialization.md |
| workplan | RAILIANCE-WP-0019 | finished | — | workplans/RAILIANCE-WP-0019-knative-qonto-runtime-on-reef-railiance.md |
| workplan | RAILIANCE-WP-0020 | backlog | — | workplans/RAILIANCE-WP-0020-openbao-migration-to-reef-railiance.md |
| workplan | RAILIANCE-WP-0021 | ready | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| workplan | RAILIANCE-WP-0021 | finished | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | ADHOC-2026-07-30-T01 | done | — | workplans/ADHOC-2026-07-30.md |
| task | RAILIANCE-WP-0017-T01 | done | — | workplans/RAILIANCE-WP-0017-rail-rapp-reef-repo-separation.md |
| task | RAILIANCE-WP-0017-T02 | done | — | workplans/RAILIANCE-WP-0017-rail-rapp-reef-repo-separation.md |
@ -47,11 +47,11 @@
| task | RAILIANCE-WP-0020-T07 | done | — | workplans/RAILIANCE-WP-0020-openbao-migration-to-reef-railiance.md |
| task | RAILIANCE-WP-0020-T08 | todo | — | workplans/RAILIANCE-WP-0020-openbao-migration-to-reef-railiance.md |
| task | RAILIANCE-WP-0021-T01 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T02 | todo | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T03 | todo | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T04 | todo | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T05 | todo | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T06 | todo | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T07 | todo | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T08 | todo | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T09 | wait | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T02 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T03 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T04 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T05 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T06 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T07 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T08 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |
| task | RAILIANCE-WP-0021-T09 | done | — | workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md |

View file

@ -0,0 +1,76 @@
# ADR-0007: Rapp Declaration Contract
Date: 2026-08-13
Status: Accepted
## Context
`RAILIANCE-WP-0017` through `RAILIANCE-WP-0019` established the four-axis
model and materialized the first family repos. The model held up. Its
enforcement did not.
A 2026-08-11 survey by `railiance-platform` found that the three live
`rapp.yaml` files were mutually unreadable: rollout, smoke, and rollback
contracts used different shapes; metadata that both rails carry consistently
appeared in only one rapp; `reef-railiance` `bound_rapps` listed `rapp-qonto`
only, while `rapp-openbao` and `rapp-postgres` were already live on the same
reef. `docs/repo-family-bootstrap-contract.md` named fields in prose and
could not catch any of this.
The same survey treated rapp grouping as something that might be derived from
Forgejo organizations or from State Hub domains. Neither works. A repo lives
in exactly one Forgejo org, so `org:repo` is one-to-many. A repo may
legitimately contribute to more than one rapp, so `rapp:repo` is
many-to-many. A many-to-many grouping cannot be derived from a one-to-many
one. Domains fail in both directions. The three dimensions also change at
different speeds.
Canon OAS P1 requires independent perspectives to be separate orthogonal
dimensions. Decision `d07ee5f9` already recorded this from the
`railiance-platform` side; this ADR is the framework record.
## Decision
1. **Bounded rapp context is its own dimension.** It is derived neither from
Forgejo organizations nor from State Hub domains. Grouping is a first-class
declaration, not a projection of some other axis.
2. **Two cardinalities stay distinct.** Repos to rapps is many-to-many: a
repo may appear in the `composition.member_repos` of more than one rapp.
Deployables to rapps is one-to-one: every running deployable has exactly
one rapp that owns its rollout. The coverage question — *does every live
deployable belong to exactly one rapp?* — is well-formed only if these
stay distinct.
3. **Granularity is grouped-by-bounded-context.** One rapp per cohesive group
that deploys, versions, and rolls back together, not one rapp per
deployable. Grouping is legitimate only where members share rollout and
rollback fate. A single-repo rapp is the one-member case of the same
`composition` block, not a second shape.
4. **The schema is normative.** `schemas/rapp.schema.json`,
`schemas/rail.schema.json`, and `schemas/reef.schema.json` define the
shapes. Framework prose cites those files. It does not restate their
fields. Reef `bound_rapps` is a derived projection of `rapp.bound_reefs`,
not a hand-maintained registry.
The detailed shapes, including the single normative form of the rollout,
smoke, and rollback contracts, live in the schema files and
`schemas/README.md`.
## Consequences
- Drift across family declarations fails in
`tools/validate-family-declarations.py` instead of accumulating in prose.
- `railiance-platform` `RAILIANCE-WP-0015-T02` can converge `rapp-openbao`
and `rapp-postgres` onto one shape. Migration belongs to the owning repos;
this ADR does not move any declaration.
- `reef-railiance` must stop treating `bound_rapps: [rapp-qonto]` as source
of truth. The list is already stale.
- Three further `rapp-*` repos (`rapp-secrets-engine`, `rapp-tenant-engine`,
`rapp-user-engine`) carry the family prefix and no declaration. They are
visible to the validator as undeclared and must be declared, renamed, or
retired by their owners.
- Calling the validator from `fix-consistency` still waits on
`the-custodian` admitting the family prefixes into the classification
standard. That sequencing is not this repo's.

View file

@ -1,15 +1,48 @@
# First-Wave `rapp-*` Candidates
Date: 2026-07-25
Updated: 2026-08-13 (RAILIANCE-WP-0021-T08)
## Purpose
Choose and order the first `rapp-*` candidates for Railiance.
Choose and order the first `rapp-*` candidates for Railiance, and record
what was actually built against that plan.
`rapp-*` repos are for managed workload packaging and scaffolding. They are not
new ownership homes. The decision here is therefore not "which repos are
important?" but "which workloads already have a strong enough package boundary
to become first-class managed wrappers without weakening ownership?"
`rapp-*` repos are for managed workload packaging and scaffolding. They are
not new ownership homes. The decision is not "which repos are important?"
but "which workloads already have a strong enough package boundary to become
first-class managed wrappers without weakening ownership?"
## What Was Actually Built
The 2026-07-25 plan was:
1. `rapp-openbao`
2. `rapp-vergabe-teilnahme`
3. `rapp-forgejo` after the forge runtime target stabilized
What exists on 2026-08-13:
| Repo | Status | How it arrived |
|---|---|---|
| `rapp-openbao` | live, declared | as planned |
| `rapp-postgres` | live, declared | ad hoc, from the need for a shared platform database |
| `rapp-qonto` | live, declared | ad hoc, from the need to run Qonto on `rail-knative` |
| `rapp-vergabe-teilnahme` | never built | the user-facing wrapper it existed to prove is unproven |
| `rapp-forgejo` | not built | forge placement is now decided (`d151d817`): workload to `rapp-forgejo`, layer to S4 |
| `rapp-secrets-engine` | exists, undeclared | family prefix, no `declarations/` |
| `rapp-tenant-engine` | exists, undeclared | family prefix, no `declarations/` |
| `rapp-user-engine` | exists, undeclared | family prefix, no `declarations/` |
The platform-service pattern is proven twice (`rapp-openbao`,
`rapp-postgres`). The user-facing wrapper pattern is unproven. That is the
consequence of building to immediate need instead of to the planned second
slot.
The operator decision that follows is: build `rapp-vergabe-teilnahme` to
prove the user-facing shape before mass extraction. Do not treat the three
engine repos, or any other live undeclared workload, as a substitute for
that proof.
## Selection Criteria
@ -23,147 +56,159 @@ The first-wave candidates should satisfy most of the following:
- low ambiguity between packaging ownership and domain ownership
- good demonstration value for later `rapp-*` extractions
## Assessed Candidates
## Planned First-Wave Assessment (2026-07-25)
The original assessment is kept below so the gap between plan and outcome
stays visible. It is not the current worklist.
### 1. OpenBao from `railiance-platform`
Assessment: **choose first**
Assessment: **choose first** — and this one landed.
Why it fits:
Why it fitted:
- It is a clear third-party upstream product with durable identity.
- The workload already has a substantial Railiance packaging surface:
`helm/openbao-values.yaml`, middleware, UI overlay assets, deploy/verify
scripts, and operator runbooks.
- The repo already distinguishes between the OpenBao workload itself and the
wider S3 platform policy that consumes it.
- It is important enough to prove that `rapp-*` is not only for user-facing
apps; platform services can also be managed workloads.
- The workload already had a substantial Railiance packaging surface.
- The repo already distinguished the OpenBao workload from the wider S3
platform policy that consumes it.
- It proved that `rapp-*` is not only for user-facing apps.
What should move into `rapp-openbao`:
- Helm values and Kubernetes-facing package assets for the OpenBao workload
- UI overlay wrapper assets
- generic deploy, verify, and workload-health runbooks
- workload-specific smoke and recovery expectations
What should remain in `railiance-platform`:
- the S3 ownership of secrets custody as a platform capability
- workload lane policy, credential approval, and platform-wide access models
- cross-workload secret-delivery conventions consumed by many workloads
Conclusion:
`rapp-openbao` should be the first `rapp-*` extraction.
What stayed in `railiance-platform`: secrets custody as a platform
capability, lane policy, credential approval, and cross-workload
secret-delivery conventions.
### 2. Forge workload from `railiance-forge`
Assessment: **defer from first wave**
Assessment then: **defer from first wave**. That deferral still holds as a
first-wave fact, but the placement question is now closed. Decision
`d151d817` puts the Forgejo workload in `rapp-forgejo` and the layer
responsibility (runner placement and labels, registry retention, artifact
lifecycle, package credentials) in S4 `railiance-enablement`.
`railiance-forge` is placed, not retired. Gitea is confirmed replaced.
Why it is not first:
- The current live workload is still Gitea on CoulombCore.
- The intended production direction is Forgejo on Railiance01.
- Packaging identity is therefore not stable enough yet: extracting now would
either enshrine a temporary Gitea compatibility package or pretend the
Forgejo cutover is already complete.
- The runtime and migration story still spans current Gitea operation, future
Forgejo deployment, runner substrate, registries, and cutover sequencing.
Decision:
- Do **not** make the forge workload the first `rapp-*` extraction.
- Prefer `rapp-forgejo` as the eventual target package once the production
direction is the real operating target.
- Create `rapp-gitea` only if Railiance discovers it needs a long-lived
compatibility wrapper for the current workload rather than a short migration
bridge.
What this means:
- `railiance-forge` keeps runtime ownership for now.
- The forge wrapper should follow after the current Gitea-versus-Forgejo
packaging identity stops moving.
Conclusion:
The forge workload is packaging-worthy, but it should not be first-wave
`rapp-*`.
`rapp-forgejo` is therefore a wave-2 package with a decided home, not an
open placement debate.
### 3. User-facing S5 workload from `railiance-apps`
Assessment: **choose `vergabe-teilnahme` for wave 1**
Assessment then: **choose `vergabe-teilnahme` for wave 1**. It was never
built. The reasons it was the right user-facing slot remain: one named
workload, explicit release surface, secret handling that is not entangled
with shared platform semantics. That is why it is still the package that
must prove the user-facing shape before mass extraction.
Why `vergabe-teilnahme` wins the S5 slot:
`reuse-surface` remains the strongest follow-on self-built service
candidate. `core-hub` should still wait.
- It is clearly user-facing and already operates as one named workload package.
- Its release surface is explicit: chart, values, ingress, deployment targets,
migration command, smoke checks, and operator runbook.
- Secret handling is clear without being entangled with shared platform
semantics: app credentials are consumed from Kubernetes Secrets and the app
env secret is locally rebuilt by operator procedure.
- It is simpler and more stable than the current Core Hub / Inter-Hub history
and more obviously user-facing than service-style workloads such as
`reuse-surface`.
## Wave-2 Inventory
What should move into `rapp-vergabe-teilnahme`:
Grouped by bounded context, in the composition shape from ADR-0007. These
are indicative packages, not created repos. A package is listed only where
members share rollout and rollback fate. Names that already exist as
undeclared `rapp-*` repos are marked so they are not invented twice.
- the chart and workload values
- the ingress and app-specific release runbook
- workload-specific rollout, migration, smoke, and rollback guidance
- workload-specific secret consumption contract
### `rapp-vergabe-teilnahme` — prove the user-facing wrapper
What should remain in `railiance-apps`:
```yaml
composition:
purpose: Package the Vergabe Teilnahme application as a managed user-facing workload.
member_repos:
- repo: vergabe-teilnahme
role: application source and chart
deployables: [vergabe-teilnahme]
```
- S5 ownership of generic application release patterns
- reusable onboarding and operator recipes
- cross-app S5 guardrails that should not be duplicated per workload package
Build this before any further user-facing extraction.
Second-wave S5 note:
### `rapp-forgejo` — decided placement
- `reuse-surface` is the strongest follow-on self-built service candidate after
`vergabe-teilnahme`, because it already shows explicit OpenBao-backed runtime
secret lanes and a tidy single-workload chart surface.
- `core-hub` and retired `inter-hub` should wait because their packaging
history is still entangled with cutover and legacy service evolution.
```yaml
composition:
purpose: Package Forgejo, its runner, and the package registry as one managed forge.
member_repos:
- repo: railiance-forge
role: operational helm, manifests, runner, and registry assets
deployables: [forgejo, forgejo-runner, forgejo-registry]
```
Conclusion:
Layer concerns stay in `railiance-enablement`. Do not create `rapp-gitea`.
`rapp-vergabe-teilnahme` should be the first user-facing S5 `rapp-*`.
### `rapp-netkingdom-identity` — identity bounded context
## First-Wave Order
```yaml
composition:
purpose: Package NetKingdom identity — users, tenants, SSO, MFA, and authorization — as one rollback unit.
member_repos:
- repo: user-engine
role: user identity
deployables: [user-engine]
- repo: tenant-engine
role: tenant identity
deployables: [tenant-engine]
- repo: key-cape
role: SSO and MFA
deployables: [keycloak]
- repo: flex-auth
role: authorization decisions
deployables: [flex-auth]
```
The recommended order is:
`rapp-user-engine` and `rapp-tenant-engine` already exist as undeclared
prefix claims. They are not separate rapps under grouped-by-bounded-context
granularity. Their owners should either fold them into this package or
justify an independent rollback fate and then declare them. `rapp-secrets-engine`
is the same question against `rapp-openbao` / `railiance-platform`, not a
fourth identity member.
1. `rapp-openbao`
2. `rapp-vergabe-teilnahme`
3. `rapp-forgejo` after the forge runtime target is stable enough to avoid
immediate rename or migration churn
### `rapp-coulomb-social`
This order is intentional.
```yaml
composition:
purpose: Package the Coulomb Social application as a managed user-facing workload.
member_repos:
- repo: coulomb-social
role: application
deployables: [coulomb-social]
```
- `rapp-openbao` proves the third-party platform-service wrapper model.
- `rapp-vergabe-teilnahme` proves the self-built user-facing app wrapper model.
- The forge wrapper then follows with a clearer target identity and after the
first two wrappers have established the pattern.
Do not start this until `rapp-vergabe-teilnahme` has proven the user-facing
shape.
### `rapp-helix-forge`
```yaml
composition:
purpose: Package the Helix Forge methodological workspace as a managed workload.
member_repos:
- repo: helix-forge
role: capability-first development ecosystem
deployables: [helix-forge]
```
Helix Forge is a different lineage from `railiance-forge`. It is not a
rename of `rapp-forgejo`.
## Risks To Watch
- If `rapp-openbao` absorbs platform-wide policy and credential-governance
logic, the wrapper will become a shadow S3 repo.
- If `rapp-vergabe-teilnahme` absorbs generic S5 onboarding or app recipes, it
will weaken `railiance-apps` as the shared S5 release home.
- If the forge wrapper is extracted too early, Railiance will immediately face
a Gitea-versus-Forgejo package rename or parallel-wrapper problem.
logic, the wrapper becomes a shadow S3 repo.
- If `rapp-postgres` is asked to mint the backup account, key, or bucket, a
consumed destination is being mistaken for package ownership. The bucket
is `resource-control` (`RESOURCE-WP-0002`); the credential is
`railiance-platform` OpenBao custody; purchase is decision `9c21c0e0`.
- If `rapp-vergabe-teilnahme` absorbs generic S5 onboarding, it weakens
`railiance-apps`.
- If the three undeclared engine repos are treated as rapps without a
declaration, the coverage check cannot see them and will list their live
deployables as uncovered.
- If wave-2 extraction starts before the user-facing wrapper is proven,
Railiance will mass-produce the platform-service shape and still not know
whether the other shape works.
## Outcome
Railiance now has a concrete first-wave `rapp-*` sequence:
- first `rapp-openbao`
- then `rapp-vergabe-teilnahme`
- then the forge wrapper once the production package identity is stable
That is enough to start repo-local extraction planning without pretending every
workload needs its own `rapp` immediately.
Wave 1 proved the platform-service wrapper, twice, and did not prove the
user-facing wrapper. The next package that must exist is
`rapp-vergabe-teilnahme`. Everything else in the wave-2 inventory waits on
that proof, on owner-side declaration of the three undeclared prefix
claims, and on the schema migration owned by `RAILIANCE-WP-0015-T02`.

View file

@ -65,54 +65,51 @@ described.
The first wave should standardize one obvious declaration path per repo family.
The field lists below are an index, not a specification. The JSON Schema for
each family is normative. If this document and a schema disagree, the schema
wins. Shapes, types, enums, and cardinality live in the schema files; do not
restate them here.
### `rail-*`
Path:
Path: `declarations/rail.yaml`
`declarations/rail.yaml`
Schema: [`schemas/rail.schema.json`](../schemas/rail.schema.json)
Minimum fields:
- `rail_id`
- `ownership_repo`
- `execution_architecture`
- `substrate_prerequisites`
- `supported_rollout_modes`
- `compatibility_notes`
A rail is either `composition_kind: base` or `composition_kind: derived`.
A derived rail names its base contract rather than copying it. See
[`docs/rail-composition-contract.md`](rail-composition-contract.md) and
ADR-0005. The live name for substrate needs is
`required_substrate_capabilities`; the earlier `substrate_prerequisites`
label is retired.
### `rapp-*`
Path:
Path: `declarations/rapp.yaml`
`declarations/rapp.yaml`
Schema: [`schemas/rapp.schema.json`](../schemas/rapp.schema.json)
Minimum fields:
A rapp is a composition: a stated purpose, first-party member repos, and
pinned third-party components. Repos to rapps is many-to-many; deployables
to rapps is one-to-one. See ADR-0007. `bound_reefs` is how a rapp names the
reefs it is placed on; that field is the source of a reef's `bound_rapps`.
- `rapp_id`
- `workload_identity`
- `ownership_repo`
- `supported_rails`
- `runtime_dependencies`
- `rollout_contract`
- `smoke_contract`
- `rollback_contract`
`runtime_dependencies` names capabilities, not the resources that satisfy
them. Who provisions a capability (for example the postgres backup object
store) is owned outside the rapp: the bucket by `resource-control`, the
credential by `railiance-platform`. The rapp consumes a reviewed destination.
### `reef-*`
Path:
Path: `declarations/reef.yaml`
`declarations/reef.yaml`
Schema: [`schemas/reef.schema.json`](../schemas/reef.schema.json)
Minimum fields:
- `reef_id`
- `substrate_kind`
- `ownership_repo`
- `lifecycle_state`
- `criticality`
- `hosted_rails`
- `bound_rapps`
- `primary_rail`
`bound_rapps` is a derived projection of every rapp whose `bound_reefs`
lists this reef. It is not a hand-maintained registry. A reef may omit the
field; if it keeps the field, the validator requires an exact match against
the projection. `primary_rail` must appear in `hosted_rails`. Hosting a rail
or binding a rapp is topology, not readiness — see ADR-0006.
## Required Boundary Rules
@ -145,20 +142,11 @@ defined in `railiance-master`.
## First-Wave Candidates
The first materialization wave should target:
- `rail-kubernetes`
- `rapp-openbao`
- `reef-railiance`
The follow-on first-wave candidates after those anchors are stable:
- `rapp-vergabe-teilnahme`
- `reef-coulombcore`
- `reef-ops-workstations`
This keeps the first concrete rollout narrow while still covering all three new
repo families.
The planned first materialization wave was `rail-kubernetes`, `rapp-openbao`,
`reef-railiance`, then `rapp-vergabe-teilnahme`. What was actually built, and
the wave-2 inventory, is recorded in
[`docs/rapp-first-wave-candidates.md`](rapp-first-wave-candidates.md). Do not
restate that list here.
## Integration Expectations

View file

@ -7,8 +7,8 @@ authority where the two disagree.
| Family | Declaration path | Schema |
|---|---|---|
| `rapp-*` | `declarations/rapp.yaml` | `rapp.schema.json` |
| `rail-*` | `declarations/rail.yaml` | `rail.schema.json` (RAILIANCE-WP-0021-T04) |
| `reef-*` | `declarations/reef.yaml` | `reef.schema.json` (RAILIANCE-WP-0021-T04) |
| `rail-*` | `declarations/rail.yaml` | `rail.schema.json` |
| `reef-*` | `declarations/reef.yaml` | `reef.schema.json` |
Written under `RAILIANCE-WP-0021` from the drift survey routed by
`railiance-platform` under its `RAILIANCE-WP-0015` (State Hub messages
@ -34,7 +34,7 @@ Rapp bounded context is therefore its own dimension, derived neither from
Forgejo organizations nor from State Hub domains. A repo lives in exactly one
Forgejo org, so `org:repo` is one-to-many, and a many-to-many grouping cannot
be derived from a one-to-many one. Domains fail in both directions. See
`docs/adr/ADR-0007` (T07) and decision `d07ee5f9`.
`docs/adr/ADR-0007-rapp-declaration-contract.md` and decision `d07ee5f9`.
## Normative choices, and what they cost
@ -89,12 +89,97 @@ layer repo that owns dozens of unrelated workloads. That is a modelling call
about where accountability sits, not a lint, and it belongs to the architecture
owner. Until it is made, treat the permissive rule as provisional.
## Validating
## Validator-only constraints
The validator lands under `tools/` in `RAILIANCE-WP-0021-T05`. Until then these
schemas are normative but unenforced — which is precisely the condition that
produced the drift, so T05 should not lag T02/T03 by long.
JSON Schema cannot express these; `tools/validate-family-declarations.py` does:
Two constraints are validator-only, because JSON Schema cannot express them:
`primary_rail` must appear in `supported_rails`, and reef `bound_rapps` must be
derivable from the rapp declarations rather than hand-listed.
- `primary_rail` must appear in `supported_rails` (rapp) or `hosted_rails` (reef)
- reef `bound_rapps`, if present, must match the projection of `rapp.bound_reefs`
- 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
## Rail schema (T04)
`rail.schema.json` codifies the two live rails. It does not tighten the family
beyond current practice.
Required on every rail: `kind`, `rail_id`, `repo`, `ownership_repo`,
`contract_version`, `composition_kind`, `execution_architecture`,
`readiness_state`, `required_substrate_capabilities`, `supported_rollout_modes`.
`composition_kind: derived` additionally requires `base_rail`,
`base_rail_contract`, `inherited_semantics`, `overridden_semantics`, and
`compatibility_constraints`. A `base` rail must not carry those fields.
`required_substrate_capabilities` is the live name. The bootstrap contract's
older `substrate_prerequisites` label is retired; do not reintroduce it.
Rail `readiness_state` uses the composition-contract vocabulary (`declared`,
`installed`, `verified`, `production-approved`, `deprecated`). That is
deliberately not the rapp vocabulary and not the reef `lifecycle_state`.
Validated 2026-08-13 against both live rails with `jsonschema` 4.10.3: both
conform. That is the intended contrast with the rapp family.
## Reef schema (T04)
`reef.schema.json` codifies the live `reef-railiance` declaration, with one
normative change: `bound_rapps` is no longer a hand-maintained required field.
Required on every reef: `kind`, `reef_id`, `repo`, `ownership_repo`,
`substrate_kind`, `lifecycle_state`, `criticality`, `primary_rail`,
`hosted_rails`, `current_members`.
`bound_rapps` is optional. Its source of truth is `rapp.bound_reefs`. The
validator inverts that field. If a reef still lists `bound_rapps`, the list
must match the projection exactly.
**Migration for `reef-railiance`.** The current list is `[rapp-qonto]`. That
is already stale: `rapp-openbao` and `rapp-postgres` are live on Railiance01.
Until those three declarations name `reef-railiance` in `bound_reefs`, the
derived projection is empty and a hand-list of anything fails. The owning
repo should delete `bound_rapps` now, or replace it with the derived set once
the rapps name the reef. Either is a one-line change; leaving the stale list
is the failure mode this check exists to catch.
`rapp.bound_reefs` was added as a required field in the same task. An empty
list is legal and means the package is declared but not yet placed.
Validated 2026-08-13 against the live reef: it conforms as a document. The
`bound_rapps` value does not survive the derivation check, which is the
intended result.
## Runtime dependencies name capabilities, not resources
`runtime_dependencies` is a list of capability names (`s3-backup-target`,
`openbao-database-secrets-engine`). The schema does not, and must not, say
who provisions the thing that satisfies a name.
The postgres backup object store is the worked example. `rapp-postgres`
declares `s3-backup-target` as a consumed capability. The bucket is a
`resource-control` resource (`RESOURCE-WP-0002`). The credential is
`railiance-platform` OpenBao custody (`Secret platform-pg-backup-s3`).
Purchase itself is the escalated human decision `9c21c0e0`. Until
`resource-control` hands an endpoint to `railiance-platform`,
`make postgres-backup-deploy` stays fail-closed on purpose. That is not a
hole in `rapp-postgres` ownership and not a hole in this schema.
## Running the validator
```bash
tools/validate-family-declarations.py
tools/validate-family-declarations.py --root ..
tools/validate-family-declarations.py --inventory path/to/live-deployables.json
tools/validate-family-declarations.py --self-test
```
`--inventory` is the T06 coverage check. This repo does not query a cluster.
The file is produced by an implementation repo and must be a JSON mapping
with a `deployables` list of `{name, namespace?, kind?}` (or bare name
strings). Live names that no rapp claims are reported as wave-2 worklist
items, not suppressed.
The validator is `tools/validate-family-declarations.py` (RAILIANCE-WP-0021-T05).
It is runnable standalone now. Calling it from `fix-consistency` waits on
`the-custodian` admitting the family prefixes; that sequencing is not ours.

178
schemas/rail.schema.json Normal file
View file

@ -0,0 +1,178 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://railiance.dev/schemas/rail.schema.json",
"title": "Railiance rail declaration",
"description": "Normative shape for declarations/rail.yaml in every rail-* repo. Authored by railiance-master under RAILIANCE-WP-0021-T04 from the two live rails and docs/rail-composition-contract.md. Codifies current practice; it does not tighten the rail family beyond what both live declarations already carry.",
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"rail_id",
"repo",
"ownership_repo",
"contract_version",
"composition_kind",
"execution_architecture",
"readiness_state",
"required_substrate_capabilities",
"supported_rollout_modes"
],
"properties": {
"kind": {
"const": "execution-rail",
"description": "Fixed discriminator. Both live rails already agree on this value."
},
"rail_id": {
"$ref": "#/$defs/railSlug",
"description": "Canonical identifier for the rail. Must equal the repo slug."
},
"repo": {
"$ref": "#/$defs/repoSlug",
"description": "The rail-* repo holding this declaration."
},
"ownership_repo": {
"$ref": "#/$defs/repoSlug",
"description": "The ownership-axis repo accountable for this execution contract. Both live rails name railiance-cluster."
},
"contract_version": {
"$ref": "#/$defs/semver",
"description": "Version of this rail contract. A derived rail pins the base contract with base_rail_contract, which is a separate field."
},
"composition_kind": {
"enum": ["base", "derived"],
"description": "base owns a complete workload execution contract for a substrate class. derived inherits a versioned base-rail contract and owns only its specialization. See docs/rail-composition-contract.md and ADR-0005."
},
"execution_architecture": {
"type": "string",
"minLength": 3,
"description": "The operations architecture this rail names, e.g. kubernetes, knative-serving-on-kubernetes. Free-form on purpose: a new rail is justified by new semantics, not by an enum update."
},
"readiness_state": {
"enum": ["declared", "installed", "verified", "production-approved", "deprecated"],
"description": "Readiness of the rail contract itself, matching docs/rail-composition-contract.md. Distinct from rapp readiness_state (draft/declared/deployed/verified/retired) and from reef lifecycle_state. Do not unify the three vocabularies without a separate decision."
},
"required_substrate_capabilities": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "Capabilities the substrate must publish before this rail is usable. This is the live name; the bootstrap contract's older substrate_prerequisites label is retired in favour of this field."
},
"supported_rollout_modes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "Rollout modes this rail supports, e.g. stage1-run, revision-canary."
},
"default_for": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "Workload classes this rail is the default for. Present on the base rail today; optional so a derived rail is not forced to claim default status."
},
"compatibility_notes": {
"$ref": "#/$defs/notes",
"description": "Human-readable caveats. Never load-bearing."
},
"base_rail": {
"$ref": "#/$defs/railSlug",
"description": "Required on derived rails. The rail whose contract is inherited."
},
"base_rail_contract": {
"$ref": "#/$defs/semver",
"description": "Required on derived rails. Exact contract_version of the base rail this derivation is written against. Ranges are not pins."
},
"inherited_semantics": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "Required on derived rails. Semantics taken from the base contract rather than restated."
},
"overridden_semantics": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "Required on derived rails. The only semantics this rail owns. If this list is empty the derived rail has no reason to exist."
},
"compatibility_constraints": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 },
"description": "Required on derived rails. Constraints a substrate or binding must satisfy to use this derivation."
},
"source_documents": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/sourceDocument" }
}
},
"allOf": [
{
"description": "A derived rail must name its base contract and the semantics it inherits or overrides.",
"if": {
"properties": { "composition_kind": { "const": "derived" } },
"required": ["composition_kind"]
},
"then": {
"required": [
"base_rail",
"base_rail_contract",
"inherited_semantics",
"overridden_semantics",
"compatibility_constraints"
]
}
},
{
"description": "A base rail must not carry derived-only fields. Those fields would imply an inheritance it does not have.",
"if": {
"properties": { "composition_kind": { "const": "base" } },
"required": ["composition_kind"]
},
"then": {
"not": {
"anyOf": [
{ "required": ["base_rail"] },
{ "required": ["base_rail_contract"] },
{ "required": ["inherited_semantics"] },
{ "required": ["overridden_semantics"] },
{ "required": ["compatibility_constraints"] }
]
}
}
}
],
"$defs": {
"repoSlug": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 2
},
"railSlug": {
"type": "string",
"pattern": "^rail-[a-z0-9]+(-[a-z0-9]+)*$"
},
"semver": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
},
"notes": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"description": "Human-readable caveats. Never load-bearing: nothing may depend on parsing a note."
},
"sourceDocument": {
"type": "object",
"additionalProperties": false,
"required": ["repo", "path"],
"properties": {
"repo": { "$ref": "#/$defs/repoSlug" },
"path": { "type": "string", "minLength": 1 }
}
}
}
}

View file

@ -18,6 +18,7 @@
"criticality",
"primary_rail",
"supported_rails",
"bound_reefs",
"runtime_dependencies",
"composition",
"rollout_contract",
@ -73,6 +74,12 @@
"uniqueItems": true,
"items": { "$ref": "#/$defs/railSlug" }
},
"bound_reefs": {
"type": "array",
"uniqueItems": true,
"items": { "$ref": "#/$defs/reefSlug" },
"description": "Reefs this package is bound to. Added by RAILIANCE-WP-0021-T04 as the source of reef.yaml bound_rapps. Required so the derivation is always defined: an empty list means the package is declared but not yet placed. The validator inverts this field to produce each reef's bound_rapps."
},
"runtime_dependencies": {
"type": "array",
"minItems": 1,
@ -128,6 +135,10 @@
"type": "string",
"pattern": "^rail-[a-z0-9]+(-[a-z0-9]+)*$"
},
"reefSlug": {
"type": "string",
"pattern": "^reef-[a-z0-9]+(-[a-z0-9]+)*$"
},
"semver": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"

118
schemas/reef.schema.json Normal file
View file

@ -0,0 +1,118 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://railiance.dev/schemas/reef.schema.json",
"title": "Railiance reef declaration",
"description": "Normative shape for declarations/reef.yaml in every reef-* repo. Authored by railiance-master under RAILIANCE-WP-0021-T04 from the live reef-railiance declaration and docs/reef-substrate-model.md. bound_rapps is a derived projection of rapp.bound_reefs, not a hand-maintained registry.",
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"reef_id",
"repo",
"ownership_repo",
"substrate_kind",
"lifecycle_state",
"criticality",
"primary_rail",
"hosted_rails",
"current_members"
],
"properties": {
"kind": {
"const": "substrate-reef",
"description": "Fixed discriminator. The live reef already uses this value."
},
"reef_id": {
"$ref": "#/$defs/reefSlug",
"description": "Canonical identifier for the reef. Must equal the repo slug."
},
"repo": {
"$ref": "#/$defs/repoSlug",
"description": "The reef-* repo holding this declaration."
},
"ownership_repo": {
"$ref": "#/$defs/repoSlug",
"description": "The ownership-axis repo accountable for this substrate boundary. The live reef names railiance-infra."
},
"substrate_kind": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 3,
"description": "What kind of substrate this reef is. Live value is server-group. Kept as a slug rather than a closed enum so a new substrate shape does not require a schema change. See docs/reef-substrate-model.md."
},
"lifecycle_state": {
"enum": ["planned", "active", "retired"],
"description": "Lifecycle of the reef as a substrate boundary. Distinct from rail readiness_state and from the per-binding admission states in docs/reef-production-readiness-contract.md (declared/installed/verified/production-approved/deprecated)."
},
"criticality": {
"enum": ["low", "medium", "high", "critical"],
"description": "Operational criticality of the substrate. Same vocabulary as rapp.yaml. The live reef is high."
},
"primary_rail": {
"$ref": "#/$defs/railSlug",
"description": "The default rail on this reef. Must appear in hosted_rails. The validator enforces that sibling check."
},
"hosted_rails": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/$defs/railSlug" },
"description": "Rails this reef hosts or is prepared to host. Topology, not readiness: hosting a rail does not make it production-approved. See ADR-0006."
},
"bound_rapps": {
"type": "array",
"uniqueItems": true,
"items": { "$ref": "#/$defs/rappSlug" },
"description": "DERIVED. Projection of every rapp declaration whose bound_reefs lists this reef. Optional so a reef may omit the field and let the validator compute it. If present, the validator requires an exact match against that projection. reef-railiance currently hand-lists rapp-qonto only; that list is already stale (rapp-openbao and rapp-postgres are live on the same reef). Migration: either delete the field, or replace it with the derived set once those rapps name this reef."
},
"current_members": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "Named machines or units that currently compose this reef. Not slugs: live value is Railiance01. A reef is a substrate boundary, not a hostname list, but the members must be named so the boundary is inspectable."
},
"compatibility_notes": {
"$ref": "#/$defs/notes",
"description": "Human-readable caveats. Never load-bearing."
},
"source_documents": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/sourceDocument" }
}
},
"$defs": {
"repoSlug": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 2
},
"railSlug": {
"type": "string",
"pattern": "^rail-[a-z0-9]+(-[a-z0-9]+)*$"
},
"rappSlug": {
"type": "string",
"pattern": "^rapp-[a-z0-9]+(-[a-z0-9]+)*$"
},
"reefSlug": {
"type": "string",
"pattern": "^reef-[a-z0-9]+(-[a-z0-9]+)*$"
},
"notes": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"description": "Human-readable caveats. Never load-bearing: nothing may depend on parsing a note."
},
"sourceDocument": {
"type": "object",
"additionalProperties": false,
"required": ["repo", "path"],
"properties": {
"repo": { "$ref": "#/$defs/repoSlug" },
"path": { "type": "string", "minLength": 1 }
}
}
}
}

View file

@ -0,0 +1,9 @@
{
"source": "fixture",
"captured_at": "2026-08-13T00:00:00Z",
"reef": "reef-example",
"deployables": [
{"name": "example", "namespace": "example", "kind": "helm-release"},
{"name": "undeclared-live", "namespace": "other", "kind": "deployment"}
]
}

View file

@ -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

View file

@ -0,0 +1,33 @@
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: []
runtime_dependencies:
- kubernetes-api
composition:
purpose: Example rapp that does not name a reef.
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

View file

@ -0,0 +1 @@
Undeclared family-prefix fixture for the validator. No declarations/.

View file

@ -0,0 +1,14 @@
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
bound_rapps:
- rapp-example
current_members:
- Example01

View file

@ -0,0 +1,8 @@
{
"source": "fixture",
"captured_at": "2026-08-13T00:00:00Z",
"reef": "reef-example",
"deployables": [
{"name": "example", "namespace": "example", "kind": "helm-release"}
]
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,34 @@
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: Example single-repo rapp used as a validator fixture.
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

View file

@ -0,0 +1,15 @@
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
bound_rapps:
- rapp-example
current_members:
- Example01

View file

@ -0,0 +1,596 @@
#!/usr/bin/env python3
"""Validate rail.yaml, rapp.yaml, and reef.yaml family declarations.
RAILIANCE-WP-0021-T05.
Discovers rail-*, rapp-*, and reef-* repos under a root (default: the
parent of this repository), loads each declarations/<family>.yaml, and
checks, in this order:
1. the file conforms to its family schema
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
This repo does not query a cluster. Live coverage consumes an inventory
file produced by an implementation repo (RAILIANCE-WP-0021-T06).
Usage:
tools/validate-family-declarations.py
tools/validate-family-declarations.py --root /path/to/siblings
tools/validate-family-declarations.py --repo /path/to/rapp-openbao
tools/validate-family-declarations.py --inventory inventory.json
tools/validate-family-declarations.py --self-test
Exit 0 if there are no errors (warnings are allowed). Exit 1 on any error.
Depends on PyYAML and jsonschema, the same pair used to author the schemas.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
import yaml
from jsonschema import Draft202012Validator
REPO_ROOT = Path(__file__).resolve().parent.parent
SCHEMA_DIR = REPO_ROOT / "schemas"
TESTDATA = Path(__file__).resolve().parent / "testdata" / "family-declarations"
FAMILY_PREFIXES = ("rail-", "rapp-", "reef-")
DECL_BY_PREFIX = {
"rail-": ("rail.yaml", "rail.schema.json"),
"rapp-": ("rapp.yaml", "rapp.schema.json"),
"reef-": ("reef.yaml", "reef.schema.json"),
}
FLOATING_PIN = re.compile(r"^(latest|[\^~*]|.*\*|.*x$)", re.IGNORECASE)
SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
@dataclass
class Finding:
path: str
field: str
message: str
level: str = "error"
def __str__(self) -> str:
loc = self.field if self.field else "(file)"
return f"{self.path}: {self.level}: {loc}: {self.message}"
@dataclass
class Loaded:
repo_dir: Path
rel: str
family: str
doc: dict[str, Any]
@dataclass
class Report:
findings: list[Finding] = field(default_factory=list)
files: int = 0
def add(self, path: str, field: str, message: str, level: str = "error") -> None:
self.findings.append(Finding(path, field, message, level))
@property
def errors(self) -> list[Finding]:
return [f for f in self.findings if f.level == "error"]
@property
def warnings(self) -> list[Finding]:
return [f for f in self.findings if f.level == "warning"]
def load_yaml(path: Path) -> Any:
with path.open() as fh:
return yaml.safe_load(fh)
def load_schemas() -> dict[str, Draft202012Validator]:
validators: dict[str, Draft202012Validator] = {}
for prefix, (_decl, schema_name) in DECL_BY_PREFIX.items():
family = prefix.rstrip("-")
schema = load_yaml(SCHEMA_DIR / schema_name)
Draft202012Validator.check_schema(schema)
validators[family] = Draft202012Validator(schema)
return validators
def discover_repos(root: Path, extra: Iterable[Path] = ()) -> list[Path]:
found: dict[str, Path] = {}
if root.is_dir():
for child in sorted(root.iterdir()):
if child.is_dir() and child.name.startswith(FAMILY_PREFIXES):
found[child.name] = child
for repo in extra:
repo = repo.resolve()
if repo.is_dir():
found[repo.name] = repo
return [found[name] for name in sorted(found)]
def family_of(repo_dir: Path) -> str | None:
for prefix in FAMILY_PREFIXES:
if repo_dir.name.startswith(prefix):
return prefix.rstrip("-")
return None
def json_path(error: Any) -> str:
parts = [str(p) for p in error.absolute_path]
return ".".join(parts) if parts else "(root)"
def schema_check(loaded: Loaded, validator: Draft202012Validator, report: Report) -> None:
for error in sorted(validator.iter_errors(loaded.doc), key=lambda e: list(e.absolute_path)):
report.add(loaded.rel, json_path(error), error.message)
def as_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def check_rail(loaded: Loaded, report: Report) -> None:
doc = loaded.doc
if not isinstance(doc, dict):
return
if doc.get("rail_id") and doc.get("repo") and doc["rail_id"] != doc["repo"]:
report.add(loaded.rel, "rail_id", f"{doc['rail_id']!r} must equal repo {doc['repo']!r}")
def check_rapp(loaded: Loaded, report: Report) -> None:
doc = loaded.doc
if not isinstance(doc, dict):
return
primary = doc.get("primary_rail")
supported = as_list(doc.get("supported_rails"))
if primary and supported and primary not in supported:
report.add(
loaded.rel,
"primary_rail",
f"{primary!r} is not in supported_rails {supported}",
)
repo = doc.get("repo")
owner = doc.get("ownership_repo")
if repo and owner and repo == owner:
report.add(
loaded.rel,
"ownership_repo",
"must not be the rapp repo itself",
)
for component in as_list((doc.get("composition") or {}).get("upstream_components")):
if not isinstance(component, dict):
continue
version = str(component.get("version") or "")
name = component.get("name") or "(unnamed)"
if not version or FLOATING_PIN.match(version):
report.add(
loaded.rel,
f"composition.upstream_components.{name}.version",
f"{version!r} is not an exact pin",
)
def check_reef(loaded: Loaded, report: Report) -> None:
doc = loaded.doc
if not isinstance(doc, dict):
return
if doc.get("reef_id") and doc.get("repo") and doc["reef_id"] != doc["repo"]:
report.add(loaded.rel, "reef_id", f"{doc['reef_id']!r} must equal repo {doc['repo']!r}")
primary = doc.get("primary_rail")
hosted = as_list(doc.get("hosted_rails"))
if primary and hosted and primary not in hosted:
report.add(
loaded.rel,
"primary_rail",
f"{primary!r} is not in hosted_rails {hosted}",
)
def present_slugs(repos: list[Path]) -> set[str]:
return {repo.name for repo in repos}
def resolve_family_slug(slug: Any, present: set[str], root: Path) -> bool:
if not isinstance(slug, str) or not SLUG.match(slug):
return False
if slug in present:
return True
return (root / slug).is_dir()
def check_resolution(loaded: Loaded, present: set[str], root: Path, report: Report) -> None:
doc = loaded.doc
if not isinstance(doc, dict):
return
family = loaded.family
def need(field: str, slug: Any) -> None:
if not isinstance(slug, str):
return
if not slug.startswith(FAMILY_PREFIXES):
return
if not resolve_family_slug(slug, present, root):
report.add(loaded.rel, field, f"{slug} does not resolve under {root}")
if family == "rapp":
need("primary_rail", doc.get("primary_rail"))
for i, rail in enumerate(as_list(doc.get("supported_rails"))):
need(f"supported_rails[{i}]", rail)
for i, reef in enumerate(as_list(doc.get("bound_reefs"))):
need(f"bound_reefs[{i}]", reef)
members = as_list((doc.get("composition") or {}).get("member_repos"))
for i, member in enumerate(members):
if not isinstance(member, dict):
continue
slug = member.get("repo")
if not isinstance(slug, str):
continue
if slug == doc.get("repo"):
continue
if slug.startswith(FAMILY_PREFIXES) and not resolve_family_slug(slug, present, root):
report.add(
loaded.rel,
f"composition.member_repos[{i}].repo",
f"{slug} does not resolve under {root}",
)
elif not slug.startswith(FAMILY_PREFIXES) and not (root / slug).is_dir():
report.add(
loaded.rel,
f"composition.member_repos[{i}].repo",
f"{slug} is not present under {root}",
level="warning",
)
elif family == "rail":
need("base_rail", doc.get("base_rail"))
elif family == "reef":
need("primary_rail", doc.get("primary_rail"))
for i, rail in enumerate(as_list(doc.get("hosted_rails"))):
need(f"hosted_rails[{i}]", rail)
def check_bound_rapps(loaded_docs: list[Loaded], report: Report) -> None:
derived: dict[str, set[str]] = defaultdict(set)
rapp_ids: set[str] = set()
for loaded in loaded_docs:
if loaded.family != "rapp" or not isinstance(loaded.doc, dict):
continue
rapp_id = loaded.doc.get("rapp_id")
if isinstance(rapp_id, str):
rapp_ids.add(rapp_id)
for reef in as_list(loaded.doc.get("bound_reefs")):
if isinstance(reef, str) and isinstance(rapp_id, str):
derived[reef].add(rapp_id)
for loaded in loaded_docs:
if loaded.family != "reef" or not isinstance(loaded.doc, dict):
continue
reef_id = loaded.doc.get("reef_id")
if not isinstance(reef_id, str):
continue
expected = sorted(derived.get(reef_id, set()))
declared = loaded.doc.get("bound_rapps")
if declared is None:
if expected:
report.add(
loaded.rel,
"bound_rapps",
f"omitted; derived projection is {expected}",
level="warning",
)
continue
if not isinstance(declared, list):
continue
actual = sorted(str(item) for item in declared)
if actual != expected:
report.add(
loaded.rel,
"bound_rapps",
f"hand-listed {actual} != derived {expected}",
)
unknown = [item for item in actual if item not in rapp_ids and item.startswith("rapp-")]
for item in unknown:
report.add(
loaded.rel,
"bound_rapps",
f"{item} is listed but no rapp declaration was loaded",
)
def check_deployable_uniqueness(loaded_docs: list[Loaded], report: Report) -> None:
owners = declared_deployables(loaded_docs)
for deployable, claimed in owners.items():
rapps = sorted({rapp for _path, rapp in claimed})
if len(rapps) > 1:
paths = ", ".join(f"{path} ({rapp})" for path, rapp in claimed)
report.add(
claimed[0][0],
f"composition.member_repos.deployables.{deployable}",
f"claimed by more than one rapp: {rapps} via {paths}",
)
def declared_deployables(loaded_docs: list[Loaded]) -> dict[str, list[tuple[str, str]]]:
owners: dict[str, list[tuple[str, str]]] = defaultdict(list)
for loaded in loaded_docs:
if loaded.family != "rapp" or not isinstance(loaded.doc, dict):
continue
rapp_id = str(loaded.doc.get("rapp_id") or loaded.repo_dir.name)
members = as_list((loaded.doc.get("composition") or {}).get("member_repos"))
for member in members:
if not isinstance(member, dict):
continue
for deployable in as_list(member.get("deployables")):
if isinstance(deployable, str):
owners[deployable].append((loaded.rel, rapp_id))
return owners
def load_inventory(path: Path) -> tuple[dict[str, Any] | None, Finding | None]:
try:
raw = path.read_text()
data = json.loads(raw)
except FileNotFoundError:
return None, Finding(str(path), "(file)", "inventory file not found")
except json.JSONDecodeError as exc:
return None, Finding(str(path), "(file)", f"inventory is not JSON: {exc}")
if not isinstance(data, dict):
return None, Finding(str(path), "(root)", "inventory must be a mapping")
deployables = data.get("deployables")
if not isinstance(deployables, list):
return None, Finding(str(path), "deployables", "must be a list")
return data, None
def check_inventory_coverage(
loaded_docs: list[Loaded],
inventory: dict[str, Any],
inventory_path: str,
report: Report,
) -> None:
owners = declared_deployables(loaded_docs)
live_names: list[str] = []
for i, item in enumerate(as_list(inventory.get("deployables"))):
if isinstance(item, str):
name = item
elif isinstance(item, dict) and isinstance(item.get("name"), str):
name = item["name"]
else:
report.add(inventory_path, f"deployables[{i}]", "each entry needs a name")
continue
live_names.append(name)
claimed = owners.get(name, [])
if not claimed:
report.add(
inventory_path,
f"deployables.{name}",
"live deployable is not claimed by any rapp (wave-2 worklist item)",
)
elif len({rapp for _path, rapp in claimed}) > 1:
rapps = sorted({rapp for _path, rapp in claimed})
report.add(
inventory_path,
f"deployables.{name}",
f"live deployable is claimed by more than one rapp: {rapps}",
)
live_set = set(live_names)
for name, claimed in owners.items():
if name not in live_set:
report.add(
claimed[0][0],
f"composition.member_repos.deployables.{name}",
"declared deployable is not in the live inventory",
level="warning",
)
def check_undeclared(
repos: list[Path], loaded_docs: list[Loaded], root: Path, report: Report
) -> None:
declared = {loaded.repo_dir.resolve() for loaded in loaded_docs}
for repo in repos:
if repo.resolve() in declared:
continue
family = family_of(repo)
if family is None:
continue
decl_name, _schema = DECL_BY_PREFIX[f"{family}-"]
path = repo / "declarations" / decl_name
try:
rel = str(path.relative_to(root))
except ValueError:
rel = str(path)
report.add(
rel,
"(file)",
f"{repo.name} claims the {family}- prefix but has no declarations/{decl_name}",
)
def load_repo(repo_dir: Path, root: Path) -> tuple[Loaded | None, Finding | None]:
family = family_of(repo_dir)
if family is None:
return None, Finding(str(repo_dir), "(file)", "not a rail-*, rapp-*, or reef-* repo")
decl_name, _schema = DECL_BY_PREFIX[f"{family}-"]
path = repo_dir / "declarations" / decl_name
if not path.is_file():
return None, None
try:
rel = str(path.relative_to(root))
except ValueError:
rel = str(path)
try:
doc = load_yaml(path)
except yaml.YAMLError as exc:
return None, Finding(rel, "(file)", f"YAML parse error: {exc}")
if not isinstance(doc, dict):
return None, Finding(rel, "(root)", "declaration must be a mapping")
return Loaded(repo_dir=repo_dir, rel=rel, family=family, doc=doc), None
def validate(
root: Path,
repos: list[Path],
validators: dict[str, Draft202012Validator],
inventory_path: Path | None = None,
) -> Report:
report = Report()
loaded_docs: list[Loaded] = []
present = present_slugs(repos)
for repo in repos:
loaded, finding = load_repo(repo, root)
if finding is not None:
report.findings.append(finding)
continue
if loaded is None:
continue
report.files += 1
loaded_docs.append(loaded)
schema_check(loaded, validators[loaded.family], report)
if loaded.family == "rail":
check_rail(loaded, report)
elif loaded.family == "rapp":
check_rapp(loaded, report)
elif loaded.family == "reef":
check_reef(loaded, report)
check_resolution(loaded, present, root, report)
check_bound_rapps(loaded_docs, report)
check_deployable_uniqueness(loaded_docs, report)
check_undeclared(repos, loaded_docs, root, report)
if inventory_path is not None:
inventory, finding = load_inventory(inventory_path)
if finding is not None:
report.findings.append(finding)
elif inventory is not None:
check_inventory_coverage(loaded_docs, inventory, str(inventory_path), report)
return report
def print_report(report: Report) -> None:
if not report.findings:
print(f"{report.files} declaration(s) ok")
return
for finding in report.findings:
print(finding)
print(
f"{report.files} declaration(s), "
f"{len(report.errors)} error(s), "
f"{len(report.warnings)} warning(s)"
)
def self_test() -> int:
validators = load_schemas()
failures: list[str] = []
good_root = TESTDATA / "good"
good = validate(
good_root,
discover_repos(good_root),
validators,
inventory_path=TESTDATA / "good" / "inventory.json",
)
if good.errors:
failures.append("good fixture produced errors:\n " + "\n ".join(str(f) for f in good.errors))
bad_root = TESTDATA / "bad-stale-bound"
bad = validate(
bad_root,
discover_repos(bad_root),
validators,
inventory_path=TESTDATA / "bad-stale-bound" / "inventory.json",
)
messages = "\n".join(str(f) for f in bad.errors)
if not any("hand-listed" in f.message for f in bad.errors):
failures.append(f"bad-stale-bound did not flag bound_rapps drift:\n{messages}")
if not any("rapp-orphan" in f.path or "rapp-orphan" in f.message for f in bad.errors):
failures.append(f"bad-stale-bound did not flag undeclared rapp-orphan:\n{messages}")
if not any("wave-2 worklist" in f.message for f in bad.errors):
failures.append(f"bad-stale-bound did not flag uncovered live deployable:\n{messages}")
if not bad.errors:
failures.append("bad-stale-bound produced no errors")
live_rails = [
Path("/home/worsch/rail-kubernetes"),
Path("/home/worsch/rail-knative"),
]
if all(path.is_dir() for path in live_rails):
live_root = Path("/home/worsch")
live = validate(live_root, live_rails, validators)
rail_errors = [f for f in live.errors if "/rail-" in f.path or f.path.startswith("rail-")]
if rail_errors:
failures.append(
"live rails no longer conform:\n " + "\n ".join(str(f) for f in rail_errors)
)
if failures:
print("self-test FAILED")
for item in failures:
print(item)
return 1
print("self-test ok")
print(f" good: {good.files} file(s), {len(good.errors)} error(s)")
print(f" bad-stale-bound: {bad.files} file(s), {len(bad.errors)} error(s) (expected)")
return 0
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
parser.add_argument(
"--root",
type=Path,
default=REPO_ROOT.parent,
help="directory that holds rail-*/rapp-*/reef-* siblings (default: parent of this repo)",
)
parser.add_argument(
"--repo",
type=Path,
action="append",
default=[],
help="extra family repo to include (repeatable)",
)
parser.add_argument(
"--inventory",
type=Path,
help="JSON inventory of live deployables produced by an implementation repo",
)
parser.add_argument(
"--self-test",
action="store_true",
help="run fixture checks and exit",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv if argv is not None else sys.argv[1:])
if args.self_test:
return self_test()
try:
validators = load_schemas()
except Exception as exc: # noqa: BLE001 — surface schema load failures cleanly
print(f"failed to load schemas from {SCHEMA_DIR}: {exc}", file=sys.stderr)
return 2
root = args.root.resolve()
repos = discover_repos(root, args.repo)
if not repos:
print(f"no rail-*, rapp-*, or reef-* repos under {root}", file=sys.stderr)
return 2
report = validate(root, repos, validators, inventory_path=args.inventory)
print_report(report)
return 1 if report.errors else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -4,13 +4,13 @@ type: workplan
title: "Rapp Declaration Schema and Family Declaration Validator"
domain: financials
repo: railiance-master
status: active
status: finished
owner: codex
topic_slug: railiance
planning_priority: high
planning_order: 21
created: "2026-08-11"
updated: "2026-08-11"
updated: "2026-08-13"
related_repos:
- railiance-master
- railiance-platform
@ -303,7 +303,7 @@ whose members carry their own package types.
```task
id: RAILIANCE-WP-0021-T04
status: todo
status: done
priority: medium
state_hub_task_id: "50ffbff9-f8f0-4085-975a-6c5123d9c2f0"
```
@ -317,11 +317,30 @@ Change `reef.yaml` `bound_rapps` from a hand-maintained list to a derived
projection of the rapp declarations that name the reef, and record the
migration path for `reef-railiance`, whose current list is already known stale.
**Done 2026-08-13.** `schemas/rail.schema.json` and `schemas/reef.schema.json`
written from the live declarations and the composition/substrate contracts,
not from the bootstrap field lists (those lists used an older
`substrate_prerequisites` name the live rails do not carry).
Both live rails conform. The live reef conforms as a document.
`bound_rapps` is optional and derived from a new required `rapp.bound_reefs`
field (empty list means declared but not placed). The live
`reef-railiance` hand-list `[rapp-qonto]` fails the derivation check, which
is the intended result. Migration for that repo: delete the field now, or
replace it with the derived set once the three live rapps name the reef.
Adjacent ownership, recorded so it is not mistaken for a schema hole: the
postgres backup object store is a consumed capability (`s3-backup-target`),
not a rapp-owned resource. Bucket = `resource-control` / `RESOURCE-WP-0002`.
Credential = `railiance-platform` OpenBao `Secret platform-pg-backup-s3`.
Purchase = decision `9c21c0e0`. `make postgres-backup-deploy` stays
fail-closed until that handoff lands.
## T05 - Build the family declaration validator
```task
id: RAILIANCE-WP-0021-T05
status: todo
status: done
priority: high
state_hub_task_id: "0a212d33-2169-4472-80d0-69ec4edba5cb"
```
@ -342,11 +361,22 @@ runnable standalone now, and callable from `fix-consistency` later; do not
block on canon admitting the prefixes, since that is `the-custodian`'s
sequencing, not ours.
**Done 2026-08-13.** `tools/validate-family-declarations.py` walks
`rail-*` / `rapp-*` / `reef-*` siblings of `--root` (default: parent of
this repo). `--self-test` passes against `tools/testdata/family-declarations`.
First live scan of `/home/worsch` (no inventory): 6 declarations, 31
errors. The two rails are clean. The three declared rapps fail on the
already-known shape drift plus the new `bound_reefs` field. The reef fails
only on stale `bound_rapps`. The three undeclared engine repos fail as
prefix-without-declaration. That is exactly the drift the schema exists to
make loud.
## T06 - Add the deployable coverage check
```task
id: RAILIANCE-WP-0021-T06
status: todo
status: done
priority: medium
state_hub_task_id: "519c2838-c4af-46e9-81fa-612fb09e91e3"
```
@ -367,11 +397,22 @@ here.
Expect the first run to report a large uncovered set. That output is the wave-2
worklist, not a failure to suppress.
**Done 2026-08-13.** The validator takes `--inventory PATH`. This repo does
not query a cluster. The file is a JSON mapping with `source`,
`captured_at`, optional `reef`, and `deployables` as a list of `{name,
namespace?, kind?}` (bare name strings also accepted). Live names that no
rapp claims are errors labelled as wave-2 worklist items. Declared names
missing from the inventory are warnings.
The first live run is not in this repo: it needs `railiance-platform` to
emit the inventory T01 asked for. The check itself is implemented and
covered by `--self-test`.
## T07 - Update the bootstrap contract to point at the schemas
```task
id: RAILIANCE-WP-0021-T07
status: todo
status: done
priority: medium
state_hub_task_id: "aa71bc2f-7d9e-4351-8130-8b6e23b8ee36"
```
@ -384,11 +425,18 @@ Record an ADR (next free number, `ADR-0007`) for the framework decisions this
workplan settles: bounded rapp context as an independent dimension, the two
cardinalities, and grouped-by-bounded-context granularity.
**Done 2026-08-13.** Bootstrap contract now points at the three schema files
and refuses to restate their fields. `docs/adr/ADR-0007-rapp-declaration-contract.md`
records the dimension, the two cardinalities, grouped-by-bounded-context
granularity, and that the schemas are normative. Decision `d07ee5f9` remains
the platform-side record; this ADR is the framework record, not a second
decision.
## T08 - Refresh rapp-first-wave-candidates.md
```task
id: RAILIANCE-WP-0021-T08
status: todo
status: done
priority: medium
state_hub_task_id: "28e4ef82-62ea-41d2-a059-05c047292240"
```
@ -407,11 +455,17 @@ candidates: `rapp-netkingdom-identity` (sso, mfa, user-engine, tenant-engine,
flex-auth), `rapp-forgejo` (forgejo, runner, registry), `rapp-coulomb-social`,
`rapp-helix-forge`.
**Done 2026-08-13.** The document now leads with the plan-versus-outcome
table, states that the user-facing wrapper is unproven, keeps the 2026-07-25
assessment as history, and carries the wave-2 inventory in composition
shape. The three undeclared engine repos are named so they are not invented
again as separate rapps. `rapp-forgejo` placement cites `d151d817`.
## T09 - Release the schema to downstream repos
```task
id: RAILIANCE-WP-0021-T09
status: wait
status: done
priority: high
state_hub_task_id: "b8dc2452-8ec0-4301-98b7-4ec96411f428"
```
@ -426,3 +480,15 @@ contracts and what each repo must change.
Migration itself belongs to the owning repos. This task ends when they have
what they need, not when they are done.
**Done 2026-08-13.** Release messages sent. Downstream now has the shape and
the per-repo change list; migration itself is not this task.
| To | Message | What they received |
|---|---|---|
| `railiance-platform` | `072c5820` | WP-0015-T02 unblocked; inventory shape for T06; S3 split restated |
| `rapp-openbao` | `093f2f73` | 11-error migration (metadata, composition, `bound_reefs`, contract rename) |
| `rapp-postgres` | `7b1cb9b7` | same 11-error set; S3 is a consumed destination, not a hole |
| `rapp-qonto` | `16742192` | 5-error migration (`composition`, `bound_reefs`, `package_type`, name, `fallback`) |
| `reef-railiance` | `6b395e26` | delete or replace stale `bound_rapps` |
| `resource-control` | `aa8393e3` | coordination only: bucket is theirs (`RESOURCE-WP-0002`) |