patchr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. apps/__init__.py +2 -0
  2. apps/api/__init__.py +2 -0
  3. apps/api/main.py +652 -0
  4. apps/benchmarks/__init__.py +1 -0
  5. apps/benchmarks/main.py +20 -0
  6. apps/sandbox/__init__.py +1 -0
  7. apps/sandbox/main.py +20 -0
  8. apps/worker/__init__.py +2 -0
  9. apps/worker/main.py +15 -0
  10. apps/worker/verify.py +14 -0
  11. patchr/__init__.py +12 -0
  12. patchr/sdk/__init__.py +20 -0
  13. patchr/sdk/client.py +12 -0
  14. patchr-0.1.0.dist-info/METADATA +137 -0
  15. patchr-0.1.0.dist-info/RECORD +116 -0
  16. patchr-0.1.0.dist-info/WHEEL +5 -0
  17. patchr-0.1.0.dist-info/entry_points.txt +5 -0
  18. patchr-0.1.0.dist-info/licenses/LICENSE +17 -0
  19. patchr-0.1.0.dist-info/top_level.txt +3 -0
  20. picux/__init__.py +6 -0
  21. picux/agents/__init__.py +5 -0
  22. picux/agents/registry.py +204 -0
  23. picux/api/__init__.py +5 -0
  24. picux/api/service.py +5075 -0
  25. picux/audit/__init__.py +31 -0
  26. picux/audit/activity.py +97 -0
  27. picux/audit/observability.py +55 -0
  28. picux/audit/verification/__init__.py +21 -0
  29. picux/audit/verification/ledger.py +633 -0
  30. picux/benchmarks/__init__.py +5 -0
  31. picux/benchmarks/local.py +286 -0
  32. picux/config.py +140 -0
  33. picux/contracts/__init__.py +22 -0
  34. picux/contracts/handshake.py +122 -0
  35. picux/contracts/integration.py +385 -0
  36. picux/contracts/openapi.py +187 -0
  37. picux/contracts/protocol_map.py +152 -0
  38. picux/contracts/routes.py +980 -0
  39. picux/contracts/schema_catalog.py +125 -0
  40. picux/core/__init__.py +17 -0
  41. picux/core/models.py +148 -0
  42. picux/core/router.py +131 -0
  43. picux/core/runtime.py +42 -0
  44. picux/core/state_machine.py +38 -0
  45. picux/domains/__init__.py +2 -0
  46. picux/domains/bridge/HostRun.py +1104 -0
  47. picux/domains/bridge/__init__.py +6 -0
  48. picux/domains/bridge/engine.py +345 -0
  49. picux/domains/hunt/__init__.py +6 -0
  50. picux/domains/hunt/engine.py +307 -0
  51. picux/domains/hunt/models.py +88 -0
  52. picux/domains/pay/__init__.py +16 -0
  53. picux/domains/pay/adapters.py +607 -0
  54. picux/domains/pay/engine.py +950 -0
  55. picux/domains/pay/models.py +95 -0
  56. picux/domains/proxy/__init__.py +5 -0
  57. picux/domains/proxy/engine.py +466 -0
  58. picux/domains/resolve/__init__.py +5 -0
  59. picux/domains/resolve/engine.py +546 -0
  60. picux/orchestrator/__init__.py +3 -0
  61. picux/orchestrator/engine.py +2840 -0
  62. picux/portals/__init__.py +17 -0
  63. picux/portals/templates.py +272 -0
  64. picux/protocols/__init__.py +1 -0
  65. picux/protocols/a2a/__init__.py +6 -0
  66. picux/protocols/a2a/client.py +51 -0
  67. picux/protocols/a2a/envelope.py +132 -0
  68. picux/protocols/mcp/__init__.py +7 -0
  69. picux/protocols/mcp/client.py +69 -0
  70. picux/protocols/mcp/contract.py +67 -0
  71. picux/protocols/mcp/server.py +76 -0
  72. picux/sandbox/__init__.py +6 -0
  73. picux/sandbox/midnight_arbitrage.py +215 -0
  74. picux/sandbox/models.py +90 -0
  75. picux/sdk/__init__.py +13 -0
  76. picux/sdk/client.py +768 -0
  77. picux/sdk/external.py +245 -0
  78. picux/security/__init__.py +18 -0
  79. picux/security/auth.py +86 -0
  80. picux/security/config_validator.py +58 -0
  81. picux/security/policy.py +158 -0
  82. picux/security/secrets.py +144 -0
  83. picux/signals/__init__.py +1 -0
  84. picux/signals/community/__init__.py +24 -0
  85. picux/signals/community/adapters/__init__.py +7 -0
  86. picux/signals/community/adapters/reddit.py +37 -0
  87. picux/signals/community/adapters/shopify.py +23 -0
  88. picux/signals/community/adapters/web.py +23 -0
  89. picux/signals/community/disambiguation.py +51 -0
  90. picux/signals/community/intake.py +227 -0
  91. picux/signals/community/models.py +102 -0
  92. picux/signals/community/rules.py +91 -0
  93. picux/signals/community/scoring.py +64 -0
  94. picux/storage/__init__.py +41 -0
  95. picux/storage/agents.py +50 -0
  96. picux/storage/cases.py +440 -0
  97. picux/storage/channels.py +476 -0
  98. picux/storage/connectors.py +411 -0
  99. picux/storage/envelopes.py +137 -0
  100. picux/storage/escrows.py +168 -0
  101. picux/storage/events.py +989 -0
  102. picux/storage/keyspace.py +60 -0
  103. picux/storage/mandates.py +107 -0
  104. picux/storage/portals.py +222 -0
  105. picux/storage/postgres.py +2049 -0
  106. picux/storage/providers.py +148 -0
  107. picux/storage/proxy.py +231 -0
  108. picux/storage/receipts.py +131 -0
  109. picux/storage/signals.py +147 -0
  110. picux/storage/tasks.py +179 -0
  111. picux/tools/__init__.py +11 -0
  112. picux/tools/shared.py +2048 -0
  113. picux/verification/__init__.py +5 -0
  114. picux/verification/rollout.py +183 -0
  115. picux/workflows/__init__.py +5 -0
  116. picux/workflows/templates.py +74 -0
