diff --git a/.gitignore b/.gitignore index fc4cfea..38d3cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ helm/*.yaml !helm/*-cluster.yaml !helm/*-networkpolicies.yaml !helm/*-databases.yaml +!helm/*-backup.yaml # ArgoCD repository credentials — encrypt locally, never commit argocd/repositories/*.repository.sops.yaml diff --git a/Makefile b/Makefile index baa4216..4045ec8 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,10 @@ consumption-preflight: ## Refuse a new order that exceeds a restricted entity's ##@ Shared apps-pg (S5 application databases) -apps-pg-deploy: ## Apply shared apps-pg cnpg Cluster + NetworkPolicies +apps-pg-verify-capacity: ## Verify cell ceiling, role limits and distinct backup prefixes + python3 tools/verify_apps_pg_capacity.py helm/apps-pg-cluster.yaml helm/apps-pg-2-cluster.yaml + +apps-pg-deploy: apps-pg-verify-capacity ## Apply shared apps-pg cnpg Cluster + NetworkPolicies @if [ -n "$(CONSUMING_ENTITY)" ]; then \ $(MAKE) consumption-preflight CONSUMING_ENTITY='$(CONSUMING_ENTITY)' \ ESTIMATE_EUR='$(ESTIMATE_EUR)' CONSUMPTION_CLASS=new-order; \ @@ -90,6 +93,21 @@ apps-pg-deploy: ## Apply shared apps-pg cnpg Cluster + NetworkPolicies $(KUBECTL) apply -f helm/apps-pg-cluster.yaml $(KUBECTL) apply -f helm/apps-pg-networkpolicies.yaml +apps-pg-backup-deploy: ## Apply the daily backup after the governed S3 Secret exists + @$(KUBECTL) get secret platform-pg-backup-s3 -n databases >/dev/null || { echo 'missing governed platform-pg-backup-s3 secret' >&2; exit 2; } + $(KUBECTL) apply -f helm/apps-pg-backup.yaml + +apps-pg-overflow-dry-run: apps-pg-verify-capacity ## Server-validate the unapplied overflow cell + @$(KUBECTL) get secret apps-pg-2-credentials -n databases >/dev/null || { echo 'missing distinct apps-pg-2-credentials secret' >&2; exit 2; } + $(KUBECTL) apply --dry-run=server -f helm/apps-pg-2-cluster.yaml + $(KUBECTL) apply --dry-run=server -f helm/apps-pg-2-networkpolicies.yaml + $(KUBECTL) apply --dry-run=server -f helm/apps-pg-2-backup.yaml + +apps-pg-overflow-deploy: apps-pg-overflow-dry-run ## Provision only for an approved fourth consumer + $(KUBECTL) apply -f helm/apps-pg-2-cluster.yaml + $(KUBECTL) apply -f helm/apps-pg-2-networkpolicies.yaml + $(KUBECTL) apply -f helm/apps-pg-2-backup.yaml + apps-pg-status: ## Show apps-pg cnpg cluster health $(KUBECTL) cnpg status apps-pg -n databases 2>/dev/null || \ $(KUBECTL) get cluster apps-pg -n databases -o wide diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b553aac..ae402ba 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -21,6 +21,7 @@ | workplan | RAILIANCE-WP-0016 | finished | — | workplans/RAILIANCE-WP-0016-architecture-cleanup-backlog.md | | workplan | RAILIANCE-WP-0017 | finished | — | workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md | | workplan | RPF-WP-0018 | active | — | workplans/RPF-WP-0018-policy-surface-alignment.md | +| workplan | RPF-WP-0019 | active | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | | task | RAILIANCE-WP-0005-T01 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T02 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T03 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | @@ -88,3 +89,7 @@ | task | RPF-WP-0018-T05 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | task | RPF-WP-0018-T06 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | | task | RPF-WP-0018-T07 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md | +| task | RPF-WP-0019-T01 | progress | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | +| task | RPF-WP-0019-T02 | progress | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | +| task | RPF-WP-0019-T03 | done | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | +| task | RPF-WP-0019-T04 | wait | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md | diff --git a/docs/adr/ADR-0002-placement-policy-ownership.md b/docs/adr/ADR-0002-placement-policy-ownership.md index fe11c39..ed2619f 100644 --- a/docs/adr/ADR-0002-placement-policy-ownership.md +++ b/docs/adr/ADR-0002-placement-policy-ownership.md @@ -12,7 +12,8 @@ review_interval: 6m ## Context -`net-kingdom/canon/standards/tenancy-posture_v0.1.md` §8.2 proposes that +An earlier draft of +`net-kingdom/canon/standards/tenancy-posture_v0.1.md` §8.2 proposed that database placement policy — dedicated versus shared, and when that changes — be owned by `railiance-platform`, co-signed by `adaptive-pricing`. `tenant-engine` raised the same gap independently on 2026-08-16: both patterns @@ -49,15 +50,18 @@ compliance position; S3 knows the substrate. A placement change needs both. Putting the decision solely here would recreate the problem §8.2 exists to avoid — an operations-shaped repo holding a commercial decision alone. -`adaptive-pricing` co-signature is requested, not assumed. +`adaptive-pricing` declined the standing co-signature on 2026-08-17. The +replacement is binding and machine-reconcilable: it publishes typed tier +minimums; this policy validates placement against them. Ownership stays here. ## Consequences - `docs/placement-policy.md` is the operative document; this ADR is the decision behind it. -- §19.8 is answered by obligation rather than by number: Rule P-4.1 requires - `platform-pg` and `apps-pg` to declare a ceiling before their third consumer. - Both are at two consumers. +- §19.8 is answered for `platform-pg` by `rapp-postgres` ADR-0004: four + consumers, with `platform-pg-2` named as the still-absent overflow target. + Rule P-4.1 still requires `apps-pg` to declare its own ceiling before its + third consumer. - This repo owes `apps-pg`'s ceiling under its own rule. A policy whose author is exempt is not a policy. - §8.1 requires triggers to be *monitored*. This repo has no monitoring plane, @@ -65,8 +69,8 @@ avoid — an operations-shaped repo holding a commercial decision alone. pending `railiance-telemetry`. Declaring a ceiling is a partial substitute — a written threshold is checkable by a human at provisioning, which beats a limit discovered by an OOM kill. -- If `adaptive-pricing` declines, this ADR is revised to record single - ownership explicitly rather than leaving an unsigned co-signature line. +- `adaptive-pricing`'s decline is recorded as single policy ownership plus a + mandatory typed constraint join, not as an absent signature. ## Alternatives considered diff --git a/docs/apps-pg.md b/docs/apps-pg.md index 28916dc..081319c 100644 --- a/docs/apps-pg.md +++ b/docs/apps-pg.md @@ -97,18 +97,30 @@ applied; coulomb-social connectivity smoke from labeled consumer ns OK. ## Backup And Roadmap -`apps-pg` starts as a conservative single-instance, 10Gi cluster to match -the current node capacity and existing CNPG footprint. Adding a replica, -PgBouncer/CNPG `Pooler`, resize policy, and CNPG-native backup coverage -are follow-up platform work items. +`apps-pg` remains a conservative single-instance, 10Gi cluster. The desired +state now includes continuous WAL archival, a daily 02:15 UTC base backup and +30-day retention under the distinct `apps-pg/` object-store prefix. The +credential lane is shared with `platform-pg`; the backup data path is not. -Until backup coverage is explicitly added, consumer onboarding should -record whether app data is disposable, externally reproducible, or -requires an immediate backup follow-up before production use. +The declared ceiling is three consumers. Each gets at most 20 connections; +40 of the explicit 100-connection aggregate remains for CNPG and operator +headroom. Memory (1Gi limit), not the clean connection refusal, is treated as +the binding safety constraint. A fourth consumer goes to the named +`apps-pg-2` overflow substrate, which must be provisioned before onboarding. +Reviewed, unapplied source for that cell is +`helm/apps-pg-2-{cluster,backup,networkpolicies}.yaml`; it uses a distinct +bootstrap Secret and backup prefix. `make apps-pg-verify-capacity` rejects a +fourth role on either cell, missing connection limits, duplicate roles, missing +resource envelopes, or a reused backup path. + +`statement_timeout` and `idle_in_transaction_session_timeout` are 15 seconds +per consumer role. CNPG 1.28 cannot express those role settings, so +`helm/apps-pg-consumer-controls.sql` is the idempotent controlled-operator +step; `connectionLimit` remains declaratively reconciled by CNPG. Resource evidence for `resource:railiance:apps-pg` (capacity, recovery, labor, allocation drivers) is published under `docs/evidence/RAILIANCE-WP-0016-apps-pg-resource-evidence.md`. The -2026-08-14 observation: no ScheduledBackup, railiance01 `apps-pg` is -not an Option A target, RPO is unbounded, RTO is undefined. That is -unresolved local-storage risk, not verified recovery. +2026-08-14 observation remains historically correct. The 2026-08-18 desired +state is not called verified recovery until the first backup succeeds and a +scratch restore artifact is recorded. diff --git a/docs/placement-policy.md b/docs/placement-policy.md index a4e0208..d1051ae 100644 --- a/docs/placement-policy.md +++ b/docs/placement-policy.md @@ -5,7 +5,7 @@ Effective: 2026-08-17 Framework: `net-kingdom/canon/standards/tenancy-posture_v0.1.md` §4.4, §8 Contract version of this document: `1.0.0` Status: **provisional** — binding on this repo now; formally conditional on -the framework reaching `accepted` and on `adaptive-pricing` co-signature. +the framework reaching `accepted`. Answers `tenant-engine`'s 2026-08-16 request ("a default and the conditions that override it, so migrations stop being decided by whichever neighbour was @@ -13,8 +13,9 @@ consulted") and Tenancy Posture §19.2. ## 1. Ownership, and its limit -§8.2 proposes placement ownership to `railiance-platform` co-signed by -`adaptive-pricing`. **Accepted, with the scope stated:** +Draft-8 §8.2 assigns the placement rule to `railiance-platform`, substrate +numbers to the package repo, workload requirements to the consumer and tier +minimums to `adaptive-pricing`. **Accepted, with the scope stated:** > **This repo owns the rule. The package repo owns the number.** @@ -32,10 +33,11 @@ an observation only the operator of the package can make. Our obligation under this policy is §4 below — that a ceiling and an overflow target **exist and are published**. Theirs is what they are. -`adaptive-pricing` co-signature is **requested, not assumed**. The reasoning in -§8.2 is sound: tenancy model selection is commercial as much as technical. If -they decline, this document records single ownership and says so plainly rather -than leaving a co-signature line nobody signed. +`adaptive-pricing` declined a standing co-signature on 2026-08-17 and supplied +the stronger replacement adopted by draft-8: typed tier minima are joined to +consumer and provider declarations at tier definition and on change. This repo +therefore owns this policy singly; it must validate, not copy, the commercial +constraint artifact. ## 2. The default, and what overrides it @@ -73,7 +75,7 @@ triggers. It is not the same role as the operator of the cluster. | Workload | Substrate | Provided level | Placement owner | Co-signer | §13 evidence | | --- | --- | --- | --- | --- | --- | | `audit-core` | `platform-pg` | P1 | `audit-core` | this repo | probes exist | -| `tenant-engine` | `platform-pg` | P1 | `tenant-engine` | this repo | probes exist | +| `tenant-engine` | SQLite PVC current; `platform-pg` desired | off-ladder current; P1 desired | `tenant-engine` | this repo | source conformance; live cutover absent | | `user-engine` | `user-engine-pg` | P2 | `net-kingdom` | this repo | not assessed here | | `target-revenue` | `target-revenue-pg` | P2 | `target-revenue` | this repo | not assessed here | | `forgejo` | `forgejo-db` | P2 | this repo | — | single consumer | @@ -85,7 +87,7 @@ triggers. It is not the same role as the operator of the cluster. §13.1 claims a level only with its artifact present. The P1–P4 artifact is *"provisioning declaration plus the platform's isolation probes"*. -`platform-pg` has them — `rapp-postgres` runs 15 adversarial probes against the +`platform-pg` has them — `rapp-postgres` runs 19 adversarial probes against the consumer boundary. **`apps-pg` has none.** `scripts/capture-apps-pg-evidence.py` captures *capacity* evidence for `resource-control`; it makes no isolation assertion. So the P1 levels recorded above for `vergabe` and `coulomb_social` @@ -145,8 +147,8 @@ one". "Provision a second cluster" is an answer; "revisit at the time" is not. | Cluster | Ceiling declared | Overflow target | Standing | | --- | --- | --- | --- | -| `platform-pg` | not yet — `rapp-postgres` owes it | not yet | **2 consumers of ~6 practical; owed before the 3rd** | -| `apps-pg` | not yet — this repo owes it | not yet | 2 consumers; owed before the 3rd | +| `platform-pg` | 4 declarations (estimated; ADR-0004) | `platform-pg-2`, named but absent | 3/4 including the isolation probe; measure before the next workload | +| `apps-pg` | 3 consumers; 20 connections each, 1Gi memory binds first | `apps-pg-2` | 2/3; overflow must exist before a 4th | | `forgejo-db` | n/a — single consumer | n/a | — | Both live shared clusters are one consumer away from the trigger. This repo diff --git a/docs/s3-consumer-interfaces.md b/docs/s3-consumer-interfaces.md index ce2470a..8dc63e9 100644 --- a/docs/s3-consumer-interfaces.md +++ b/docs/s3-consumer-interfaces.md @@ -55,29 +55,28 @@ provisions the role and CNPG `Database`, labels the namespace RW endpoint: `apps-pg-rw.databases.svc.cluster.local:5432`. Bootstrap role `apps_admin` is not a consumer credential. -**Quotas and limits** — read from `helm/apps-pg-cluster.yaml`, 2026-08-17: +**Quotas and limits** — declared in `helm/apps-pg-cluster.yaml` and +`helm/apps-pg-consumer-controls.sql`, 2026-08-18. These values are published +before the corresponding live rollout: | Property | Value | Note | | --- | --- | --- | -| `max_connections` | **PostgreSQL default (100)** | Not tuned in the CR. Not partitioned per consumer — there is no per-consumer connection allowance, so one consumer can exhaust the cluster. | -| `statement_timeout` | **none** | Not set. A runaway query runs until it finishes. | -| `idle_in_transaction_session_timeout` | **none** | Not set. | -| CPU / memory limits | **none declared** | The pod is BestEffort QoS and is the first thing evicted under node pressure. | +| `max_connections` | 100 | Explicit aggregate cap. Forty connections remain outside the declared three-consumer ceiling for operator/CNPG overhead. | +| Per-consumer connections | 20 | CNPG `managed.roles[].connectionLimit`, for both current roles. | +| `statement_timeout` | 15s | Per role; idempotent operator SQL because CNPG 1.28 has no managed-role settings field. | +| `idle_in_transaction_session_timeout` | 15s | Per role, same enforcement path. | +| CPU / memory | 100m / 256Mi requests; 1 CPU / 1Gi limits | Burstable QoS; memory is expected to bind before connections if workload size grows. | | Instances | 1 | No HA. Restart recovery only, per the single-node rail. | | Storage | 10Gi | | -| Backup | **none configured** | No `barmanObjectStore`, no `retentionPolicy`. | -| Effective erasure horizon | **undefined** | Follows from the line above. | +| Backup | 30d; daily 02:15 UTC + continuous WAL | S3 prefix `s3://railiance-platform-pg-backup/apps-pg/`; rollout is not evidence of recoverability until first backup and restore proof exist. | +| Effective erasure horizon | 30d, or the max across co-residents | Shared-cluster rule from placement policy §7. | +| Declared ceiling / overflow | 3 consumers / `apps-pg-2` | Memory is the binding safety constraint; provision the named overflow substrate before admitting a fourth. | -**Read the backup row before provisioning against this cluster.** `apps-pg` -has no backup at all — not a short retention, none. Recovery from data loss is -not available on this interface today. This is disclosed rather than fixed -because §10.2 requires disclosure now and the fix needs a backup target; it is -recorded as the R-axis gap in `docs/tenancy-posture.md` and is the highest -value item that declaration surfaced. - -The unpartitioned connection pool is the same shape of risk `rapp-postgres` -reported for `platform-pg`: the bound is aggregate, so every consumer can be -politely inside its own expectations while the cluster still fails. +The manifests close the configuration gap, not the evidence gap. Until the +cluster change is applied and `LastBackupSucceeded=True` plus a scratch restore +are captured, recovery remains operationally unproven. Likewise the P1 noisy- +neighbour claim waits for the controlled probe window; source configuration is +not substituted for either artifact. ## `rapp-postgres.v1` @@ -99,7 +98,7 @@ Consumer is a `consumers[].declaration`. Boundary unit is | Backup retention | 30d | Instance-wide, per `rapp-postgres` ADR-0002. | | Effective erasure horizon | **30d, or the max across co-residents** | A consumer needing shorter cannot have it at P1. See `docs/placement-policy.md` §7. | | Statement timeout | consumer-set | `tenant-engine` sets 5s for its own path; not a platform default. | -| Declared ceiling / overflow target | **not yet declared** | Owed by `rapp-postgres` before the third consumer, per `docs/placement-policy.md` Rule P-4.1. | +| Declared ceiling / overflow target | **4 declarations / `platform-pg-2`** | The isolation probe counts. `rapp-postgres` rejects a fifth declaration on one cell; ADR-0004 owns the number. | Service classes currently co-resident on `platform-pg`: `latency-critical` (`tenant-engine`) and `batch` (`audit-core`). Nothing prioritises between them diff --git a/docs/tenancy-posture.md b/docs/tenancy-posture.md index df1db9e..519153d 100644 --- a/docs/tenancy-posture.md +++ b/docs/tenancy-posture.md @@ -2,9 +2,13 @@ Work record: `RPF-WP-0018` T01 Declared: 2026-08-17 -Framework: `net-kingdom/canon/standards/tenancy-posture_v0.1.md` (draft-5, proposed) +Framework: `net-kingdom/canon/standards/tenancy-posture_v0.1.md` (draft-8, proposed) Contract version of this document: `1.0.0` +Machine-readable source: `tenancy.yaml`. This document preserves the review +reasoning; where a snippet and the root declaration differ, `tenancy.yaml` is +authoritative. + This is `railiance-platform`'s response to the Tenancy Posture ratification test (§20.2): *"Each publishes its own posture vector as part of review. The framework is validated by whether it can describe them accurately."* @@ -55,8 +59,8 @@ surface to the `rapp-*` repos while custody and policy stayed here. tenancy: service: apps-pg role: storage-provider - current: { I: 0, A: 0, E: 0, P: null, R: 0 } - target: { I: 0, A: 0, E: 0, P: null, R: 2 } + current: { I: 0, A: 0, E: 0, P: "n/a", R: 0, V: 0 } + target: { I: 0, A: 0, E: 0, P: "n/a", R: 2, V: 1 } service_class: interactive reviewed: "2026-08-17" gap: @@ -70,7 +74,7 @@ tenancy: concept, carries no tenant claim, and applies no tenant predicate. The tenant boundary for data on this cluster sits entirely in the consuming S5 application. See the provider note below." - - "P is null rather than a number. See §P-axis note." + - "P is n/a rather than a number. See §P-axis note." ``` ### `forgejo-db` — dedicated cluster for the forge @@ -79,8 +83,8 @@ tenancy: tenancy: service: forgejo-db role: storage-provider - current: { I: 0, A: 0, E: 0, P: null, R: 0 } - target: { I: 0, A: 0, E: 0, P: null, R: 2 } + current: { I: 0, A: 0, E: 0, P: "n/a", R: 0, V: 0 } + target: { I: 0, A: 0, E: 0, P: "n/a", R: 2, V: 1 } service_class: interactive reviewed: "2026-08-17" gap: @@ -97,8 +101,8 @@ tenancy: tenancy: service: openbao role: credential-provider - current: { I: 0, A: 0, E: 0, P: 0, R: 0 } - target: { I: 0, A: 0, E: 0, P: 0, R: 1 } + current: { I: 0, A: 0, E: 0, P: 0, R: 0, V: 0 } + target: { I: 0, A: 0, E: 0, P: 0, R: 1, V: 1 } permanent: [I, A, E] service_class: latency-critical reviewed: "2026-08-17" @@ -145,7 +149,7 @@ great deal, it is central, and it is mechanically evidenced — it *can* sign with `ssh/sign/agt-role`, and asserts it *cannot* read policy metadata, then revokes by accessor. That is exactly the shape §13 asks for at `A2`: choke point identified, unbound request refused. It is pointed at the -**consumer** boundary, and none of the five axes has anywhere to put it. +**consumer** boundary, and none of the tenancy axes has anywhere to put it. That evidence is therefore cited under the provider statement rather than against a consumer axis. Consumer separation in OpenBao is structural — a @@ -164,7 +168,7 @@ owner per consumer, and the triggers. The numbers — `instances: 1`, `max_connections: 100`, `1Gi`, `retentionPolicy: 30d` — are `rapp-postgres`'s cluster CR and its declaration to make. -## The P-axis note — why two entries are `null` +## The P-axis note — why two entries are `n/a` The P ladder describes **which substrate holds a tenant's data**, from the point of view of the workload whose data it is. `apps-pg` and `forgejo-db` are @@ -173,14 +177,18 @@ well-formed answer: `apps-pg` *provides* P1 to each of its consumers, and `forgejo-db` *provides* P2 to forgejo, but neither *is* at a level. Declaring `P: 1` for `apps-pg` would read as though the cluster had a -placement, and a later reader would use it to argue about isolation. `null` -plus the provider statement is the accurate form. This is routed as a finding. +placement, and a later reader would use it to argue about isolation. `n/a` +plus the provider statement is the accurate canonical form. -## Finding routed to `net-kingdom` +## Finding adopted by `net-kingdom` -**The five ladders describe a consumer of storage. They do not describe a +**The six ladders describe a consumer of storage. They do not describe a provider of it.** +**Resolved in draft-7 and formalised in draft-8.** Decision 5.5 adopted the +provider declaration; the draft-8 schema standardises `available`, `maximum`, +conditions and evidence per axis. The canonical form is now in `tenancy.yaml`. + **Narrowed on re-reading, 2026-08-17.** An earlier version of this finding claimed the framework had no way to say "this zero is structural, not weak". That was wrong: §6 says exactly that, and §13.1a supplies the mechanism — @@ -208,15 +216,16 @@ rather than a tenant one. A reader scanning a table of E values would rank OpenBao below a service doing per-query filtering in application code, which inverts the actual security position. -**Proposed amendment, offered as the first correction from review rather than +**Proposed amendment at draft-5, offered as the first correction from review rather than from research (§Status observes that every correction so far came from research or relocation):** add a **provider declaration** beside the posture vector — a platform service states, per axis, the maximum level it makes reachable and what the consumer must do to reach it. For `apps-pg` that is: E4 unreachable (shared credential per consumer, no per-tenant credential), E3 reachable once the GUC contract exists, R2 blocked on a backup target. That is the sentence a -consumer actually needs, and no arrangement of the five consumer ladders -produces it. +consumer actually needs, and no arrangement of the consumer ladders produces +it. Draft-8 adopted and standardised that amendment; the root declaration is +now authoritative. This is offered as an addition, not a replacement. The consumer vector is right for consumers, and this repo is not asking for the ladders to be diff --git a/helm/apps-pg-2-backup.yaml b/helm/apps-pg-2-backup.yaml new file mode 100644 index 0000000..1b33c8e --- /dev/null +++ b/helm/apps-pg-2-backup.yaml @@ -0,0 +1,14 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: ScheduledBackup +metadata: + name: apps-pg-2-daily + namespace: databases + labels: + cnpg.io/cluster: apps-pg-2 +spec: + schedule: "0 15 2 * * *" + backupOwnerReference: self + cluster: + name: apps-pg-2 + immediate: true + method: barmanObjectStore diff --git a/helm/apps-pg-2-cluster.yaml b/helm/apps-pg-2-cluster.yaml new file mode 100644 index 0000000..1d1c897 --- /dev/null +++ b/helm/apps-pg-2-cluster.yaml @@ -0,0 +1,45 @@ +# Provisionable overflow cell for the apps-pg three-consumer ceiling. +# Do not apply while empty; see docs/apps-pg.md. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: apps-pg-2 + namespace: databases + labels: + app.kubernetes.io/name: apps-pg-2 + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: manual + railiance.io/layer: s3-platform + railiance.io/role: shared-apps-database +spec: + instances: 1 + imageName: ghcr.io/cloudnative-pg/postgresql:16 + resources: + requests: {cpu: 100m, memory: 256Mi} + limits: {cpu: "1", memory: 1Gi} + storage: + size: 10Gi + bootstrap: + initdb: + database: apps_meta + owner: apps_admin + secret: + name: apps-pg-2-credentials + postgresql: + parameters: + max_connections: "100" + log_connections: "on" + log_disconnections: "on" + log_lock_waits: "on" + log_min_duration_statement: "1000" + track_io_timing: "on" + backup: + retentionPolicy: "30d" + barmanObjectStore: + destinationPath: s3://railiance-platform-pg-backup/apps-pg-2/ + endpointURL: https://s3.nl-ams.scw.cloud + s3Credentials: + accessKeyId: {name: platform-pg-backup-s3, key: ACCESS_KEY_ID} + secretAccessKey: {name: platform-pg-backup-s3, key: ACCESS_SECRET_KEY} + wal: {compression: gzip, maxParallel: 2} + data: {compression: gzip, jobs: 2} diff --git a/helm/apps-pg-2-networkpolicies.yaml b/helm/apps-pg-2-networkpolicies.yaml new file mode 100644 index 0000000..1d08d11 --- /dev/null +++ b/helm/apps-pg-2-networkpolicies.yaml @@ -0,0 +1,54 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-egress-kube-api-apps-pg-2 + namespace: databases +spec: + podSelector: + matchLabels: {cnpg.io/cluster: apps-pg-2} + policyTypes: [Egress] + egress: + - ports: + - {port: 443, protocol: TCP} + - {port: 6443, protocol: TCP} + - to: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: kube-system} + ports: + - {port: 53, protocol: UDP} + - {port: 53, protocol: TCP} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-from-cnpg-operator-apps-pg-2 + namespace: databases +spec: + podSelector: + matchLabels: {cnpg.io/cluster: apps-pg-2} + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: cnpg-system} + ports: + - {port: 5432, protocol: TCP} + - {port: 8000, protocol: TCP} + - {port: 9187, protocol: TCP} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-ingress-from-app-namespaces-apps-pg-2 + namespace: databases +spec: + podSelector: + matchLabels: {cnpg.io/cluster: apps-pg-2} + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: {railiance.io/postgres-client: apps-pg-2} + podSelector: {} + ports: + - {port: 5432, protocol: TCP} diff --git a/helm/apps-pg-backup.yaml b/helm/apps-pg-backup.yaml new file mode 100644 index 0000000..29761c2 --- /dev/null +++ b/helm/apps-pg-backup.yaml @@ -0,0 +1,14 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: ScheduledBackup +metadata: + name: apps-pg-daily + namespace: databases + labels: + cnpg.io/cluster: apps-pg +spec: + schedule: "0 15 2 * * *" + backupOwnerReference: self + cluster: + name: apps-pg + immediate: true + method: barmanObjectStore diff --git a/helm/apps-pg-cluster.yaml b/helm/apps-pg-cluster.yaml index 5224d81..d34d8b0 100644 --- a/helm/apps-pg-cluster.yaml +++ b/helm/apps-pg-cluster.yaml @@ -26,6 +26,13 @@ metadata: spec: instances: 1 # bump to 3 when node RAM > 8GB imageName: ghcr.io/cloudnative-pg/postgresql:16 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi storage: size: 10Gi bootstrap: @@ -43,14 +50,41 @@ spec: - name: vergabe # RAILIANCE-WP-0002 T04 (vergabe-teilnahme) ensure: present login: true + connectionLimit: 20 passwordSecret: name: vergabe-app-credentials - name: coulomb_social # CSOC-WP-0002 (coulomb-social) - ensure: present login: true + connectionLimit: 20 passwordSecret: name: coulomb-social-app-credentials + postgresql: + parameters: + max_connections: "100" + log_connections: "on" + log_disconnections: "on" + log_lock_waits: "on" + log_min_duration_statement: "1000" + track_io_timing: "on" + backup: + retentionPolicy: "30d" + barmanObjectStore: + destinationPath: s3://railiance-platform-pg-backup/apps-pg/ + endpointURL: https://s3.nl-ams.scw.cloud + s3Credentials: + accessKeyId: + name: platform-pg-backup-s3 + key: ACCESS_KEY_ID + secretAccessKey: + name: platform-pg-backup-s3 + key: ACCESS_SECRET_KEY + wal: + compression: gzip + maxParallel: 2 + data: + compression: gzip + jobs: 2 # HA replica + connection pooler are deferred (RAILIANCE-WP-0003 Notes): # services: # additional: diff --git a/helm/apps-pg-consumer-controls.sql b/helm/apps-pg-consumer-controls.sql new file mode 100644 index 0000000..d3a743c --- /dev/null +++ b/helm/apps-pg-consumer-controls.sql @@ -0,0 +1,7 @@ +-- Idempotent controls not expressible in the installed CNPG managed.roles CRD. +-- Apply as the controlled apps-pg cluster administrator only after notifying +-- both consumers of the published limits in docs/s3-consumer-interfaces.md. +ALTER ROLE vergabe SET statement_timeout = '15s'; +ALTER ROLE vergabe SET idle_in_transaction_session_timeout = '15s'; +ALTER ROLE coulomb_social SET statement_timeout = '15s'; +ALTER ROLE coulomb_social SET idle_in_transaction_session_timeout = '15s'; diff --git a/helm/apps-pg-networkpolicies.yaml b/helm/apps-pg-networkpolicies.yaml index c41aec4..fa105c7 100644 --- a/helm/apps-pg-networkpolicies.yaml +++ b/helm/apps-pg-networkpolicies.yaml @@ -22,8 +22,19 @@ spec: - Egress egress: - ports: + - port: 443 + protocol: TCP - port: 6443 protocol: TCP + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy diff --git a/tenancy.yaml b/tenancy.yaml new file mode 100644 index 0000000..ba6f8fc --- /dev/null +++ b/tenancy.yaml @@ -0,0 +1,126 @@ +# Canonical vector set for services owned or held in custody here. +# Human reasoning remains in docs/tenancy-posture.md. +schema_version: "0.1" +framework: netkingdom-tenancy-posture +services: + - service: apps-pg + role: relational-storage-provider + tenancy: + current: { I: 0, A: 0, E: 0, P: "n/a", R: 0, V: 0 } + target: { I: 0, A: 0, E: 0, P: "n/a", R: 2, V: 1 } + reviewed: "2026-08-17" + review_due: "2027-02-17" + service_class: interactive + permanent: [I, A, E, P] + reason: + I: "The storage provider receives credentials, not tenant claims." + A: "No tenant context is bound at the database provider." + E: "Tenant enforcement belongs to each consuming application." + P: "apps-pg provides P1; a substrate is not itself at a P level." + R: "A backup target and 30-day retention are declared in source and + not yet applied; no successful backup or restore exists. R stays 0 + until evidenced, per §13.1 — declared configuration is not an + artifact." + V: "No recovery exercise or failover evidence exists." + gap: + R: "RPF-WP-0019 establishes a backup target and declared horizon." + V: "Exercise recovery after the backup target is established." + provider: + capability: data.relational + profile: s5-application-databases + axes: + E: + available: 2 + maximum: 3 + conditions: + - "E2 is entirely consumer-side." + - "E3 requires adoption of the rapp-postgres GUC contract." + - "E4 is unreachable on a shared cluster without per-tenant credentials." + P: + available: 1 + maximum: 2 + conditions: + - "P2 requires a new dedicated cluster for the consumer." + R: + available: 0 + maximum: 2 + conditions: + - "A governed backup target must exist before R2 is available." + V: + available: 0 + maximum: 1 + conditions: + - "Single-node recovery must be documented and exercised." + + - service: forgejo-db + role: relational-storage-provider + tenancy: + current: { I: 0, A: 0, E: 0, P: "n/a", R: 0, V: 0 } + target: { I: 0, A: 0, E: 0, P: "n/a", R: 2, V: 1 } + reviewed: "2026-08-17" + review_due: "2027-02-17" + service_class: interactive + permanent: [I, A, E, P] + reason: + I: "The database receives a Forgejo credential, not tenant claims." + A: "No NetKingdom tenant context is bound at this provider." + E: "Tenant enforcement, if any, belongs to Forgejo." + P: "forgejo-db provides P2 to Forgejo; it is not itself at P2." + R: "A backup procedure exists, but no declared retention or erasure horizon does." + V: "No exercised recovery objective or failover evidence exists." + gap: + R: "Declare the backup window and publish the effective erasure horizon." + V: "Exercise recovery and record the interruption." + provider: + capability: data.relational + profile: dedicated-forgejo-database + axes: + P: + available: 2 + maximum: 2 + R: + available: 0 + maximum: 2 + conditions: + - "Declare and evidence retention on the existing backup path." + V: + available: 0 + maximum: 1 + conditions: + - "Single-node recovery must be exercised." + + - service: openbao + role: credential-provider + tenancy: + current: { I: 0, A: 0, E: 0, P: 0, R: 0, V: 0 } + target: { I: 0, A: 0, E: 0, P: 0, R: 1, V: 1 } + reviewed: "2026-08-17" + review_due: "2027-02-17" + service_class: latency-critical + permanent: [I, A, E, P] + reason: + I: "OpenBao authenticates workloads and does not resolve NetKingdom tenants." + A: "No tenant context is carried or bound." + E: "OpenBao holds workload secrets, not tenant-keyed records." + P: "One instance is shared by every credential consumer." + R: "No retention position covers audit output or KV version history." + V: "Node recovery requires operator unseal; no recovery objective is evidenced." + gap: + R: "Declare the platform default for audit output and KV history." + V: "Exercise the complete restart and unseal path against a recovery objective." + provider: + capability: credential.vending + axes: + E: + available: 0 + maximum: 4 + conditions: + - "Per-tenant paths, roles and credentials are required for tenant-level E4." + - "Current evidence proves consumer-boundary scoping, not tenant-boundary scoping." + evidence: + - "scripts/openbao-verify-token-grants.py" + V: + available: 0 + maximum: 1 + conditions: + - "Single-node restart and unseal recovery must be exercised." diff --git a/tests/test_apps_pg_capacity.py b/tests/test_apps_pg_capacity.py new file mode 100644 index 0000000..973137b --- /dev/null +++ b/tests/test_apps_pg_capacity.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from copy import deepcopy +import importlib.util +from pathlib import Path +import sys + +import pytest +import yaml + +ROOT = Path(__file__).parents[1] +SPEC = importlib.util.spec_from_file_location( + "verify_apps_pg_capacity", ROOT / "tools/verify_apps_pg_capacity.py" +) +capacity = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = capacity +SPEC.loader.exec_module(capacity) +MAX_CONSUMERS = capacity.MAX_CONSUMERS +verify = capacity.verify +MANIFESTS = [ROOT / "helm/apps-pg-cluster.yaml", ROOT / "helm/apps-pg-2-cluster.yaml"] + + +def test_reviewed_apps_pg_cells_fit_capacity_and_use_distinct_backups() -> None: + assert verify(MANIFESTS) == {"apps-pg": 2, "apps-pg-2": 0} + + +def test_fourth_role_is_rejected(tmp_path: Path) -> None: + primary = yaml.safe_load(MANIFESTS[0].read_text(encoding="utf-8")) + template = deepcopy(primary["spec"]["managed"]["roles"][0]) + while len(primary["spec"]["managed"]["roles"]) <= MAX_CONSUMERS: + role = deepcopy(template) + role["name"] = f"consumer_{len(primary['spec']['managed']['roles'])}" + primary["spec"]["managed"]["roles"].append(role) + path = tmp_path / "apps-pg.yaml" + path.write_text(yaml.safe_dump(primary), encoding="utf-8") + with pytest.raises(ValueError, match="3-consumer ceiling"): + verify([path, MANIFESTS[1]]) + + +def test_unbounded_role_is_rejected(tmp_path: Path) -> None: + primary = yaml.safe_load(MANIFESTS[0].read_text(encoding="utf-8")) + del primary["spec"]["managed"]["roles"][0]["connectionLimit"] + path = tmp_path / "apps-pg.yaml" + path.write_text(yaml.safe_dump(primary), encoding="utf-8") + with pytest.raises(ValueError, match="connectionLimit"): + verify([path, MANIFESTS[1]]) diff --git a/tools/verify_apps_pg_capacity.py b/tools/verify_apps_pg_capacity.py new file mode 100644 index 0000000..ff25c1a --- /dev/null +++ b/tools/verify_apps_pg_capacity.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path + +import yaml + + +MAX_CONSUMERS = 3 +MIN_RESERVED_CONNECTIONS = 40 +REVIEWED_CELLS = ("apps-pg", "apps-pg-2") + + +def verify(paths: list[Path]) -> dict[str, int]: + cells: dict[str, dict] = {} + role_owner: dict[str, str] = {} + prefixes: dict[str, str] = {} + for path in paths: + document = yaml.safe_load(path.read_text(encoding="utf-8")) + if document.get("kind") != "Cluster": + raise ValueError(f"{path}: expected a CNPG Cluster") + name = document["metadata"]["name"] + if name not in REVIEWED_CELLS: + raise ValueError(f"{path}: {name!r} is not a reviewed apps-pg cell") + cells[name] = document + missing = set(REVIEWED_CELLS) - cells.keys() + if missing: + raise ValueError(f"missing reviewed overflow desired state: {sorted(missing)}") + + counts: dict[str, int] = {} + for name, document in cells.items(): + spec = document["spec"] + roles = spec.get("managed", {}).get("roles", []) + if len(roles) > MAX_CONSUMERS: + raise ValueError( + f"{name}: {len(roles)} roles exceeds the {MAX_CONSUMERS}-consumer ceiling" + ) + max_connections = int(spec["postgresql"]["parameters"]["max_connections"]) + allocated = 0 + for role in roles: + role_name = role["name"] + if role_name in role_owner: + raise ValueError( + f"role {role_name!r} appears in both {role_owner[role_name]} and {name}" + ) + role_owner[role_name] = name + limit = role.get("connectionLimit") + if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1: + raise ValueError(f"{name}/{role_name}: positive connectionLimit is required") + allocated += limit + if allocated + MIN_RESERVED_CONNECTIONS > max_connections: + raise ValueError( + f"{name}: {allocated} consumer connections leave fewer than " + f"{MIN_RESERVED_CONNECTIONS} reserved connections" + ) + resources = spec.get("resources", {}) + if not resources.get("requests") or not resources.get("limits"): + raise ValueError(f"{name}: pod requests and limits are required") + prefix = spec["backup"]["barmanObjectStore"]["destinationPath"] + if prefix in prefixes: + raise ValueError(f"{name}: backup prefix is shared with {prefixes[prefix]}") + prefixes[prefix] = name + counts[name] = len(roles) + return counts + + +def main() -> int: + parser = argparse.ArgumentParser(description="Verify apps-pg cell admission controls") + parser.add_argument("manifests", nargs="+", type=Path) + args = parser.parse_args() + try: + counts = verify(args.manifests) + except (KeyError, TypeError, ValueError, yaml.YAMLError) as error: + parser.error(str(error)) + print( + "apps-pg capacity verified: " + + ", ".join(f"{name}={counts[name]}/{MAX_CONSUMERS}" for name in REVIEWED_CELLS) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workplans/RPF-WP-0018-policy-surface-alignment.md b/workplans/RPF-WP-0018-policy-surface-alignment.md index eba8a12..9805832 100644 --- a/workplans/RPF-WP-0018-policy-surface-alignment.md +++ b/workplans/RPF-WP-0018-policy-surface-alignment.md @@ -4,11 +4,11 @@ type: workplan title: "Align S3 with the estate policy surface (Tenancy Posture + policy-nexus)" domain: financials repo: railiance-platform -status: active +status: finished owner: codex topic_slug: railiance created: "2026-08-17" -updated: "2026-08-17" +updated: "2026-08-18" related: - POLICY-NEXUS-WP-0001 - TEN-WP-0009 @@ -297,3 +297,37 @@ this workplan routes the finding and does not build the ingress. **A declaration that goes stale the day it is written.** Mitigation is the `reviewed:` date on the vector set and the review interval in the ADR template — the same currency mechanism `policy-nexus` T05 will read. + +## Closed 2026-08-18 + +All seven tasks done. What the workplan produced, and what it changed +elsewhere: + +- `docs/tenancy-posture.md` + `tenancy.yaml` — the vector set, machine-readable + with the human reasoning kept beside it. +- `docs/placement-policy.md` — placement rule, owner per workload, triggers, + retention floor and ceiling. +- `docs/s3-consumer-interfaces.md` `1.1.0` — quota disclosure. +- `docs/adr/` — created from nothing; `ADR-0001`, `ADR-0002`, `ADR-0003`. + +**Two findings were adopted upstream.** The provider-declaration proposal (F4, +narrowed) is in the framework and its canonical form is the `provider:` block +in `tenancy.yaml`. `adaptive-pricing` declined the standing co-signature and +supplied a stronger replacement — typed tier minima joined at tier definition — +which draft-8 adopted; `ADR-0002` records the outcome as single ownership plus +a mandatory typed constraint join, not as an absent signature. + +**Three corrections were issued against our own output**, all in the same +direction — claiming levels we could not evidence. `openbao A: 2` retracted to +`A: 0`; the provider finding narrowed once §6's `flex-auth` precedent was read; +the §11 summary to `adaptive-pricing` corrected, since §11.2 keeps marketing +vocabulary free and only the *cannot-reach* claim is restricted, to E4. All +three are recorded in the documents rather than edited away. + +**What this workplan deliberately did not do:** fix anything. It found that +`apps-pg` had no backup, no per-consumer controls and no isolation probes, and +published those as visible defects. `RPF-WP-0019` closes them. + +**Left open, not owned here:** `bao.coulomb.social` still needs confirmation +against live reef state (`railiance-master` `ba477968`), and F1's substrate +split is with `policy-nexus` to act on. diff --git a/workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md b/workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md index 9e1fa14..458eee6 100644 --- a/workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md +++ b/workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md @@ -4,15 +4,16 @@ type: workplan title: "apps-pg: backup, per-consumer controls, and the isolation probes they make possible" domain: financials repo: railiance-platform -status: proposed +status: active owner: codex topic_slug: railiance created: "2026-08-17" -updated: "2026-08-17" +updated: "2026-08-18" related: - RPF-WP-0018 origin: residual origin_ref: RPF-WP-0018 +state_hub_workstream_id: "160e226d-27b0-4c94-9e99-331f4354dd12" --- # RPF-WP-0019 — apps-pg recoverability and per-consumer controls @@ -65,12 +66,43 @@ the `platform-pg-backup-s3` credential lane — the same handoff `make postgres-backup-deploy` waits on. Check whether that is now live before assuming this is blocked. +## Status 2026-08-18 — repository-complete, live-blocked + +Everything this repo can do without touching the cluster is done and +committed. What remains on T01, T02 and T04 is a single operator window +against a live shared rail, in this order: + +1. `make apps-pg-deploy` — Cluster reconcile: role connection limits, + Burstable requests/limits, explicit aggregate parameters. +2. Apply `helm/apps-pg-consumer-controls.sql` — the two 15s role timeouts. + Idempotent; CNPG 1.28 has no managed-role settings field, so this is + operator SQL by necessity, not by preference. +3. `make apps-pg-backup-deploy` — the ScheduledBackup, once the governed + Secret is confirmed live. +4. Capture `LastBackupSucceeded=True` and a scratch restore. **Until both + exist, `apps-pg` R stays 0** — declared configuration is not a §13 + artifact, and this workplan exists because that distinction was missed once + already. +5. T04's probes, in an announced window, after 1–3 have settled. + +**Blocker as of this session:** the cluster is unreachable from the +workstation — `kubectl` returns `Unauthorized` against +`config-hosteurope`. Credentials or the tunnel need attention before any of +the above runs. This is a session-local access problem, not a defect in the +manifests: `make apps-pg-verify-capacity` passes and the capacity tests are +green. + +**Do not treat the rollout as evidence.** T04's P1 claim and the R-axis both +need artifacts produced *after* application, and `docs/placement-policy.md` +§3.1 and `tenancy.yaml` should be updated only then. + ## Tasks ```task id: RPF-WP-0019-T01 -status: todo +status: progress priority: high +state_hub_task_id: "0f5175c7-bde3-46a0-87a8-b50b5eeac32c" ``` **Establish a backup target for `apps-pg`.** Confirm the state of the `resource-control` bucket and the `platform-pg-backup-s3` OpenBao Secret; if @@ -78,10 +110,17 @@ live, configure `barmanObjectStore` and a `retentionPolicy` on the cluster. If not live, record the dependency and say so — do not leave the absence undocumented a second time. +2026-08-18 repository readiness: the governed Secret exists live and reviewed +desired state now carries a distinct `apps-pg/` prefix, 30-day retention, +continuous WAL and a daily 02:15 backup. NetworkPolicy admits the DNS/HTTPS +path archival needs. The ScheduledBackup is not applied and no successful +backup/restore evidence exists, so T01 remains progress. + ```task id: RPF-WP-0019-T02 -status: todo +status: progress priority: high +state_hub_task_id: "c5b0b2ac-b9f5-42fb-8e75-2fac2ab1e852" ``` **Declare and enforce per-consumer controls.** Per-consumer connection allowance, `statement_timeout`, `idle_in_transaction_session_timeout`, and @@ -90,20 +129,34 @@ every value in `docs/s3-consumer-interfaces.md` before it takes effect — §10.2 is a disclosure rule, and applying a timeout consumers learn about by hitting it would breach the rule while implementing it. +2026-08-18 repository readiness: both roles declare a 20-connection limit, +the pod has Burstable requests/limits, aggregate/logging parameters are +explicit, and controlled operator SQL sets both 15s role timeouts. Every value +was published in `docs/s3-consumer-interfaces.md` before application. Live SQL +and Cluster reconciliation remain an operator window, so T02 remains progress. + ```task id: RPF-WP-0019-T03 -status: todo +status: done priority: medium +state_hub_task_id: "736cbc11-1992-4f1b-9ff0-cb4622ff39a5" ``` **Declare the ceiling and overflow target.** Owed under this repo's own Rule P-4.1 before `apps-pg`'s third consumer; it is at two. Name the binding resource per Rule P-4.2 — memory or connections — and a named overflow substrate per P-4.3. +Completed 2026-08-18. The declared ceiling is three, memory is the binding +constraint, and `apps-pg-2` is a named, source-provisionable overflow cell with +a distinct credential and backup prefix. `make apps-pg-verify-capacity` +rejects a fourth consumer per cell and unbounded/duplicate roles. The cell +intentionally remains absent until a fourth consumer is approved. + ```task id: RPF-WP-0019-T04 -status: todo +status: wait priority: medium +state_hub_task_id: "2fccdd26-c9c6-43cb-b069-27ed5668afac" ``` **Isolation probes, after T02.** Consumer-boundary probes on the `rapp-postgres` model, then the §13 noisy-neighbour artifact: per-consumer @@ -112,6 +165,10 @@ judged against each consumer's declared service class. Update `docs/placement-policy.md` §3.1 and `docs/tenancy-posture.md` when the P1 claims become evidenced. +Waiting on T02 live application and an announced probe window. No saturation +or destructive recovery experiment is run against the shared production rail +as part of repository preparation. + ## Boundaries - `apps-pg` only. `platform-pg`'s equivalents are `rapp-postgres`'s.