Finish RAILIANCE-WP-0017 consumption-mode enforcement
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:
parent
bf8c26cd61
commit
34a3123799
8 changed files with 437 additions and 4 deletions
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
20
Makefile
20
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
1
data/consumption-mode/current.json
Normal file
1
data/consumption-mode/current.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
63
docs/consumption-mode-enforcement.md
Normal file
63
docs/consumption-mode-enforcement.md
Normal 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.
|
||||||
|
|
@ -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
199
scripts/consumption_mode.py
Executable 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())
|
||||||
139
tests/test_consumption_mode.py
Normal file
139
tests/test_consumption_mode.py
Normal 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)
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue