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.
- hank_contracts-1.0/PKG-INFO +22 -0
- hank_contracts-1.0/README.md +11 -0
- hank_contracts-1.0/hank_contracts/__init__.py +108 -0
- hank_contracts-1.0/hank_contracts/agent_api.py +43 -0
- hank_contracts-1.0/hank_contracts/commercial_authority.py +79 -0
- hank_contracts-1.0/hank_contracts/commercial_claims.py +99 -0
- hank_contracts-1.0/hank_contracts/commercial_work.py +232 -0
- hank_contracts-1.0/hank_contracts/contracts/commercial-authority-v1/commercial-authority-invalidation-feed-v1.schema.json +125 -0
- hank_contracts-1.0/hank_contracts/contracts/commercial-usage-v3/commercial-usage-event-v3.schema.json +689 -0
- hank_contracts-1.0/hank_contracts/contracts/commercial-usage-v3/commercial-usage-v3-digests.json +6 -0
- hank_contracts-1.0/hank_contracts/investment_claims.py +53 -0
- hank_contracts-1.0/hank_contracts/subject_assertion.py +84 -0
- hank_contracts-1.0/hank_contracts/usage.py +346 -0
- hank_contracts-1.0/hank_contracts.egg-info/PKG-INFO +22 -0
- hank_contracts-1.0/hank_contracts.egg-info/SOURCES.txt +19 -0
- hank_contracts-1.0/hank_contracts.egg-info/dependency_links.txt +1 -0
- hank_contracts-1.0/hank_contracts.egg-info/requires.txt +4 -0
- hank_contracts-1.0/hank_contracts.egg-info/top_level.txt +1 -0
- hank_contracts-1.0/pyproject.toml +18 -0
- hank_contracts-1.0/setup.cfg +4 -0
- hank_contracts-1.0/tests/test_contracts_import.py +34 -0
|
@@ -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
|