railiance-platform/docs/rapp-platform-service-pattern.md
codex 2597fa46da
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Rapp context is its own grouping dimension
Operator design review considered binding bounded rapp context to Forgejo orgs
or State Hub domains and rejected both. OAS P1 governs - independent
perspectives must stay in separate dimensions - and cardinality forces it: a
repo has exactly one Forgejo org (a path segment in the clone URL) so org:repo
is 1:many, while rapp:repo is many:many, and a many:many grouping cannot be
derived from a 1:many one.

Records the dimension table, the composition block (first-party member repos
plus pinned upstream components and a stated purpose), and the precision that
makes enforcement well-defined: repos are many:many with rapps but deployables
are 1:1, so the validator can ask whether every live deployable belongs to
exactly one rapp. That is the coverage check that would have caught all three
of this survey's drift findings at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:34:51 +02:00

197 lines
8.7 KiB
Markdown

# The S3 Platform-Service Rapp Pattern
Date: 2026-08-11
Owner: `railiance-platform` (S3)
Work record: `RAILIANCE-WP-0015-T01`
## Purpose
This is the reference shape for packaging a **platform service** as a `rapp-*`
managed workload package. It generalizes the two boundaries S3 has already
drawn — `docs/rapp-openbao-boundary.md` and `docs/rapp-postgres-boundary.md`
so the third and fourth platform rapps do not each re-derive the split from
scratch.
It is deliberately *not* a general rapp standard. The four-axis repo family
model belongs to `railiance-master/docs/repository-axes.md`, and the
`rapp.yaml` schema and canon promotion are in flight there and in
`the-custodian`. This document covers only the half S3 owns: what a
platform-service rapp looks like, and where the line falls between the package
and the platform.
## The rule
> A rapp owns how its workload is **packaged, deployed, verified, rolled back
> and recovered**. S3 retains everything that is **true across workloads**:
> policy, credential custody, lane approval, and the shared substrate the
> package assumes.
Both existing platform rapps already state this in their own words. From
`rapp-openbao-boundary.md`: the package may own how OpenBao is packaged,
deployed, verified and skinned, and must not become the home for platform-admin
policy, workload KV lane policy, delegated metadata authority, or credential
grants. From `rapp-postgres-boundary.md`: the package owns manifests, consumer
declarations, provisioning surface, isolation tests and recovery, while S3
retains the operator, storage policy, backup target and the credential-broker
grant catalog.
## The ownership test
When an asset's home is unclear, ask in this order. The first answer that
applies decides it.
1. **Would this asset survive replacing the workload with a different product?**
If yes, it is S3's. Credential lane approval survives swapping OpenBao for
another secrets engine; an OpenBao Helm values file does not.
2. **Does any other workload depend on this asset?** If yes, it is S3's. A
`workload-kv-read-*` policy is consumed by the workload it names but governed
by a lane model shared by all of them.
3. **Does it encode who may approve, not how to apply?** Approval authority is
S3's; application mechanics are the package's. This is the line that keeps a
rapp from becoming a shadow S3 repo — the failure mode named explicitly in
`railiance-master/docs/rapp-first-wave-candidates.md`.
4. **Otherwise it is the package's.** Charts, values, overlays, deploy and
verify scripts, workload-specific smoke and recovery procedure, and the
Makefile targets that drive them.
### Applied
| Asset class | Home | Test |
| --- | --- | --- |
| Helm values, chart pins, overlays, ingress/middleware manifests | rapp | 4 |
| Deploy / dry-run / status / verify / rollback commands | rapp | 4 |
| Workload-specific smoke and recovery procedure | rapp | 4 |
| Consumer declarations and provisioning surface | rapp | 4 |
| Operator and read-only policy surface | S3 | 1 |
| Credential lane approval, CCRs, grant catalog | S3 | 3 |
| Cross-workload secret delivery (ESO, KV lanes) | S3 | 2 |
| Backup target procurement and its credentials | S3 | 1 |
| Storage class, monitoring substrate, cluster access | S3 | 1 |
## Reference `rapp.yaml` for a platform service
Pending the normative schema from `railiance-master`, a platform-service rapp
should carry at least the following. Fields marked † are the consistency fields
currently present in `rail.yaml` and `rapp-qonto` but missing from both platform
rapps; `RAILIANCE-WP-0015-T02` adds them once the schema settles.
```yaml
kind: managed-workload-package
repo_family: rapp
rapp_id: rapp-<workload>
repo: rapp-<workload>
ownership_repo: railiance-platform # the S3 home retaining governance
contract_version: 1.0.0 # †
readiness_state: verified # †
data_classification: <internal|restricted> # †
criticality: <high|critical> # †
workload_identity:
name: <workload> # the workload, never the repo name
package_type: helm-managed-platform-service
chart: <repo/chart>
chart_version: <pinned>
app_version: <pinned>
primary_rail: rail-kubernetes
supported_rails: [rail-kubernetes]
runtime_dependencies: [...] # what must exist for this to run
rollout_contract: {...}
smoke_contract: {...}
rollback_contract: {...}
source_documents: [...]
```
Two conventions worth stating because they have already drifted:
- `workload_identity.name` is the **workload**, not the repo. `openbao`, not
`rapp-openbao`.
- `ownership_repo` is the repo that retains governance after extraction — for
platform services that is `railiance-platform`, and it is not the same thing
as the repo the package was carved out of.
## Grouped rapps
Operator decision of 2026-08-11: rapp granularity is **grouped by bounded
context** — one rapp per cohesive group of services that deploys, versions and
rolls back together, rather than one rapp per deployable.
### Rapp context is its own dimension
A bounded rapp context is **not** derived from a Forgejo organization, nor from
a State Hub domain. It is a grouping in its own right. This follows OAS P1
(`canon/standards/orthogonal-architecture_v1.0.md`) — independent perspectives
MUST stay in separate dimensions — and it is forced by cardinality:
| Grouping | Answers | Cardinality to repos |
| --- | --- | --- |
| Forgejo org | who may push; what is discoverable together | 1:many (a repo has one org — it is a path segment in the clone URL) |
| State Hub domain | which strand of work, for attention and priority | 1:many |
| Rapp context | what deploys, versions and rolls back together | **many:many** |
A repo can legitimately contribute to more than one rapp — a shared library, or
a service that is both a member of one bundle and a dependency of another. A
many:many grouping cannot be derived from a 1:many one. Domains fail in both
directions: several rapps sit within one domain, and a cross-cutting rapp such
as an identity bundle serves every domain rather than sitting inside one.
The three also change at different speeds — org membership is expensive to
change, domains change occasionally, rapp contexts change often as workloads
consolidate. Deriving the volatile grouping from the expensive one guarantees
churn in the wrong place.
**One precision matters for enforcement:** repos are many:many with rapps, but
*deployables* are **1:1** — each running deployable has exactly one rapp owning
its rollout. That distinction is what makes the coverage check well-defined:
*does every live deployable belong to exactly one rapp?*
### Declaring the composition
A rapp declares what it combines: first-party repos and pinned third-party
components, plus the purpose the combination serves. The normative shape is
`railiance-master`'s to settle; the proposed form is:
```yaml
composition:
purpose: <what this coherent workload does>
member_repos:
- repo: <slug>
role: <what it contributes>
deployables: [<names>]
upstream_components:
- name: <chart|image|operator>
source: <repo/chart or registry ref>
version: <pin>
```
For a grouped rapp:
- members **must** be declared explicitly; an undeclared bundle is not a rapp,
it is a drawer
- grouping is legitimate **only** where members share rollout and rollback fate.
If one member can be rolled back without the others, it is a separate rapp
- the group's smoke contract must cover the group, not just its largest member
`rapp-postgres` is already a grouped package in this sense — it owns the CNPG
cluster manifests plus per-consumer declarations, and declares its consumers
explicitly in `consumers:`. That is the shape to copy.
## Credential lanes
A platform-service rapp never owns credential custody. It declares what it
needs; S3 vends it through the existing broker. The binding between a rapp's
`runtime_dependencies` / `secret_references` and the S3 grant catalog and CCR
lanes is specified in `RAILIANCE-WP-0015-T06` — until that lands, follow
`docs/credential-broker.md` and `docs/credential-change-approval.md` directly
and do not create a package-local lane.
The existing rule holds without exception: the package never commits
credentials, and a workload receives a short-lived lease through the platform
broker rather than a package-managed secret.
## When a platform service earns a rapp
Not every S3 service needs one. A platform service is ready for extraction when
it has a stable workload identity, a package surface already visible in Git, an
explicit dependency and secret story, and described deploy/verify/recover
behavior — the criteria from `rapp-first-wave-candidates.md`. A service that
fails these is not blocked from being operated; it simply stays owned by S3
until its packaging identity stops moving.