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:
parent
d3b3512a60
commit
3cc0dc31d6
20 changed files with 1509 additions and 187 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
76
docs/adr/ADR-0007-rapp-declaration-contract.md
Normal file
76
docs/adr/ADR-0007-rapp-declaration-contract.md
Normal 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.
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
178
schemas/rail.schema.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
118
schemas/reef.schema.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
tools/testdata/family-declarations/bad-stale-bound/inventory.json
vendored
Normal file
9
tools/testdata/family-declarations/bad-stale-bound/inventory.json
vendored
Normal 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"}
|
||||
]
|
||||
}
|
||||
12
tools/testdata/family-declarations/bad-stale-bound/rail-kubernetes/declarations/rail.yaml
vendored
Normal file
12
tools/testdata/family-declarations/bad-stale-bound/rail-kubernetes/declarations/rail.yaml
vendored
Normal 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
|
||||
33
tools/testdata/family-declarations/bad-stale-bound/rapp-example/declarations/rapp.yaml
vendored
Normal file
33
tools/testdata/family-declarations/bad-stale-bound/rapp-example/declarations/rapp.yaml
vendored
Normal 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
|
||||
1
tools/testdata/family-declarations/bad-stale-bound/rapp-orphan/README.md
vendored
Normal file
1
tools/testdata/family-declarations/bad-stale-bound/rapp-orphan/README.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
Undeclared family-prefix fixture for the validator. No declarations/.
|
||||
14
tools/testdata/family-declarations/bad-stale-bound/reef-example/declarations/reef.yaml
vendored
Normal file
14
tools/testdata/family-declarations/bad-stale-bound/reef-example/declarations/reef.yaml
vendored
Normal 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
|
||||
8
tools/testdata/family-declarations/good/inventory.json
vendored
Normal file
8
tools/testdata/family-declarations/good/inventory.json
vendored
Normal 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"}
|
||||
]
|
||||
}
|
||||
21
tools/testdata/family-declarations/good/rail-knative/declarations/rail.yaml
vendored
Normal file
21
tools/testdata/family-declarations/good/rail-knative/declarations/rail.yaml
vendored
Normal 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
|
||||
12
tools/testdata/family-declarations/good/rail-kubernetes/declarations/rail.yaml
vendored
Normal file
12
tools/testdata/family-declarations/good/rail-kubernetes/declarations/rail.yaml
vendored
Normal 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
|
||||
34
tools/testdata/family-declarations/good/rapp-example/declarations/rapp.yaml
vendored
Normal file
34
tools/testdata/family-declarations/good/rapp-example/declarations/rapp.yaml
vendored
Normal 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
|
||||
15
tools/testdata/family-declarations/good/reef-example/declarations/reef.yaml
vendored
Normal file
15
tools/testdata/family-declarations/good/reef-example/declarations/reef.yaml
vendored
Normal 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
|
||||
596
tools/validate-family-declarations.py
Executable file
596
tools/validate-family-declarations.py
Executable 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())
|
||||
|
|
@ -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`) |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue