diff --git a/docs/adr/ADR-0011-release-units-and-federated-registry.md b/docs/adr/ADR-0011-release-units-and-federated-registry.md new file mode 100644 index 0000000..6741234 --- /dev/null +++ b/docs/adr/ADR-0011-release-units-and-federated-registry.md @@ -0,0 +1,126 @@ +--- +id: RMASTER-ADR-0011 +title: "Release units and the federated release-unit registry" +status: proposed +revision: "proposed-1" +owner: railiance-master +last_reviewed: "2026-10-21" +review_interval: 6m +--- + +# ADR-0011: Release units and the federated release-unit registry + +Date: 2026-10-21 +Status: Proposed + +Amends ADR-0007. Founder review is `RMASTER-WP-0028`. + +## Context + +ADR-0007 requires every managed running deployable to have exactly one rollout +owner, grouped by shared rollback fate. It stored each group as a `rapp-*` +repository and a `declarations/rapp.yaml` file validated by +`schemas/rapp.schema.json`. + +That cardinality is right. The storage is not, once a group is only a +declaration, a pin of another repository's files, and a render script. Those +repositories do not make the coverage question checkable. A hand-maintained +namespace map, an orphaned Helm release, a unit declared twice, and a Helm +history that no longer matches the live workload all stayed invisible. + +`release-registry` is the composer and the read-only checker. This repository +keeps the normative declaration, as it does for rapp, rail, and reef. + +## Decision + +1. **Three layers.** A workload is a live object, observed and never declared + by hand. A release unit is the governed fate group. Composition is a + relation between release units: containment, and separately a dependency + between units that still roll back on their own. Repositories that + contribute to a unit stay a third relation, many-to-many, as ADR-0007 + already has for repos and rapps. + +2. **The coverage invariant stands.** Every managed running deployable belongs + to exactly one release unit. ADR-0007's `unknown` and `not-applicable` + outcomes stay distinct. A child object controlled by a claimed workload is + the same fate, not a second subject. + +3. **The claim is the authority.** The declaration names each workload. The + label `railiance.io/release-unit` on that object must carry the same unit + id. The label assigns no owner. A missing or different label is a + disagreement, not a second way to resolve ownership. The checker reports + three coverage findings and does not collapse them: unclaimed (`unknown`), + a claim whose object is absent, and a label mismatch. + +4. **The schema is normative.** `schemas/release-unit.schema.json` defines the + declaration. It keeps the rapp composition block, the rollout, smoke and + rollback contracts, exposure, `data_classification` and `criticality`, and + adds the claim, the two relations, and a specification-level OCM pin + subset. This ADR cites that file. It does not restate its fields. + +5. **Repo policy.** A separate `rapp-*` repository exists only for a unit + that has substantial content of its own: its own manifests or tests, or + several source repositories combined. A thin unit is a declaration in the + owner's repository at `registry/release-units/`. Retiring a thin repository + waits until this ADR is accepted and the owner has declared the unit. + +6. **Deviations.** Anything this schema does on purpose that OCM or Backstage + would not do is listed below, with the reason. Elsewhere the declaration + follows those norms. There is no OCM library or controller dependency. + +7. **This repository does not compose or check.** `release-registry` validates + against the schema file here. It does not copy the schema. It does not + deploy, and a declaration grants no authority. + +## Deviations + +- **OCM component references are one list.** Containment and ordering are + different fate rules: an umbrella does not share rollback, and a dependency + orders units that roll back alone. The pin block keeps OCM references. The + operational relations are the ones the coverage model uses. One reference + list would hide which edge shares fate. + +- **An OCM resource version is not a Helm release revision.** Chart version + and Helm history revision move apart. The pin records the revision beside + the chart version because the drift that matters is the history against the + live object. The chart version remains the OCM resource version. + +- **An OCM digest value is raw bytes under a normalisation algorithm.** The + pin stores the digest string the live workload already shows, including the + `sha256:` prefix on an image. The drift check is a comparison with that + object. A second encoding would report a match as drift. + +- **Backstage `owner` is a Group.** Here the owner is the accountable + repository. This estate has no catalog runtime, and rollout accountability + is already a repository. + +- **Backstage `spec.system` and `dependsOn` name catalog entities.** The + relations name release-unit ids. A later catalog export can prefix them. + Inventing System and Component entities now would invent a catalog. + +- **`app.kubernetes.io/*` has no fate-group id.** The additional label + `railiance.io/release-unit` carries the unit id so the checker can see + disagreement. It is agreement with the claim. ADR-0007 still forbids + inferring an owner from a label, a namespace, or a repository name. + +- **OCM signing, transport, and controllers are unused.** OCM v2's own case + is that a consumer takes the specification without the monolith. Coverage + and drift need the descriptor shape, not a controller. + +## Consequences + +- `schemas/rapp.schema.json` remains the schema for a `rapp-*` declaration. + A release unit is a further declaration, not a silent rewrite of every + rapp file. +- Until this ADR is accepted, `release-registry` may validate against the + proposed schema. An amendment during review updates those fixtures. +- Owner repositories declare units. `release-registry` does not write those + files. +- The checker is read-only. A Helm fact that exists only inside a release + Secret stays unobservable. The checker does not read Secret values. + +## Confirmation + +Proposed on 2026-10-21 for founder review under `RMASTER-WP-0028`. +Acceptance records the revision here. No release-unit declaration is +normative before that. diff --git a/schemas/README.md b/schemas/README.md index 993e96a..18f49e9 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -9,6 +9,11 @@ authority where the two disagree. | `rapp-*` | `declarations/rapp.yaml` | `rapp.schema.json` | | `rail-*` | `declarations/rail.yaml` | `rail.schema.json` | | `reef-*` | `declarations/reef.yaml` | `reef.schema.json` | +| release unit | `registry/release-units/*.yaml` in the owner repo | `release-unit.schema.json` | + +`release-unit.schema.json` is proposed under ADR-0011. It evolves the rapp +declaration for a fate group that does not need a repository of its own. +ADR-0011 cites the file. The rapp schema remains the schema for `rapp-*`. Written under `RMASTER-WP-0021` from the drift survey routed by `railiance-platform` under its `RAILIANCE-WP-0015` (State Hub messages diff --git a/schemas/release-unit.schema.json b/schemas/release-unit.schema.json new file mode 100644 index 0000000..6460b74 --- /dev/null +++ b/schemas/release-unit.schema.json @@ -0,0 +1,362 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://railiance.dev/schemas/release-unit.schema.json", + "title": "Railiance release-unit declaration", + "description": "Normative shape for registry/release-units/*.yaml, authored by railiance-master under ADR-0011 as an amendment to ADR-0007. Evolves rapp.schema.json. This schema is normative; ADR-0011 cites it and does not restate its fields. release-registry validates against this file and does not carry a copy.", + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "unit_id", + "owner", + "contract_version", + "data_classification", + "criticality", + "composition", + "rollout_contract", + "smoke_contract", + "rollback_contract", + "workloads", + "pins" + ], + "properties": { + "kind": { "const": "release-unit" }, + "unit_id": { + "$ref": "#/$defs/unitId", + "description": "Canonical fate-group identifier. The coverage claim and the railiance.io/release-unit label both use this value." + }, + "owner": { + "$ref": "#/$defs/repoSlug", + "description": "The single repository accountable for rollout. A repository slug, not a catalog group." + }, + "contract_version": { "$ref": "#/$defs/semver" }, + "data_classification": { + "enum": ["public", "internal", "confidential", "restricted"] + }, + "criticality": { + "enum": ["low", "medium", "high", "critical"] + }, + "composition": { "$ref": "#/$defs/composition" }, + "rollout_contract": { "$ref": "#/$defs/rolloutContract" }, + "smoke_contract": { "$ref": "#/$defs/smokeContract" }, + "rollback_contract": { "$ref": "#/$defs/rollbackContract" }, + "exposure": { "$ref": "#/$defs/exposure" }, + "workloads": { + "type": "array", + "minItems": 1, + "description": "Coverage claims. Each entry names one top-level workload by kind, namespace and name. The claim is the coverage authority.", + "items": { "$ref": "#/$defs/workloadClaim" } + }, + "part_of": { + "$ref": "#/$defs/unitId", + "description": "Optional containment. The named unit is an umbrella. Rollback fate stays with this unit." + }, + "depends_on": { + "type": "array", + "uniqueItems": true, + "description": "Ordering and compatibility between independently rolled units. Data only; this schema grants no deploy right.", + "items": { "$ref": "#/$defs/dependency" } + }, + "pins": { "$ref": "#/$defs/pins" } + }, + "allOf": [ + { + "description": "public exposure requires a grant object, as in rapp.schema.json.", + "if": { + "required": ["exposure"], + "properties": { + "exposure": { + "required": ["posture"], + "properties": { "posture": { "const": "public" } } + } + } + }, + "then": { + "properties": { + "exposure": { "required": ["grant"] } + } + } + } + ], + "$defs": { + "repoSlug": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 2 + }, + "unitId": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "minLength": 2 + }, + "semver": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" + }, + "notes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "dnsLabel": { + "type": "string", + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "maxLength": 63 + }, + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["purpose", "member_repos"], + "description": "Same composition block as rapp.schema.json. member_repos stay many-to-many across units. Workload claims stay one-to-one.", + "properties": { + "purpose": { "type": "string", "minLength": 12 }, + "member_repos": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "role", "deployables"], + "properties": { + "repo": { "$ref": "#/$defs/repoSlug" }, + "role": { "type": "string", "minLength": 3 }, + "deployables": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + } + }, + "upstream_components": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "source", "version"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "source": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "rolloutContract": { + "type": "object", + "additionalProperties": false, + "required": ["default_mode"], + "properties": { + "default_mode": { "type": "string", "minLength": 3 }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "production_gate": { "type": "string" }, + "notes": { "$ref": "#/$defs/notes" } + } + }, + "smokeContract": { + "type": "object", + "additionalProperties": false, + "required": ["required"], + "properties": { + "required": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "notes": { "$ref": "#/$defs/notes" } + } + }, + "rollbackContract": { + "type": "object", + "additionalProperties": false, + "required": ["order"], + "properties": { + "order": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "commands": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "fallback": { "type": "string" }, + "notes": { "$ref": "#/$defs/notes" } + } + }, + "exposure": { + "type": "object", + "additionalProperties": false, + "required": ["posture"], + "description": "Who may reach the listener. Omitted means private (ADR-0008). Distinct from data_classification.", + "properties": { + "posture": { "enum": ["private", "operator", "public"] }, + "grant": { "$ref": "#/$defs/exposureGrant" }, + "tunnel": { "$ref": "#/$defs/exposureTunnel" }, + "binding_admission": { + "enum": ["declared", "installed", "verified", "production-approved", "deprecated"] + } + } + }, + "exposureGrant": { + "type": "object", + "additionalProperties": false, + "required": ["reason", "approved_on", "residual_risk_owner"], + "anyOf": [ + { "required": ["hostname"] }, + { "required": ["port"] } + ], + "properties": { + "hostname": { "type": "string", "minLength": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "reason": { "type": "string", "minLength": 1 }, + "approved_on": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "residual_risk_owner": { "type": "string", "minLength": 1 } + } + }, + "exposureTunnel": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "local_port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "workloadClaim": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "namespace", "name"], + "properties": { + "kind": { + "enum": ["Deployment", "StatefulSet", "DaemonSet", "CronJob", "Job"] + }, + "namespace": { "$ref": "#/$defs/dnsLabel" }, + "name": { "$ref": "#/$defs/dnsLabel" } + } + }, + "dependency": { + "type": "object", + "additionalProperties": false, + "required": ["unit_id"], + "properties": { + "unit_id": { "$ref": "#/$defs/unitId" }, + "readiness": { + "type": "string", + "minLength": 1, + "description": "Readiness the depended-on unit must show before this unit rolls. Recorded as data. No controller is implied." + }, + "version": { + "type": "string", + "minLength": 1, + "description": "Compatibility constraint on the depended-on unit." + } + } + }, + "pins": { + "type": "object", + "additionalProperties": false, + "required": ["resources", "sources"], + "description": "Specification-level subset of an OCM component descriptor. No OCM library is required to read it.", + "properties": { + "component": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } + }, + "resources": { + "type": "array", + "items": { "$ref": "#/$defs/resource" } + }, + "sources": { + "type": "array", + "items": { "$ref": "#/$defs/source" } + }, + "references": { + "type": "array", + "description": "OCM component references. Operational containment and ordering are part_of and depends_on.", + "items": { "$ref": "#/$defs/reference" } + } + } + }, + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["hashAlgorithm", "normalisationAlgorithm", "value"], + "properties": { + "hashAlgorithm": { "type": "string", "minLength": 1 }, + "normalisationAlgorithm": { "type": "string", "minLength": 1 }, + "value": { + "type": "string", + "minLength": 1, + "description": "Digest string as the live object shows it. An image digest keeps the sha256: prefix." + } + } + }, + "resource": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type", "version"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "type": { + "type": "string", + "minLength": 1, + "description": "OCM resource type. ociImage and helmChart are the types the drift check reads." + }, + "version": { "type": "string", "minLength": 1 }, + "relation": { "enum": ["local", "external"] }, + "digest": { "$ref": "#/$defs/digest" }, + "releaseRevision": { + "type": "string", + "minLength": 1, + "description": "Helm release revision. Compared with the revision annotation on the workload object. Absent from the object, the revision is unobservable. It is not read from a Helm release Secret." + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "ociImage" } }, "required": ["type"] }, + "then": { "required": ["digest"] } + }, + { + "if": { "properties": { "type": { "const": "helmChart" } }, "required": ["type"] }, + "then": { "required": ["releaseRevision"] } + } + ] + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type", "version", "digest"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "type": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "digest": { "$ref": "#/$defs/digest" } + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": ["name", "componentName", "version"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "componentName": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/workplans/RMASTER-WP-0028-release-unit-schema-review.md b/workplans/RMASTER-WP-0028-release-unit-schema-review.md new file mode 100644 index 0000000..5eb2cd6 --- /dev/null +++ b/workplans/RMASTER-WP-0028-release-unit-schema-review.md @@ -0,0 +1,45 @@ +--- +id: RMASTER-WP-0028 +type: workplan +title: "Review proposed ADR-0011 and the release-unit schema" +domain: financials +repo: railiance-master +status: proposed +flavor: review +owner: railiance-master +topic_slug: release-unit-schema-review +created: "2026-10-21" +updated: "2026-10-21" +related: [RELREG-WP-0001] +--- + +# RMASTER-WP-0028 - Review proposed ADR-0011 and the release-unit schema + +## Goal + +Founder review of the release-unit declaration before it is treated as +normative. `release-registry` may already validate against the proposed file. +Acceptance, amendment, or rejection is recorded on the ADR. + +## Boundaries + +- The schema stays in this repository. `release-registry` does not receive a copy. +- This review does not declare units for other owners and does not retire any + `rapp-*` repository. +- Field shapes live in `schemas/release-unit.schema.json`. The ADR cites that + file. + +## Review ADR-0011 and the schema + +```task +id: RMASTER-WP-0028-T01 +status: todo +priority: high +needs_human: true +``` + +Review `docs/adr/ADR-0011-release-units-and-federated-registry.md` (status +`proposed`) and `schemas/release-unit.schema.json`. + +Done when the ADR revision records accepted, amended, or rejected, and any +amendment is in the schema file the ADR cites.