dominus-sdk-python 9.0.15__tar.gz → 9.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.
Files changed (92) hide show
  1. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/PKG-INFO +5 -4
  2. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/README.md +4 -3
  3. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/__init__.py +42 -1
  4. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/cache.py +6 -6
  5. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/core.py +260 -0
  6. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/start.py +143 -16
  7. dominus_sdk_python-9.1.0/dominus/target.py +342 -0
  8. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/PKG-INFO +5 -4
  9. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/SOURCES.txt +3 -0
  10. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/pyproject.toml +16 -1
  11. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_authority_public_vocabulary.py +73 -0
  12. dominus_sdk_python-9.1.0/tests/test_cache_contracts.py +115 -0
  13. dominus_sdk_python-9.1.0/tests/test_public_exports.py +137 -0
  14. dominus_sdk_python-9.1.0/tests/test_target_contract.py +556 -0
  15. dominus_sdk_python-9.1.0/tests/test_transport_compat.py +961 -0
  16. dominus_sdk_python-9.0.15/tests/test_public_exports.py +0 -51
  17. dominus_sdk_python-9.0.15/tests/test_transport_compat.py +0 -395
  18. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/config/__init__.py +0 -0
  19. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/config/endpoints.py +0 -0
  20. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/contract/__init__.py +0 -0
  21. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/contract/envelope.py +0 -0
  22. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/contract/projections.py +0 -0
  23. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/errors.py +0 -0
  24. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/__init__.py +0 -0
  25. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/auth.py +0 -0
  26. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/console_capture.py +0 -0
  27. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/crypto.py +0 -0
  28. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/sse.py +0 -0
  29. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/trace.py +0 -0
  30. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/__init__.py +0 -0
  31. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/admin.py +0 -0
  32. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/ai.py +0 -0
  33. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/artifacts.py +0 -0
  34. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/auth.py +0 -0
  35. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/authority.py +0 -0
  36. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/browser.py +0 -0
  37. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/coder.py +0 -0
  38. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/courier.py +0 -0
  39. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/db.py +0 -0
  40. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/ddl.py +0 -0
  41. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/deployer.py +0 -0
  42. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/fastapi.py +0 -0
  43. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/files.py +0 -0
  44. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/health.py +0 -0
  45. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/jobs.py +0 -0
  46. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/logs.py +0 -0
  47. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/platform.py +0 -0
  48. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/portal.py +0 -0
  49. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/processor.py +0 -0
  50. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/publisher.py +0 -0
  51. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/recipes.py +0 -0
  52. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/redis.py +0 -0
  53. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/secrets.py +0 -0
  54. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/secure.py +0 -0
  55. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/stash.py +0 -0
  56. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/sync.py +0 -0
  57. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/usage.py +0 -0
  58. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/warden.py +0 -0
  59. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/workflow.py +0 -0
  60. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/py.typed +0 -0
  61. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/services/__init__.py +0 -0
  62. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/dependency_links.txt +0 -0
  63. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/requires.txt +0 -0
  64. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/top_level.txt +0 -0
  65. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/setup.cfg +0 -0
  66. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_ai_namespace.py +0 -0
  67. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_artifacts_namespace.py +0 -0
  68. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_auth.py +0 -0
  69. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_browser_namespace.py +0 -0
  70. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_contract.py +0 -0
  71. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_control_plane_namespaces.py +0 -0
  72. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_error_recovery_action.py +0 -0
  73. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_errors.py +0 -0
  74. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_errors_aliases.py +0 -0
  75. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_flat_commands.py +0 -0
  76. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_health.py +0 -0
  77. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_logs.py +0 -0
  78. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_namespace_barrel_exports.py +0 -0
  79. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_platform_coder_namespaces.py +0 -0
  80. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_provisioning_parity.py +0 -0
  81. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_publisher_namespace.py +0 -0
  82. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_readme_quickstart_catalog.py +0 -0
  83. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_recipes_namespace.py +0 -0
  84. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_recipes_stash_routing.py +0 -0
  85. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_secrets_presence_contract.py +0 -0
  86. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_stash_artifact_facade.py +0 -0
  87. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_stash_tables_namespace.py +0 -0
  88. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_token_precedence.py +0 -0
  89. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_usage_namespace.py +0 -0
  90. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_version_consistency.py +0 -0
  91. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_workflow_lifecycle.py +0 -0
  92. {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_workflow_refs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dominus-sdk-python
3
- Version: 9.0.15
3
+ Version: 9.1.0
4
4
  Summary: Python SDK for the Dominus gateway-first platform
5
5
  Author-email: CareBridge Systems <dev@carebridge.io>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -38,8 +38,9 @@ Async Python SDK for the Dominus gateway-first service plane.
38
38
  ## Agent Guide
39
39
 
40
40
  Start with `docs/agent-guide/INDEX.md`. The current snapshot is
41
- `docs/agent-guide/2026-09-19-1300-sdk-python/00-reading-order.md`; the latest
42
- cleanup audit is `docs/janitor/2026-09-19-1300-sdk-python-cleanup-audit.md`.
41
+ `docs/agent-guide/2026-10-16-1300-sdk-python/00-reading-order.md`; the latest
42
+ cleanup audit is `docs/janitor/2026-10-16-1300-sdk-python-cleanup-audit.md`.
43
+ Knowledge graph: graphify-out/GRAPH_REPORT.md — query with `graphify-run query "<question>"` before grepping.
43
44
 
44
45
  ## What This Repo Ships
45
46
 
@@ -48,7 +49,7 @@ cleanup audit is `docs/janitor/2026-09-19-1300-sdk-python-cleanup-audit.md`.
48
49
  - Gateway-scoped client mode for MCP and other user-JWT sessions
49
50
  - Transport compatibility for wrapped `{success,data}` responses and unwrapped Warden/control-plane success objects
50
51
  - Local helpers for JWT verification, trace propagation, retries, and console capture
51
- - Current package version: `9.0.15`
52
+ - Current package version: `9.0.16`
52
53
 
53
54
  ## Install
54
55
 
@@ -5,8 +5,9 @@ Async Python SDK for the Dominus gateway-first service plane.
5
5
  ## Agent Guide
6
6
 
7
7
  Start with `docs/agent-guide/INDEX.md`. The current snapshot is
8
- `docs/agent-guide/2026-09-19-1300-sdk-python/00-reading-order.md`; the latest
9
- cleanup audit is `docs/janitor/2026-09-19-1300-sdk-python-cleanup-audit.md`.
8
+ `docs/agent-guide/2026-10-16-1300-sdk-python/00-reading-order.md`; the latest
9
+ cleanup audit is `docs/janitor/2026-10-16-1300-sdk-python-cleanup-audit.md`.
10
+ Knowledge graph: graphify-out/GRAPH_REPORT.md — query with `graphify-run query "<question>"` before grepping.
10
11
 
11
12
  ## What This Repo Ships
12
13
 
@@ -15,7 +16,7 @@ cleanup audit is `docs/janitor/2026-09-19-1300-sdk-python-cleanup-audit.md`.
15
16
  - Gateway-scoped client mode for MCP and other user-JWT sessions
16
17
  - Transport compatibility for wrapped `{success,data}` responses and unwrapped Warden/control-plane success objects
17
18
  - Local helpers for JWT verification, trace propagation, retries, and console capture
18
- - Current package version: `9.0.15`
19
+ - Current package version: `9.0.16`
19
20
 
20
21
  ## Install
21
22
 
@@ -148,6 +148,29 @@ from .helpers.core import (
148
148
  mint_selected_scope_jwt,
149
149
  )
150
150
 
151
+ # Export the exact-target surface. The shared kernel codec stays a lazy,
152
+ # out-of-band dependency (documented git-tag lane); importing this module never
153
+ # requires it, so the base install stays targetless.
154
+ from .target import (
155
+ EXACT_EXCHANGE_VERSION,
156
+ EXACT_TARGET_DEPENDENCY_LANE,
157
+ EXACT_TARGET_VERSION,
158
+ SERVICE_CORE_TAG,
159
+ TARGET_HEADER,
160
+ TOPOLOGY_TARGET_VERSION,
161
+ ExactTargetDependencyError,
162
+ ExactTargetError,
163
+ ExactTicketRecord,
164
+ assert_ticket_proves_target,
165
+ canonical_target_bytes,
166
+ selected_scope_target,
167
+ target_fingerprint,
168
+ target_header_value,
169
+ topology_target_hash,
170
+ validate_target,
171
+ validate_target_exchange_response,
172
+ )
173
+
151
174
  # Export trace utilities for distributed tracing
152
175
  from .helpers.trace import (
153
176
  generate_trace_id,
@@ -191,7 +214,7 @@ from .errors import (
191
214
  TimeoutError as DominusTimeoutError,
192
215
  )
193
216
 
194
- __version__ = "9.0.15"
217
+ __version__ = "9.1.0"
195
218
  __all__ = [
196
219
  # Main SDK instance
197
220
  "dominus",
@@ -267,6 +290,24 @@ __all__ = [
267
290
  "verify_jwt_locally",
268
291
  "is_jwt_valid",
269
292
  "mint_selected_scope_jwt",
293
+ # Exact-target surface (lazy shared kernel codec)
294
+ "EXACT_TARGET_VERSION",
295
+ "TOPOLOGY_TARGET_VERSION",
296
+ "EXACT_EXCHANGE_VERSION",
297
+ "TARGET_HEADER",
298
+ "SERVICE_CORE_TAG",
299
+ "EXACT_TARGET_DEPENDENCY_LANE",
300
+ "ExactTargetError",
301
+ "ExactTargetDependencyError",
302
+ "ExactTicketRecord",
303
+ "validate_target",
304
+ "selected_scope_target",
305
+ "validate_target_exchange_response",
306
+ "assert_ticket_proves_target",
307
+ "canonical_target_bytes",
308
+ "target_header_value",
309
+ "target_fingerprint",
310
+ "topology_target_hash",
270
311
  # Trace utilities
271
312
  "generate_trace_id",
272
313
  "get_current_trace_id",
@@ -108,7 +108,7 @@ def exponential_backoff_with_jitter(
108
108
  """
109
109
  delay = min(base_delay * (2 ** attempt), max_delay)
110
110
  jitter_range = delay * jitter
111
- return delay + random.uniform(-jitter_range, jitter_range)
111
+ return min(max_delay, delay + random.uniform(-jitter_range, jitter_range))
112
112
 
113
113
 
114
114
  class DominusCache:
@@ -124,7 +124,7 @@ class DominusCache:
124
124
  """
125
125
 
126
126
  def __init__(self, default_ttl: int = 300):
127
- self._store: Dict[str, Tuple[bytes, float]] = {}
127
+ self._store: Dict[str, Tuple[bytes, float, int]] = {}
128
128
  self._default_ttl = default_ttl
129
129
  self._cipher: Optional[Fernet] = None
130
130
 
@@ -141,7 +141,7 @@ class DominusCache:
141
141
  if not entry:
142
142
  return None
143
143
 
144
- encrypted_value, expires_at = entry
144
+ encrypted_value, expires_at, duration = entry
145
145
 
146
146
  # Check expiry
147
147
  if time.time() >= expires_at:
@@ -159,8 +159,8 @@ class DominusCache:
159
159
  else:
160
160
  value = json.loads(encrypted_value.decode())
161
161
 
162
- # Touch TTL
163
- self._store[key] = (encrypted_value, time.time() + self._default_ttl)
162
+ # Touch TTL using the entry's own duration
163
+ self._store[key] = (encrypted_value, time.time() + duration, duration)
164
164
  return value
165
165
 
166
166
  def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
@@ -173,7 +173,7 @@ class DominusCache:
173
173
  else:
174
174
  encrypted_value = json.dumps(value).encode()
175
175
 
176
- self._store[key] = (encrypted_value, time.time() + duration)
176
+ self._store[key] = (encrypted_value, time.time() + duration, duration)
177
177
 
178
178
  def delete(self, key: str) -> bool:
179
179
  """Delete key."""
@@ -5,6 +5,8 @@ Includes circuit breaker pattern to prevent runaway retries from exhausting
5
5
  Cloud Run CPU quota during service outages or circular dependency issues.
6
6
  """
7
7
  import asyncio
8
+ import dataclasses
9
+ import hashlib
8
10
  import httpx
9
11
  import base64
10
12
  import json
@@ -21,6 +23,21 @@ MAX_RETRIES = 3
21
23
  # When multiple concurrent coroutines need a new JWT, only one will mint
22
24
  _jwt_refresh_lock = asyncio.Lock()
23
25
 
26
+ # Exact-target ticket cache. One entry is one immutable record — a ticket is
27
+ # never paired with target bytes derived from a different request. The key is
28
+ # the credential context plus the FULL target fingerprint, so any refinement
29
+ # (branch, tenant, provisioning target, group, environment, project) or a
30
+ # different credential gets its own entry.
31
+ EXACT_TICKET_CACHE_PREFIX = "jwt:exact:"
32
+ EXACT_TICKET_REFRESH_BUFFER_SECONDS = 60
33
+ # Upper bound on the shared encrypted cache entry; the ticket's own `exp`
34
+ # (minus the refresh buffer) wins when it is sooner.
35
+ EXACT_TICKET_CACHE_TTL = 3300 # 55 minutes
36
+ # One mutex per cache entry: concurrent callers for the same credential+target
37
+ # share a single exchange, while unrelated credentials/targets never queue
38
+ # behind each other's network round-trip.
39
+ _exact_ticket_locks: dict[str, asyncio.Lock] = {}
40
+
24
41
  # Type alias
25
42
  DominusResponse = dict[str, Any]
26
43
 
@@ -642,6 +659,249 @@ async def mint_selected_scope_jwt(
642
659
  raise RuntimeError(f"Selected-scope JWT mint failed: {e}") from e
643
660
 
644
661
 
662
+ def _exact_credential_key(psk_token: str) -> str:
663
+ """Credential discriminator for the exact-target cache. The PSK never enters a key."""
664
+ digest = hashlib.sha256(psk_token.encode("utf-8")).digest()
665
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
666
+
667
+
668
+ def _exact_refinement_key(
669
+ ttl_seconds: Optional[int],
670
+ run_id: Optional[str],
671
+ scopes: Optional[list],
672
+ ) -> str:
673
+ """Digest of the mint refinements — they land in the ticket's own claims."""
674
+ if ttl_seconds is None and run_id is None and scopes is None:
675
+ return ""
676
+ payload = {
677
+ "ttl_seconds": ttl_seconds,
678
+ "run_id": run_id,
679
+ "scopes": list(scopes) if scopes is not None else None,
680
+ }
681
+ digest = hashlib.sha256(
682
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
683
+ ).digest()
684
+ return ":" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
685
+
686
+
687
+ def _exact_ticket_cache_key(
688
+ psk_token: str,
689
+ fingerprint: str,
690
+ refinements: str = "",
691
+ ) -> str:
692
+ return (
693
+ f"{EXACT_TICKET_CACHE_PREFIX}{_exact_credential_key(psk_token)}"
694
+ f":{fingerprint}{refinements}"
695
+ )
696
+
697
+
698
+ def _exact_ticket_lock_for(cache_key: str) -> asyncio.Lock:
699
+ lock = _exact_ticket_locks.get(cache_key)
700
+ if lock is None:
701
+ lock = asyncio.Lock()
702
+ _exact_ticket_locks[cache_key] = lock
703
+ return lock
704
+
705
+
706
+ def _exact_ticket_expiry(ticket: str) -> float:
707
+ """Local expiry from the ticket's own ``exp``, refreshed before it lapses."""
708
+ try:
709
+ payload = _decode_jwt_payload(ticket)
710
+ exp = payload.get("exp")
711
+ if isinstance(exp, (int, float)) and not isinstance(exp, bool):
712
+ return float(exp) - EXACT_TICKET_REFRESH_BUFFER_SECONDS
713
+ except Exception:
714
+ pass
715
+ return time.time() + EXACT_TICKET_CACHE_TTL
716
+
717
+
718
+ def _cached_exact_ticket(cache_key: str):
719
+ """Read one cached record, re-validating its bytes; drop anything stale/tampered."""
720
+ from ..target import ExactTicketRecord
721
+
722
+ entry = dominus_cache.get(cache_key)
723
+ if not isinstance(entry, dict):
724
+ return None
725
+ try:
726
+ expires_at = float(entry.get("expires_at") or 0)
727
+ except (TypeError, ValueError):
728
+ expires_at = 0.0
729
+ if expires_at <= time.time():
730
+ dominus_cache.delete(cache_key)
731
+ return None
732
+ try:
733
+ return ExactTicketRecord.from_mapping(entry)
734
+ except Exception:
735
+ dominus_cache.delete(cache_key)
736
+ return None
737
+
738
+
739
+ def _target_payload(target: Any) -> dict:
740
+ if dataclasses.is_dataclass(target) and not isinstance(target, type):
741
+ return dataclasses.asdict(target)
742
+ return dict(target)
743
+
744
+
745
+ async def _exchange_exact_target(
746
+ psk_token: str,
747
+ target: Any,
748
+ *,
749
+ ttl_seconds: Optional[int] = None,
750
+ run_id: Optional[str] = None,
751
+ scopes: Optional[list] = None,
752
+ timeout: float = 30.0,
753
+ ):
754
+ """Perform one ``target-exchange.v1`` mint and validate the whole response."""
755
+ from ..config.endpoints import get_gateway_url, get_proxy_config
756
+ from ..errors import (
757
+ DominusError,
758
+ ConnectionError as DominusConnectionError,
759
+ TimeoutError as DominusTimeoutError,
760
+ raise_for_status,
761
+ )
762
+ from ..target import EXACT_EXCHANGE_VERSION, validate_target_exchange_response
763
+ from .trace import get_current_trace_id, get_current_span_id
764
+
765
+ headers = {
766
+ "Authorization": f"Bearer {psk_token}",
767
+ "Content-Type": "text/plain",
768
+ "X-Trace-Id": get_current_trace_id(),
769
+ "X-Parent-Span-Id": get_current_span_id(),
770
+ }
771
+
772
+ body: dict = {"version": EXACT_EXCHANGE_VERSION, "target": _target_payload(target)}
773
+ if ttl_seconds is not None:
774
+ body["ttl_seconds"] = ttl_seconds
775
+ if run_id is not None:
776
+ body["run_id"] = run_id
777
+ if scopes is not None:
778
+ body["scopes"] = list(scopes)
779
+
780
+ try:
781
+ async with httpx.AsyncClient(
782
+ base_url=get_gateway_url(),
783
+ headers=headers,
784
+ timeout=timeout,
785
+ proxy=get_proxy_config(),
786
+ ) as client:
787
+ response = await client.post("/jwt/mint", content=_b64_encode(body))
788
+ except httpx.TimeoutException as exc:
789
+ raise DominusTimeoutError(
790
+ "Exact-target exchange timed out", endpoint="/jwt/mint"
791
+ ) from exc
792
+ except httpx.NetworkError as exc:
793
+ raise DominusConnectionError(
794
+ f"Exact-target exchange failed: {exc}", endpoint="/jwt/mint"
795
+ ) from exc
796
+
797
+ if response.is_error:
798
+ message = f"Exact-target exchange failed: HTTP {response.status_code}"
799
+ details: dict = {}
800
+ try:
801
+ error_payload = _decode_json_or_b64_json(response.text)
802
+ except Exception:
803
+ error_payload = {}
804
+ if isinstance(error_payload, dict):
805
+ message = (
806
+ error_payload.get("message")
807
+ or error_payload.get("error")
808
+ or error_payload.get("detail")
809
+ or message
810
+ )
811
+ nested = error_payload.get("error")
812
+ error_details = error_payload.get("details")
813
+ details = error_details if isinstance(error_details, dict) else {}
814
+ for src in (error_payload, nested if isinstance(nested, dict) else {}):
815
+ for key in ("kind", "code", "category", "retryable", "trace_id", "request_id"):
816
+ if key in src and key not in details:
817
+ details[key] = src[key]
818
+ raise_for_status(response.status_code, str(message), details, "/jwt/mint")
819
+
820
+ result = _decode_json_or_b64_json(response.text)
821
+ if not isinstance(result, dict):
822
+ raise DominusError(
823
+ "Exact-target exchange returned a non-object response",
824
+ endpoint="/jwt/mint",
825
+ )
826
+ if result.get("success") is False:
827
+ error_details = result.get("details")
828
+ raise DominusError(
829
+ str(result.get("error") or result.get("message") or "Unknown exact-target exchange error"),
830
+ details=error_details if isinstance(error_details, dict) else {},
831
+ endpoint="/jwt/mint",
832
+ )
833
+
834
+ data = result.get("data", result)
835
+ return validate_target_exchange_response(data, target)
836
+
837
+
838
+ async def mint_exact_target_ticket(
839
+ psk_token: str,
840
+ target: Any,
841
+ *,
842
+ ttl_seconds: Optional[int] = None,
843
+ run_id: Optional[str] = None,
844
+ scopes: Optional[list] = None,
845
+ force_refresh: bool = False,
846
+ timeout: float = 30.0,
847
+ ):
848
+ """Exchange an exact target for one atomic ticket record, cached.
849
+
850
+ The ticket, canonical target, fingerprint, and header are one immutable
851
+ unit keyed by credential context + full target fingerprint: identical
852
+ targets under the same credential reuse the entry, and any target-field or
853
+ credential change separates it.
854
+ """
855
+ from ..target import target_fingerprint, validate_target
856
+
857
+ canonical = validate_target(target)
858
+ fingerprint = target_fingerprint(canonical)
859
+ refinements = _exact_refinement_key(ttl_seconds, run_id, scopes)
860
+ cache_key = _exact_ticket_cache_key(psk_token, fingerprint, refinements)
861
+
862
+ if refinements:
863
+ # Refinement identities are caller-controlled (run ids, TTLs) and carry
864
+ # per-run claims, so they are exchanged fresh instead of cached — the
865
+ # cache (and its lock map) stays bounded by credential × target. A
866
+ # ticket minted for one run can therefore never be served to another.
867
+ return await _exchange_exact_target(
868
+ psk_token,
869
+ canonical,
870
+ ttl_seconds=ttl_seconds,
871
+ run_id=run_id,
872
+ scopes=scopes,
873
+ timeout=timeout,
874
+ )
875
+
876
+ if force_refresh:
877
+ dominus_cache.delete(cache_key)
878
+ else:
879
+ cached = _cached_exact_ticket(cache_key)
880
+ if cached is not None:
881
+ return cached
882
+
883
+ async with _exact_ticket_lock_for(cache_key):
884
+ if not force_refresh:
885
+ cached = _cached_exact_ticket(cache_key)
886
+ if cached is not None:
887
+ return cached
888
+
889
+ record = await _exchange_exact_target(
890
+ psk_token,
891
+ canonical,
892
+ ttl_seconds=ttl_seconds,
893
+ run_id=run_id,
894
+ scopes=scopes,
895
+ timeout=timeout,
896
+ )
897
+ expires_at = _exact_ticket_expiry(record.ticket)
898
+ ttl = int(max(1, min(EXACT_TICKET_CACHE_TTL, expires_at - time.time())))
899
+ dominus_cache.set(
900
+ cache_key, {**record.to_mapping(), "expires_at": expires_at}, ttl=ttl
901
+ )
902
+ return record
903
+
904
+
645
905
  def verify_token_format(token: str) -> bool:
646
906
  """
647
907
  Verify token format is valid (basic check, not server verification).