From 28cbba1025ada71e81ae8b3fda4ffb9ffece7ad7 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 11 Aug 2026 22:56:22 +0200 Subject: [PATCH] Add rapp declaration schema with composition block (WP-0021 T02, T03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schemas/rapp.schema.json defines one normative shape for the rollout, smoke and rollback contracts in place of the three mutually unreadable variants found across the live rapps, promotes contract_version, readiness_state, data_classification and criticality to required, and forbids the rapp- prefix on workload_identity.name. composition replaces the flat members list per amendment f88f938d: purpose, member_repos with deployables, and pinned upstream_components. Repos are many:many with rapps; deployables are 1:1, which is what makes the T06 coverage check well-formed. ownership_repo left permissive pending an architecture-owner call; the tighter alternative is written up in schemas/README.md. Validated against all three live declarations: openbao 10 errors, postgres 10, qonto 4 — precisely the reported drift and nothing else. Also found: three further rapp-* repos (secrets-engine, tenant-engine, user-engine) carry no declarations at all, which the routed survey missed. Co-Authored-By: Claude Opus 5 --- schemas/README.md | 100 +++++ schemas/rapp.schema.json | 350 ++++++++++++++++++ ...ILIANCE-WP-0021-rapp-declaration-schema.md | 77 +++- 3 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 schemas/README.md create mode 100644 schemas/rapp.schema.json diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..9551ee7 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,100 @@ +# Railiance family declaration schemas + +Normative schemas for the repo-family declaration files. `docs/repo-family-bootstrap-contract.md` +names the minimum fields; these schemas define their shapes and are the +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) | + +Written under `RAILIANCE-WP-0021` from the drift survey routed by +`railiance-platform` under its `RAILIANCE-WP-0015` (State Hub messages +`04c776c4`, `f88f938d`). + +## The two cardinalities + +These are the load-bearing rules. Everything the validator can usefully check +follows from keeping them distinct. + +- **Repos to rapps is many-to-many.** A repo may contribute to more than one + rapp — a shared library, or a service that is a member of one bundle and a + dependency of another. +- **Deployables to rapps is one-to-one.** Every running deployable has exactly + one rapp that owns its rollout. + +The second is what makes the coverage question well-formed: *does every live +deployable belong to exactly one rapp?* That single check catches stale reef +bindings, unpackaged live workloads, and the workload-count gap at once. It is +what turns this schema from a one-shot lint into a standing control loop. + +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`. + +## Normative choices, and what they cost + +The three live rapp declarations expressed their rollout, smoke and rollback +contracts three mutually unreadable ways. The schema picks one shape. Where it +picks, it says so and it says who pays. + +**`default_mode` is kept** — all three declarations already carried it. No cost. + +**`supported_commands` becomes `commands`** in all three contracts. Mechanical +rename for `rapp-openbao` and `rapp-postgres`. + +**`smoke_contract.required` is required, and `commands` is optional.** Taken +from `rapp-qonto`. This is the one migration with real cost: the platform rapps +declare only commands, so their owners must name the outcomes those commands +establish. That cost is the point. A command list records what was run; a +required list records what must be true. Only the second can be checked by +anything other than the person who ran it. + +**`rollback_contract.order` is required.** Taken from `rapp-qonto`. Recoverable +from the command sequences the platform rapps already document, so the cost is +low. `direct_kubernetes_fallback` generalizes to `fallback`. + +**`workload_identity.name` is the workload, never the repo**, and the schema +forbids the `rapp-` prefix on it outright. `rapp-qonto` currently sets it to +`rapp-qonto`; the correct value is `qonto`. + +**`contract_version`, `readiness_state`, `data_classification` and +`criticality` are required.** All four exist only in `rapp-qonto` today. The +first two are promoted because the rail family has carried them consistently in +both rails and has not drifted — that contrast is the whole argument for +schema. The second two are promoted because a package whose classification and +criticality are unstated cannot be admitted to a reef on evidence. + +**`consumer_contract` is schema-defined but optional.** Only `rapp-postgres` +provisions isolated units to other services. Requiring it everywhere would be +false precision. + +## Open question: what may `ownership_repo` name? + +Currently `rapp-openbao` and `rapp-postgres` name `railiance-platform`, a layer +repo, and `rapp-qonto` names `qonto-assistant`, an application repo. + +The schema admits **both**, requiring only that there be exactly one and that +it not be the rapp repo itself. This is deliberately the permissive reading, +taken so the schema does not silently invalidate two of three live declarations +on a question the survey cannot settle. + +The tighter alternative is that `ownership_repo` must name the repo whose team +is accountable for the workload in production, which would likely disqualify a +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 + +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. + +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. diff --git a/schemas/rapp.schema.json b/schemas/rapp.schema.json new file mode 100644 index 0000000..811278e --- /dev/null +++ b/schemas/rapp.schema.json @@ -0,0 +1,350 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://railiance.dev/schemas/rapp.schema.json", + "title": "Railiance rapp declaration", + "description": "Normative shape for declarations/rapp.yaml in every rapp-* repo. Authored by railiance-master under RAILIANCE-WP-0021 from the drift survey routed by railiance-platform (RAILIANCE-WP-0015). This schema is normative; docs/repo-family-bootstrap-contract.md cites it rather than restating it.", + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "repo_family", + "rapp_id", + "repo", + "ownership_repo", + "contract_version", + "readiness_state", + "workload_identity", + "data_classification", + "criticality", + "primary_rail", + "supported_rails", + "runtime_dependencies", + "composition", + "rollout_contract", + "smoke_contract", + "rollback_contract" + ], + "properties": { + "kind": { + "const": "managed-workload-package", + "description": "Fixed discriminator. All three live declarations already agree on this value." + }, + "repo_family": { + "const": "rapp" + }, + "rapp_id": { + "$ref": "#/$defs/rappSlug", + "description": "Canonical identifier for the rapp. Must equal the repo slug for a single-repo rapp; for a grouped rapp it names the bounded context and need not match any member repo." + }, + "repo": { + "$ref": "#/$defs/repoSlug", + "description": "The rapp-* repo holding this declaration." + }, + "ownership_repo": { + "$ref": "#/$defs/repoSlug", + "description": "The single repo accountable for this workload's lifecycle. Both layer repos (railiance-platform) and application repos (qonto-assistant) are legal values; what the schema requires is exactly one, and that it not be the rapp repo itself. See schemas/README.md for the open question on tightening this." + }, + "contract_version": { + "$ref": "#/$defs/semver", + "description": "Version of this declaration contract. Promoted from rapp-qonto-only to required, matching the rail family, which has carried it consistently in both rails and has not drifted." + }, + "readiness_state": { + "enum": ["draft", "declared", "deployed", "verified", "retired"], + "description": "Lifecycle state of the package. Promoted from rapp-qonto-only to required, matching rail.yaml." + }, + "workload_identity": { + "$ref": "#/$defs/workloadIdentity" + }, + "data_classification": { + "enum": ["public", "internal", "confidential", "restricted"], + "description": "Highest classification of data the workload handles. Promoted from rapp-qonto-only to required: a package whose classification is unstated cannot be admitted to a reef on evidence." + }, + "criticality": { + "enum": ["low", "medium", "high", "critical"], + "description": "Operational criticality. Promoted from rapp-qonto-only to required. reef.yaml already carries the same vocabulary." + }, + "primary_rail": { + "$ref": "#/$defs/railSlug", + "description": "The rail this package is deployed on by default. Must appear in supported_rails." + }, + "supported_rails": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/railSlug" } + }, + "runtime_dependencies": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 }, + "description": "Capabilities the workload requires at runtime. Free-form strings today; a controlled vocabulary shared with rail.required_substrate_capabilities is deferred, not rejected." + }, + "composition": { "$ref": "#/$defs/composition" }, + "secret_references": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 }, + "description": "OpenBao paths the workload reads. Paths only — never values. Optional, since not every package holds secrets." + }, + "consumer_contract": { "$ref": "#/$defs/consumerContract" }, + "consumers": { + "type": "array", + "items": { "$ref": "#/$defs/consumer" }, + "description": "Present only where consumer_contract is present." + }, + "rollout_contract": { "$ref": "#/$defs/rolloutContract" }, + "smoke_contract": { "$ref": "#/$defs/smokeContract" }, + "rollback_contract": { "$ref": "#/$defs/rollbackContract" }, + "source_documents": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/sourceDocument" }, + "description": "Documents this declaration was derived from. All three live rapps already carry this consistently." + } + }, + "allOf": [ + { + "description": "primary_rail must be one of supported_rails. Expressed as a validator check rather than in schema, since JSON Schema cannot compare a value against a sibling array; the validator enforces it.", + "type": "object" + }, + { + "description": "consumers requires consumer_contract.", + "if": { "required": ["consumers"] }, + "then": { "required": ["consumer_contract"] } + } + ], + "$defs": { + "repoSlug": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 2 + }, + "rappSlug": { + "type": "string", + "pattern": "^rapp-[a-z0-9]+(-[a-z0-9]+)*$" + }, + "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." + }, + + "workloadIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["name", "package_type"], + "description": "Identity of the WORKLOAD, never of the repo. rapp-qonto currently sets name to the repo slug (rapp-qonto); that is the drift this definition closes. The correct value there is qonto.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "not": { "pattern": "^rapp-" }, + "description": "The workload's own name. Must not carry the rapp- prefix: that prefix names a repo, and this field does not name a repo." + }, + "package_type": { + "enum": [ + "helm-managed-platform-service", + "manifest-managed-platform-service", + "knative-managed-service", + "grouped-composition" + ], + "description": "grouped-composition is for rapps whose members carry their own package types." + }, + "principal": { "type": "string" }, + "service_account": { "type": "string" }, + "tenant": { + "type": "string", + "description": "Opaque NetKingdom tenant identifier (tenant::, net-kingdom ADR-0013). A rapp never mints, parses, or authorizes on this." + }, + "chart": { "type": "string" }, + "chart_version": { "type": "string" }, + "app_version": { "type": "string" }, + "notes": { "$ref": "#/$defs/notes" }, + "note": { "type": "string" } + } + }, + + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["purpose", "member_repos"], + "description": "What this rapp is composed of. Supersedes the flat members: list from proposal 04c776c4 per amendment f88f938d. A rapp is a composition of first-party repos AND pinned third-party components, serving a stated purpose.", + "properties": { + "purpose": { + "type": "string", + "minLength": 12, + "description": "What this coherent workload does. One sentence. This is the bounded-context statement; if it cannot be written without 'and also', the grouping is probably wrong." + }, + "member_repos": { + "type": "array", + "minItems": 1, + "description": "First-party repos, each sharing rollout and rollback fate with the others. A single-repo rapp declares exactly one member. Grouping is legitimate ONLY where members share rollout and rollback fate; members that can be rolled back independently belong in separate rapps.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "role", "deployables"], + "properties": { + "repo": { "$ref": "#/$defs/repoSlug" }, + "role": { + "type": "string", + "minLength": 3, + "description": "What this repo contributes to the composition." + }, + "deployables": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 }, + "description": "Names of the running units this repo contributes. CARDINALITY: a repo may appear in the member_repos of MORE THAN ONE rapp (many-to-many), but each deployable belongs to EXACTLY ONE rapp (one-to-one) — exactly one rapp owns its rollout. The validator's coverage check depends on this asymmetry; see schemas/README.md." + } + } + } + }, + "upstream_components": { + "type": "array", + "description": "Third-party components, pinned. An unpinned upstream is not a composition member, it is a liability.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "source", "version"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "source": { + "type": "string", + "minLength": 1, + "description": "Chart repo/chart or registry reference." + }, + "version": { + "type": "string", + "minLength": 1, + "description": "An exact pin. Ranges and floating tags such as latest are not pins." + } + } + } + } + } + }, + + "rolloutContract": { + "type": "object", + "additionalProperties": false, + "required": ["default_mode"], + "description": "NORMATIVE SHAPE. Resembles rapp-openbao and rapp-postgres for the imperative fields (commands) and rapp-qonto for the gate. default_mode was already common to all three live declarations and is kept.", + "properties": { + "default_mode": { + "type": "string", + "minLength": 3, + "description": "How this package is rolled out by default, e.g. helm-upgrade-install, kubectl-server-side-apply, revision-canary." + }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Renamed from supported_commands. The imperative execution path where one exists; absent for packages rolled out declaratively." + }, + "production_gate": { + "type": "string", + "description": "Named gate that must be satisfied before production rollout, e.g. reef-production-approved." + }, + "notes": { "$ref": "#/$defs/notes" } + } + }, + + "smokeContract": { + "type": "object", + "additionalProperties": false, + "required": ["required"], + "description": "NORMATIVE SHAPE, taken from rapp-qonto. required: names the OUTCOMES that must hold; commands: is how they are exercised. The platform rapps currently declare only commands, so migrating them means naming their checks — that is real migration cost and it is the point: a command list says what was run, not what must be true.", + "properties": { + "required": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 }, + "description": "Named checks that must pass, e.g. authenticated-activation-from-zero, wrong-tenant-denied." + }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Renamed from supported_commands." + }, + "notes": { "$ref": "#/$defs/notes" } + } + }, + + "rollbackContract": { + "type": "object", + "additionalProperties": false, + "required": ["order"], + "description": "NORMATIVE SHAPE, taken from rapp-qonto. order: is the ordered preference of rollback strategies, most-preferred first. The platform rapps currently declare only commands; their order is recoverable from the command sequence they already document.", + "properties": { + "order": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, + "description": "Ordered rollback strategies, most preferred first, e.g. previous-verified-knative-revision, temporary-minimum-scale, unavailable." + }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "description": "Renamed from supported_commands." + }, + "fallback": { + "type": "string", + "description": "Generalized from rapp-qonto's direct_kubernetes_fallback. Names the exceptional path and its expiry condition." + }, + "notes": { "$ref": "#/$defs/notes" } + } + }, + + "consumerContract": { + "type": "object", + "additionalProperties": false, + "required": ["boundary_unit", "credential_lane", "credential_type"], + "description": "Present only on packages that provision isolated units to other services. Schema-defined but optional: today only rapp-postgres carries one, and requiring it everywhere would be false precision.", + "properties": { + "boundary_unit": { "type": "string", "minLength": 1 }, + "credential_lane": { "type": "string", "minLength": 1 }, + "credential_type": { "type": "string", "minLength": 1 }, + "tenant_keying_required": { "type": "boolean" }, + "notes": { "$ref": "#/$defs/notes" } + } + }, + + "consumer": { + "type": "object", + "additionalProperties": false, + "required": ["name", "declaration"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "declaration": { + "type": "string", + "minLength": 1, + "description": "Repo-relative path to the consumer declaration." + }, + "cost_attribution_key": { + "type": "string", + "description": "Ties consumption to the cost evidence contract (fin-hub FIN-WP-0004)." + } + } + }, + + "sourceDocument": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "path"], + "properties": { + "repo": { "$ref": "#/$defs/repoSlug" }, + "path": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md b/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md index 5b8b41e..9c0ff3d 100644 --- a/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md +++ b/workplans/RAILIANCE-WP-0021-rapp-declaration-schema.md @@ -162,7 +162,7 @@ acquire a cluster dependency. ```task id: RAILIANCE-WP-0021-T02 -status: todo +status: done priority: high state_hub_task_id: "87ae4551-1fac-4613-b258-28c8b22bbb82" ``` @@ -190,11 +190,62 @@ Pick one normative shape for `rollout_contract`, `smoke_contract` and the schema which existing declaration the chosen shape most resembles, so migration cost is visible. +**Done 2026-08-11.** `schemas/rapp.schema.json` (draft 2020-12) and +`schemas/README.md` written from the three live declarations rather than from +the survey summary, which turned out to understate the drift — see the extra +finding below. + +Normative choices, with who pays: + +- `default_mode` kept; all three declarations already carried it. No cost. +- `supported_commands` → `commands` in all three contracts. Mechanical. +- `smoke_contract.required` is required, `commands` optional — taken from + `rapp-qonto`. This is the one migration with real cost: the platform rapps + declare only commands and their owners must now name the outcomes those + commands establish. A command list records what was run; a required list + records what must be true, and only the second is checkable by anyone other + than the person who ran it. +- `rollback_contract.order` required, also from `rapp-qonto`, recoverable from + the command sequences already documented. `direct_kubernetes_fallback` + generalizes to `fallback`. +- `workload_identity.name` is the workload, never the repo; the schema forbids + the `rapp-` prefix outright. +- `contract_version`, `readiness_state`, `data_classification`, `criticality` + all promoted to required. +- `consumer_contract` schema-defined but optional. + +`ownership_repo` is left permissive — exactly one repo, not the rapp repo +itself, layer and application repos both legal — so the schema does not +silently invalidate two of three live declarations on a question the survey +cannot settle. The tighter alternative and its consequence are written up in +`schemas/README.md` for the architecture owner. Treat the permissive rule as +provisional. + +Validated against all three live declarations with `jsonschema` 4.10.3. The +schema is well-formed and reproduces precisely the drift the survey reported, +which is the intended result — it fails on real drift and not on anything else: + +| Declaration | Errors | Character | +|---|---|---| +| `rapp-openbao` | 10 | four missing metadata fields, no composition, three contracts on the old shape | +| `rapp-postgres` | 10 | identical set — the two platform rapps drifted together | +| `rapp-qonto` | 4 | composition, `direct_kubernetes_fallback`, missing `package_type`, and `workload_identity.name` = `rapp-qonto` | + +**Extra finding, not in the routed survey.** There are six `rapp-*` repos, not +three: `rapp-secrets-engine`, `rapp-tenant-engine` and `rapp-user-engine` exist +and carry no `declarations/` directory at all. The survey counted only declared +rapps, so the real gap is wider than the roughly 17-workloads-versus-3-rapps +figure suggests — three further repos claim the family prefix while declaring +nothing. Raised to `railiance-platform`; whether these are stubs, abandoned, or +undeclared live workloads should be settled before T06 draws its coverage +baseline, since undeclared repos are invisible to a coverage check that reads +declarations. + ## T03 - Define the composition block and its cardinality rules ```task id: RAILIANCE-WP-0021-T03 -status: todo +status: done priority: high state_hub_task_id: "8b130ff8-0601-40bb-8539-d6f5847c2f41" ``` @@ -226,6 +277,28 @@ and rolls back together, not one per deployable. Grouping is legitimate only where members share rollout and rollback fate; make that testable rather than advisory where possible. +**Done 2026-08-11.** `composition` is required on every rapp, in +`schemas/rapp.schema.json` under `$defs/composition`. A single-repo rapp +declares exactly one member, so there is no second shape for the ungrouped +case — the composition block is the only way to state membership. + +`purpose` carries a `minLength` and is documented as the bounded-context +statement: if it cannot be written without "and also", the grouping is probably +wrong. That is the closest this schema gets to testing shared rollout fate; +the substantive check is structural, in that `deployables` is required on every +member, which is what T06 consumes. + +Both cardinalities are encoded where they bite. `member_repos[].repo` is +unconstrained across rapps, so a repo may appear in several — many-to-many. +`member_repos[].deployables` is documented as one-to-one and enforced by the +validator's coverage check rather than by the schema, since no single-file +schema can see across declarations. `upstream_components` requires an exact +`version` pin, with the schema stating that ranges and floating tags are not +pins. + +`workload_identity.package_type` gains a `grouped-composition` value for rapps +whose members carry their own package types. + ## T04 - Schematize rail.yaml and reef.yaml, and derive bound_rapps ```task