diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index ae64feb..f3a6cde 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -11,6 +11,7 @@ make db-status # gitea-db / forgejo-db cnpg cluster health (read-only) make apps-pg-status # shared apps-pg cluster health (read-only) make pg-status # confirms retired Bitnami postgresql-ha is absent make valkey-status # confirms Valkey is absent +make consumption-preflight CONSUMING_ENTITY=entity: ESTIMATE_EUR= make db-deploy | apps-pg-deploy # production-touching ``` diff --git a/Makefile b/Makefile index 7d959be..baa4216 100644 --- a/Makefile +++ b/Makefile @@ -66,9 +66,27 @@ forgejo-db-status: ## Show forgejo-db cnpg cluster health forgejo-db-shell: ## Open psql shell on forgejo-db primary $(KUBECTL) cnpg psql forgejo-db -n databases -- -U forgejo forgejo +CONSUMING_ENTITY ?= +ESTIMATE_EUR ?= +CONSUMPTION_CLASS ?= new-order +CONSUMPTION_MODE_FILE ?= data/consumption-mode/current.json + +##@ Consumption mode (RAILIANCE-WP-0017) + +consumption-preflight: ## Refuse a new order that exceeds a restricted entity's allowance + @test -n "$(CONSUMING_ENTITY)" || { echo 'set CONSUMING_ENTITY=entity:' >&2; exit 2; } + CONSUMPTION_MODE_FILE='$(CONSUMPTION_MODE_FILE)' python3 scripts/consumption_mode.py check \ + --entity '$(CONSUMING_ENTITY)' \ + --class '$(CONSUMPTION_CLASS)' \ + $(if $(ESTIMATE_EUR),--estimate-eur '$(ESTIMATE_EUR)') + ##@ Shared apps-pg (S5 application databases) apps-pg-deploy: ## 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; \ + fi $(KUBECTL) apply -f helm/apps-pg-cluster.yaml $(KUBECTL) apply -f helm/apps-pg-networkpolicies.yaml @@ -387,4 +405,4 @@ help: ## Show this help /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) -.PHONY: db-deploy db-status db-shell db-logs forgejo-db-deploy forgejo-db-status forgejo-db-shell apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-configure-external-secrets-forgejo openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup forgejo-backup forgejo-backup-dry-run forgejo-backup-status forgejo-package-prune forgejo-package-prune-dry-run reef-deployables apps-pg-evidence help +.PHONY: db-deploy db-status db-shell db-logs forgejo-db-deploy forgejo-db-status forgejo-db-shell apps-pg-deploy apps-pg-status apps-pg-shell apps-pg-logs net-kingdom-pg-inter-hub-networkpolicy-deploy pg-deploy pg-status pg-pgpool-check valkey-deploy valkey-status openbao-repo openbao-dry-run openbao-overlay-apply openbao-verify-login-overlay openbao-deploy openbao-status openbao-verify openbao-verify-post-unseal openbao-configure-initial openbao-configure-ssh openbao-verify-ssh openbao-verify-authenticated openbao-configure-external-secrets-issue-core openbao-configure-external-secrets-activity-core openbao-configure-external-secrets-forgejo openbao-validate-restore-evidence openbao-validate-emergency-evidence credential-grants-validate credential-change-applier-dry-run credential-change-applier-apply-plan credential-change-applier-apply credential-change-runbook credential-change-record-evidence credential-change-lifecycle-plan credential-change-lifecycle-event credential-change-import-inventory openbao-credential-change-appliers-dry-run openbao-configure-credential-change-appliers openbao-token-grants-dry-run openbao-configure-token-grants openbao-verify-token-grants-dry-run openbao-verify-token-grants openbao-verify-token-grants-smoke credential-helper-dry-run credential-tests credential-exec-ops-warden-smoke argocd-bootstrap-dry-run argocd-bootstrap-deploy argocd-repo-apply argocd-status backup forgejo-backup forgejo-backup-dry-run forgejo-backup-status forgejo-package-prune forgejo-package-prune-dry-run reef-deployables apps-pg-evidence consumption-preflight help diff --git a/data/consumption-mode/current.json b/data/consumption-mode/current.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/consumption-mode/current.json @@ -0,0 +1 @@ +[] diff --git a/docs/consumption-mode-enforcement.md b/docs/consumption-mode-enforcement.md new file mode 100644 index 0000000..56ab684 --- /dev/null +++ b/docs/consumption-mode-enforcement.md @@ -0,0 +1,63 @@ +# Consumption-mode enforcement + +Work record: `RAILIANCE-WP-0017-T01` +Signal owner: `resource-control` (`docs/resource-procurement-facility.md`) +Terms: `resource-control/docs/TermsOfResourceProcurement.md` § 11.4 + +`resource-control` publishes `open` / `restricted` and the remaining +transfer-price allowance. This repo refuses **new orders** and **elastic +usage** that would exceed that allowance. It does not throttle a running +cluster and does not cancel a committed provider contract (OQ-2). + +## Signal + +Default file: `data/consumption-mode/current.json` + +Shape (one record or a list): + +```json +{ + "schema_version": "0.1", + "record_type": "consumption_mode", + "financial_entity_id": "entity:coulomb", + "period": "2026-09", + "consumption_mode": "restricted", + "new_transfer_charges_allowed_eur": "45.83", + "terms_version": "0.1" +} +``` + +Refresh after a settlement close by writing that file (or pointing +`CONSUMPTION_MODE_FILE` at a settlement-statement JSON — the helper +projects `next_month_allowance_eur`). An empty list means no entity is +restricted. Missing mode is **not** `open` and **not** `restricted`; it +does not refuse. + +`entity:railiance` is the procuring entity and is never restricted. + +## Check + +```bash +make consumption-preflight \ + CONSUMING_ENTITY=entity:coulomb \ + ESTIMATE_EUR=20 \ + CONSUMPTION_CLASS=new-order +``` + +| Class | Restricted behaviour | +| --- | --- | +| `new-order` / `elastic` | Refuse if estimate is missing or greater than the allowance | +| `safety` | Admit; print an exception line (backup, identity, ingress needed to avoid data loss) | +| `committed` | Admit; flag for human financial authority (do not cancel) | + +## Where it is wired + +| Target | Class | Notes | +| --- | --- | --- | +| `make consumption-preflight` | caller-supplied | required on any new consuming order | +| `make apps-pg-deploy` | `new-order` | only when `CONSUMING_ENTITY` is set (a new consumer, not a re-apply) | +| `make valkey-deploy` | `new-order` | already gated; would still have to pass this check | +| Backup / restore deploys | `safety` | continue under restriction; overage is an exception | + +A restricted entity cannot place a new order that would exceed the +published allowance. An `open` entity is unchanged. diff --git a/docs/s3-consumer-interfaces.md b/docs/s3-consumer-interfaces.md index 76b5ea4..bb9a596 100644 --- a/docs/s3-consumer-interfaces.md +++ b/docs/s3-consumer-interfaces.md @@ -15,6 +15,7 @@ unless the consumer-visible fields change. | Standing runtime secret | `ccr-workload-kv.v1` | Workloads that need a KV path via External Secrets | `schemas/credential-change-request.schema.yaml` | | Short-lived lease | `credential-grant.v1` | Operators/agents and dynamic DB roles | `credential-grants/catalog.yaml` | | Rapp credential bind | `rapp-lane.v1` | How a rapp names the two lanes above | `docs/rapp-credential-lane-binding.md` | +| Consumption-mode gate | `consumption-mode.v1` | New orders for a restricted financial entity | `docs/consumption-mode-enforcement.md` | ## Compatibility rules diff --git a/scripts/consumption_mode.py b/scripts/consumption_mode.py new file mode 100755 index 0000000..ab5716d --- /dev/null +++ b/scripts/consumption_mode.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Admit or refuse a platform order from the resource-control consumption-mode signal. + +RAILIANCE-WP-0017-T01. resource-control publishes the signal; this repo +enforces it. Missing or unknown mode is not restricted (facility runbook). +Only restricted + a new-order/elastic charge that exceeds the published +allowance is refused. Safety paths are admitted and recorded as exceptions. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any + +REPO_DIR = Path(__file__).resolve().parents[1] +DEFAULT_SIGNAL = REPO_DIR / "data" / "consumption-mode" / "current.json" +ENTITY_RE_PREFIX = "entity:" +MODES = {"open", "restricted"} +CLASSES = {"new-order", "elastic", "safety", "committed"} +RAILIANCE = "entity:railiance" + + +def money(value: Any) -> Decimal | None: + if value is None or value == "": + return None + try: + amount = Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + if amount < 0: + return None + return amount.quantize(Decimal("0.01")) + + +def load_payload(path: Path) -> list[dict[str, Any]]: + if not path.is_file(): + return [] + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, list): + out: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + if item.get("record_type") == "settlement_statement": + out.append(signal_from_statement(item)) + else: + out.append(item) + return out + if isinstance(raw, dict) and raw.get("record_type") == "consumption_mode": + return [raw] + if isinstance(raw, dict) and isinstance(raw.get("signals"), list): + return [item for item in raw["signals"] if isinstance(item, dict)] + if isinstance(raw, dict) and raw.get("record_type") == "settlement_statement": + return [signal_from_statement(raw)] + return [] + + +def signal_from_statement(statement: dict[str, Any]) -> dict[str, Any]: + return { + "schema_version": "0.1", + "record_type": "consumption_mode", + "financial_entity_id": statement.get("financial_entity_id"), + "period": statement.get("period"), + "consumption_mode": statement.get("consumption_mode"), + "new_transfer_charges_allowed_eur": statement.get("next_month_allowance_eur"), + "terms_version": statement.get("terms_version"), + } + + +def signal_for(signals: list[dict[str, Any]], entity_id: str) -> dict[str, Any] | None: + matches = [ + item + for item in signals + if item.get("financial_entity_id") == entity_id + and item.get("record_type", "consumption_mode") == "consumption_mode" + ] + return matches[-1] if matches else None + + +def decide( + *, + entity_id: str, + order_class: str, + estimate_eur: Decimal | None, + signal: dict[str, Any] | None, +) -> dict[str, Any]: + if entity_id == RAILIANCE: + return _result("admit", "railiance-self-use", "open", None, order_class) + if signal is None: + return _result("admit", "no-signal-not-restricted", None, None, order_class) + mode = signal.get("consumption_mode") + if mode not in MODES: + return _result("admit", "unknown-mode-not-restricted", None, None, order_class) + allowance = money(signal.get("new_transfer_charges_allowed_eur")) + if mode == "open": + return _result("admit", "open", mode, allowance, order_class) + if order_class == "safety": + return _result( + "admit", + "safety-exception", + mode, + allowance, + order_class, + exception=True, + ) + if order_class == "committed": + return _result( + "admit", + "committed-flagged", + mode, + allowance, + order_class, + exception=True, + ) + if allowance is None: + return _result("refuse", "restricted-without-allowance", mode, allowance, order_class) + if estimate_eur is None: + return _result("refuse", "restricted-estimate-required", mode, allowance, order_class) + if estimate_eur > allowance: + return _result("refuse", "exceeds-allowance", mode, allowance, order_class) + return _result("admit", "within-allowance", mode, allowance, order_class) + + +def _result( + decision: str, + reason: str, + mode: str | None, + allowance: Decimal | None, + order_class: str, + exception: bool = False, +) -> dict[str, Any]: + return { + "decision": decision, + "reason": reason, + "consumption_mode": mode, + "allowance_eur": None if allowance is None else f"{allowance:.2f}", + "order_class": order_class, + "exception": exception, + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + parser.add_argument("command", choices=["check"], help="check an order against the signal") + parser.add_argument("--entity", required=True, help="financial_entity_id, e.g. entity:coulomb") + parser.add_argument( + "--class", + dest="order_class", + choices=sorted(CLASSES), + default="new-order", + ) + parser.add_argument("--estimate-eur", help="transfer-price estimate for the new order") + parser.add_argument( + "--signal", + type=Path, + default=Path(os.environ.get("CONSUMPTION_MODE_FILE", DEFAULT_SIGNAL)), + help="JSON signal file (list, one record, or a settlement statement)", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + if not args.entity.startswith(ENTITY_RE_PREFIX) or len(args.entity) < 8: + print(f"[FAIL] --entity must be entity:: {args.entity}", file=sys.stderr) + return 2 + estimate = money(args.estimate_eur) + if args.estimate_eur and estimate is None: + print(f"[FAIL] --estimate-eur is not a non-negative EUR amount", file=sys.stderr) + return 2 + try: + signals = load_payload(args.signal) + except json.JSONDecodeError as exc: + print(f"[FAIL] signal is not JSON: {exc}", file=sys.stderr) + return 2 + signal = signal_for(signals, args.entity) + result = decide( + entity_id=args.entity, + order_class=args.order_class, + estimate_eur=estimate, + signal=signal, + ) + tag = "OK" if result["decision"] == "admit" else "REFUSE" + print( + f"[{tag}] {args.entity} {result['order_class']} " + f"mode={result['consumption_mode'] or 'unknown'} " + f"allowance={result['allowance_eur']} reason={result['reason']}" + ) + if result["exception"]: + print("[EXCEPTION] restricted safety/committed path; record overage on the next statement") + return 0 if result["decision"] == "admit" else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_consumption_mode.py b/tests/test_consumption_mode.py new file mode 100644 index 0000000..4cf1dcc --- /dev/null +++ b/tests/test_consumption_mode.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from decimal import Decimal +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "consumption_mode", REPO_DIR / "scripts/consumption_mode.py" +) +cm = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = cm +SPEC.loader.exec_module(cm) + + +def restricted(allowance: str = "45.83") -> dict: + return { + "schema_version": "0.1", + "record_type": "consumption_mode", + "financial_entity_id": "entity:coulomb", + "period": "2026-09", + "consumption_mode": "restricted", + "new_transfer_charges_allowed_eur": allowance, + "terms_version": "0.1", + } + + +class ConsumptionModeTests(unittest.TestCase): + def test_open_entity_unchanged(self) -> None: + signal = restricted() + signal["consumption_mode"] = "open" + signal["new_transfer_charges_allowed_eur"] = None + result = cm.decide( + entity_id="entity:coulomb", + order_class="new-order", + estimate_eur=Decimal("200.00"), + signal=signal, + ) + self.assertEqual(result["decision"], "admit") + self.assertEqual(result["reason"], "open") + + def test_restricted_cannot_exceed_allowance(self) -> None: + result = cm.decide( + entity_id="entity:coulomb", + order_class="new-order", + estimate_eur=Decimal("45.84"), + signal=restricted(), + ) + self.assertEqual(result["decision"], "refuse") + self.assertEqual(result["reason"], "exceeds-allowance") + + def test_restricted_within_allowance(self) -> None: + result = cm.decide( + entity_id="entity:coulomb", + order_class="new-order", + estimate_eur=Decimal("45.83"), + signal=restricted(), + ) + self.assertEqual(result["decision"], "admit") + self.assertEqual(result["reason"], "within-allowance") + + def test_restricted_without_estimate_refused(self) -> None: + result = cm.decide( + entity_id="entity:coulomb", + order_class="elastic", + estimate_eur=None, + signal=restricted(), + ) + self.assertEqual(result["decision"], "refuse") + self.assertEqual(result["reason"], "restricted-estimate-required") + + def test_safety_admitted_as_exception(self) -> None: + result = cm.decide( + entity_id="entity:coulomb", + order_class="safety", + estimate_eur=Decimal("200.00"), + signal=restricted(), + ) + self.assertEqual(result["decision"], "admit") + self.assertTrue(result["exception"]) + + def test_no_signal_is_not_restricted(self) -> None: + result = cm.decide( + entity_id="entity:coulomb", + order_class="new-order", + estimate_eur=Decimal("999.00"), + signal=None, + ) + self.assertEqual(result["decision"], "admit") + self.assertEqual(result["reason"], "no-signal-not-restricted") + + def test_railiance_self_use_always_open(self) -> None: + result = cm.decide( + entity_id="entity:railiance", + order_class="new-order", + estimate_eur=Decimal("999.00"), + signal=restricted(), + ) + self.assertEqual(result["decision"], "admit") + self.assertEqual(result["reason"], "railiance-self-use") + + def test_cli_refuses_restricted_overage(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "signal.json" + path.write_text(json.dumps([restricted()]), encoding="utf-8") + code = cm.main( + [ + "check", + "--entity", + "entity:coulomb", + "--estimate-eur", + "50", + "--signal", + str(path), + ] + ) + self.assertEqual(code, 2) + + def test_cli_admits_open(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "signal.json" + path.write_text("[]", encoding="utf-8") + code = cm.main( + [ + "check", + "--entity", + "entity:coulomb", + "--estimate-eur", + "50", + "--signal", + str(path), + ] + ) + self.assertEqual(code, 0) diff --git a/workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md b/workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md index 90a408d..8690917 100644 --- a/workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md +++ b/workplans/RAILIANCE-WP-0017-consumption-mode-enforcement.md @@ -4,11 +4,11 @@ type: workplan title: "Enforce resource-control consumption mode" domain: financials repo: railiance-platform -status: proposed +status: finished owner: codex topic_slug: railiance created: "2026-08-14" -updated: "2026-08-14" +updated: "2026-08-15" related: - RESOURCE-WP-0005 origin: residual @@ -42,7 +42,7 @@ Runbook: `resource-control/docs/resource-procurement-facility.md`. ```task id: RAILIANCE-WP-0017-T01 -status: todo +status: done priority: high state_hub_task_id: "1c872918-0872-4b2a-8596-89b106239e54" ``` @@ -53,3 +53,14 @@ the current period. Done when a restricted entity cannot place a new order that would exceed the published allowance, and an `open` entity is unchanged. + +Delivered 2026-08-15: `scripts/consumption_mode.py`, +`docs/consumption-mode-enforcement.md`, `make consumption-preflight`. +Restricted + estimate above allowance exits 2. Open and missing-signal +admit. Safety/committed paths admit with an exception line. `apps-pg-deploy` +runs the gate when `CONSUMING_ENTITY` is set. + +## Outcome + +T01 done. Workplan finished. resource-control still owns the signal; +this repo only enforces it.