Add rapp declaration schema with composition block (WP-0021 T02, T03)

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 <noreply@anthropic.com>
This commit is contained in:
codex 2026-08-11 22:56:22 +02:00
parent 8fc92bd7d2
commit 28cbba1025
3 changed files with 525 additions and 2 deletions

100
schemas/README.md Normal file
View file

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

350
schemas/rapp.schema.json Normal file
View file

@ -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:<grouping>:<name>, 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 }
}
}
}
}

View file

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