Finish RAILIANCE-WP-0017 consumption-mode enforcement
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Read the resource-control open/restricted signal and refuse new orders
that would exceed a restricted entity's published allowance. Open and
missing signals stay unchanged. Safety paths admit with an exception.
This commit is contained in:
codex 2026-08-15 14:56:02 +02:00
parent bf8c26cd61
commit 34a3123799
8 changed files with 437 additions and 4 deletions

View file

@ -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 apps-pg-status # shared apps-pg cluster health (read-only)
make pg-status # confirms retired Bitnami postgresql-ha is absent make pg-status # confirms retired Bitnami postgresql-ha is absent
make valkey-status # confirms Valkey is absent make valkey-status # confirms Valkey is absent
make consumption-preflight CONSUMING_ENTITY=entity:<slug> ESTIMATE_EUR=<eur>
make db-deploy | apps-pg-deploy # production-touching make db-deploy | apps-pg-deploy # production-touching
``` ```

View file

@ -66,9 +66,27 @@ forgejo-db-status: ## Show forgejo-db cnpg cluster health
forgejo-db-shell: ## Open psql shell on forgejo-db primary forgejo-db-shell: ## Open psql shell on forgejo-db primary
$(KUBECTL) cnpg psql forgejo-db -n databases -- -U forgejo forgejo $(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:<slug>' >&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) ##@ Shared apps-pg (S5 application databases)
apps-pg-deploy: ## Apply shared apps-pg cnpg Cluster + NetworkPolicies 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-cluster.yaml
$(KUBECTL) apply -f helm/apps-pg-networkpolicies.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 } \ /^[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) /^##@/ { 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

View file

@ -0,0 +1 @@
[]

View file

@ -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.

View file

@ -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` | | 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` | | 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` | | 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 ## Compatibility rules

199
scripts/consumption_mode.py Executable file
View file

@ -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:<slug>: {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())

View file

@ -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)

View file

@ -4,11 +4,11 @@ type: workplan
title: "Enforce resource-control consumption mode" title: "Enforce resource-control consumption mode"
domain: financials domain: financials
repo: railiance-platform repo: railiance-platform
status: proposed status: finished
owner: codex owner: codex
topic_slug: railiance topic_slug: railiance
created: "2026-08-14" created: "2026-08-14"
updated: "2026-08-14" updated: "2026-08-15"
related: related:
- RESOURCE-WP-0005 - RESOURCE-WP-0005
origin: residual origin: residual
@ -42,7 +42,7 @@ Runbook: `resource-control/docs/resource-procurement-facility.md`.
```task ```task
id: RAILIANCE-WP-0017-T01 id: RAILIANCE-WP-0017-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "1c872918-0872-4b2a-8596-89b106239e54" 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 Done when a restricted entity cannot place a new order that would
exceed the published allowance, and an `open` entity is unchanged. 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.