picux/sdk/external.py ADDED
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import mimetypes
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from picux.sdk.client import PicuxClient
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ExternalPricing:
16
+ successUsd: float = 1.0
17
+ mgmtRate: float = 0.05
18
+ mgmtCapUsd: float = 5.0
19
+
20
+ def quote(self, *, valueUsd: float = 0.0, laborUsd: float = 0.0) -> dict[str, Any]:
21
+ value = max(0.0, float(valueUsd or 0.0))
22
+ labor = max(0.0, float(laborUsd or 0.0))
23
+ success = self.successUsd if value > 0 else 0.0
24
+ mgmt = min(labor * max(0.0, self.mgmtRate), max(0.0, self.mgmtCapUsd)) if labor > 0 else 0.0
25
+ return {
26
+ "currency": "USD",
27
+ "valueUsd": round(value, 2),
28
+ "laborUsd": round(labor, 2),
29
+ "successFeeUsd": round(success, 2),
30
+ "mgmtFeeUsd": round(mgmt, 2),
31
+ "totalFeeUsd": round(success + mgmt, 2),
32
+ "adapter": "external",
33
+ }
34
+
35
+
36
+ class ExternalAppClient:
37
+ """External app/service/agent adapter that calls Picux through the SDK boundary."""
38
+
39
+ def __init__(self, client: PicuxClient, *, pricing: ExternalPricing | None = None, appId: str = "external") -> None:
40
+ self.client = client
41
+ self.pricing = pricing or ExternalPricing()
42
+ self.appId = appId
43
+
44
+ def deployAgent(
45
+ self,
46
+ *,
47
+ goal: str,
48
+ userId: str = "external",
49
+ domain: str = "hunt",
50
+ needsApproval: bool = False,
51
+ meta: dict[str, Any] | None = None,
52
+ ) -> dict[str, Any]:
53
+ payload = {
54
+ "userId": userId,
55
+ "domain": domain,
56
+ "channel": self.appId,
57
+ "goal": goal,
58
+ "needsApproval": needsApproval,
59
+ "meta": {"appId": self.appId, **(meta or {})},
60
+ }
61
+ return self.client.createTask(payload)
62
+
63
+ def publishSignal(
64
+ self,
65
+ signal: dict[str, Any],
66
+ *,
67
+ targetDomain: str = "hunt",
68
+ userId: str = "external",
69
+ approved: bool = False,
70
+ ) -> dict[str, Any]:
71
+ return self.client.launchCommunityTask(
72
+ {
73
+ "userId": userId,
74
+ "targetDomain": targetDomain,
75
+ "approved": approved,
76
+ "signal": signal,
77
+ }
78
+ )
79
+
80
+ def requestResolve(
81
+ self,
82
+ *,
83
+ text: str,
84
+ userId: str = "external",
85
+ requestId: str = "",
86
+ provider: str = "",
87
+ location: str = "",
88
+ issueType: str = "",
89
+ attachments: list[dict[str, Any]] | None = None,
90
+ filePaths: list[str] | None = None,
91
+ contact: dict[str, Any] | None = None,
92
+ meta: dict[str, Any] | None = None,
93
+ needsApproval: bool = False,
94
+ ) -> dict[str, Any]:
95
+ clean = _redactText(text)
96
+ contactRefs = _contactRefs(contact or {})
97
+ encodedFiles = _fileAttachments(filePaths or [])
98
+ request = {
99
+ "kind": "resolveRequest",
100
+ "requestId": requestId,
101
+ "provider": provider,
102
+ "location": location,
103
+ "issueType": issueType,
104
+ "text": clean,
105
+ "attachments": [*(attachments or []), *encodedFiles],
106
+ "contact": contactRefs,
107
+ }
108
+ return self.client.createTask(
109
+ {
110
+ "userId": userId,
111
+ "domain": "resolve",
112
+ "channel": self.appId,
113
+ "goal": clean,
114
+ "extRef": requestId,
115
+ "needsApproval": needsApproval,
116
+ "inData": {"query": clean, "request": request},
117
+ "meta": {"appId": self.appId, "requestId": requestId, **(meta or {})},
118
+ }
119
+ )
120
+
121
+ def requestProxy(
122
+ self,
123
+ *,
124
+ task: str,
125
+ userId: str = "external",
126
+ taskId: str = "",
127
+ kind: str = "review",
128
+ reason: str = "humanIntervention",
129
+ context: dict[str, Any] | None = None,
130
+ requestId: str = "",
131
+ refs: dict[str, Any] | None = None,
132
+ proofReq: list[str] | None = None,
133
+ route: dict[str, Any] | None = None,
134
+ channelMsg: dict[str, Any] | None = None,
135
+ voiceCall: dict[str, Any] | None = None,
136
+ policy: dict[str, Any] | None = None,
137
+ ) -> dict[str, Any]:
138
+ payload: dict[str, Any] = {
139
+ "userId": userId,
140
+ "kind": kind,
141
+ "reason": reason,
142
+ "task": task,
143
+ "context": {"appId": self.appId, **(context or {})},
144
+ }
145
+ optional = {
146
+ "taskId": taskId,
147
+ "requestId": requestId,
148
+ "refs": refs,
149
+ "proofReq": proofReq,
150
+ "route": route,
151
+ "channelMsg": channelMsg,
152
+ "voiceCall": voiceCall,
153
+ "policy": policy,
154
+ }
155
+ payload.update({key: value for key, value in optional.items() if value})
156
+ return self.client.createProxyMission(payload)
157
+
158
+ def submitProxy(
159
+ self,
160
+ *,
161
+ proxyId: str = "",
162
+ requestId: str = "",
163
+ note: str = "",
164
+ text: str = "",
165
+ proof: dict[str, Any] | None = None,
166
+ refs: dict[str, Any] | None = None,
167
+ requiresPhysicalProof: bool | None = None,
168
+ policy: dict[str, Any] | None = None,
169
+ ) -> dict[str, Any]:
170
+ payload: dict[str, Any] = {"appId": self.appId, **(proof or {})}
171
+ optional = {
172
+ "requestId": requestId,
173
+ "note": note,
174
+ "text": text,
175
+ "refs": refs,
176
+ "requiresPhysicalProof": requiresPhysicalProof,
177
+ "policy": policy,
178
+ }
179
+ payload.update({key: value for key, value in optional.items() if value is not None and value != ""})
180
+ if proxyId:
181
+ return self.client.submitProxyOutcome(proxyId, payload)
182
+ return self.client.submitProxyOutcomeByRef(payload)
183
+
184
+ def priceOutcome(self, *, valueUsd: float = 0.0, laborUsd: float = 0.0) -> dict[str, Any]:
185
+ return self.pricing.quote(valueUsd=valueUsd, laborUsd=laborUsd)
186
+
187
+
188
+ def _redactText(text: str) -> str:
189
+ clean = re.sub(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "[email]", str(text or ""), flags=re.IGNORECASE)
190
+ return re.sub(r"\+?\b(?:\d[\s().-]?){8,15}\d\b", "[phone]", clean)
191
+
192
+
193
+ def _contactRefs(contact: dict[str, Any]) -> dict[str, Any]:
194
+ result: dict[str, Any] = {}
195
+ emailRef = str(contact.get("emailRef", "") or "")
196
+ phoneRef = str(contact.get("phoneRef", "") or "")
197
+ email = str(contact.get("email", "") or "")
198
+ phone = str(contact.get("phone", contact.get("phoneNumber", "")) or "")
199
+ if emailRef:
200
+ result["emailRef"] = emailRef
201
+ elif email:
202
+ result["emailRef"] = _hashedRef("email", email)
203
+ if phoneRef:
204
+ result["phoneRef"] = phoneRef
205
+ elif phone:
206
+ result["phoneRef"] = _hashedRef("phone", phone)
207
+ if result:
208
+ result["redacted"] = True
209
+ if contact.get("preferredChannel"):
210
+ result["preferredChannel"] = str(contact.get("preferredChannel") or "")
211
+ return result
212
+
213
+
214
+ def _hashedRef(kind: str, value: str) -> str:
215
+ digest = hashlib.sha256(str(value or "").strip().lower().encode("utf-8")).hexdigest()[:16]
216
+ return f"external:{kind}:{digest}"
217
+
218
+
219
+ def _fileAttachments(filePaths: list[str]) -> list[dict[str, Any]]:
220
+ attachments: list[dict[str, Any]] = []
221
+ for rawPath in filePaths:
222
+ path = Path(rawPath)
223
+ data = path.read_bytes()
224
+ attachments.append(
225
+ {
226
+ "kind": _kindFromPath(path),
227
+ "name": path.name,
228
+ "mime": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
229
+ "size": len(data),
230
+ "sha256": "sha256:" + hashlib.sha256(data).hexdigest(),
231
+ "dataBase64": base64.b64encode(data).decode("ascii"),
232
+ }
233
+ )
234
+ return attachments
235
+
236
+
237
+ def _kindFromPath(path: Path) -> str:
238
+ name = path.name.lower()
239
+ if "receipt" in name:
240
+ return "receipt"
241
+ if "damage" in name or "photo" in name:
242
+ return "damagePhoto"
243
+ if "screen" in name:
244
+ return "screenshot"
245
+ return "attachment"
@@ -0,0 +1,18 @@
1
+ """Security and production readiness primitives."""
2
+
3
+ from .auth import AuthResult, PicuxAuthGate
4
+ from .config_validator import ProductionReadiness, assertProductionReady, validateProductionReady
5
+ from .policy import PolicyDecision, PolicyEngine
6
+ from .secrets import SecretResolution, SecretResolver
7
+
8
+ __all__ = [
9
+ "AuthResult",
10
+ "PicuxAuthGate",
11
+ "PolicyDecision",
12
+ "PolicyEngine",
13
+ "ProductionReadiness",
14
+ "SecretResolution",
15
+ "SecretResolver",
16
+ "assertProductionReady",
17
+ "validateProductionReady",
18
+ ]
picux/security/auth.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from picux.config import PicuxSettings
8
+
9
+
10
+ PUBLIC_ROUTES = {
11
+ ("GET", "/healthz"),
12
+ ("GET", "/runtime"),
13
+ ("GET", "/v1/status"),
14
+ ("GET", "/openapi.json"),
15
+ ("GET", "/v1/contract"),
16
+ ("GET", "/v1/manifest"),
17
+ ("GET", "/v1/protocol-map"),
18
+ ("GET", "/v1/a2a/contract"),
19
+ ("GET", "/v1/pay/adapters/contract"),
20
+ ("POST", "/v1/handshake"),
21
+ }
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AuthResult:
26
+ ok: bool
27
+ error: str = ""
28
+ status: int = 200
29
+ transport: str = "api"
30
+
31
+ def toMap(self) -> dict[str, Any]:
32
+ if self.ok:
33
+ return {"ok": True, "transport": self.transport}
34
+ return {"ok": False, "error": self.error, "transport": self.transport}
35
+
36
+
37
+ class PicuxAuthGate:
38
+ """Bearer token gate for external API and MCP transports."""
39
+
40
+ def __init__(self, *, apiToken: str = "", mcpToken: str = "", a2aToken: str = "") -> None:
41
+ self.apiToken = str(apiToken or "").strip()
42
+ self.mcpToken = str(mcpToken or "").strip()
43
+ self.a2aToken = str(a2aToken or "").strip()
44
+
45
+ @classmethod
46
+ def fromEnv(cls) -> "PicuxAuthGate":
47
+ settings = PicuxSettings.fromEnv()
48
+ return cls(apiToken=settings.apiToken, mcpToken=settings.mcpToken, a2aToken=settings.a2aToken)
49
+
50
+ def authorize(self, method: str, path: str, headers: dict[str, Any] | None = None) -> AuthResult:
51
+ method = method.upper()
52
+ path = str(path or "/")
53
+ transport = self._transport(path)
54
+ if (method, path) in PUBLIC_ROUTES or (method == "GET" and path.startswith("/v1/schemas")):
55
+ return AuthResult(ok=True, transport=transport)
56
+
57
+ expected = self._expectedToken(transport)
58
+ if not expected:
59
+ return AuthResult(ok=True, transport=transport)
60
+ actual = self._bearer(headers or {})
61
+ if not hmac.compare_digest(actual, expected):
62
+ return AuthResult(ok=False, error="unauthorized", status=401, transport=transport)
63
+ return AuthResult(ok=True, transport=transport)
64
+
65
+ def _expectedToken(self, transport: str) -> str:
66
+ if transport == "mcp":
67
+ return self.mcpToken or self.apiToken
68
+ if transport == "a2a":
69
+ return self.a2aToken or self.apiToken
70
+ return self.apiToken
71
+
72
+ @staticmethod
73
+ def _transport(path: str) -> str:
74
+ if path == "/mcp":
75
+ return "mcp"
76
+ if path.startswith("/v1/a2a"):
77
+ return "a2a"
78
+ return "api"
79
+
80
+ @staticmethod
81
+ def _bearer(headers: dict[str, Any]) -> str:
82
+ normalized = {str(key).lower(): str(value) for key, value in headers.items()}
83
+ raw = normalized.get("authorization", "").strip()
84
+ if raw.lower().startswith("bearer "):
85
+ return raw[7:].strip()
86
+ return ""
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from picux.config import PicuxSettings
7
+
8
+
9
+ REQUIRED_PROD_FIELDS = {
10
+ "apiToken": "PICUX_API_TOKEN",
11
+ "redisUrl": "PICUX_REDIS_URL",
12
+ "postgresDsn": "PICUX_POSTGRES_DSN",
13
+ "mcpUrl": "PICUX_MCP_BASE_URL",
14
+ "mcpToken": "PICUX_MCP_TOKEN",
15
+ "a2aUrl": "PICUX_A2A_BASE_URL",
16
+ "a2aToken": "PICUX_A2A_TOKEN",
17
+ "bridgeUrl": "PICUX_BRIDGE_API_BASE",
18
+ "bridgeToken": "PICUX_BRIDGE_API_TOKEN",
19
+ "resolveToken": "PICUX_RESOLVE_OUTCOME_WEBHOOK_TOKEN",
20
+ "bridgeOutcomeToken": "PICUX_BRIDGE_OUTCOME_WEBHOOK_TOKEN",
21
+ "vaultKey": "PICUX_VAULT_KEY_B64",
22
+ }
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ProductionReadiness:
27
+ ok: bool
28
+ prod: bool
29
+ issues: tuple[str, ...]
30
+
31
+ def toMap(self) -> dict[str, Any]:
32
+ return {"ok": self.ok, "prod": self.prod, "issues": list(self.issues)}
33
+
34
+
35
+ def validateProductionReady(settings: PicuxSettings) -> ProductionReadiness:
36
+ if not settings.prod:
37
+ return ProductionReadiness(ok=True, prod=False, issues=())
38
+
39
+ issues: list[str] = []
40
+ for attr, envName in REQUIRED_PROD_FIELDS.items():
41
+ value = getattr(settings, attr)
42
+ if not str(value or "").strip():
43
+ issues.append(f"missing:{envName}")
44
+
45
+ if not settings.policyEnforcement:
46
+ issues.append("invalid:PICUX_POLICY_ENFORCEMENT")
47
+ if str(settings.apiUrl or "").startswith("http://127.0.0.1"):
48
+ issues.append("invalid:PICUX_API_BASE_URL")
49
+ if str(settings.redisUrl or "").startswith("redis://localhost"):
50
+ issues.append("invalid:PICUX_REDIS_URL")
51
+
52
+ return ProductionReadiness(ok=not issues, prod=True, issues=tuple(issues))
53
+
54
+
55
+ def assertProductionReady(settings: PicuxSettings) -> None:
56
+ readiness = validateProductionReady(settings)
57
+ if not readiness.ok:
58
+ raise RuntimeError("productionNotReady:" + ",".join(readiness.issues))
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class PolicyDecision:
11
+ allowed: bool
12
+ reasons: tuple[str, ...]
13
+ subjectId: str
14
+ action: str
15
+ resource: str
16
+ domain: str
17
+ mandateId: str = ""
18
+
19
+ def toMap(self) -> dict[str, Any]:
20
+ return {
21
+ "allowed": self.allowed,
22
+ "reasons": list(self.reasons),
23
+ "subjectId": self.subjectId,
24
+ "action": self.action,
25
+ "resource": self.resource,
26
+ "domain": self.domain,
27
+ "mandateId": self.mandateId,
28
+ }
29
+
30
+
31
+ class PolicyEngine:
32
+ """Least-privilege checks for mandate sessions and capability grants."""
33
+
34
+ def __init__(self, *, clock: Callable[[], datetime] | None = None) -> None:
35
+ self.clock = clock or (lambda: datetime.now(timezone.utc))
36
+
37
+ def evaluate(self, payload: dict[str, Any]) -> dict[str, Any]:
38
+ decision = self.decision(payload)
39
+ return {"ok": decision.allowed, "decision": decision.toMap()}
40
+
41
+ def decision(self, payload: dict[str, Any]) -> PolicyDecision:
42
+ subjectId = str(payload.get("subjectId", payload.get("agentId", payload.get("clientId", ""))) or "")
43
+ action = str(payload.get("action", "") or "")
44
+ resource = str(payload.get("resource", "") or "")
45
+ domain = str(payload.get("domain", "") or "")
46
+ mandateId = str(payload.get("mandateId", "") or "")
47
+ povRef = str(payload.get("povRef", payload.get("povId", "")) or "")
48
+ session = payload.get("session", {}) if isinstance(payload.get("session"), dict) else {}
49
+ grants = payload.get("grants", []) if isinstance(payload.get("grants"), list) else []
50
+ reasons: list[str] = []
51
+
52
+ if not subjectId:
53
+ reasons.append("missing:subjectId")
54
+ if not action:
55
+ reasons.append("missing:action")
56
+ if not resource:
57
+ reasons.append("missing:resource")
58
+ if not domain:
59
+ reasons.append("missing:domain")
60
+ reasons.extend(self._sessionReasons(session, subjectId=subjectId, action=action, domain=domain, mandateId=mandateId))
61
+ if action.startswith("pay.") and not mandateId:
62
+ reasons.append("missing:mandateId")
63
+ if action == "pay.settle" and not povRef:
64
+ reasons.append("missing:povRef")
65
+ if not self._grantAllowed(grants, subjectId=subjectId, action=action, resource=resource, domain=domain):
66
+ reasons.append("grantDenied")
67
+
68
+ cleanReasons = tuple(dict.fromkeys(reason for reason in reasons if reason))
69
+ return PolicyDecision(
70
+ allowed=not cleanReasons,
71
+ reasons=cleanReasons,
72
+ subjectId=subjectId,
73
+ action=action,
74
+ resource=resource,
75
+ domain=domain,
76
+ mandateId=mandateId,
77
+ )
78
+
79
+ def _sessionReasons(
80
+ self,
81
+ session: dict[str, Any],
82
+ *,
83
+ subjectId: str,
84
+ action: str,
85
+ domain: str,
86
+ mandateId: str,
87
+ ) -> list[str]:
88
+ reasons: list[str] = []
89
+ if not session:
90
+ return ["missing:session"]
91
+ if str(session.get("status", "active") or "active") != "active":
92
+ reasons.append("sessionInactive")
93
+ if self._expired(str(session.get("expiresAt", "") or "")):
94
+ reasons.append("sessionExpired")
95
+ sessionSubject = str(session.get("subjectId", "") or "")
96
+ if sessionSubject and subjectId and sessionSubject != subjectId:
97
+ reasons.append("subjectMismatch")
98
+ sessionMandate = str(session.get("mandateId", "") or "")
99
+ if sessionMandate and mandateId and sessionMandate != mandateId:
100
+ reasons.append("mandateMismatch")
101
+ domains = _cleanList(session.get("domains", []))
102
+ if domains and domain and domain not in domains:
103
+ reasons.append("domainDenied")
104
+ actions = _cleanList(session.get("actions", []))
105
+ if actions and action and action not in actions:
106
+ reasons.append("actionDenied")
107
+ return reasons
108
+
109
+ def _grantAllowed(self, grants: list[Any], *, subjectId: str, action: str, resource: str, domain: str) -> bool:
110
+ for grant in grants:
111
+ if not isinstance(grant, dict):
112
+ continue
113
+ if self._expired(str(grant.get("expiresAt", "") or "")):
114
+ continue
115
+ grantSubject = str(grant.get("subjectId", "") or "")
116
+ if grantSubject and subjectId and grantSubject != subjectId:
117
+ continue
118
+ domains = _cleanList(grant.get("domains", []))
119
+ if domains and domain not in domains:
120
+ continue
121
+ actions = _cleanList(grant.get("actions", []))
122
+ if action not in actions and "*" not in actions:
123
+ continue
124
+ resources = _cleanList(grant.get("resources", []))
125
+ if resource not in resources and "*" not in resources:
126
+ continue
127
+ return True
128
+ return False
129
+
130
+ def _expired(self, value: str) -> bool:
131
+ parsed = _parseTime(value)
132
+ return bool(parsed and parsed <= self.clock())
133
+
134
+
135
+ def _cleanList(value: Any) -> list[str]:
136
+ if not isinstance(value, list):
137
+ return []
138
+ deduped: list[str] = []
139
+ for item in value:
140
+ clean = str(item or "").strip()
141
+ if clean and clean not in deduped:
142
+ deduped.append(clean)
143
+ return deduped
144
+
145
+
146
+ def _parseTime(value: str) -> datetime | None:
147
+ raw = str(value or "").strip()
148
+ if not raw:
149
+ return None
150
+ if raw.endswith("Z"):
151
+ raw = raw[:-1] + "+00:00"
152
+ try:
153
+ parsed = datetime.fromisoformat(raw)
154
+ except Exception:
155
+ return None
156
+ if parsed.tzinfo is None:
157
+ parsed = parsed.replace(tzinfo=timezone.utc)
158
+ return parsed.astimezone(timezone.utc)