Skip to content

Commit 1b34506

Browse files
authored
Merge pull request #270 from QuantStrategyLab/codex/paper-risk-admission-receipt
feat: add paper risk admission receipt adapter
2 parents 2fe7e6e + 3b85c09 commit 1b34506

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
#!/usr/bin/env python3
2+
"""Build a redacted PAPER admission receipt from a deterministic risk decision.
3+
4+
This is a pure adapter between ``deterministic_risk_gate`` and the shared
5+
``paper_risk_admission_receipt.v1`` contract. It does not read a runtime
6+
configuration, account, broker, credential, position, or order. The receipt
7+
is evidence of one already-computed risk decision only; it does not grant
8+
runtime or broker authority.
9+
10+
The source decision contains projected exposure values so that the local gate
11+
can be evaluated. Those values deliberately never cross this boundary. A
12+
consumer receives only the immutable policy digest, release binding, decision
13+
digest, session, disposition, and stable reason codes.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import hashlib
19+
import json
20+
import re
21+
from datetime import date
22+
from typing import Any, Mapping
23+
24+
from deterministic_risk_gate import (
25+
RISK_GATE_DECISION_SCHEMA_ID,
26+
calculate_risk_gate_decision_sha256,
27+
)
28+
29+
30+
PAPER_RISK_ADMISSION_RECEIPT_SCHEMA_VERSION = "paper_risk_admission_receipt.v1"
31+
32+
_IDENTITY_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
33+
_RELEASE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$")
34+
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
35+
_REASON_CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,127}$")
36+
_SOURCE_DECISION_FIELDS = {
37+
"schema",
38+
"evaluation_id",
39+
"observed_at",
40+
"policy",
41+
"decision",
42+
"reason_codes",
43+
"next_circuit_breaker_state",
44+
"manual_reset_required",
45+
"projected",
46+
"decision_sha256",
47+
}
48+
_SOURCE_POLICY_FIELDS = {"risk_policy_id", "risk_policy_version", "risk_policy_sha256"}
49+
_SOURCE_PROJECTED_FIELDS = {
50+
"gross_notional_cents",
51+
"symbol_gross_notional_cents",
52+
"strategy_gross_notional_cents",
53+
"leverage_bps",
54+
"decisions_in_session",
55+
}
56+
_RECEIPT_FIELDS = {
57+
"schema_version",
58+
"strategy_profile",
59+
"release_id",
60+
"risk_policy_sha256",
61+
"decision_digest",
62+
"effective_session",
63+
"disposition",
64+
"reason_codes",
65+
"receipt_sha256",
66+
}
67+
_DISPOSITIONS = frozenset({"allow_new_risk", "reducing_only", "halted"})
68+
_UNKNOWN_DECISION_REASON = "UNKNOWN_DETERMINISTIC_RISK_DECISION"
69+
_KNOWN_PROHIBITION_REASONS = frozenset(
70+
{
71+
"OBSERVATION_NOT_COMPLETE",
72+
"RECONCILIATION_NOT_VERIFIED",
73+
"CIRCUIT_BREAKER_OPEN",
74+
"GROSS_EXPOSURE_LIMIT_EXCEEDED",
75+
"SINGLE_SYMBOL_LIMIT_EXCEEDED",
76+
"SINGLE_STRATEGY_LIMIT_EXCEEDED",
77+
"LEVERAGE_LIMIT_EXCEEDED",
78+
"DAILY_LOSS_LIMIT_EXCEEDED",
79+
"SESSION_DECISION_LIMIT_EXCEEDED",
80+
}
81+
)
82+
83+
84+
class PaperRiskAdmissionReceiptError(ValueError):
85+
"""Raised when a source decision or resulting receipt is not trustworthy."""
86+
87+
88+
def _fail(message: str) -> None:
89+
raise PaperRiskAdmissionReceiptError(message)
90+
91+
92+
def _expect_mapping(value: Any, path: str) -> Mapping[str, Any]:
93+
if not isinstance(value, Mapping):
94+
_fail(f"{path} must be an object")
95+
return value
96+
97+
98+
def _expect_exact_keys(value: Mapping[str, Any], expected: set[str], path: str) -> None:
99+
missing = sorted(expected - set(value))
100+
unknown = sorted(set(value) - expected)
101+
if missing:
102+
_fail(f"{path} missing required field(s): {', '.join(missing)}")
103+
if unknown:
104+
_fail(f"{path} has unknown field(s): {', '.join(unknown)}")
105+
106+
107+
def _expect_identity(value: Any, path: str) -> str:
108+
if not isinstance(value, str) or not _IDENTITY_PATTERN.fullmatch(value):
109+
_fail(f"{path} must be a lowercase immutable identity")
110+
return value
111+
112+
113+
def _expect_release_id(value: Any, path: str) -> str:
114+
if not isinstance(value, str) or not _RELEASE_ID_PATTERN.fullmatch(value):
115+
_fail(f"{path} must be a visible immutable release identity")
116+
return value
117+
118+
119+
def _expect_sha256(value: Any, path: str) -> str:
120+
if not isinstance(value, str) or not _SHA256_PATTERN.fullmatch(value):
121+
_fail(f"{path} must be a lowercase SHA-256 digest")
122+
return value
123+
124+
125+
def _expect_effective_session(value: Any, path: str) -> str:
126+
if not isinstance(value, str):
127+
_fail(f"{path} must be an ISO date")
128+
try:
129+
return date.fromisoformat(value).isoformat()
130+
except ValueError as exc:
131+
raise PaperRiskAdmissionReceiptError(f"{path} must be an ISO date") from exc
132+
133+
134+
def _validate_reason_codes(value: Any, path: str) -> list[str]:
135+
if not isinstance(value, list):
136+
_fail(f"{path} must be an array")
137+
normalized: list[str] = []
138+
for index, reason in enumerate(value):
139+
if not isinstance(reason, str) or not _REASON_CODE_PATTERN.fullmatch(reason):
140+
_fail(f"{path}[{index}] must be a stable uppercase reason code")
141+
if reason in normalized:
142+
_fail(f"{path} must not contain duplicate reason codes")
143+
normalized.append(reason)
144+
return normalized
145+
146+
147+
def _validate_projected(value: Any) -> Mapping[str, int]:
148+
projected = _expect_mapping(value, "decision.projected")
149+
_expect_exact_keys(projected, _SOURCE_PROJECTED_FIELDS, "decision.projected")
150+
normalized: dict[str, int] = {}
151+
for field, amount in projected.items():
152+
if isinstance(amount, bool) or not isinstance(amount, int) or amount < 0:
153+
_fail(f"decision.projected.{field} must be a non-negative integer")
154+
normalized[field] = amount
155+
return normalized
156+
157+
158+
def _validate_source_decision(decision: Any) -> Mapping[str, Any]:
159+
"""Validate a complete local decision before projecting it into a receipt."""
160+
value = _expect_mapping(decision, "decision")
161+
_expect_exact_keys(value, _SOURCE_DECISION_FIELDS, "decision")
162+
if value["schema"] != RISK_GATE_DECISION_SCHEMA_ID:
163+
_fail(f"decision.schema must be {RISK_GATE_DECISION_SCHEMA_ID}")
164+
_expect_identity(value["evaluation_id"], "decision.evaluation_id")
165+
policy = _expect_mapping(value["policy"], "decision.policy")
166+
_expect_exact_keys(policy, _SOURCE_POLICY_FIELDS, "decision.policy")
167+
_expect_identity(policy["risk_policy_id"], "decision.policy.risk_policy_id")
168+
_expect_identity(policy["risk_policy_version"], "decision.policy.risk_policy_version")
169+
_expect_sha256(policy["risk_policy_sha256"], "decision.policy.risk_policy_sha256")
170+
if not isinstance(value["decision"], str) or not value["decision"]:
171+
_fail("decision.decision must be a non-empty string")
172+
if value["next_circuit_breaker_state"] not in {"CLOSED", "OPEN"}:
173+
_fail("decision.next_circuit_breaker_state must be CLOSED or OPEN")
174+
if value["manual_reset_required"] is not True:
175+
_fail("decision.manual_reset_required must be true")
176+
_validate_reason_codes(value["reason_codes"], "decision.reason_codes")
177+
_validate_projected(value["projected"])
178+
_expect_sha256(value["decision_sha256"], "decision.decision_sha256")
179+
if value["decision_sha256"] != calculate_risk_gate_decision_sha256(value):
180+
_fail("decision.decision_sha256 mismatch")
181+
return value
182+
183+
184+
def _disposition_for(validated_decision: Mapping[str, Any]) -> tuple[str, list[str]]:
185+
"""Map only the known deterministic-gate semantics to safe dispositions."""
186+
decision = validated_decision["decision"]
187+
breaker = validated_decision["next_circuit_breaker_state"]
188+
reasons = list(validated_decision["reason_codes"])
189+
if decision == "ALLOW_NEW_RISK" and breaker == "CLOSED" and not reasons:
190+
return "allow_new_risk", []
191+
if (
192+
decision == "NEW_RISK_PROHIBITED"
193+
and breaker == "OPEN"
194+
and reasons
195+
and set(reasons).issubset(_KNOWN_PROHIBITION_REASONS)
196+
):
197+
return "reducing_only", reasons
198+
return "halted", [_UNKNOWN_DECISION_REASON]
199+
200+
201+
def canonical_paper_risk_admission_receipt_json(receipt: Mapping[str, Any]) -> str:
202+
"""Return the QPK-compatible canonical receipt JSON without its digest."""
203+
if not isinstance(receipt, Mapping):
204+
_fail("paper risk admission receipt must be an object")
205+
content = dict(receipt)
206+
content.pop("receipt_sha256", None)
207+
try:
208+
return json.dumps(content, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False)
209+
except (TypeError, ValueError) as exc:
210+
raise PaperRiskAdmissionReceiptError(
211+
"paper risk admission receipt cannot be represented as canonical JSON"
212+
) from exc
213+
214+
215+
def calculate_paper_risk_admission_receipt_sha256(receipt: Mapping[str, Any]) -> str:
216+
"""Return the SHA-256 of the exact QPK receipt projection."""
217+
return hashlib.sha256(canonical_paper_risk_admission_receipt_json(receipt).encode("utf-8")).hexdigest()
218+
219+
220+
def validate_paper_risk_admission_receipt(receipt: Any) -> Mapping[str, Any]:
221+
"""Validate the exact, redacted QPK-compatible receipt contract."""
222+
value = _expect_mapping(receipt, "paper risk admission receipt")
223+
_expect_exact_keys(value, _RECEIPT_FIELDS, "paper risk admission receipt")
224+
if value["schema_version"] != PAPER_RISK_ADMISSION_RECEIPT_SCHEMA_VERSION:
225+
_fail(f"receipt.schema_version must be {PAPER_RISK_ADMISSION_RECEIPT_SCHEMA_VERSION}")
226+
_expect_identity(value["strategy_profile"], "receipt.strategy_profile")
227+
_expect_release_id(value["release_id"], "receipt.release_id")
228+
_expect_sha256(value["risk_policy_sha256"], "receipt.risk_policy_sha256")
229+
_expect_sha256(value["decision_digest"], "receipt.decision_digest")
230+
_expect_effective_session(value["effective_session"], "receipt.effective_session")
231+
disposition = value["disposition"]
232+
if disposition not in _DISPOSITIONS:
233+
_fail("receipt.disposition must be allow_new_risk, reducing_only, or halted")
234+
reasons = _validate_reason_codes(value["reason_codes"], "receipt.reason_codes")
235+
if disposition == "allow_new_risk" and reasons:
236+
_fail("receipt.allow_new_risk must not contain reason codes")
237+
if disposition in {"reducing_only", "halted"} and not reasons:
238+
_fail(f"receipt.{disposition} must contain at least one reason code")
239+
_expect_sha256(value["receipt_sha256"], "receipt.receipt_sha256")
240+
if value["receipt_sha256"] != calculate_paper_risk_admission_receipt_sha256(value):
241+
_fail("receipt.receipt_sha256 mismatch")
242+
return value
243+
244+
245+
def build_paper_risk_admission_receipt(
246+
*,
247+
decision: Any,
248+
strategy_profile: Any,
249+
release_id: Any,
250+
effective_session: Any,
251+
) -> dict[str, object]:
252+
"""Project one deterministic decision into a minimal PAPER admission receipt.
253+
254+
Structural or digest failures raise rather than fabricate a receipt. A
255+
caller must fail closed in that case. A valid but unknown source decision
256+
remains auditable as ``halted`` and cannot grant new-risk permission.
257+
"""
258+
validated_decision = _validate_source_decision(decision)
259+
resolved_profile = _expect_identity(strategy_profile, "strategy_profile")
260+
resolved_release_id = _expect_release_id(release_id, "release_id")
261+
resolved_session = _expect_effective_session(effective_session, "effective_session")
262+
disposition, reason_codes = _disposition_for(validated_decision)
263+
receipt: dict[str, object] = {
264+
"schema_version": PAPER_RISK_ADMISSION_RECEIPT_SCHEMA_VERSION,
265+
"strategy_profile": resolved_profile,
266+
"release_id": resolved_release_id,
267+
"risk_policy_sha256": validated_decision["policy"]["risk_policy_sha256"],
268+
"decision_digest": validated_decision["decision_sha256"],
269+
"effective_session": resolved_session,
270+
"disposition": disposition,
271+
"reason_codes": reason_codes,
272+
"receipt_sha256": "",
273+
}
274+
receipt["receipt_sha256"] = calculate_paper_risk_admission_receipt_sha256(receipt)
275+
validate_paper_risk_admission_receipt(receipt)
276+
return receipt
277+
278+
279+
__all__ = [
280+
"PAPER_RISK_ADMISSION_RECEIPT_SCHEMA_VERSION",
281+
"PaperRiskAdmissionReceiptError",
282+
"build_paper_risk_admission_receipt",
283+
"calculate_paper_risk_admission_receipt_sha256",
284+
"canonical_paper_risk_admission_receipt_json",
285+
"validate_paper_risk_admission_receipt",
286+
]

0 commit comments

Comments
 (0)