Propose ADR-0011 and the release-unit schema.

Amends ADR-0007 with a fate-group declaration whose claim is the coverage authority. Founder review is RMASTER-WP-0028. release-registry validates against this file and does not copy it.

Assistant: grok
Assistant-Session: 01a15f9f-3d77-7d21-911b-d93c86f9d14f
This commit is contained in:
codex 2026-10-21 21:00:54 +02:00
parent a0be0d3142
commit 56fe6bda80
4 changed files with 538 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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