hank-contracts 1.0__tar.gz

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.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: hank-contracts
3
+ Version: 1.0
4
+ Summary: Hank wire-contract types: commercial claims, work authorization, usage, capability and subject shapes with JSON schemas and canonical serialization
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pydantic<3,>=2.10
8
+ Requires-Dist: cryptography>=41
9
+ Requires-Dist: PyJWT>=2.8
10
+ Requires-Dist: jsonschema>=4.26
11
+
12
+ # hank-contracts
13
+
14
+ Wire-contract types for Hank: commercial claims, work authorization, usage
15
+ rows and reports, investment capability grants, the agent_api_v1 claim shape,
16
+ and the Risk web subject assertion — frozen dataclasses, constants, JSON
17
+ schemas, and canonical serialization only.
18
+
19
+ No signing, verification, key handling, HTTP, or storage lives here. Runtimes
20
+ keep that behavior and import these shapes. Private validation helpers moved
21
+ with their dataclasses so trust-snapshot construction validates exactly as
22
+ before.
@@ -0,0 +1,11 @@
1
+ # hank-contracts
2
+
3
+ Wire-contract types for Hank: commercial claims, work authorization, usage
4
+ rows and reports, investment capability grants, the agent_api_v1 claim shape,
5
+ and the Risk web subject assertion — frozen dataclasses, constants, JSON
6
+ schemas, and canonical serialization only.
7
+
8
+ No signing, verification, key handling, HTTP, or storage lives here. Runtimes
9
+ keep that behavior and import these shapes. Private validation helpers moved
10
+ with their dataclasses so trust-snapshot construction validates exactly as
11
+ before.
@@ -0,0 +1,108 @@
1
+ """Package entrypoint: re-export public wire-contract types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .agent_api import (
6
+ AGENT_API_CLAIM_AUDIENCE,
7
+ AGENT_API_CLAIM_ENV_VARS,
8
+ AGENT_API_CLAIM_HEADERS,
9
+ AGENT_API_CLAIM_TTL_SECONDS_DEFAULT,
10
+ canonical_agent_api_claim_bytes,
11
+ )
12
+ from .commercial_authority import (
13
+ CommercialAuthorityInvalidation,
14
+ CommercialAuthoritySnapshot,
15
+ DecodedCommercialAuthorityFeedPage,
16
+ )
17
+ from .commercial_claims import (
18
+ COMMERCIAL_CLAIM_ALGORITHM,
19
+ COMMERCIAL_CLAIM_AUDIENCE,
20
+ COMMERCIAL_CLAIM_ISSUER,
21
+ COMMERCIAL_CLAIM_REQUIRED_FIELDS,
22
+ CommercialClaimError,
23
+ CommercialClaimTrustSnapshot,
24
+ CommercialContextState,
25
+ VerifiedCommercialClaim,
26
+ )
27
+ from .commercial_work import (
28
+ COMMERCIAL_CLAIM_HEADER,
29
+ COMMERCIAL_WORK_AUTHORIZATION_HEADER,
30
+ CommercialWorkStartContext,
31
+ CommercialWorkStartFacts,
32
+ CommercialWorkStartFactsResolver,
33
+ CommercialWorkStartPreflight,
34
+ PendingCommercialWorkStart,
35
+ VerifiedCommercialWorkStartClaim,
36
+ VerifiedWorkAuthorization,
37
+ WORK_AUTHORIZATION_ALGORITHM,
38
+ WORK_AUTHORIZATION_AUDIENCE,
39
+ WORK_AUTHORIZATION_ISSUER,
40
+ WORK_AUTHORIZATION_REQUIRED_FIELDS,
41
+ WORK_AUTHORIZATION_V3_REQUIRED_FIELDS,
42
+ WorkAuthorizationError,
43
+ WorkAuthorizationTrustSnapshot,
44
+ WorkStartSpendLedger,
45
+ )
46
+ from .investment_claims import (
47
+ INVESTMENT_CAPABILITY_CLAIM_AUDIENCE,
48
+ INVESTMENT_CAPABILITY_CLAIM_ISSUER,
49
+ INVESTMENT_CAPABILITY_CLAIM_KEY_BYTES,
50
+ INVESTMENT_CAPABILITY_CLAIM_KIND,
51
+ INVESTMENT_CAPABILITY_CLAIM_PRIVATE_KEY_ENV,
52
+ INVESTMENT_CAPABILITY_CLAIM_PUBLIC_KEY_ENV,
53
+ INVESTMENT_CAPABILITY_CLAIM_SERVER,
54
+ INVESTMENT_CAPABILITY_CLAIM_TTL_SECONDS,
55
+ INVESTMENT_CAPABILITY_CLAIM_VERSION,
56
+ INVESTMENT_CAPABILITY_FACADE_TOOLS,
57
+ INVESTMENT_JOB_PRINCIPAL_CLAIM_KIND,
58
+ INVESTMENT_JOB_PRINCIPAL_CLAIM_VERSION,
59
+ INVESTMENT_JOB_PRINCIPAL_TOOLS,
60
+ INVESTMENT_SELECTED_CONTENT_CLAIM_PURPOSE,
61
+ InvestmentCapabilityGrant,
62
+ RISK_CAPABILITY_CLAIM_AUDIENCE,
63
+ RISK_JOB_CAPABILITY_CLAIM_KIND,
64
+ RISK_JOB_CAPABILITY_CLAIM_VERSION,
65
+ )
66
+ from .subject_assertion import (
67
+ SUBJECT_ASSERTION_ALGORITHM,
68
+ SUBJECT_ASSERTION_AUDIENCE,
69
+ SUBJECT_ASSERTION_CLOCK_SKEW_SECONDS,
70
+ SUBJECT_ASSERTION_ISSUER,
71
+ SUBJECT_ASSERTION_MAX_LIFETIME_SECONDS,
72
+ SUBJECT_ASSERTION_PUBLIC_KEYS_ENV,
73
+ SUBJECT_ASSERTION_REPLAY_CACHE_SIZE,
74
+ SubjectAssertionError,
75
+ VerifiedGatewaySubject,
76
+ )
77
+ from .usage import (
78
+ MAX_SHIPPER_BATCH_SIZE,
79
+ OUTBOX_SCHEMA_VERSION,
80
+ SHIPPABLE_USAGE_PAYLOAD_SCHEMA_VERSION,
81
+ USAGE_DURABILITY_RANK,
82
+ AcceptanceStatus,
83
+ CommercialUsageBatchSender,
84
+ CommercialUsageCircuitOpen,
85
+ CommercialUsageCircuitSnapshot,
86
+ CommercialUsageDeliveryError,
87
+ CommercialUsageLineage,
88
+ CommercialUsageOutboxConflict,
89
+ CommercialUsageOutboxError,
90
+ CommercialUsageOutboxRow,
91
+ CommercialUsageOutboxSink,
92
+ CommercialUsageOutboxT,
93
+ CommercialUsagePermanentDeliveryError,
94
+ CommercialUsageReconciliationBatchSender,
95
+ CommercialUsageReconciliationReport,
96
+ CommercialUsageReconciliationRow,
97
+ CommercialUsageReconciliationShipmentRow,
98
+ CommercialUsageResponseError,
99
+ CommercialUsageShipperConfig,
100
+ CommercialUsageSpoolError,
101
+ OutboxState,
102
+ ReconciliationAcceptance,
103
+ ReconciliationShipmentState,
104
+ UsageAcceptance,
105
+ UsageDurability,
106
+ )
107
+
108
+ __version__ = "1.0"
@@ -0,0 +1,43 @@
1
+ """Agent API v1 claim wire constants and canonical format (M10)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final, Mapping
6
+
7
+
8
+ AGENT_API_CLAIM_AUDIENCE: Final = "agent_api_v1"
9
+ AGENT_API_CLAIM_TTL_SECONDS_DEFAULT: Final = 300
10
+ AGENT_API_CLAIM_ENV_VARS: Final[Mapping[str, str]] = {
11
+ "audience": "AGENT_API_CLAIM_AUDIENCE",
12
+ "issued_at": "AGENT_API_CLAIM_ISSUED_AT",
13
+ "expiry": "AGENT_API_CLAIM_EXPIRY",
14
+ "user_id": "AGENT_API_CLAIM_USER_ID",
15
+ "user_email": "AGENT_API_CLAIM_USER_EMAIL",
16
+ "nonce": "AGENT_API_CLAIM_NONCE",
17
+ "signature": "AGENT_API_CLAIM_SIGNATURE",
18
+ }
19
+ AGENT_API_CLAIM_HEADERS: Final[Mapping[str, str]] = {
20
+ "audience": "X-Agent-Claim-Audience",
21
+ "issued_at": "X-Agent-Claim-Issued-At",
22
+ "expiry": "X-Agent-Claim-Expiry",
23
+ "user_id": "X-Agent-Claim-User-Id",
24
+ "user_email": "X-Agent-Claim-User-Email",
25
+ "nonce": "X-Agent-Claim-Nonce",
26
+ "signature": "X-Agent-Claim-Signature",
27
+ }
28
+
29
+
30
+ def canonical_agent_api_claim_bytes(
31
+ *,
32
+ audience: str,
33
+ issued_at: int,
34
+ expiry: int,
35
+ user_id: str,
36
+ user_email: str | None,
37
+ nonce: str,
38
+ ) -> bytes:
39
+ """The exact byte sequence HMAC-SHA256 signs for an agent_api_v1 claim."""
40
+ normalized_email = user_email or ""
41
+ return f"{audience}\n{issued_at}\n{expiry}\n{user_id}\n{normalized_email}\n{nonce}".encode(
42
+ "utf-8"
43
+ )
@@ -0,0 +1,79 @@
1
+ """Commercial authority snapshot/invalidation shapes (M10: moved from agent_gateway)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from importlib.resources import files
7
+ import json
8
+ from typing import Literal
9
+ from uuid import UUID
10
+
11
+ from jsonschema import Draft202012Validator
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class CommercialAuthoritySnapshot:
16
+ context_id: UUID
17
+ active: bool
18
+ entitlement_revision: int
19
+ commercial_account_id: int | None
20
+ token_id: UUID | None
21
+
22
+ def __post_init__(self) -> None:
23
+ if type(self.context_id) is not UUID or (
24
+ self.token_id is not None and type(self.token_id) is not UUID
25
+ ):
26
+ raise ValueError("commercial authority UUID identity is invalid")
27
+ if type(self.active) is not bool:
28
+ raise ValueError("commercial authority active state must be boolean")
29
+ if type(self.entitlement_revision) is not int or self.entitlement_revision < 0:
30
+ raise ValueError("commercial authority revision cannot be negative")
31
+ if self.commercial_account_id is not None and (
32
+ type(self.commercial_account_id) is not int or self.commercial_account_id <= 0
33
+ ):
34
+ raise ValueError("commercial account identity must be positive")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CommercialAuthorityInvalidation:
39
+ kind: Literal["context", "token", "entitlement", "agreement", "emergency"]
40
+ commercial_account_id: int
41
+ entitlement_revision: int
42
+ context_id: UUID | None = None
43
+ token_id: UUID | None = None
44
+
45
+ def __post_init__(self) -> None:
46
+ if self.kind not in {"context", "token", "entitlement", "agreement", "emergency"}:
47
+ raise ValueError("commercial invalidation kind is invalid")
48
+ if (
49
+ self.context_id is not None and type(self.context_id) is not UUID
50
+ ) or (self.token_id is not None and type(self.token_id) is not UUID):
51
+ raise ValueError("commercial invalidation UUID identity is invalid")
52
+ if (
53
+ type(self.commercial_account_id) is not int
54
+ or type(self.entitlement_revision) is not int
55
+ or self.commercial_account_id <= 0
56
+ or self.entitlement_revision <= 0
57
+ ):
58
+ raise ValueError("commercial invalidation identity is invalid")
59
+ if self.kind == "context" and self.context_id is None:
60
+ raise ValueError("context invalidation requires context identity")
61
+ if self.kind == "token" and self.token_id is None:
62
+ raise ValueError("token invalidation requires token identity")
63
+
64
+
65
+ _SCHEMA_PATH = (
66
+ files("hank_contracts")
67
+ / "contracts"
68
+ / "commercial-authority-v1"
69
+ / "commercial-authority-invalidation-feed-v1.schema.json"
70
+ )
71
+ _SCHEMA = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
72
+ _VALIDATOR = Draft202012Validator(_SCHEMA)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class DecodedCommercialAuthorityFeedPage:
77
+ invalidations: tuple[CommercialAuthorityInvalidation, ...]
78
+ next_sequence: int
79
+ high_water_sequence: int
@@ -0,0 +1,99 @@
1
+ """Commercial execution claim shapes (M10: moved from agent_gateway)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Mapping, TypeGuard
8
+ from uuid import UUID
9
+
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
11
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
12
+
13
+
14
+ COMMERCIAL_CLAIM_ALGORITHM = "EdDSA"
15
+ COMMERCIAL_CLAIM_ISSUER = "risk-module-commercial-control"
16
+ COMMERCIAL_CLAIM_AUDIENCE = "hank-agent-gateway"
17
+ _STABLE_CODE = re.compile(r"^[a-z][a-z0-9._:-]{0,127}$")
18
+ COMMERCIAL_CLAIM_REQUIRED_FIELDS = frozenset({
19
+ "schema_version", "kid", "iss", "aud", "sub", "environment", "surface",
20
+ "commercial_account_id", "agreement_id", "agreement_terms_revision",
21
+ "offer_code", "effective_scopes", "entitlement_revision", "payer_policy_version",
22
+ "budget_policy_version", "shadow_rate_version", "manifest_version",
23
+ "authorized_work_start_deadline", "usage_accept_until", "iat", "exp", "jti",
24
+ })
25
+
26
+
27
+ class CommercialClaimError(ValueError):
28
+ def __init__(self, code: str) -> None:
29
+ self.code = code
30
+ super().__init__(code)
31
+
32
+
33
+ def _load_ed25519_public_key(pem: bytes) -> Ed25519PublicKey:
34
+ key = load_pem_public_key(pem)
35
+ if not isinstance(key, Ed25519PublicKey):
36
+ raise ValueError("commercial verification keys must be Ed25519")
37
+ return key
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CommercialContextState:
42
+ active: bool
43
+ entitlement_revision: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class CommercialClaimTrustSnapshot:
48
+ public_keys_by_id: Mapping[str, bytes]
49
+ environment: str
50
+ manifest_versions: frozenset[str]
51
+ payer_policy_versions: frozenset[str]
52
+ budget_policy_versions: frozenset[str]
53
+ shadow_rate_versions: frozenset[str]
54
+ current_agreement_terms_revision: Callable[[UUID], int | None]
55
+ resolve_context_state: Callable[[UUID], CommercialContextState] | None = None
56
+
57
+ def __post_init__(self) -> None:
58
+ if self.environment not in {"dev", "staging", "prod"}:
59
+ raise ValueError("commercial trust environment is invalid")
60
+ if not self.public_keys_by_id:
61
+ raise ValueError("commercial trust snapshot requires verification keys")
62
+ for key_id, pem in self.public_keys_by_id.items():
63
+ if not _STABLE_CODE.fullmatch(key_id):
64
+ raise ValueError("commercial verification key id is invalid")
65
+ _load_ed25519_public_key(pem)
66
+ for versions in (
67
+ self.manifest_versions, self.payer_policy_versions,
68
+ self.budget_policy_versions, self.shadow_rate_versions,
69
+ ):
70
+ if not versions or any(not _valid_version(value) for value in versions):
71
+ raise ValueError("commercial trust versions must be non-empty bounded strings")
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class VerifiedCommercialClaim:
76
+ schema_version: int
77
+ key_id: str
78
+ subject: str
79
+ environment: str
80
+ surface: str
81
+ commercial_account_id: UUID
82
+ agreement_id: UUID
83
+ agreement_terms_revision: int
84
+ offer_code: str
85
+ effective_scopes: tuple[str, ...]
86
+ entitlement_revision: int
87
+ payer_policy_version: str
88
+ budget_policy_version: str
89
+ shadow_rate_version: str
90
+ manifest_version: str
91
+ authorized_work_start_deadline: int
92
+ usage_accept_until: int
93
+ issued_at: int
94
+ expires_at: int
95
+ context_id: UUID
96
+
97
+
98
+ def _valid_version(value: object) -> TypeGuard[str]:
99
+ return isinstance(value, str) and 1 <= len(value) <= 256 and not value.isspace()
@@ -0,0 +1,232 @@
1
+ """Commercial work authorization and work-start shapes (M10: moved from agent_gateway)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from decimal import Decimal, ROUND_CEILING
7
+ import re
8
+ from threading import Lock
9
+ from typing import Any, Callable, Literal, Mapping
10
+ from uuid import UUID
11
+
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
13
+ from cryptography.hazmat.primitives.serialization import load_pem_public_key
14
+
15
+ from .commercial_claims import VerifiedCommercialClaim
16
+
17
+
18
+ WORK_AUTHORIZATION_ALGORITHM = "EdDSA"
19
+ WORK_AUTHORIZATION_ISSUER = "risk-module-commercial-work-control"
20
+ WORK_AUTHORIZATION_AUDIENCE = "hank-agent-gateway-work-start"
21
+ _CODE = re.compile(r"^[a-z][a-z0-9._:-]{0,127}$")
22
+ WORK_AUTHORIZATION_REQUIRED_FIELDS = frozenset({
23
+ "schema_version", "kid", "iss", "aud", "jti", "environment",
24
+ "execution_claim_jti", "workflow_run_id", "workflow_attempt_group_id",
25
+ "workflow_attempt_number", "retry_of_workflow_run_id",
26
+ "workflow_attempt_kind", "primary_inference_observability",
27
+ "funding_route_id", "provider", "billing_mode", "reservation_id",
28
+ "operation", "capability_id", "request_id", "session_id", "iat", "exp",
29
+ })
30
+ # V3 binds the account and a reserved budget, never a model or provider
31
+ # (rulings-index WA-ACCOUNT-BUDGET). V1 and V2 tokens are not accepted.
32
+ WORK_AUTHORIZATION_V3_REQUIRED_FIELDS = WORK_AUTHORIZATION_REQUIRED_FIELDS | {
33
+ "credential_principal", "reserved_spend_microusd",
34
+ }
35
+
36
+
37
+ def _load_ed25519_public_key(pem: bytes) -> Ed25519PublicKey:
38
+ key = load_pem_public_key(pem)
39
+ if not isinstance(key, Ed25519PublicKey):
40
+ raise ValueError("work authorization verification keys must be Ed25519")
41
+ return key
42
+
43
+
44
+ class WorkAuthorizationError(ValueError):
45
+ def __init__(self, code: str) -> None:
46
+ self.code = code
47
+ super().__init__(code)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class WorkAuthorizationTrustSnapshot:
52
+ public_keys_by_id: Mapping[str, bytes]
53
+ environment: str
54
+ manifest_version: str
55
+ capability_required_scopes: Mapping[str, frozenset[str]]
56
+ capability_allowed_operations: Mapping[str, frozenset[str]]
57
+ retired_at_by_key_id: Mapping[str, int | None]
58
+
59
+ def __post_init__(self) -> None:
60
+ if self.environment not in {"dev", "staging", "prod"}:
61
+ raise ValueError("work authorization trust environment is invalid")
62
+ if not isinstance(self.manifest_version, str) or not self.manifest_version:
63
+ raise ValueError("work authorization manifest version is invalid")
64
+ if not self.public_keys_by_id:
65
+ raise ValueError("work authorization trust requires verification keys")
66
+ if set(self.retired_at_by_key_id) != set(self.public_keys_by_id):
67
+ raise ValueError("work authorization key retirement inventory is incomplete")
68
+ for key_id, pem in self.public_keys_by_id.items():
69
+ if not _CODE.fullmatch(key_id):
70
+ raise ValueError("work authorization verification key id is invalid")
71
+ _load_ed25519_public_key(pem)
72
+ retired_at = self.retired_at_by_key_id[key_id]
73
+ if retired_at is not None and (type(retired_at) is not int or retired_at <= 0):
74
+ raise ValueError("work authorization retirement cutoff is invalid")
75
+ if not self.capability_required_scopes:
76
+ raise ValueError("work authorization manifest capability map is empty")
77
+ if set(self.capability_allowed_operations) != set(
78
+ self.capability_required_scopes
79
+ ):
80
+ raise ValueError("work authorization manifest operation map is incomplete")
81
+ for capability, scopes in self.capability_required_scopes.items():
82
+ if (
83
+ not _CODE.fullmatch(capability)
84
+ or not isinstance(scopes, frozenset)
85
+ or not scopes
86
+ ):
87
+ raise ValueError("work authorization manifest capability is invalid")
88
+ if any(not _CODE.fullmatch(scope) for scope in scopes):
89
+ raise ValueError("work authorization manifest scope is invalid")
90
+ operations = self.capability_allowed_operations[capability]
91
+ if (
92
+ not isinstance(operations, frozenset)
93
+ or not operations
94
+ or any(not _CODE.fullmatch(operation) for operation in operations)
95
+ ):
96
+ raise ValueError("work authorization manifest operation is invalid")
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class VerifiedWorkAuthorization:
101
+ schema_version: int
102
+ key_id: str
103
+ token_sha256: str
104
+ authorization_id: UUID
105
+ environment: str
106
+ execution_context_id: UUID
107
+ workflow_run_id: UUID
108
+ workflow_attempt_group_id: UUID
109
+ workflow_attempt_number: int
110
+ retry_of_workflow_run_id: UUID | None
111
+ workflow_attempt_kind: str
112
+ primary_inference_observability: str
113
+ funding_route_id: UUID
114
+ provider: str
115
+ billing_mode: Literal["byok", "metered"]
116
+ reservation_id: UUID | None
117
+ operation: str
118
+ capability_id: str | None
119
+ request_id: str
120
+ session_id: str
121
+ issued_at: int
122
+ expires_at: int
123
+ # None exactly when billing_mode is byok (no reservation hold).
124
+ reserved_spend_microusd: int | None = None
125
+ credential_principal: Literal["user", "service"] | None = None
126
+
127
+
128
+ COMMERCIAL_CLAIM_HEADER = "X-Hank-Commercial-Claim"
129
+ COMMERCIAL_WORK_AUTHORIZATION_HEADER = "X-Hank-Work-Authorization"
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class CommercialWorkStartFacts:
134
+ operation: str
135
+ billing_mode: str
136
+ capability_id: str | None
137
+ credential_principal: str
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class VerifiedCommercialWorkStartClaim:
142
+ """Verified account identity awaiting facts from the prepared request."""
143
+
144
+ claim: VerifiedCommercialClaim
145
+ _authorization_token: str = field(repr=False, compare=False)
146
+ _gate_binding: object = field(repr=False, compare=False)
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class PendingCommercialWorkStart:
151
+ """Token-free verified authority awaiting durable one-time consumption."""
152
+
153
+ claim: VerifiedCommercialClaim
154
+ authorization: VerifiedWorkAuthorization
155
+ _gate_binding: object = field(repr=False, compare=False)
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class CommercialWorkStartContext:
160
+ """Token-free request context available only after durable consumption."""
161
+
162
+ claim: VerifiedCommercialClaim
163
+ authorization: VerifiedWorkAuthorization
164
+ # Opaque handle: the durable consumption record lives with the gateway
165
+ # store that owns it; the contract layer never inspects it.
166
+ consumption: Any
167
+ spend_ledger: "WorkStartSpendLedger | None" = None
168
+
169
+ def __post_init__(self) -> None:
170
+ if self.spend_ledger is None:
171
+ object.__setattr__(
172
+ self,
173
+ "spend_ledger",
174
+ WorkStartSpendLedger.for_authorization(self.authorization),
175
+ )
176
+
177
+ CommercialWorkStartFactsResolver = Callable[
178
+ [Any, Any, str | None], CommercialWorkStartFacts
179
+ ]
180
+ CommercialWorkStartPreflight = Callable[[PendingCommercialWorkStart], None]
181
+
182
+
183
+ _MICRO_USD = Decimal(1_000_000)
184
+
185
+
186
+ @dataclass
187
+ class WorkStartSpendLedger:
188
+ """Cumulative producer-estimated microUSD for one work authorization.
189
+
190
+ One ledger per work start, shared by the root and its children because they
191
+ share the context. Fed only from CommercialUsageProducer.emit.
192
+ """
193
+
194
+ authorization_id: UUID
195
+ reserved_spend_microusd: int | None
196
+ _spent_microusd: int = 0
197
+ _lock: Lock = field(default_factory=Lock, repr=False, compare=False)
198
+
199
+ @classmethod
200
+ def for_authorization(
201
+ cls, authorization: VerifiedWorkAuthorization
202
+ ) -> "WorkStartSpendLedger":
203
+ reserved = authorization.reserved_spend_microusd
204
+ if authorization.billing_mode != "metered":
205
+ reserved = None
206
+ return cls(
207
+ authorization_id=authorization.authorization_id,
208
+ reserved_spend_microusd=reserved,
209
+ )
210
+
211
+ def record_usd(self, cost_usd: object) -> None:
212
+ if cost_usd is None:
213
+ return
214
+ amount = Decimal(str(cost_usd))
215
+ if amount <= 0:
216
+ return
217
+ micro = int(
218
+ (amount * _MICRO_USD).to_integral_value(rounding=ROUND_CEILING)
219
+ )
220
+ with self._lock:
221
+ self._spent_microusd += micro
222
+
223
+ @property
224
+ def spent_microusd(self) -> int:
225
+ with self._lock:
226
+ return self._spent_microusd
227
+
228
+ def reached(self) -> bool:
229
+ reserved = self.reserved_spend_microusd
230
+ if reserved is None:
231
+ return False
232
+ return self.spent_microusd >= reserved