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

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