From f9af7518f52cc704cec22ba1b204418ca52017dc Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 14 Aug 2026 16:18:16 +0200 Subject: [PATCH] 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. --- .../platform-audit-storage.proposed.json | 34 +++++++- ...CE-WP-0002-primary-selection-2026-08-14.md | 82 +++++++++++++++++++ schemas/resource-inventory.schema.json | 31 +++++++ tests/test_portfolio.py | 12 +++ tools/portfolio.py | 12 +++ ...WP-0002-procure-postgres-backup-storage.md | 13 ++- 6 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/RESOURCE-WP-0002-primary-selection-2026-08-14.md diff --git a/data/resources/platform-audit-storage.proposed.json b/data/resources/platform-audit-storage.proposed.json index 49dba12..72e59d6 100644 --- a/data/resources/platform-audit-storage.proposed.json +++ b/data/resources/platform-audit-storage.proposed.json @@ -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"} ] } diff --git a/docs/evidence/RESOURCE-WP-0002-primary-selection-2026-08-14.md b/docs/evidence/RESOURCE-WP-0002-primary-selection-2026-08-14.md new file mode 100644 index 0000000..eb7efee --- /dev/null +++ b/docs/evidence/RESOURCE-WP-0002-primary-selection-2026-08-14.md @@ -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 2–3 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 Scaleway’s 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_ diff --git a/schemas/resource-inventory.schema.json b/schemas/resource-inventory.schema.json index 2f3f719..33b16cb 100644 --- a/schemas/resource-inventory.schema.json +++ b/schemas/resource-inventory.schema.json @@ -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"]}, diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 475d951..756dd2f 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -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 diff --git a/tools/portfolio.py b/tools/portfolio.py index 0a71f84..e9e39bf 100644 --- a/tools/portfolio.py +++ b/tools/portfolio.py @@ -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: diff --git a/workplans/RESOURCE-WP-0002-procure-postgres-backup-storage.md b/workplans/RESOURCE-WP-0002-procure-postgres-backup-storage.md index de50eab..72d6594 100644 --- a/workplans/RESOURCE-WP-0002-procure-postgres-backup-storage.md +++ b/workplans/RESOURCE-WP-0002-procure-postgres-backup-storage.md @@ -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