diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index bd91541..7187dc6 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/adr/ADR-0007-rapp-declaration-contract.md b/docs/adr/ADR-0007-rapp-declaration-contract.md new file mode 100644 index 0000000..541d1d5 --- /dev/null +++ b/docs/adr/ADR-0007-rapp-declaration-contract.md @@ -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. diff --git a/docs/rapp-first-wave-candidates.md b/docs/rapp-first-wave-candidates.md index 37a9f17..b617856 100644 --- a/docs/rapp-first-wave-candidates.md +++ b/docs/rapp-first-wave-candidates.md @@ -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`. diff --git a/docs/repo-family-bootstrap-contract.md b/docs/repo-family-bootstrap-contract.md index ff04bf0..d2b8727 100644 --- a/docs/repo-family-bootstrap-contract.md +++ b/docs/repo-family-bootstrap-contract.md @@ -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 diff --git a/schemas/README.md b/schemas/README.md index 9551ee7..3803cbc 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -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. diff --git a/schemas/rail.schema.json b/schemas/rail.schema.json new file mode 100644 index 0000000..009d9a7 --- /dev/null +++ b/schemas/rail.schema.json @@ -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 } + } + } + } +} diff --git a/schemas/rapp.schema.json b/schemas/rapp.schema.json index 811278e..f762173 100644 --- a/schemas/rapp.schema.json +++ b/schemas/rapp.schema.json @@ -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*)$" diff --git a/schemas/reef.schema.json b/schemas/reef.schema.json new file mode 100644 index 0000000..dda250e --- /dev/null +++ b/schemas/reef.schema.json @@ -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 } + } + } + } +} diff --git a/tools/testdata/family-declarations/bad-stale-bound/inventory.json b/tools/testdata/family-declarations/bad-stale-bound/inventory.json new file mode 100644 index 0000000..c3a45d6 --- /dev/null +++ b/tools/testdata/family-declarations/bad-stale-bound/inventory.json @@ -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"} + ] +} diff --git a/tools/testdata/family-declarations/bad-stale-bound/rail-kubernetes/declarations/rail.yaml b/tools/testdata/family-declarations/bad-stale-bound/rail-kubernetes/declarations/rail.yaml new file mode 100644 index 0000000..fc2d33e --- /dev/null +++ b/tools/testdata/family-declarations/bad-stale-bound/rail-kubernetes/declarations/rail.yaml @@ -0,0 +1,12 @@ +kind: execution-rail +rail_id: rail-kubernetes +repo: rail-kubernetes +ownership_repo: railiance-cluster +contract_version: 1.0.0 +composition_kind: base +execution_architecture: kubernetes +readiness_state: verified +required_substrate_capabilities: + - kubernetes-api +supported_rollout_modes: + - stage1-run diff --git a/tools/testdata/family-declarations/bad-stale-bound/rapp-example/declarations/rapp.yaml b/tools/testdata/family-declarations/bad-stale-bound/rapp-example/declarations/rapp.yaml new file mode 100644 index 0000000..68fcc65 --- /dev/null +++ b/tools/testdata/family-declarations/bad-stale-bound/rapp-example/declarations/rapp.yaml @@ -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 diff --git a/tools/testdata/family-declarations/bad-stale-bound/rapp-orphan/README.md b/tools/testdata/family-declarations/bad-stale-bound/rapp-orphan/README.md new file mode 100644 index 0000000..c680a81 --- /dev/null +++ b/tools/testdata/family-declarations/bad-stale-bound/rapp-orphan/README.md @@ -0,0 +1 @@ +Undeclared family-prefix fixture for the validator. No declarations/. diff --git a/tools/testdata/family-declarations/bad-stale-bound/reef-example/declarations/reef.yaml b/tools/testdata/family-declarations/bad-stale-bound/reef-example/declarations/reef.yaml new file mode 100644 index 0000000..646ae74 --- /dev/null +++ b/tools/testdata/family-declarations/bad-stale-bound/reef-example/declarations/reef.yaml @@ -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 diff --git a/tools/testdata/family-declarations/good/inventory.json b/tools/testdata/family-declarations/good/inventory.json new file mode 100644 index 0000000..3efcdd9 --- /dev/null +++ b/tools/testdata/family-declarations/good/inventory.json @@ -0,0 +1,8 @@ +{ + "source": "fixture", + "captured_at": "2026-08-13T00:00:00Z", + "reef": "reef-example", + "deployables": [ + {"name": "example", "namespace": "example", "kind": "helm-release"} + ] +} diff --git a/tools/testdata/family-declarations/good/rail-knative/declarations/rail.yaml b/tools/testdata/family-declarations/good/rail-knative/declarations/rail.yaml new file mode 100644 index 0000000..ac65358 --- /dev/null +++ b/tools/testdata/family-declarations/good/rail-knative/declarations/rail.yaml @@ -0,0 +1,21 @@ +kind: execution-rail +rail_id: rail-knative +repo: rail-knative +ownership_repo: railiance-cluster +contract_version: 1.0.0 +composition_kind: derived +base_rail: rail-kubernetes +base_rail_contract: 1.0.0 +execution_architecture: knative-serving-on-kubernetes +readiness_state: verified +required_substrate_capabilities: + - kubernetes-api + - knative-serving +supported_rollout_modes: + - revision-canary +inherited_semantics: + - workload-identity +overridden_semantics: + - request-activation +compatibility_constraints: + - rail-kubernetes contract must equal 1.0.0 diff --git a/tools/testdata/family-declarations/good/rail-kubernetes/declarations/rail.yaml b/tools/testdata/family-declarations/good/rail-kubernetes/declarations/rail.yaml new file mode 100644 index 0000000..fc2d33e --- /dev/null +++ b/tools/testdata/family-declarations/good/rail-kubernetes/declarations/rail.yaml @@ -0,0 +1,12 @@ +kind: execution-rail +rail_id: rail-kubernetes +repo: rail-kubernetes +ownership_repo: railiance-cluster +contract_version: 1.0.0 +composition_kind: base +execution_architecture: kubernetes +readiness_state: verified +required_substrate_capabilities: + - kubernetes-api +supported_rollout_modes: + - stage1-run diff --git a/tools/testdata/family-declarations/good/rapp-example/declarations/rapp.yaml b/tools/testdata/family-declarations/good/rapp-example/declarations/rapp.yaml new file mode 100644 index 0000000..cc6fcef --- /dev/null +++ b/tools/testdata/family-declarations/good/rapp-example/declarations/rapp.yaml @@ -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 diff --git a/tools/testdata/family-declarations/good/reef-example/declarations/reef.yaml b/tools/testdata/family-declarations/good/reef-example/declarations/reef.yaml new file mode 100644 index 0000000..84751cd --- /dev/null +++ b/tools/testdata/family-declarations/good/reef-example/declarations/reef.yaml @@ -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 diff --git a/tools/validate-family-declarations.py b/tools/validate-family-declarations.py new file mode 100755 index 0000000..6003eb6 --- /dev/null +++ b/tools/validate-family-declarations.py @@ -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/.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()) diff --git a/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md b/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md index 05df87f..0e587b6 100644 --- a/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md +++ b/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md @@ -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`) |