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:
parent
a0be0d3142
commit
56fe6bda80
4 changed files with 538 additions and 0 deletions
126
docs/adr/ADR-0011-release-units-and-federated-registry.md
Normal file
126
docs/adr/ADR-0011-release-units-and-federated-registry.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
362
schemas/release-unit.schema.json
Normal file
362
schemas/release-unit.schema.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
workplans/RMASTER-WP-0028-release-unit-schema-review.md
Normal file
45
workplans/RMASTER-WP-0028-release-unit-schema-review.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue