chitmark 0.6.2__tar.gz → 0.6.3__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.3
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
 
@@ -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
 
@@ -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.3"
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
  ]
@@ -22,6 +22,8 @@ from chitmark.types import (
22
22
  ChitmarkEnv,
23
23
  Decision,
24
24
  FeedbackResponse,
25
+ FeedbackSource,
26
+ FeedbackUnit,
25
27
  OnDegraded,
26
28
  Outcome,
27
29
  PiiMode,
@@ -30,7 +32,7 @@ from chitmark.types import (
30
32
  VerifyContext,
31
33
  )
32
34
 
33
- __all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION"]
35
+ __all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION", "is_eligible_allow"]
34
36
 
35
37
  SDK_VERSION = "0.6.0"
36
38
 
@@ -66,6 +68,15 @@ def _enforce_degraded_never_allow(verdict: Verdict) -> Verdict:
66
68
  return verdict
67
69
 
68
70
 
71
+ def is_eligible_allow(verdict: Mapping[str, Any] | Verdict) -> bool:
72
+ """True only for an explicit non-degraded allow.
73
+
74
+ Use this before any protected action so unknown or malformed decisions
75
+ cannot fall through. Fail closed: anything else is ineligible.
76
+ """
77
+ return verdict.get("decision") == "allow" and not verdict.get("degraded")
78
+
79
+
69
80
  def _is_well_formed_verdict(value: Any) -> bool:
70
81
  """Minimal shape guard for a 200 verify response.
71
82
 
@@ -204,13 +215,26 @@ class Chitmark:
204
215
  context: VerifyContext | Mapping[str, Any] | None = None,
205
216
  idempotency_key: str | None = None,
206
217
  ) -> Verdict:
207
- """Score + route an inbound action. Degrades to challenge on timeout/transport.
218
+ """Score + route an inbound action.
219
+
220
+ Store-outage degradation arrives as HTTP 200 with a Verdict body
221
+ (``degraded: True``, ``decision: "challenge"``). The client
222
+ deserializes that like any other Verdict. Verify never returns
223
+ HTTP 503.
224
+
225
+ HTTP 4xx / 429 Error responses raise ``ChitmarkApiError``. Transport
226
+ timeout / network failure synthesizes a local degraded challenge
227
+ Verdict (never allow).
228
+
229
+ ``session`` is a binding id you supply (prefer high-entropy,
230
+ server-generated); it binds challenge and ``verdictToken``.
208
231
 
209
232
  ``context`` carries per-request policy flags: ``challengeId``
210
233
  (honor a cleared challenge once), ``challengePolicy``
211
234
  ("default" | "confirmed_tail_only"), and ``decisionCosts``
212
- (falseAllow/falseChallenge/falseDeny: repositions the
213
- allow/challenge/deny boundaries, defaults 1:1:3).
235
+ (relative, dimensionless weights for falseAllow / falseChallenge /
236
+ falseDeny: abusive allow, over-friction challenge on legitimate
237
+ traffic, legitimate deny; defaults 1:1:3).
214
238
  """
215
239
  body: dict[str, Any] = {
216
240
  "action": action,
@@ -249,15 +273,30 @@ class Chitmark:
249
273
  event_id: str,
250
274
  outcome: Outcome,
251
275
  value: float | None = None,
252
- unit: str = "usd",
276
+ unit: FeedbackUnit = "usd",
277
+ source: FeedbackSource | None = None,
253
278
  observed_at: str | None = None,
254
279
  idempotency_key: str | None = None,
255
280
  ) -> FeedbackResponse:
256
281
  """Report a downstream outcome joined on eventId.
257
282
 
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.
283
+ Security invariant: feedback cannot create attribution. It only
284
+ attaches an outcome to an existing verify event for this tenant.
285
+ Unknown or cross-tenant ``event_id`` values return 404. Never invent
286
+ a join from email/IP.
287
+
288
+ Append-only (not latest-wins): multiple outcomes may attach to one
289
+ eventId. Exact duplicates share one feedbackId. Compatible labels
290
+ coexist; conflicting pairs quarantine the new observation without
291
+ deleting prior evidence. HTTP ``{ok: true}`` still acknowledges
292
+ quarantined ingest.
293
+
294
+ ``value`` is the measured economic magnitude for this outcome,
295
+ expressed in ``unit`` (``usd`` | ``credits`` | ``count``). ``unit``
296
+ rides along only when ``value`` is present. Different units are not
297
+ converted or comparable. ``source`` is optional provenance (billing,
298
+ usage_metering, trust_queue, manual_review, customer_system,
299
+ automated_detector) and is included in warehouse identity when set.
261
300
  ``idempotency_key`` is sent as both the ``Idempotency-Key`` header
262
301
  and body ``idempotencyKey`` so exact retries replay the original
263
302
  acknowledgement; a reused key with a different payload conflicts.
@@ -266,6 +305,8 @@ class Chitmark:
266
305
  if value is not None:
267
306
  body["value"] = value
268
307
  body["unit"] = unit
308
+ if source is not None:
309
+ body["source"] = source
269
310
  if observed_at is not None:
270
311
  body["observedAt"] = observed_at
271
312
  if idempotency_key is not None:
@@ -290,9 +331,11 @@ class Chitmark:
290
331
  ) -> ChallengeResponse:
291
332
  """Issue an asymmetric-cost challenge.
292
333
 
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.
334
+ ``session`` is a binding id you supply (prefer high-entropy,
335
+ server-generated). ``risk_tier`` scales issued PoW difficulty
336
+ monotonically; difficulty is sealed in ``instructions`` and
337
+ completion validates that value only. ``prefer`` is ordered hints:
338
+ first issuable method wins.
296
339
  """
297
340
  body: dict[str, Any] = {"eventId": event_id, "session": session}
298
341
  if prefer is not None:
@@ -314,9 +357,10 @@ class Chitmark:
314
357
  ) -> ChallengeCompletionResponse:
315
358
  """Complete a challenge by submitting its solved proof.
316
359
 
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.
360
+ Clears only the bound session, once. Does not authorize the action:
361
+ re-verify with ``context={"challengeId": challenge_id}`` and proceed
362
+ only on non-degraded ``allow`` (honored clear sets
363
+ ``challengeCleared: True``).
320
364
  """
321
365
  body: dict[str, Any] = {
322
366
  "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="")
@@ -169,11 +169,13 @@ def test_feedback_sends_value_unit_and_observed_at() -> None:
169
169
  event_id="evt_x",
170
170
  outcome="credit_burn",
171
171
  value=4.2,
172
+ source="usage_metering",
172
173
  observed_at="2026-08-01T00:00:00Z",
173
174
  )
174
175
  sent = respx.calls[-1].request.content
175
176
  assert b'"value":4.2' in sent
176
177
  assert b'"unit":"usd"' in sent
178
+ assert b'"source":"usage_metering"' in sent
177
179
  assert b'"observedAt":"2026-08-01T00:00:00Z"' in sent
178
180
 
179
181
 
@@ -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",
@@ -114,12 +115,7 @@ def _jwks(keys: dict) -> dict:
114
115
  def test_accepts_valid_signed_token(keys: dict) -> None:
115
116
  token = _mint(keys, sub="sess_token_a", aud="https://app.acme.com")
116
117
 
117
- claims = verify_verdict_token(
118
- token,
119
- session="sess_token_a",
120
- aud="https://app.acme.com",
121
- jwks=_jwks(keys),
122
- )
118
+ claims = verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", aud="https://app.acme.com", jwks=_jwks(keys))
123
119
 
124
120
  assert claims["eventId"] == "evt_token_test"
125
121
  assert claims["decision"] == "allow"
@@ -133,7 +129,7 @@ def test_rejects_wrong_session(keys: dict) -> None:
133
129
  token = _mint(keys, sub="sess_other")
134
130
 
135
131
  with pytest.raises(VerdictTokenError) as exc:
136
- verify_verdict_token(token, session="sess_token_a", jwks=_jwks(keys))
132
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
137
133
  assert exc.value.code == "wrong_session"
138
134
 
139
135
 
@@ -141,7 +137,7 @@ def test_rejects_wrong_audience(keys: dict) -> None:
141
137
  token = _mint(keys, aud="https://other.app")
142
138
 
143
139
  with pytest.raises(VerdictTokenError) as exc:
144
- verify_verdict_token(token, aud="https://app.acme.com", jwks=_jwks(keys))
140
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", aud="https://app.acme.com", jwks=_jwks(keys))
145
141
  assert exc.value.code == "wrong_aud"
146
142
 
147
143
 
@@ -149,16 +145,22 @@ def test_rejects_wrong_tenant(keys: dict) -> None:
149
145
  token = _mint(keys, tid="org_other")
150
146
 
151
147
  with pytest.raises(VerdictTokenError) as exc:
152
- verify_verdict_token(token, tenant_id="org_unit", jwks=_jwks(keys))
148
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
153
149
  assert exc.value.code == "wrong_tenant"
154
150
 
155
151
 
152
+ def test_rejects_wrong_issuer(keys: dict) -> None:
153
+ token = _mint(keys, iss="https://evil.example")
154
+
155
+ with pytest.raises(VerdictTokenError) as exc:
156
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
157
+ assert exc.value.code == "wrong_issuer"
158
+
159
+
156
160
  def test_accepts_expected_tenant(keys: dict) -> None:
157
161
  token = _mint(keys, tid="org_unit", sub="sess_token_a")
158
162
 
159
- claims = verify_verdict_token(
160
- token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
161
- )
163
+ claims = verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
162
164
  assert claims["tid"] == "org_unit"
163
165
 
164
166
 
@@ -166,7 +168,7 @@ def test_rejects_expired_token(keys: dict) -> None:
166
168
  token = _mint(keys, exp=int(time.time()) - 120)
167
169
 
168
170
  with pytest.raises(VerdictTokenError) as exc:
169
- verify_verdict_token(token, jwks=_jwks(keys))
171
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
170
172
  assert exc.value.code == "expired"
171
173
 
172
174
 
@@ -174,7 +176,7 @@ def test_rejects_unknown_kid(keys: dict) -> None:
174
176
  token = _mint(keys)
175
177
 
176
178
  with pytest.raises(VerdictTokenError) as exc:
177
- verify_verdict_token(token, jwks={"keys": []})
179
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []})
178
180
  assert exc.value.code == "unknown_kid"
179
181
 
180
182
 
@@ -187,7 +189,7 @@ def test_rejects_tampered_signature(keys: dict) -> None:
187
189
  tampered = f"{header}.{payload}.{tampered_sig}"
188
190
 
189
191
  with pytest.raises(VerdictTokenError) as exc:
190
- verify_verdict_token(tampered, jwks=_jwks(keys))
192
+ verify_verdict_token(tampered, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
191
193
  assert exc.value.code == "invalid_signature"
192
194
 
193
195
 
@@ -198,7 +200,7 @@ def test_rejects_wrong_algorithm(keys: dict) -> None:
198
200
  wrong = f"{wrong_header}.{payload}.{signature}"
199
201
 
200
202
  with pytest.raises(VerdictTokenError) as exc:
201
- verify_verdict_token(wrong, jwks=_jwks(keys))
203
+ verify_verdict_token(wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
202
204
  assert exc.value.code == "wrong_algorithm"
203
205
 
204
206
 
@@ -209,13 +211,13 @@ def test_rejects_missing_jwt_typ(keys: dict) -> None:
209
211
  wrong = f"{wrong_header}.{payload}.{signature}"
210
212
 
211
213
  with pytest.raises(VerdictTokenError) as exc:
212
- verify_verdict_token(wrong, jwks=_jwks(keys))
214
+ verify_verdict_token(wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
213
215
  assert exc.value.code == "wrong_type"
214
216
 
215
217
 
216
218
  def test_rejects_malformed_token() -> None:
217
219
  with pytest.raises(VerdictTokenError) as exc:
218
- verify_verdict_token("not-a-jwt", jwks={"keys": []})
220
+ verify_verdict_token("not-a-jwt", session="sess_token_a", tenant_id="org_unit", jwks={"keys": []})
219
221
  assert exc.value.code == "malformed_token"
220
222
 
221
223
 
@@ -226,7 +228,7 @@ def test_fetches_jwks_from_url(keys: dict) -> None:
226
228
  return_value=httpx.Response(200, json=_jwks(keys)),
227
229
  )
228
230
 
229
- claims = verify_verdict_token(token, session="sess_fetch")
231
+ claims = verify_verdict_token(token, session="sess_fetch", tenant_id="org_unit")
230
232
 
231
233
  assert route.called
232
234
  assert claims["sub"] == "sess_fetch"
@@ -240,14 +242,14 @@ def test_jwks_fetch_failure_is_surfaced(keys: dict) -> None:
240
242
  )
241
243
 
242
244
  with pytest.raises(VerdictTokenError) as exc:
243
- verify_verdict_token(token)
245
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
244
246
  assert exc.value.code == "jwks_unavailable"
245
247
 
246
248
 
247
249
  def test_accepts_degraded_receipt(keys: dict) -> None:
248
250
  token = _mint(keys, decision="challenge", degraded=True)
249
251
 
250
- claims = verify_verdict_token(token, jwks=_jwks(keys))
252
+ claims = verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
251
253
 
252
254
  assert claims["decision"] == "challenge"
253
255
  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,38 @@ 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(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
129
130
  assert excinfo.value.code == "unknown_kid"
130
131
 
131
132
  token2 = _mint(keys, _header={"kid": ""})
132
133
  with pytest.raises(VerdictTokenError) as excinfo2:
133
- verify_verdict_token(token2, jwks=_jwks(keys))
134
+ verify_verdict_token(token2, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
134
135
  assert excinfo2.value.code == "unknown_kid"
135
136
 
136
137
  token3 = _mint(keys, _header={"kid": 123})
137
138
  with pytest.raises(VerdictTokenError) as excinfo3:
138
- verify_verdict_token(token3, jwks=_jwks(keys))
139
+ verify_verdict_token(token3, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
139
140
  assert excinfo3.value.code == "malformed_token"
140
141
 
141
142
 
142
143
  def test_rejects_future_iat(keys: dict) -> None:
143
144
  token = _mint(keys, iat=int(time.time()) + 3600)
144
145
  with pytest.raises(VerdictTokenError) as excinfo:
145
- verify_verdict_token(token, jwks=_jwks(keys))
146
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
146
147
  assert excinfo.value.code == "not_yet_valid"
147
148
 
148
149
 
149
150
  def test_rejects_undecodable_payload(keys: dict) -> None:
150
151
  token = _mint(keys, _raw_payload=b"hello")
151
152
  with pytest.raises(VerdictTokenError) as excinfo:
152
- verify_verdict_token(token, jwks=_jwks(keys))
153
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys))
153
154
  assert excinfo.value.code == "malformed_token"
154
155
 
155
156
 
156
157
  def test_rejects_malformed_header() -> None:
157
158
  token = "a.b.c"
158
159
  with pytest.raises(VerdictTokenError) as excinfo:
159
- verify_verdict_token(token, jwks={"keys": []})
160
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []})
160
161
  assert excinfo.value.code == "malformed_token"
161
162
 
162
163
 
@@ -170,7 +171,7 @@ def test_rejects_jwk_with_bad_curve_data(keys: dict) -> None:
170
171
  }
171
172
  token = _mint(keys)
172
173
  with pytest.raises(VerdictTokenError) as excinfo:
173
- verify_verdict_token(token, jwks={"keys": [bad]})
174
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": [bad]})
174
175
  assert excinfo.value.code == "invalid_signature"
175
176
 
176
177
 
@@ -181,7 +182,7 @@ def test_jwks_fetch_network_error(keys: dict) -> None:
181
182
  )
182
183
  token = _mint(keys)
183
184
  with pytest.raises(VerdictTokenError) as excinfo:
184
- verify_verdict_token(token)
185
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
185
186
  assert excinfo.value.code == "jwks_unavailable"
186
187
 
187
188
 
@@ -192,7 +193,7 @@ def test_jwks_fetch_non_200(keys: dict) -> None:
192
193
  )
193
194
  token = _mint(keys)
194
195
  with pytest.raises(VerdictTokenError) as excinfo:
195
- verify_verdict_token(token)
196
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
196
197
  assert excinfo.value.code == "jwks_unavailable"
197
198
 
198
199
 
@@ -203,7 +204,7 @@ def test_jwks_fetch_invalid_json(keys: dict) -> None:
203
204
  )
204
205
  token = _mint(keys)
205
206
  with pytest.raises(VerdictTokenError) as excinfo:
206
- verify_verdict_token(token)
207
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
207
208
  assert excinfo.value.code == "jwks_unavailable"
208
209
 
209
210
 
@@ -214,7 +215,7 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
214
215
  )
215
216
  client = httpx.Client()
216
217
  try:
217
- claims = verify_verdict_token(_mint(keys), http_client=client)
218
+ claims = verify_verdict_token(_mint(keys), session="sess_token_a", tenant_id="org_unit", http_client=client)
218
219
  assert claims["jti"] == "jwt_1"
219
220
  assert client.is_closed is False
220
221
  finally:
@@ -224,19 +225,14 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
224
225
  def test_jwks_mapping_and_keys_list_shapes(keys: dict) -> None:
225
226
  token = _mint(keys)
226
227
  with pytest.raises(VerdictTokenError) as excinfo:
227
- verify_verdict_token(token, jwks=["not", "a", "mapping"])
228
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks=["not", "a", "mapping"])
228
229
  assert excinfo.value.code == "jwks_unavailable"
229
230
 
230
231
  with pytest.raises(VerdictTokenError) as excinfo2:
231
- verify_verdict_token(token, jwks={"nokeys": True})
232
+ verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit", jwks={"nokeys": True})
232
233
  assert excinfo2.value.code == "jwks_unavailable"
233
234
 
234
235
 
235
236
  def test_accepts_valid_token_with_explicit_audience(keys: dict) -> None:
236
- claims = verify_verdict_token(
237
- _mint(keys),
238
- session="sess_token_a",
239
- aud="https://api.chitmark.com",
240
- jwks=_jwks(keys),
241
- )
237
+ claims = verify_verdict_token(_mint(keys), session="sess_token_a", tenant_id="org_unit", aud="https://api.chitmark.com", jwks=_jwks(keys))
242
238
  assert claims["v"] == 1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes