chitmark 0.6.2__tar.gz → 0.6.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chitmark
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Official Python SDK for Chitmark by Open Agent Ledger: trust decisions on agent-mediated actions (verify, feedback, challenge)
5
5
  Project-URL: Homepage, https://chitmark.com
6
6
  Project-URL: Documentation, https://chitmark.com/docs
@@ -80,7 +80,14 @@ with Chitmark(api_key="ck_live_...") as client:
80
80
  | `client.feedback(...)` | `POST /v1/feedback` | Learn |
81
81
  | `client.challenge(...)` | `POST /v1/challenge` | Escalate |
82
82
 
83
- Timeouts and transport errors return a degraded challenge verdict and never raise on `verify`. HTTP errors raise `ChitmarkApiError` with the status attached. Set `on_degraded` to `challenge`, never allow on degraded.
83
+ Store-outage degradation on verify is HTTP 200 with a Verdict
84
+ (`degraded: True`); the client deserializes it like any other Verdict.
85
+ Verify never returns HTTP 503. Timeouts and transport errors synthesize a
86
+ local degraded challenge Verdict and never raise on `verify`. HTTP 4xx /
87
+ 429 Error responses raise `ChitmarkApiError`. Set `on_degraded` to
88
+ `challenge`, never allow on degraded.
89
+
90
+ Use `is_eligible_allow(verdict)` before any protected action: only an explicit non-degraded `allow` is eligible. Unknown or malformed decisions fail closed.
84
91
 
85
92
  ### Report outcomes
86
93
 
@@ -95,12 +102,12 @@ db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
95
102
  client.feedback(
96
103
  event_id=account.chitmark_event_id, # the stored join key
97
104
  outcome="credit_burn",
98
- value=87.4, # dollar amount: unit rides along (default "usd")
105
+ value=87.4, # measured magnitude: unit rides along (default "usd"; also "credits" | "count")
99
106
  observed_at="2026-08-06T04:00:00Z",
100
107
  )
101
108
  ```
102
109
 
103
- Exact duplicate feedback bodies derive the same warehouse id, so retrying a connector never double-counts a burned value. `unit` ships only alongside `value`.
110
+ Exact duplicate feedback bodies derive the same warehouse id (`eventId` + `outcome` + `value` + `unit` + `observedAt`), so retrying a connector with a stable business timestamp never double-counts a burned value. Prefer an idempotency key; omitting `observedAt` uses server time. `unit` ships only alongside `value`.
104
111
 
105
112
  ### Handle a challenge
106
113
 
@@ -129,12 +136,12 @@ if instructions["type"] == "pow":
129
136
  session="sess_9f3a",
130
137
  proof={"type": "proof_of_work", "nonce": str(nonce)},
131
138
  )
132
- # Re-verify with context={"challengeId": ...} so the next verdict honors it.
139
+ # Re-verify with context={"challengeId": ...}; completion alone does not authorize.
133
140
  ```
134
141
 
135
142
  ### Verify the receipt
136
143
 
137
- Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify it before acting on high-value decisions (install the `verdict` extra for the crypto dependency):
144
+ Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify authenticity, then authorize on the decision (install the `verdict` extra for the crypto dependency):
138
145
 
139
146
  ```python
140
147
  pip install "chitmark[verdict]"
@@ -145,13 +152,19 @@ from chitmark.verify_token import verify_verdict_token
145
152
 
146
153
  claims = verify_verdict_token(
147
154
  verdict["verdictToken"],
148
- session="sess_9f3a", # enforce session binding (recommended)
149
- aud="api.chitmark.com", # enforce origin binding
155
+ session="sess_9f3a",
156
+ tenant_id="org_acme",
150
157
  )
151
- # claims: {"eventId", "decision", "actorType", "confidence", "jti", "exp", ...}
158
+
159
+ if claims["degraded"] or claims["decision"] != "allow":
160
+ raise ChallengeRequiredError() # HTTP 428 / challenge response
161
+
162
+ # Only now trust the allow.
152
163
  ```
153
164
 
154
- Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes.
165
+ Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes. A verified token that is not a non-degraded `allow` must still fail closed.
166
+
167
+ Cache JWKS using HTTP headers: respect `Cache-Control` (`max-age=60`), and when `ETag` or `Last-Modified` are present use conditional requests (`If-None-Match` / `If-Modified-Since`) instead of a hard-coded refresh interval. Refresh immediately on unknown `kid`. Do not fetch on every verification. Every signed JWT requires `kid`. Keys are EC P-256 for ES256: require JWT `alg === "ES256"` before verifying (`wrong_algorithm` otherwise); never trust the token-requested alg. Rotation overlaps old and new keys; the old key stays until tokens under it expire (keep ≥ 5 minutes + 60 seconds). An empty JWKS (`keys: []`) means verification is impossible: fail closed; never treat an empty set as proof a token is valid (local/CI may also emit `unsigned.<eventId>`). Pass a cached `jwks=` mapping into `verify_verdict_token` to skip the network call.
155
168
 
156
169
  ### PII modes
157
170
 
@@ -161,6 +174,12 @@ Rejects expired tokens, unknown keys, bad signatures, and session or origin mism
161
174
  | `none` | Derived/header-shape signals only |
162
175
  | `raw` | Tenant opt-in only; higher compliance review |
163
176
 
177
+ ### Timeouts and local development
178
+
179
+ The default timeout is 800ms in production. A local base URL (`localhost`,
180
+ `127.0.0.1`, `::1`, `0.0.0.0`, or `*.localhost`) defaults to 2000ms so a cold
181
+ local edge can start. Pass `timeout_ms=` to override either default.
182
+
164
183
  ### Dependency injection and lifecycle
165
184
 
166
185
  The client owns its `httpx.Client` by default and closes it on context exit. Pass your own for connection pooling or tests:
@@ -46,7 +46,14 @@ with Chitmark(api_key="ck_live_...") as client:
46
46
  | `client.feedback(...)` | `POST /v1/feedback` | Learn |
47
47
  | `client.challenge(...)` | `POST /v1/challenge` | Escalate |
48
48
 
49
- Timeouts and transport errors return a degraded challenge verdict and never raise on `verify`. HTTP errors raise `ChitmarkApiError` with the status attached. Set `on_degraded` to `challenge`, never allow on degraded.
49
+ Store-outage degradation on verify is HTTP 200 with a Verdict
50
+ (`degraded: True`); the client deserializes it like any other Verdict.
51
+ Verify never returns HTTP 503. Timeouts and transport errors synthesize a
52
+ local degraded challenge Verdict and never raise on `verify`. HTTP 4xx /
53
+ 429 Error responses raise `ChitmarkApiError`. Set `on_degraded` to
54
+ `challenge`, never allow on degraded.
55
+
56
+ Use `is_eligible_allow(verdict)` before any protected action: only an explicit non-degraded `allow` is eligible. Unknown or malformed decisions fail closed.
50
57
 
51
58
  ### Report outcomes
52
59
 
@@ -61,12 +68,12 @@ db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
61
68
  client.feedback(
62
69
  event_id=account.chitmark_event_id, # the stored join key
63
70
  outcome="credit_burn",
64
- value=87.4, # dollar amount: unit rides along (default "usd")
71
+ value=87.4, # measured magnitude: unit rides along (default "usd"; also "credits" | "count")
65
72
  observed_at="2026-08-06T04:00:00Z",
66
73
  )
67
74
  ```
68
75
 
69
- Exact duplicate feedback bodies derive the same warehouse id, so retrying a connector never double-counts a burned value. `unit` ships only alongside `value`.
76
+ Exact duplicate feedback bodies derive the same warehouse id (`eventId` + `outcome` + `value` + `unit` + `observedAt`), so retrying a connector with a stable business timestamp never double-counts a burned value. Prefer an idempotency key; omitting `observedAt` uses server time. `unit` ships only alongside `value`.
70
77
 
71
78
  ### Handle a challenge
72
79
 
@@ -95,12 +102,12 @@ if instructions["type"] == "pow":
95
102
  session="sess_9f3a",
96
103
  proof={"type": "proof_of_work", "nonce": str(nonce)},
97
104
  )
98
- # Re-verify with context={"challengeId": ...} so the next verdict honors it.
105
+ # Re-verify with context={"challengeId": ...}; completion alone does not authorize.
99
106
  ```
100
107
 
101
108
  ### Verify the receipt
102
109
 
103
- Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify it before acting on high-value decisions (install the `verdict` extra for the crypto dependency):
110
+ Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify authenticity, then authorize on the decision (install the `verdict` extra for the crypto dependency):
104
111
 
105
112
  ```python
106
113
  pip install "chitmark[verdict]"
@@ -111,13 +118,19 @@ from chitmark.verify_token import verify_verdict_token
111
118
 
112
119
  claims = verify_verdict_token(
113
120
  verdict["verdictToken"],
114
- session="sess_9f3a", # enforce session binding (recommended)
115
- aud="api.chitmark.com", # enforce origin binding
121
+ session="sess_9f3a",
122
+ tenant_id="org_acme",
116
123
  )
117
- # claims: {"eventId", "decision", "actorType", "confidence", "jti", "exp", ...}
124
+
125
+ if claims["degraded"] or claims["decision"] != "allow":
126
+ raise ChallengeRequiredError() # HTTP 428 / challenge response
127
+
128
+ # Only now trust the allow.
118
129
  ```
119
130
 
120
- Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes.
131
+ Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes. A verified token that is not a non-degraded `allow` must still fail closed.
132
+
133
+ Cache JWKS using HTTP headers: respect `Cache-Control` (`max-age=60`), and when `ETag` or `Last-Modified` are present use conditional requests (`If-None-Match` / `If-Modified-Since`) instead of a hard-coded refresh interval. Refresh immediately on unknown `kid`. Do not fetch on every verification. Every signed JWT requires `kid`. Keys are EC P-256 for ES256: require JWT `alg === "ES256"` before verifying (`wrong_algorithm` otherwise); never trust the token-requested alg. Rotation overlaps old and new keys; the old key stays until tokens under it expire (keep ≥ 5 minutes + 60 seconds). An empty JWKS (`keys: []`) means verification is impossible: fail closed; never treat an empty set as proof a token is valid (local/CI may also emit `unsigned.<eventId>`). Pass a cached `jwks=` mapping into `verify_verdict_token` to skip the network call.
121
134
 
122
135
  ### PII modes
123
136
 
@@ -127,6 +140,12 @@ Rejects expired tokens, unknown keys, bad signatures, and session or origin mism
127
140
  | `none` | Derived/header-shape signals only |
128
141
  | `raw` | Tenant opt-in only; higher compliance review |
129
142
 
143
+ ### Timeouts and local development
144
+
145
+ The default timeout is 800ms in production. A local base URL (`localhost`,
146
+ `127.0.0.1`, `::1`, `0.0.0.0`, or `*.localhost`) defaults to 2000ms so a cold
147
+ local edge can start. Pass `timeout_ms=` to override either default.
148
+
130
149
  ### Dependency injection and lifecycle
131
150
 
132
151
  The client owns its `httpx.Client` by default and closes it on context exit. Pass your own for connection pooling or tests:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "chitmark"
7
- version = "0.6.2"
7
+ version = "0.6.4"
8
8
  description = "Official Python SDK for Chitmark by Open Agent Ledger: trust decisions on agent-mediated actions (verify, feedback, challenge)"
9
9
  readme = "README.md"
10
10
  license = { text = "Proprietary: no open-source grant. Copyright (c) 2026 Open Agent Ledger. All rights reserved." }
@@ -4,7 +4,7 @@ Trust decisions on agent-mediated actions (verify, feedback, challenge),
4
4
  tuned by business outcomes.
5
5
  """
6
6
 
7
- from chitmark.client import SDK_VERSION, Chitmark
7
+ from chitmark.client import SDK_VERSION, Chitmark, is_eligible_allow
8
8
  from chitmark.errors import ChitmarkApiError, ChitmarkError
9
9
  from chitmark.pii import hash_email_sha256, truncate_ip
10
10
  from chitmark.types import (
@@ -14,6 +14,7 @@ from chitmark.types import (
14
14
  Decision,
15
15
  DecisionCosts,
16
16
  FeedbackResponse,
17
+ FeedbackSource,
17
18
  Outcome,
18
19
  Principal,
19
20
  Subject,
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "Decision",
36
37
  "DecisionCosts",
37
38
  "FeedbackResponse",
39
+ "FeedbackSource",
38
40
  "Outcome",
39
41
  "Principal",
40
42
  "Subject",
@@ -43,6 +45,7 @@ __all__ = [
43
45
  "VerdictTokenError",
44
46
  "VerifyContext",
45
47
  "hash_email_sha256",
48
+ "is_eligible_allow",
46
49
  "truncate_ip",
47
50
  "verify_verdict_token",
48
51
  ]
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import os
9
9
  from collections.abc import Mapping
10
10
  from typing import Any, cast
11
+ from urllib.parse import urlparse
11
12
 
12
13
  import httpx
13
14
 
@@ -22,6 +23,8 @@ from chitmark.types import (
22
23
  ChitmarkEnv,
23
24
  Decision,
24
25
  FeedbackResponse,
26
+ FeedbackSource,
27
+ FeedbackUnit,
25
28
  OnDegraded,
26
29
  Outcome,
27
30
  PiiMode,
@@ -30,9 +33,9 @@ from chitmark.types import (
30
33
  VerifyContext,
31
34
  )
32
35
 
33
- __all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION"]
36
+ __all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION", "is_eligible_allow"]
34
37
 
35
- SDK_VERSION = "0.6.0"
38
+ SDK_VERSION = "0.6.4"
36
39
 
37
40
  DEFAULT_BASE_URLS: dict[ChitmarkEnv, str] = {
38
41
  "production": "https://api.chitmark.com",
@@ -41,6 +44,19 @@ DEFAULT_BASE_URLS: dict[ChitmarkEnv, str] = {
41
44
  }
42
45
 
43
46
  DEFAULT_TIMEOUT_MS = 800
47
+ LOCAL_TIMEOUT_MS = 2000
48
+
49
+
50
+ def _is_local_base_url(base_url: str) -> bool:
51
+ try:
52
+ host = (urlparse(base_url).hostname or "").lower()
53
+ except ValueError:
54
+ return False
55
+ return (
56
+ host == "localhost"
57
+ or host.endswith(".localhost")
58
+ or host in {"127.0.0.1", "0.0.0.0", "::1"}
59
+ )
44
60
 
45
61
 
46
62
  def _degraded_challenge_verdict(reason: str, session: str | None = None) -> Verdict:
@@ -66,6 +82,15 @@ def _enforce_degraded_never_allow(verdict: Verdict) -> Verdict:
66
82
  return verdict
67
83
 
68
84
 
85
+ def is_eligible_allow(verdict: Mapping[str, Any] | Verdict) -> bool:
86
+ """True only for an explicit non-degraded allow.
87
+
88
+ Use this before any protected action so unknown or malformed decisions
89
+ cannot fall through. Fail closed: anything else is ineligible.
90
+ """
91
+ return verdict.get("decision") == "allow" and not verdict.get("degraded")
92
+
93
+
69
94
  def _is_well_formed_verdict(value: Any) -> bool:
70
95
  """Minimal shape guard for a 200 verify response.
71
96
 
@@ -149,7 +174,7 @@ class Chitmark:
149
174
  *,
150
175
  base_url: str | None = None,
151
176
  env: ChitmarkEnv = "production",
152
- timeout_ms: int = DEFAULT_TIMEOUT_MS,
177
+ timeout_ms: int | None = None,
153
178
  pii_mode: PiiMode = "hashed",
154
179
  on_degraded: OnDegraded = "challenge",
155
180
  client_name: str | None = None,
@@ -166,7 +191,13 @@ class Chitmark:
166
191
  self._base_url = (
167
192
  base_url or os.environ.get("CHITMARK_BASE_URL") or DEFAULT_BASE_URLS[env]
168
193
  ).rstrip("/")
169
- self._timeout = timeout_ms / 1000.0
194
+ if timeout_ms is not None:
195
+ resolved_timeout_ms = timeout_ms
196
+ elif _is_local_base_url(self._base_url):
197
+ resolved_timeout_ms = LOCAL_TIMEOUT_MS
198
+ else:
199
+ resolved_timeout_ms = DEFAULT_TIMEOUT_MS
200
+ self._timeout = resolved_timeout_ms / 1000.0
170
201
  self._pii_mode = pii_mode
171
202
  self._on_degraded: OnDegraded = "challenge"
172
203
  self._client_name = client_name or f"chitmark-python/{SDK_VERSION}"
@@ -204,13 +235,26 @@ class Chitmark:
204
235
  context: VerifyContext | Mapping[str, Any] | None = None,
205
236
  idempotency_key: str | None = None,
206
237
  ) -> Verdict:
207
- """Score + route an inbound action. Degrades to challenge on timeout/transport.
238
+ """Score + route an inbound action.
239
+
240
+ Store-outage degradation arrives as HTTP 200 with a Verdict body
241
+ (``degraded: True``, ``decision: "challenge"``). The client
242
+ deserializes that like any other Verdict. Verify never returns
243
+ HTTP 503.
244
+
245
+ HTTP 4xx / 429 Error responses raise ``ChitmarkApiError``. Transport
246
+ timeout / network failure synthesizes a local degraded challenge
247
+ Verdict (never allow).
248
+
249
+ ``session`` is a binding id you supply (prefer high-entropy,
250
+ server-generated); it binds challenge and ``verdictToken``.
208
251
 
209
252
  ``context`` carries per-request policy flags: ``challengeId``
210
253
  (honor a cleared challenge once), ``challengePolicy``
211
254
  ("default" | "confirmed_tail_only"), and ``decisionCosts``
212
- (falseAllow/falseChallenge/falseDeny: repositions the
213
- allow/challenge/deny boundaries, defaults 1:1:3).
255
+ (relative, dimensionless weights for falseAllow / falseChallenge /
256
+ falseDeny: abusive allow, over-friction challenge on legitimate
257
+ traffic, legitimate deny; defaults 1:1:3).
214
258
  """
215
259
  body: dict[str, Any] = {
216
260
  "action": action,
@@ -249,15 +293,30 @@ class Chitmark:
249
293
  event_id: str,
250
294
  outcome: Outcome,
251
295
  value: float | None = None,
252
- unit: str = "usd",
296
+ unit: FeedbackUnit = "usd",
297
+ source: FeedbackSource | None = None,
253
298
  observed_at: str | None = None,
254
299
  idempotency_key: str | None = None,
255
300
  ) -> FeedbackResponse:
256
301
  """Report a downstream outcome joined on eventId.
257
302
 
258
- ``unit`` rides along only when ``value`` is present: a currency
259
- without an amount is wire noise, and parity with the TypeScript SDK
260
- means neither field is sent unless the caller set it.
303
+ Security invariant: feedback cannot create attribution. It only
304
+ attaches an outcome to an existing verify event for this tenant.
305
+ Unknown or cross-tenant ``event_id`` values return 404. Never invent
306
+ a join from email/IP.
307
+
308
+ Append-only (not latest-wins): multiple outcomes may attach to one
309
+ eventId. Exact duplicates share one feedbackId. Compatible labels
310
+ coexist; conflicting pairs quarantine the new observation without
311
+ deleting prior evidence. HTTP ``{ok: true}`` still acknowledges
312
+ quarantined ingest.
313
+
314
+ ``value`` is the measured economic magnitude for this outcome,
315
+ expressed in ``unit`` (``usd`` | ``credits`` | ``count``). ``unit``
316
+ rides along only when ``value`` is present. Different units are not
317
+ converted or comparable. ``source`` is optional provenance (billing,
318
+ usage_metering, trust_queue, manual_review, customer_system,
319
+ automated_detector) and is included in warehouse identity when set.
261
320
  ``idempotency_key`` is sent as both the ``Idempotency-Key`` header
262
321
  and body ``idempotencyKey`` so exact retries replay the original
263
322
  acknowledgement; a reused key with a different payload conflicts.
@@ -266,6 +325,8 @@ class Chitmark:
266
325
  if value is not None:
267
326
  body["value"] = value
268
327
  body["unit"] = unit
328
+ if source is not None:
329
+ body["source"] = source
269
330
  if observed_at is not None:
270
331
  body["observedAt"] = observed_at
271
332
  if idempotency_key is not None:
@@ -290,9 +351,11 @@ class Chitmark:
290
351
  ) -> ChallengeResponse:
291
352
  """Issue an asymmetric-cost challenge.
292
353
 
293
- ``risk_tier`` scales the issued proof-of-work difficulty
294
- so the challenge's economic tax rises with risk; completion is
295
- always validated against the difficulty that was issued.
354
+ ``session`` is a binding id you supply (prefer high-entropy,
355
+ server-generated). ``risk_tier`` scales issued PoW difficulty
356
+ monotonically; difficulty is sealed in ``instructions`` and
357
+ completion validates that value only. ``prefer`` is ordered hints:
358
+ first issuable method wins.
296
359
  """
297
360
  body: dict[str, Any] = {"eventId": event_id, "session": session}
298
361
  if prefer is not None:
@@ -314,9 +377,10 @@ class Chitmark:
314
377
  ) -> ChallengeCompletionResponse:
315
378
  """Complete a challenge by submitting its solved proof.
316
379
 
317
- Clears only the bound session, once (anti-replay). Re-verify with
318
- ``context={"challengeId": challenge_id}`` so the next verify honors
319
- the cleared session.
380
+ Clears only the bound session, once. Does not authorize the action:
381
+ re-verify with ``context={"challengeId": challenge_id}`` and proceed
382
+ only on non-degraded ``allow`` (honored clear sets
383
+ ``challengeCleared: True``).
320
384
  """
321
385
  body: dict[str, Any] = {
322
386
  "eventId": event_id,
@@ -19,6 +19,13 @@ Action = (
19
19
  ]
20
20
  | str
21
21
  )
22
+ # Outcomes reported via feedback. Prefer fast labels first
23
+ # (credit_burn, multi_account_cluster, abuse_confirmed).
24
+ # false_positive: a previously attributed abuse signal on this eventId was
25
+ # reviewed and determined not to represent abuse (T&S clear / overturn).
26
+ # Conflicts with abuse-positive labels. Not "Chitmark challenged/denied a
27
+ # legitimate user": that is context.decisionCosts.falseChallenge / falseDeny
28
+ # on verify.
22
29
  Outcome = Literal[
23
30
  "credit_burn",
24
31
  "multi_account_cluster",
@@ -28,6 +35,18 @@ Outcome = Literal[
28
35
  "churned",
29
36
  "false_positive",
30
37
  ]
38
+ # Scale for feedback value. Allowlist only (not free-form). Values in
39
+ # different units are not converted or comparable.
40
+ FeedbackUnit = Literal["usd", "credits", "count"]
41
+ # Provenance of a feedback label: why you trusted this outcome.
42
+ FeedbackSource = Literal[
43
+ "billing",
44
+ "usage_metering",
45
+ "trust_queue",
46
+ "manual_review",
47
+ "customer_system",
48
+ "automated_detector",
49
+ ]
31
50
  ChallengeMethod = Literal[
32
51
  "payment_preauth",
33
52
  "proof_of_work",
@@ -42,19 +61,49 @@ OnDegraded = Literal["challenge"]
42
61
 
43
62
  class PrincipalRequired(TypedDict):
44
63
  """Required principal fields from verify."""
64
+
45
65
  resolved: bool
46
66
  identityBasis: list[str]
47
67
 
48
68
 
49
69
  class Principal(PrincipalRequired, total=False):
50
- """Principal resolution from verify."""
70
+ """Principal attribution: who is acting and on whose behalf.
71
+
72
+ Not an authorization credential. ``resolved is True`` is not "this is a
73
+ human", not an IdP login, and not permission to unlock an action. Gate
74
+ on ``decision`` (plus ``degraded`` / ``enforcementMode``).
75
+
76
+ - ``resolved``: usable principal signal (today: verified Web Bot Auth
77
+ operator signature and/or enrichment known-good).
78
+ - ``operator``: bot maker from Web Bot Auth Signature-Agent when the
79
+ operator channel verified; does not by itself prove ``onBehalfOf``.
80
+ - ``onBehalfOf``: principal the agent is acting for when known
81
+ (organization **or** end-user identifier). Often absent at the wedge.
82
+ - ``identityBasis``: contributing signal sources (mixed cryptographic
83
+ and inferred strength).
84
+ """
51
85
 
52
86
  operator: str
53
87
  onBehalfOf: str
54
88
 
55
-
56
89
  class Verdict(TypedDict):
57
- """Verdict object: single source of truth for a verify call."""
90
+ """Verdict object: single source of truth for a verify call.
91
+
92
+ ``confidence`` is a model confidence measure for the returned decision
93
+ (0..1). It is not a calibrated probability of abuse, and not the
94
+ threshold ``decisionCosts`` moves. Gate on ``decision`` (plus
95
+ ``degraded`` / ``enforcementMode``); use confidence for ops triage and
96
+ dashboards, not as a substitute allow/deny cutoff.
97
+
98
+ ``reasons`` is ``list[str]`` display copy for ops today. Do not scrape
99
+ reason text as a stable machine API. A future additive revision may
100
+ expose structured ``{code, message, category}`` entries while keeping a
101
+ human-readable message.
102
+
103
+ Under ``enforcementMode == "log_only"``, ``verdictToken`` is a signed
104
+ observation receipt and does not authorize the action. Mode is on this
105
+ body (not a JWT claim today): gate only when mode is ``enforce``.
106
+ """
58
107
 
59
108
  decision: Decision
60
109
  actorType: ActorType
@@ -70,6 +119,7 @@ class Verdict(TypedDict):
70
119
  class VerdictTokenClaims(TypedDict, total=False):
71
120
  """Decoded verdictToken JWT payload (see SDK.md §6)."""
72
121
 
122
+ iss: str
73
123
  eventId: str
74
124
  decision: Decision
75
125
  actorType: ActorType
@@ -84,7 +134,13 @@ class VerdictTokenClaims(TypedDict, total=False):
84
134
 
85
135
 
86
136
  class VerdictWithChallenge(Verdict, total=False):
87
- """Optional verdict additions for cleared challenges."""
137
+ """Optional verdict additions for cleared challenges.
138
+
139
+ ``challengeCleared: true`` means this verify honored
140
+ ``context.challengeId`` for the session. Guaranteed only with
141
+ ``decision: allow`` and ``degraded: false`` (clear short-circuits
142
+ scoring). Under ``log_only``, still observation-only.
143
+ """
88
144
 
89
145
  challengeCleared: bool
90
146
 
@@ -103,13 +159,22 @@ class Subject(TypedDict, total=False):
103
159
 
104
160
 
105
161
  class DecisionCosts(TypedDict, total=False):
106
- """Cost ratio repositions the allow/challenge/deny boundaries.
162
+ """Relative, dimensionless weights for three error classes.
163
+
164
+ Not dollars and not score thresholds; only ratios matter. Repositions
165
+ the allow/challenge/deny boundaries for a single verify.
166
+
167
+ - ``falseAllow``: cost of allowing abusive traffic
168
+ - ``falseChallenge``: cost of challenging legitimate traffic when allow
169
+ would have been fine (a challenge can still be appropriate for
170
+ legitimate users; this names the over-friction case)
171
+ - ``falseDeny``: cost of denying legitimate traffic
107
172
 
108
173
  Higher ``falseAllow`` challenges earlier (abuse-averse); higher
109
174
  ``falseChallenge`` lets more of the gray zone through; higher
110
175
  ``falseDeny`` reserves deny for confirmed abuse. Defaults 1:1:3 keep
111
- the fail-to-challenge semantics; missing/non-positive fields fall back
112
- to their default.
176
+ fail-to-challenge semantics; missing/non-positive fields fall back to
177
+ their default.
113
178
  """
114
179
 
115
180
  falseAllow: float
@@ -122,15 +187,26 @@ class VerifyContext(TypedDict, total=False):
122
187
 
123
188
  Unknown keys are carried (not scored); ``decisionCosts`` repositions
124
189
  the allow/challenge/deny boundaries for this request.
190
+
191
+ ``logOnly`` is an integration-side control: set it in server code (or
192
+ rely on env/key log-only policy). Never derive it from client-provided
193
+ request data. The verdict reports the effective mode as
194
+ ``enforcementMode``.
125
195
  """
126
196
 
127
197
  challengeId: str
128
198
  challengePolicy: Literal["default", "confirmed_tail_only"]
199
+ logOnly: bool
129
200
  decisionCosts: DecisionCosts
130
201
 
131
202
 
132
203
  class VerifyParams(TypedDict, total=False):
133
- """Parameters for Client.verify."""
204
+ """Parameters for Client.verify.
205
+
206
+ ``session`` is supplied by you: prefer a high-entropy, server-generated
207
+ id (binds challenge and verdictToken). Client-generated values are
208
+ attacker-influenced.
209
+ """
134
210
 
135
211
  idempotency_key: str
136
212
  action: Action
@@ -146,13 +222,18 @@ class FeedbackParams(TypedDict, total=False):
146
222
  event_id: str
147
223
  outcome: Outcome
148
224
  value: float
149
- unit: str
225
+ unit: FeedbackUnit
226
+ source: FeedbackSource
150
227
  observed_at: str
151
228
  idempotency_key: str
152
229
 
153
230
 
154
231
  class ChallengeParams(TypedDict, total=False):
155
- """Parameters for Client.challenge (issue mode)."""
232
+ """Parameters for Client.challenge (issue mode).
233
+
234
+ ``session`` is a binding id you supply (prefer high-entropy,
235
+ server-generated). Same value across issue, complete, and re-verify.
236
+ """
156
237
 
157
238
  event_id: str
158
239
  session: str
@@ -1,13 +1,26 @@
1
1
  """Client-side verdictToken verification (MVP-016).
2
2
 
3
3
  A verdictToken is an ES256 JWT decision receipt minted by the edge and bound
4
- to session (``sub``), origin hint (``aud``), issuing tenant (``tid``), and
5
- ``jti`` with a short ``exp``. This module validates shape, signature (against
6
- the JWKS served from ``GET /.well-known/jwks.json``), expiry, session
7
- binding, and, when expected values are supplied, tenant and origin binding.
8
- ``aud`` reflects request headers and is attacker influenced: always verify
9
- ``session`` and ``tenant_id``, never ``aud`` alone. Rejection reasons are
10
- surfaced as ``VerdictTokenError.code``.
4
+ to session (``sub``), origin hint (``aud``), issuing tenant (``tid``), and a
5
+ unique ``jti`` (correlation id) with a short ``exp`` (5 minutes). Every signed
6
+ token requires a ``kid`` in the JWT header. Signature verification proves
7
+ authenticity and integrity; it does not make the JWT single-use. This module
8
+ always enforces ``session`` and ``tenant_id`` (security boundary), validates
9
+ ``iss`` against the Chitmark issuer, and optionally ``aud``. ``aud`` reflects
10
+ request headers and is attacker influenced: never enforce ``aud`` alone.
11
+ Rejection reasons are surfaced as ``VerdictTokenError.code``.
12
+
13
+ JWKS: respect ``Cache-Control`` and, when present, ``ETag`` /
14
+ ``Last-Modified`` via conditional requests (instead of a hard-coded
15
+ refresh interval). Refresh immediately on unknown ``kid``. Do not fetch on
16
+ every verification; never permanently pin a single signing key. Each JWKS
17
+ key is EC P-256 for ES256: require JWT ``alg == "ES256"`` before verifying
18
+ (``wrong_algorithm`` otherwise); never trust the token-requested algorithm.
19
+ Rotation overlaps old and new keys; the old key stays published until tokens under
20
+ it expire (keep ≥ 5 minutes + 60 seconds). Pass a cached ``jwks`` mapping
21
+ to skip the network call. An empty JWKS (``keys: []``) means no token can
22
+ be cryptographically verified: fail closed; never treat an empty set as
23
+ evidence that a token is valid (local/CI may emit ``unsigned.<eventId>``).
11
24
 
12
25
  Requires the optional extras ``PyJWT`` and ``cryptography``
13
26
  (``uv sync --extra verdict``).
@@ -42,14 +55,16 @@ class VerdictTokenError(ChitmarkError):
42
55
 
43
56
  DEFAULT_JWKS_URL = "https://api.chitmark.com/.well-known/jwks.json"
44
57
  DEFAULT_LEEWAY_SECONDS = 30
58
+ VERDICT_TOKEN_ISSUER = "https://api.chitmark.com"
45
59
 
46
60
 
47
61
  def verify_verdict_token(
48
62
  token: str,
49
63
  *,
50
- session: str | None = None,
51
- tenant_id: str | None = None,
64
+ session: str,
65
+ tenant_id: str,
52
66
  aud: str | None = None,
67
+ issuer: str = VERDICT_TOKEN_ISSUER,
53
68
  jwks: Mapping[str, Any] | None = None,
54
69
  jwks_url: str = DEFAULT_JWKS_URL,
55
70
  http_client: httpx.Client | None = None,
@@ -59,10 +74,19 @@ def verify_verdict_token(
59
74
 
60
75
  Args:
61
76
  token: The ES256 JWT from a verify verdict.
62
- session: Expected session; token ``sub`` must equal this.
63
- tenant_id: Expected issuing tenant; token ``tid`` must equal this.
77
+ session: Expected session; token ``sub`` must equal this (required).
78
+ tenant_id: Expected issuing tenant; token ``tid`` must equal this
79
+ (required).
64
80
  aud: Expected origin hint; token ``aud`` must equal this when provided.
65
- jwks: Pre-fetched JWKS mapping; skips the network fetch.
81
+ issuer: Expected ``iss``; defaults to ``https://api.chitmark.com``.
82
+ jwks: Pre-fetched/cached JWKS mapping; skips the network fetch.
83
+ Prefer caching via HTTP headers (``Cache-Control``, ``ETag``,
84
+ and ``Last-Modified`` when present) with conditional refresh
85
+ and immediate refresh on unknown ``kid``, rather than fetching
86
+ on every verification. Never permanently pin a single signing
87
+ key. Rotation overlaps old+new keys; keep the retiring key
88
+ until tokens under it expire (≥ 5 min TTL + 60s JWKS cache
89
+ skew).
66
90
  jwks_url: JWKS endpoint; defaults to the production API.
67
91
  http_client: Reusable ``httpx.Client`` for the JWKS fetch.
68
92
  leeway_seconds: Clock-skew tolerance.
@@ -71,8 +95,8 @@ def verify_verdict_token(
71
95
  VerdictTokenError: with a machine-readable ``code``:
72
96
  ``malformed_token``, ``wrong_algorithm``, ``wrong_type``,
73
97
  ``jwks_unavailable``, ``unknown_kid``, ``invalid_signature``,
74
- ``expired``, ``not_yet_valid``, ``wrong_session``,
75
- ``wrong_tenant``, or ``wrong_aud``.
98
+ ``expired``, ``not_yet_valid``, ``wrong_issuer``,
99
+ ``wrong_session``, ``wrong_tenant``, or ``wrong_aud``.
76
100
  """
77
101
  if pyjwt is None: # pragma: no cover
78
102
  raise VerdictTokenError(
@@ -140,12 +164,17 @@ def verify_verdict_token(
140
164
  code="malformed_token",
141
165
  ) from None
142
166
 
143
- if session is not None and claims.get("sub") != session:
167
+ if claims.get("iss") != issuer:
168
+ raise VerdictTokenError(
169
+ "verdictToken was not issued by Chitmark",
170
+ code="wrong_issuer",
171
+ )
172
+ if claims.get("sub") != session:
144
173
  raise VerdictTokenError(
145
174
  "verdictToken was minted for a different session",
146
175
  code="wrong_session",
147
176
  )
148
- if tenant_id is not None and claims.get("tid") != tenant_id:
177
+ if claims.get("tid") != tenant_id:
149
178
  raise VerdictTokenError(
150
179
  "verdictToken was minted for a different tenant",
151
180
  code="wrong_tenant",
@@ -9,7 +9,11 @@ import pytest
9
9
  import respx
10
10
 
11
11
  from chitmark import Chitmark, ChitmarkError
12
- from chitmark.client import _degraded_challenge_verdict, _enforce_degraded_never_allow
12
+ from chitmark.client import (
13
+ _degraded_challenge_verdict,
14
+ _enforce_degraded_never_allow,
15
+ is_eligible_allow,
16
+ )
13
17
  from chitmark.pii import hash_email_sha256, truncate_ip
14
18
  from chitmark.types import Verdict
15
19
 
@@ -45,6 +49,27 @@ def test_degraded_never_allow() -> None:
45
49
  assert _enforce_degraded_never_allow(bad)["decision"] == "challenge"
46
50
 
47
51
 
52
+ def test_is_eligible_allow() -> None:
53
+ allow: Verdict = {
54
+ "decision": "allow",
55
+ "actorType": "human",
56
+ "confidence": 0.9,
57
+ "reasons": ["ok"],
58
+ "principal": {"resolved": False, "identityBasis": []},
59
+ "verdictToken": "tok",
60
+ "eventId": "evt_ok",
61
+ "degraded": False,
62
+ "enforcementMode": "enforce",
63
+ }
64
+ assert is_eligible_allow(allow) is True
65
+
66
+ degraded_allow = {**allow, "degraded": True}
67
+ assert is_eligible_allow(degraded_allow) is False
68
+ assert is_eligible_allow({**allow, "decision": "challenge"}) is False
69
+ assert is_eligible_allow({**allow, "decision": "deny"}) is False
70
+ assert is_eligible_allow({**allow, "decision": "weird"}) is False # type: ignore[typeddict-item]
71
+
72
+
48
73
  def test_requires_api_key() -> None:
49
74
  with pytest.raises(ChitmarkError, match="api_key"):
50
75
  Chitmark(api_key="")
@@ -2,15 +2,46 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from importlib.metadata import version
6
+
5
7
  import httpx
6
8
  import pytest
7
9
  import respx
8
10
 
9
- from chitmark import Chitmark
10
- from chitmark.client import _is_well_formed_verdict, _prepare_subject
11
+ from chitmark import Chitmark, __version__
12
+ from chitmark.client import _is_local_base_url, _is_well_formed_verdict, _prepare_subject
11
13
  from chitmark.pii import hash_email_sha256
12
14
 
13
15
 
16
+ def test_version_matches_package_metadata() -> None:
17
+ assert __version__ == version("chitmark")
18
+
19
+
20
+ def test_local_base_url_detection_handles_ipv6_and_malformed_urls() -> None:
21
+ assert _is_local_base_url("http://[::1]:8787") is True
22
+ assert _is_local_base_url("http://[::1") is False
23
+ assert _is_local_base_url("https://api.chitmark.com") is False
24
+
25
+
26
+ def test_local_base_url_gets_a_longer_default_timeout() -> None:
27
+ with Chitmark(api_key="ck_test", base_url="http://127.0.0.1:8787") as client:
28
+ assert client.config["timeout_ms"] == 2000
29
+
30
+
31
+ def test_production_base_url_keeps_the_800ms_default() -> None:
32
+ with Chitmark(api_key="ck_test") as client:
33
+ assert client.config["timeout_ms"] == 800
34
+
35
+
36
+ def test_explicit_timeout_wins_for_a_local_base_url() -> None:
37
+ with Chitmark(
38
+ api_key="ck_test",
39
+ base_url="http://localhost:8787",
40
+ timeout_ms=400,
41
+ ) as client:
42
+ assert client.config["timeout_ms"] == 400
43
+
44
+
14
45
  def test_malformed_verdict_shapes() -> None:
15
46
  assert _is_well_formed_verdict("allow") is False
16
47
  assert _is_well_formed_verdict(None) is False
@@ -169,11 +200,13 @@ def test_feedback_sends_value_unit_and_observed_at() -> None:
169
200
  event_id="evt_x",
170
201
  outcome="credit_burn",
171
202
  value=4.2,
203
+ source="usage_metering",
172
204
  observed_at="2026-08-01T00:00:00Z",
173
205
  )
174
206
  sent = respx.calls[-1].request.content
175
207
  assert b'"value":4.2' in sent
176
208
  assert b'"unit":"usd"' in sent
209
+ assert b'"source":"usage_metering"' in sent
177
210
  assert b'"observedAt":"2026-08-01T00:00:00Z"' in sent
178
211
 
179
212
 
@@ -92,6 +92,7 @@ def _mint(keys: dict, **overrides: object) -> str:
92
92
  "decision": "allow",
93
93
  "actorType": "human",
94
94
  "confidence": 0.56,
95
+ "iss": "https://api.chitmark.com",
95
96
  "aud": "https://app.acme.com",
96
97
  "sub": "sess_token_a",
97
98
  "tid": "org_unit",
@@ -117,6 +118,7 @@ def test_accepts_valid_signed_token(keys: dict) -> None:
117
118
  claims = verify_verdict_token(
118
119
  token,
119
120
  session="sess_token_a",
121
+ tenant_id="org_unit",
120
122
  aud="https://app.acme.com",
121
123
  jwks=_jwks(keys),
122
124
  )
@@ -133,7 +135,9 @@ def test_rejects_wrong_session(keys: dict) -> None:
133
135
  token = _mint(keys, sub="sess_other")
134
136
 
135
137
  with pytest.raises(VerdictTokenError) as exc:
136
- verify_verdict_token(token, session="sess_token_a", jwks=_jwks(keys))
138
+ verify_verdict_token(
139
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
140
+ )
137
141
  assert exc.value.code == "wrong_session"
138
142
 
139
143
 
@@ -141,7 +145,13 @@ def test_rejects_wrong_audience(keys: dict) -> None:
141
145
  token = _mint(keys, aud="https://other.app")
142
146
 
143
147
  with pytest.raises(VerdictTokenError) as exc:
144
- verify_verdict_token(token, aud="https://app.acme.com", jwks=_jwks(keys))
148
+ verify_verdict_token(
149
+ token,
150
+ session="sess_token_a",
151
+ tenant_id="org_unit",
152
+ aud="https://app.acme.com",
153
+ jwks=_jwks(keys),
154
+ )
145
155
  assert exc.value.code == "wrong_aud"
146
156
 
147
157
 
@@ -149,10 +159,22 @@ def test_rejects_wrong_tenant(keys: dict) -> None:
149
159
  token = _mint(keys, tid="org_other")
150
160
 
151
161
  with pytest.raises(VerdictTokenError) as exc:
152
- verify_verdict_token(token, tenant_id="org_unit", jwks=_jwks(keys))
162
+ verify_verdict_token(
163
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
164
+ )
153
165
  assert exc.value.code == "wrong_tenant"
154
166
 
155
167
 
168
+ def test_rejects_wrong_issuer(keys: dict) -> None:
169
+ token = _mint(keys, iss="https://evil.example")
170
+
171
+ with pytest.raises(VerdictTokenError) as exc:
172
+ verify_verdict_token(
173
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
174
+ )
175
+ assert exc.value.code == "wrong_issuer"
176
+
177
+
156
178
  def test_accepts_expected_tenant(keys: dict) -> None:
157
179
  token = _mint(keys, tid="org_unit", sub="sess_token_a")
158
180
 
@@ -166,7 +188,9 @@ def test_rejects_expired_token(keys: dict) -> None:
166
188
  token = _mint(keys, exp=int(time.time()) - 120)
167
189
 
168
190
  with pytest.raises(VerdictTokenError) as exc:
169
- verify_verdict_token(token, jwks=_jwks(keys))
191
+ verify_verdict_token(
192
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
193
+ )
170
194
  assert exc.value.code == "expired"
171
195
 
172
196
 
@@ -174,7 +198,9 @@ def test_rejects_unknown_kid(keys: dict) -> None:
174
198
  token = _mint(keys)
175
199
 
176
200
  with pytest.raises(VerdictTokenError) as exc:
177
- verify_verdict_token(token, jwks={"keys": []})
201
+ verify_verdict_token(
202
+ token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []}
203
+ )
178
204
  assert exc.value.code == "unknown_kid"
179
205
 
180
206
 
@@ -187,7 +213,9 @@ def test_rejects_tampered_signature(keys: dict) -> None:
187
213
  tampered = f"{header}.{payload}.{tampered_sig}"
188
214
 
189
215
  with pytest.raises(VerdictTokenError) as exc:
190
- verify_verdict_token(tampered, jwks=_jwks(keys))
216
+ verify_verdict_token(
217
+ tampered, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
218
+ )
191
219
  assert exc.value.code == "invalid_signature"
192
220
 
193
221
 
@@ -198,7 +226,9 @@ def test_rejects_wrong_algorithm(keys: dict) -> None:
198
226
  wrong = f"{wrong_header}.{payload}.{signature}"
199
227
 
200
228
  with pytest.raises(VerdictTokenError) as exc:
201
- verify_verdict_token(wrong, jwks=_jwks(keys))
229
+ verify_verdict_token(
230
+ wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
231
+ )
202
232
  assert exc.value.code == "wrong_algorithm"
203
233
 
204
234
 
@@ -209,13 +239,17 @@ def test_rejects_missing_jwt_typ(keys: dict) -> None:
209
239
  wrong = f"{wrong_header}.{payload}.{signature}"
210
240
 
211
241
  with pytest.raises(VerdictTokenError) as exc:
212
- verify_verdict_token(wrong, jwks=_jwks(keys))
242
+ verify_verdict_token(
243
+ wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
244
+ )
213
245
  assert exc.value.code == "wrong_type"
214
246
 
215
247
 
216
248
  def test_rejects_malformed_token() -> None:
217
249
  with pytest.raises(VerdictTokenError) as exc:
218
- verify_verdict_token("not-a-jwt", jwks={"keys": []})
250
+ verify_verdict_token(
251
+ "not-a-jwt", session="sess_token_a", tenant_id="org_unit", jwks={"keys": []}
252
+ )
219
253
  assert exc.value.code == "malformed_token"
220
254
 
221
255
 
@@ -226,7 +260,7 @@ def test_fetches_jwks_from_url(keys: dict) -> None:
226
260
  return_value=httpx.Response(200, json=_jwks(keys)),
227
261
  )
228
262
 
229
- claims = verify_verdict_token(token, session="sess_fetch")
263
+ claims = verify_verdict_token(token, session="sess_fetch", tenant_id="org_unit")
230
264
 
231
265
  assert route.called
232
266
  assert claims["sub"] == "sess_fetch"
@@ -240,14 +274,16 @@ def test_jwks_fetch_failure_is_surfaced(keys: dict) -> None:
240
274
  )
241
275
 
242
276
  with pytest.raises(VerdictTokenError) as exc:
243
- verify_verdict_token(token)
277
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
244
278
  assert exc.value.code == "jwks_unavailable"
245
279
 
246
280
 
247
281
  def test_accepts_degraded_receipt(keys: dict) -> None:
248
282
  token = _mint(keys, decision="challenge", degraded=True)
249
283
 
250
- claims = verify_verdict_token(token, jwks=_jwks(keys))
284
+ claims = verify_verdict_token(
285
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
286
+ )
251
287
 
252
288
  assert claims["decision"] == "challenge"
253
289
  assert claims["degraded"] is True
@@ -89,6 +89,7 @@ def _mint(keys: dict, **overrides: object) -> str:
89
89
  claims = {
90
90
  "iss": "https://api.chitmark.com",
91
91
  "sub": "sess_token_a",
92
+ "tid": "org_unit",
92
93
  "aud": "https://api.chitmark.com",
93
94
  "iat": now,
94
95
  "exp": now + 300,
@@ -125,38 +126,50 @@ def _jwks(keys: dict) -> dict:
125
126
  def test_rejects_missing_or_empty_kid(keys: dict) -> None:
126
127
  token = _mint(keys, _header_remove=["kid"])
127
128
  with pytest.raises(VerdictTokenError) as excinfo:
128
- verify_verdict_token(token, jwks=_jwks(keys))
129
+ verify_verdict_token(
130
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
131
+ )
129
132
  assert excinfo.value.code == "unknown_kid"
130
133
 
131
134
  token2 = _mint(keys, _header={"kid": ""})
132
135
  with pytest.raises(VerdictTokenError) as excinfo2:
133
- verify_verdict_token(token2, jwks=_jwks(keys))
136
+ verify_verdict_token(
137
+ token2, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
138
+ )
134
139
  assert excinfo2.value.code == "unknown_kid"
135
140
 
136
141
  token3 = _mint(keys, _header={"kid": 123})
137
142
  with pytest.raises(VerdictTokenError) as excinfo3:
138
- verify_verdict_token(token3, jwks=_jwks(keys))
143
+ verify_verdict_token(
144
+ token3, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
145
+ )
139
146
  assert excinfo3.value.code == "malformed_token"
140
147
 
141
148
 
142
149
  def test_rejects_future_iat(keys: dict) -> None:
143
150
  token = _mint(keys, iat=int(time.time()) + 3600)
144
151
  with pytest.raises(VerdictTokenError) as excinfo:
145
- verify_verdict_token(token, jwks=_jwks(keys))
152
+ verify_verdict_token(
153
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
154
+ )
146
155
  assert excinfo.value.code == "not_yet_valid"
147
156
 
148
157
 
149
158
  def test_rejects_undecodable_payload(keys: dict) -> None:
150
159
  token = _mint(keys, _raw_payload=b"hello")
151
160
  with pytest.raises(VerdictTokenError) as excinfo:
152
- verify_verdict_token(token, jwks=_jwks(keys))
161
+ verify_verdict_token(
162
+ token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
163
+ )
153
164
  assert excinfo.value.code == "malformed_token"
154
165
 
155
166
 
156
167
  def test_rejects_malformed_header() -> None:
157
168
  token = "a.b.c"
158
169
  with pytest.raises(VerdictTokenError) as excinfo:
159
- verify_verdict_token(token, jwks={"keys": []})
170
+ verify_verdict_token(
171
+ token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []}
172
+ )
160
173
  assert excinfo.value.code == "malformed_token"
161
174
 
162
175
 
@@ -170,7 +183,9 @@ def test_rejects_jwk_with_bad_curve_data(keys: dict) -> None:
170
183
  }
171
184
  token = _mint(keys)
172
185
  with pytest.raises(VerdictTokenError) as excinfo:
173
- verify_verdict_token(token, jwks={"keys": [bad]})
186
+ verify_verdict_token(
187
+ token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": [bad]}
188
+ )
174
189
  assert excinfo.value.code == "invalid_signature"
175
190
 
176
191
 
@@ -181,7 +196,7 @@ def test_jwks_fetch_network_error(keys: dict) -> None:
181
196
  )
182
197
  token = _mint(keys)
183
198
  with pytest.raises(VerdictTokenError) as excinfo:
184
- verify_verdict_token(token)
199
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
185
200
  assert excinfo.value.code == "jwks_unavailable"
186
201
 
187
202
 
@@ -192,7 +207,7 @@ def test_jwks_fetch_non_200(keys: dict) -> None:
192
207
  )
193
208
  token = _mint(keys)
194
209
  with pytest.raises(VerdictTokenError) as excinfo:
195
- verify_verdict_token(token)
210
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
196
211
  assert excinfo.value.code == "jwks_unavailable"
197
212
 
198
213
 
@@ -203,7 +218,7 @@ def test_jwks_fetch_invalid_json(keys: dict) -> None:
203
218
  )
204
219
  token = _mint(keys)
205
220
  with pytest.raises(VerdictTokenError) as excinfo:
206
- verify_verdict_token(token)
221
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
207
222
  assert excinfo.value.code == "jwks_unavailable"
208
223
 
209
224
 
@@ -214,7 +229,9 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
214
229
  )
215
230
  client = httpx.Client()
216
231
  try:
217
- claims = verify_verdict_token(_mint(keys), http_client=client)
232
+ claims = verify_verdict_token(
233
+ _mint(keys), session="sess_token_a", tenant_id="org_unit", http_client=client
234
+ )
218
235
  assert claims["jti"] == "jwt_1"
219
236
  assert client.is_closed is False
220
237
  finally:
@@ -224,11 +241,18 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
224
241
  def test_jwks_mapping_and_keys_list_shapes(keys: dict) -> None:
225
242
  token = _mint(keys)
226
243
  with pytest.raises(VerdictTokenError) as excinfo:
227
- verify_verdict_token(token, jwks=["not", "a", "mapping"])
244
+ verify_verdict_token(
245
+ token,
246
+ session="sess_token_a",
247
+ tenant_id="org_unit",
248
+ jwks=["not", "a", "mapping"],
249
+ )
228
250
  assert excinfo.value.code == "jwks_unavailable"
229
251
 
230
252
  with pytest.raises(VerdictTokenError) as excinfo2:
231
- verify_verdict_token(token, jwks={"nokeys": True})
253
+ verify_verdict_token(
254
+ token, session="sess_token_a", tenant_id="org_unit", jwks={"nokeys": True}
255
+ )
232
256
  assert excinfo2.value.code == "jwks_unavailable"
233
257
 
234
258
 
@@ -236,6 +260,7 @@ def test_accepts_valid_token_with_explicit_audience(keys: dict) -> None:
236
260
  claims = verify_verdict_token(
237
261
  _mint(keys),
238
262
  session="sess_token_a",
263
+ tenant_id="org_unit",
239
264
  aud="https://api.chitmark.com",
240
265
  jwks=_jwks(keys),
241
266
  )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes