feat: WP-0002 T03 selection decision, wait on purchase

Recommend Scaleway Multi-AZ nl-ams. Inventory stays proposed with
description, decision, reef: refs, secret: handle, and consumers.
Human approval required before ordered.
This commit is contained in:
tegwick 2026-08-14 16:18:16 +02:00
parent 9d11d2a043
commit f9af7518f5
6 changed files with 179 additions and 5 deletions

View file

@ -5,6 +5,33 @@
"financial_entity_id": "entity:railiance",
"procuring_entity_id": "entity:railiance",
"entity_gap": null,
"description": "Off-host S3-compatible object store for rapp-postgres WAL archive and physical base backups. Procured by Railiance, operated as a Scaleway-delegated substrate on reef-storage, not on reef-railiance.",
"decision": {
"status": "recommended",
"chosen": "Scaleway Standard Multi-AZ nl-ams; independent secondary copy on Host Europe Backup Storage or governed Nextcloud (T06)",
"rejected": [
"Host Europe Cloud Storage as primary (S3 not confirmed orderable)",
"Hetzner Object Storage as primary (no default at-rest encryption)",
"Self-managed Garage as primary (labor and capacity lose at this workload)"
],
"approved_by": null,
"approved_on": null,
"ref": "docs/evidence/RESOURCE-WP-0002-primary-selection-2026-08-14.md"
},
"operational_refs": [
"reef:storage/declarations/reef.yaml",
"reef:storage/substrate/object-stores/platform-audit-storage.yaml",
"reef:storage/substrate/object-stores/platform-audit-storage.yaml#endpoint",
"reef:storage/substrate/object-stores/platform-audit-storage.yaml#bucket",
"reef:storage/substrate/object-stores/platform-audit-storage.yaml#region"
],
"credential_handles": [
"secret:railiance-platform/platform-pg-backup-s3"
],
"consumers": {
"potential": ["rapp-postgres"],
"actual": []
},
"resource_class": "storage",
"status": "proposed",
"management_model": "provider_managed",
@ -23,7 +50,7 @@
"location": {
"region": "nl-ams",
"country": "NL",
"failure_domains": ["provider:scaleway", "region:nl-ams"],
"failure_domains": ["provider:scaleway", "region:nl-ams", "reef:storage"],
"residency": "European Union"
},
"capacity": [
@ -44,7 +71,7 @@
"cost": {
"currency": "EUR",
"tax_status": "excluded",
"billing_model": "usage-based storage and egress; no commitment",
"billing_model": "usage-based storage and egress; no commitment; Railiance self-use at delivered cost",
"commitment_ref": null,
"price_evidence": "data/providers/object-storage.json#scaleway-standard-multi-az"
},
@ -65,6 +92,7 @@
"evidence": [
{"kind": "provider", "ref": "https://www.scaleway.com/en/pricing/storage/", "observed_at": "2026-08-10", "authority": "Scaleway"},
{"kind": "provider", "ref": "https://www.scaleway.com/en/object-storage/", "observed_at": "2026-08-10", "authority": "Scaleway"},
{"kind": "decision", "ref": "docs/evidence/RESOURCE-WP-0002-provider-due-diligence-2026-08-10.md", "observed_at": "2026-08-10", "authority": "resource-control"}
{"kind": "decision", "ref": "docs/evidence/RESOURCE-WP-0002-provider-due-diligence-2026-08-10.md", "observed_at": "2026-08-10", "authority": "resource-control"},
{"kind": "decision", "ref": "docs/evidence/RESOURCE-WP-0002-primary-selection-2026-08-14.md", "observed_at": "2026-08-14", "authority": "resource-control"}
]
}

View file

@ -0,0 +1,82 @@
# RESOURCE-WP-0002 T03 — primary object-store selection
Date: 2026-08-14
Status: **recommended — awaiting human purchase approval**
Resource: `resource:platform:audit-storage`
Procuring / consuming entity: `entity:railiance` (self-use, no 20 % markup)
Operating reef: `reef-storage` (not `reef-railiance`)
This is the decision record T03 asked for. It is not a purchase. It does
not create a Scaleway account, bucket, or key.
## Recommendation
| Role | Choice | Why |
| --- | --- | --- |
| **Primary** | Scaleway Object Storage, Standard Multi-AZ, region `nl-ams` (NL, EU) | Only A/B/C candidate that is orderable, S3/SigV4 documented, Multi-AZ, published durability, **managed encryption at rest**, and an independent failure domain from Host Europe `railiance01`. Lowest comparable 320 GB running cost among managed options that meet acceptance. |
| **Independent secondary copy** | Host Europe Backup Storage (SFTP/SCP) or the existing governed Nextcloud lane (T06) | Not S3; not a Barman primary. Keeps a second copy off Scaleway and off the same API credential. |
Do **not** put the primary bucket on `reef-railiance`. S3 is a
provider-delegated capability; `reef-storage` is the substrate.
## Ranking (A / B / C)
Evidence: demand/cost model 2026-08-10, expanded comparison 2026-08-10,
due diligence 2026-08-10. Labor €60/h. Tax excluded. Running totals at
the normalized 320 GB + 5 GB restore-drill point.
| Criterion | A Host Europe Cloud Storage | B Scaleway Multi-AZ `nl-ams` | C Hetzner Object Storage |
| --- | --- | --- | --- |
| Total cost (320 GB) | unknown (no current S3 quote) | **€65.14**/mo (€5.14 infra + €60 labor) | €96.49/mo (€6.49 min + €90 labor) |
| Compatibility (CNPG/Barman S3) | unknown / not orderable on evidence | documented S3 + SigV4; live preflight still required | documented S3; live preflight still required |
| Resilience | same provider as compute | **different provider**; Multi-AZ; 99.999999999% durability claim | different provider; no quantified storage SLA |
| Sovereignty | DE if it existed | NL / EU | DE / EU |
| Operational effort | unknown | 1 h/mo planned; no rail to run | 1.5 h/mo; SSE-C custody if we accept no default at-rest encryption |
| Exit cost | unknown | egress €0.01/GB after 75 GB free + 4 h labor | inside 1 TB included until quota exceeded |
| Blocking gap | current S3 **not confirmed orderable** | live Barman preflight; contract/tax on the paying account | **no default at-rest encryption** (SSE-C only) |
Self-managed Garage on 23 VMs is €240€336/mo at 320 GB and fails closed
before month-12 base volume. It is not a primary candidate at this
workload.
## What we are buying (if approved)
- Product: Scaleway Standard Multi-AZ Object Storage
- Region: `nl-ams`
- Commitment: **none** (usage-based)
- Payer: Railiance (`entity:railiance`); transfer price = delivered cost
- Public access: disabled
- Identity: narrowest key, bucket/prefix only
- Versioning: on
- Lifecycle: 30-day recovery window (match demand)
- Cost alert: on the Scaleway project
- Owner in inventory: `resource-control`
- Attribute home: `reef-storage/substrate/object-stores/platform-audit-storage.yaml`
- Credential home (after T04): `secret:railiance-platform/platform-pg-backup-s3`
- Consumer potential: `rapp-postgres`
- Consumer actual: none until WAL flows
## What human financial authority must approve
1. Create or reuse a Scaleway project paid as Railiance (or GmbH Hauptkonto
until the Railiance account exists).
2. Accept Scaleways contract/tax treatment for that account.
3. Accept that Host Europe S3 stays out of the race until written
orderability exists.
4. Accept Hetzner only as a price comparator unless SSE-C custody is
explicitly chosen later.
5. Spend: expected **~€3€10/mo infrastructure** at current size, plus
~1 h operator labor; not a committed term.
After **yes**: create private bucket, scoped key, versioning/lifecycle,
cost alert; fill `reef-storage` attributes (endpoint, bucket, prefix,
project ref); flip inventory `proposed → ordered`; then T04/T05.
After **no**: write the rejection on this record; do not invent another
primary without a new decision.
## Authority
Recommended by: resource-control (this file)
Approved by: _vacant — human financial authority_
Approved on: _null_

View file

@ -12,6 +12,37 @@
"financial_entity_id": {"type": ["string", "null"], "pattern": "^entity:[a-z0-9]+$"},
"procuring_entity_id": {"type": ["string", "null"], "pattern": "^entity:[a-z0-9]+$"},
"entity_gap": {"type": ["string", "null"]},
"description": {"type": "string", "minLength": 1},
"decision": {
"type": "object",
"additionalProperties": false,
"required": ["status", "ref"],
"properties": {
"status": {"enum": ["recommended", "approved", "rejected"]},
"chosen": {"type": ["string", "null"]},
"rejected": {"type": "array", "items": {"type": "string"}},
"approved_by": {"type": ["string", "null"]},
"approved_on": {"type": ["string", "null"], "format": "date"},
"ref": {"type": "string", "minLength": 1}
}
},
"operational_refs": {
"type": "array",
"items": {"type": "string", "pattern": "^reef:"}
},
"credential_handles": {
"type": "array",
"items": {"type": "string", "pattern": "^secret:"}
},
"consumers": {
"type": "object",
"additionalProperties": false,
"required": ["potential", "actual"],
"properties": {
"potential": {"type": "array", "items": {"type": "string"}},
"actual": {"type": "array", "items": {"type": "string"}}
}
},
"resource_class": {"enum": ["compute_instance", "storage", "network", "kubernetes_capacity", "database", "managed_service", "self_managed_service", "shared_platform_service", "license"]},
"status": {"enum": ["proposed", "ordered", "commissioning", "active", "suspended", "retiring", "retired", "rejected"]},
"management_model": {"enum": ["provider_managed", "self_managed", "shared_capacity"]},

View file

@ -34,6 +34,18 @@ class PortfolioTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "shared resources"):
validate_record(record)
def test_ordered_resource_requires_approved_decision(self):
record = deepcopy(next(
r for _, r in self.records() if r["id"] == "resource:platform:audit-storage"
))
record["status"] = "ordered"
with self.assertRaisesRegex(ValueError, "approved decision"):
validate_record(record)
record["decision"]["status"] = "approved"
record["decision"]["approved_by"] = "human"
record["decision"]["approved_on"] = "2026-08-14"
validate_record(record)
def test_unknown_commission_date_is_preserved(self):
record = deepcopy(next(r for _, r in self.records() if r["status"] == "active"))
record["lifecycle"]["commissioned_on"] = None

View file

@ -51,6 +51,18 @@ def validate_record(record: dict) -> None:
association_ok(record)
decision = record.get("decision")
if record.get("status") in {"ordered", "commissioning"} and (
not decision or decision.get("status") != "approved"
):
raise ValueError("ordered or commissioning resources require an approved decision")
for ref in record.get("operational_refs") or []:
if not str(ref).startswith("reef:"):
raise ValueError(f"operational_refs must be reef: references: {ref}")
for ref in record.get("credential_handles") or []:
if not str(ref).startswith("secret:"):
raise ValueError(f"credential_handles must be secret: references: {ref}")
allocation = record["ownership"]["allocation"]
if allocation["mode"] == "unattributed":
if allocation["cost_attribution_key"] is not None:

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: railiance
created: "2026-08-10"
updated: "2026-08-10"
updated: "2026-08-14"
state_hub_workstream_id: "921496a3-280b-4dc8-a3c0-b4ec314142f5"
---
@ -190,7 +190,7 @@ Barman preflight, contract review, and human approval.
```task
id: RESOURCE-WP-0002-T03
status: wait
status: progress
priority: high
state_hub_task_id: "e4184350-dab2-4a0b-bee5-1a641e8a2df3"
```
@ -207,6 +207,15 @@ Done when the decision is approved and the purchased resource has a non-secret
inventory record with provider resource ID, region, service class, contract,
renewal/cancellation dates, capacity model, owner, and cost-attribution key.
Progress 2026-08-14: decision record written —
`docs/evidence/RESOURCE-WP-0002-primary-selection-2026-08-14.md`.
Primary: Scaleway Multi-AZ `nl-ams`. Secondary copy: Host Europe Backup
Storage or Nextcloud (T06). Inventory stays `proposed` with five-facet
refs to `reef-storage`. Reef seeded (identity, topology, consumers,
planned attribute file). **Blocked on human approval to create the
Scaleway project/bucket.** After yes: fill reef attributes, set
`decision.status: approved` and inventory `ordered`.
## T04 — Establish credential custody and hand off to rapp-postgres
```task