noresponse 2.1.15 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/lib/no_response/accounts.rb +1 -1
  3. data/lib/no_response/assessments.rb +2 -2
  4. data/lib/no_response/late_results.rb +108 -35
  5. data/lib/no_response/models/shared/account.rb +1 -1
  6. data/lib/no_response/models/shared/accountdecisionsbyflow.rb +1 -1
  7. data/lib/no_response/models/shared/assessment.rb +41 -176
  8. data/lib/no_response/models/shared/assessment.rbi +10 -82
  9. data/lib/no_response/models/shared/assessmentdetail.rb +245 -0
  10. data/lib/no_response/models/shared/assessmentdetail.rbi +122 -0
  11. data/lib/no_response/models/shared/{assessmentauthcontext.rb → assessmentdetailauthcontext.rb} +1 -1
  12. data/lib/no_response/models/shared/{assessmentauthcontext.rbi → assessmentdetailauthcontext.rbi} +2 -2
  13. data/lib/no_response/models/shared/{assessmenttiming.rb → assessmentdetailtiming.rb} +1 -1
  14. data/lib/no_response/models/shared/{assessmenttiming.rbi → assessmentdetailtiming.rbi} +2 -2
  15. data/lib/no_response/models/shared/assessmentlist.rb +3 -3
  16. data/lib/no_response/models/shared/assessmentrequest.rb +15 -14
  17. data/lib/no_response/models/shared/assessmentrequest.rbi +4 -4
  18. data/lib/no_response/models/shared/assessmentrequestpolicycontext.rb +3 -16
  19. data/lib/no_response/models/shared/assessmentrequestpolicycontext.rbi +0 -6
  20. data/lib/no_response/models/shared/depth.rb +4 -1
  21. data/lib/no_response/models/shared/flow.rb +6 -4
  22. data/lib/no_response/models/shared.rb +3 -3
  23. data/lib/no_response/sdkconfiguration.rb +2 -2
  24. data/lib/no_response/webhooks.rb +1 -1
  25. metadata +7 -7
  26. data/lib/no_response/models/shared/assessmentflow.rb +0 -17
  27. data/lib/no_response/models/shared/assessmentflow.rbi +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2469d4f72cf85b77dbd86ca8a05136ff313081e86be235d86cc5190ba9141639
4
- data.tar.gz: 4368344ab696f56ae59f9dc2b932f25f5e66183f6e57a3ebf398f79a0146e965
3
+ metadata.gz: 9ced920c7288c6434e1fbd06de24541b1ac3bec7dccb43bcef7aa7ccad375377
4
+ data.tar.gz: 67f9fc2cce7b114edd72d84031d91312c04414114fd61ce3f7db0c3e63c6714e
5
5
  SHA512:
6
- metadata.gz: 70c09610fdb41378166092d7af4693538817762093e6ff73df8b6333c341e0bbee290b3b8b6156140361131ac1ef716d4277aa88b75ce30fa4f3e92814810688
7
- data.tar.gz: 6f120c6191d655a540ffeb3d78f9c8c093c0cb6f42eb3641f0c2c9fb35076edf4d958af2965e6f517c380500c77b562795a09928ec5e095ecf0b959b4a286577
6
+ metadata.gz: a7c8af727605dcef753fb10d0d3c46bf0a5fc8c90bb411d0117aceb023fb5807af788984dd5d77a09dc984a0979ee797ea75cdf6c301edc8d4aa4f74e3c83fdd
7
+ data.tar.gz: 02faf461ec16e415a4c799557fff8ae5e9bdcd5f62a192dfa5682c0a37bdbe10c00176d71b7de6f360616442f00b055ce429be012dc70a24acf75b18bdc3fc96
@@ -49,7 +49,7 @@ module NoResponse
49
49
  }
50
50
  def get(account_id:, retries: nil, timeout_ms: nil, http_headers: nil)
51
51
  # get - Read an account's one current risk.
52
- # An account is the account_id you send with your assessments. Its current risk is the risk of its latest assessment with a decision (status complete or partial), scored as GET /v1/assessments/{assessment_id} scores that assessment, with a decision for each flow. Every assessment updates its account: once an assessment completes, the account carries its risk. Requires the history scope. Read-only and safe to retry. An account with no unexpired assessment that has a decision answers 404 not_found, and a malformed account_id answers 400 invalid_request.
52
+ # An account is the account_id you send with your assessments. Its current risk is the risk of its latest assessment with a decision (status complete or partial), scored as GET /v1/assessments/{assessment_id} scores that assessment, with a decision for each flow; risk.decision is the decision for the flow of that assessment. Every assessment answers with this same account. Every assessment updates its account: once an assessment completes, the account carries its risk. Requires the history scope. Read-only and safe to retry. An account with no unexpired assessment that has a decision answers 404 not_found, and a malformed account_id answers 400 invalid_request.
53
53
  request = Models::Operations::GetAccountRequest.new(
54
54
  account_id: account_id
55
55
  )
@@ -50,7 +50,7 @@ module NoResponse
50
50
  }
51
51
  def create(body:, idempotency_key: nil, retries: nil, timeout_ms: nil, http_headers: nil)
52
52
  # create - Assess identifiers for a signup or gateway decision.
53
- # Returns within the deadline (3000 ms at most) with a complete, partial or pending assessment: 200 carries complete or partial, 202 carries pending with Location and Retry-After. A pending answer has no recommendation; apply your own fallback for pending, 429, 503 or a timeout. risk.block says when your block threshold was reached. Requires the assess scope. Idempotency-Key is optional in the SDK and is bound to the body for 24 hours: the same key with the same body replays the latest assessment with Idempotent-Replayed: true and starts no new work; the same key with a different body answers 409 idempotency_key_reused. response_mode defaults to standard; detailed needs the detailed scope.
53
+ # Assesses one signup, login or purchase (flow) and answers with the account's one current risk: account.risk.decision is the decision for this flow (allow, step_up, review or block, where block means your block threshold was reached), and detail holds this assessment's own facts. depth sets how long the answer waits for sources: quick waits only for the fast ones (500 ms), full for the whole budget (3000 ms at most); it defaults to full for signup and purchase and quick for login. 200 carries complete or partial, 202 carries pending with Location and Retry-After. While the account's first assessment is pending, account is absent: apply your own fallback for that, 429, 503 or a timeout. NoResponse blocks nothing itself; you decide. Requires the assess scope. Idempotency-Key is optional in the SDK and is bound to the body for 24 hours: the same key with the same body replays the latest assessment with Idempotent-Replayed: true and starts no new work; the same key with a different body answers 409 idempotency_key_reused. response_mode defaults to standard; detailed needs the detailed scope.
54
54
  request = Models::Operations::CreateAssessmentRequest.new(
55
55
  body: body,
56
56
  idempotency_key: idempotency_key
@@ -348,7 +348,7 @@ module NoResponse
348
348
  }
349
349
  def get(assessment_id:, response_mode: nil, retries: nil, timeout_ms: nil, http_headers: nil)
350
350
  # get - Poll the authoritative assessment of one assessment.
351
- # Returns the highest assessment_version. Requires the assess or history scope. While status is pending, or partial with a source still running, the response carries Retry-After; poll no faster than it says. Another tenant's assessment, an unknown or expired assessment and a malformed assessment_id all answer 404 not_found. response_mode=detailed needs the detailed scope.
351
+ # Returns the highest assessment_version, with the account's current risk. Requires the assess or history scope. While status is pending, or partial with a source still running, the response carries Retry-After; poll no faster than it says. Another tenant's assessment, an unknown or expired assessment and a malformed assessment_id all answer 404 not_found. response_mode=detailed needs the detailed scope.
352
352
  request = Models::Operations::GetAssessmentRequest.new(
353
353
  assessment_id: assessment_id,
354
354
  response_mode: response_mode
@@ -4,7 +4,7 @@
4
4
  # Late-result handlers. Hand-written beside the generated client.
5
5
  #
6
6
  # An assessment can be revised after the signup was allowed: a slow source
7
- # settles and the recommendation rises. NoResponse sends the new version as a
7
+ # settles and the account's decision rises. NoResponse sends the new version as a
8
8
  # signed assessment.updated webhook. LateResults.handle verifies that delivery
9
9
  # with Webhooks.construct_event, picks an action from your policy, and acts
10
10
  # through the auth provider's admin API: revoke the user's sessions or ban the
@@ -30,10 +30,12 @@ module NoResponse
30
30
  BAN = 'ban'
31
31
  ACTIONS = [NONE, REVOKE_SESSIONS, BAN].freeze
32
32
 
33
- # Revoke sessions on review; do nothing on allow or step_up. Banning is opt-in.
34
- DEFAULT_POLICY = { 'allow' => NONE, 'step_up' => NONE, 'review' => REVOKE_SESSIONS }.freeze
33
+ # Revoke sessions on review and block; do nothing on allow or step_up.
34
+ # Banning is opt-in.
35
+ DEFAULT_POLICY = { 'allow' => NONE, 'step_up' => NONE, 'review' => REVOKE_SESSIONS,
36
+ 'block' => REVOKE_SESSIONS }.freeze
35
37
 
36
- RECOMMENDATIONS = %w[allow step_up review].freeze
38
+ DECISIONS = %w[allow step_up review block].freeze
37
39
 
38
40
  # Raised when the provider's admin API refuses a call. Answer the webhook
39
41
  # with a 5xx so NoResponse retries it. The message holds no response body
@@ -61,23 +63,25 @@ module NoResponse
61
63
  Result = Struct.new(:event, :action, :user_id, keyword_init: true)
62
64
 
63
65
  class << self
64
- # The action your policy names for a verified event. Only a served
65
- # assessment with a recommendation can act; a test event never does.
66
- # policy maps allow, step_up and review (as strings or symbols) to NONE,
67
- # REVOKE_SESSIONS or BAN; a recommendation you leave out does nothing. An
68
- # unknown action raises ArgumentError.
66
+ # The action your policy names for a verified event, from the account's
67
+ # decision (data.account.risk.decision). Only a served assessment whose
68
+ # account has a decision can act; a pending assessment, an expiry and a
69
+ # test event never do. policy maps allow, step_up, review and block (as
70
+ # strings or symbols) to NONE, REVOKE_SESSIONS or BAN; a decision you
71
+ # leave out does nothing. An unknown action raises ArgumentError.
69
72
  def action_for(event, policy = DEFAULT_POLICY)
70
73
  return NONE if event.type != 'assessment.updated' || event.test? || event.expired?
71
74
 
72
75
  data = event.data
73
- recommendation = data.recommendation || data.risk&.recommendation
74
- recommendation = recommendation.to_s
75
- return NONE unless RECOMMENDATIONS.include?(recommendation)
76
+ return NONE if data.status == Models::Shared::Status::PENDING || data.account.nil?
77
+
78
+ decision = data.account.risk.decision.to_s
79
+ return NONE unless DECISIONS.include?(decision)
76
80
 
77
81
  table = policy.to_h.transform_keys(&:to_s)
78
- return NONE unless table.key?(recommendation)
82
+ return NONE unless table.key?(decision)
79
83
 
80
- action = table[recommendation]
84
+ action = table[decision]
81
85
  return NONE if action.nil?
82
86
 
83
87
  action = action.to_s
@@ -103,7 +107,7 @@ module NoResponse
103
107
  action = action_for(event, policy)
104
108
  return Result.new(event: event, action: NONE) if action == NONE || event.type != 'assessment.updated'
105
109
 
106
- account_id = event.data.account_id
110
+ account_id = event.data.detail.account_id
107
111
  resolved = user_id ? user_id.call(account_id, event) : account_id
108
112
  return Result.new(event: event, action: NONE) if resolved.nil? || resolved.to_s.empty?
109
113
 
@@ -169,13 +173,19 @@ module NoResponse
169
173
  # user before deleting the refresh tokens, and act reports BAN. Unblock
170
174
  # the user in the dashboard to undo it. Any other refusal, such as 403
171
175
  # insufficient_scope, raises.
176
+ #
177
+ # A 429 from the Management API is waited out and retried: Retry-After,
178
+ # else X-RateLimit-Reset, else 0.25 s doubling, at most 4 retries a call
179
+ # and 3 s of waiting a revoke or ban. Past that the 429 raises as
180
+ # ProviderActionError. An http_client may return [status, body, headers]
181
+ # so the headers are honoured; without them the backoff applies.
172
182
  def auth0(token:, domain: nil, base_url: nil, http_client: nil)
173
183
  raise ArgumentError, 'auth0 needs token' if token.to_s.empty?
174
184
 
175
185
  base = base_url || (domain.to_s.empty? ? nil : "https://#{domain}/api/v2")
176
186
  raise ArgumentError, 'auth0 needs domain or base_url' if base.nil?
177
187
 
178
- Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client))
188
+ Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client, retry_rate_limits: true))
179
189
  end
180
190
 
181
191
  # A provider for an app with its own login, or for trying the handler
@@ -191,16 +201,62 @@ module NoResponse
191
201
  end
192
202
 
193
203
  # One provider HTTP client: base URL, Authorization header, error mapping.
204
+ #
205
+ # Rate limits. Auth0 answers 429 when the Management API rate limit is
206
+ # hit, and a 429 means it did not act, so the call is safe to send again,
207
+ # a DELETE included. One revoke or ban waits at most RATE_LIMIT_BUDGET
208
+ # seconds in all, and each call retries at most RATE_LIMIT_RETRIES times:
209
+ # it waits Retry-After seconds, else until X-RateLimit-Reset (Unix
210
+ # seconds), else 0.25, 0.5, 1 and 2 s. A wait that would pass the budget is
211
+ # not started: the 429 raises at once as ProviderActionError, the webhook
212
+ # answers 5xx and NoResponse delivers it again later. The budget keeps the
213
+ # handler inside the 5 s a delivery has to be answered.
194
214
  class Caller
195
- def initialize(name, base_url, authorization, http_client)
215
+ RATE_LIMIT_RETRIES = 4
216
+ RATE_LIMIT_BUDGET = 3.0
217
+ RATE_LIMIT_BACKOFF = 0.25
218
+
219
+ def initialize(name, base_url, authorization, http_client, retry_rate_limits: false)
196
220
  @name = name
197
221
  @base = base_url.to_s.sub(%r{/+\z}, '')
198
222
  @authorization = authorization
199
223
  @http_client = http_client
224
+ @retry_rate_limits = retry_rate_limits
225
+ @waited = 0.0
200
226
  end
201
227
 
202
228
  attr_reader :name
203
229
 
230
+ # A caller with the same settings and its own rate-limit budget, for one action.
231
+ def fresh
232
+ Caller.new(@name, @base, @authorization, @http_client, retry_rate_limits: @retry_rate_limits)
233
+ end
234
+
235
+ # How long a 429 asks to wait, in seconds.
236
+ def self.rate_limit_wait(headers, retry_index)
237
+ after = number(header(headers, 'retry-after'))
238
+ return after if after && after >= 0
239
+
240
+ reset = number(header(headers, 'x-ratelimit-reset'))
241
+ return [0.0, reset - Time.now.to_f].max if reset
242
+
243
+ RATE_LIMIT_BACKOFF * (2**retry_index)
244
+ end
245
+
246
+ def self.header(headers, name)
247
+ (headers || {}).each { |key, value| return Array(value).first.to_s.strip if key.to_s.downcase == name }
248
+ nil
249
+ end
250
+
251
+ def self.number(text)
252
+ return nil if text.nil? || text.empty?
253
+
254
+ value = Float(text)
255
+ value.finite? ? value : nil
256
+ rescue ArgumentError
257
+ nil
258
+ end
259
+
204
260
  def call(method, path, query: nil, body: nil, allow_404: false)
205
261
  uri = URI.parse(@base + path)
206
262
  uri.query = URI.encode_www_form(query) if query && !query.empty?
@@ -210,7 +266,7 @@ module NoResponse
210
266
  headers['Content-Type'] = 'application/json'
211
267
  payload = JSON.generate(body)
212
268
  end
213
- status, text = perform(method, uri, headers, payload)
269
+ status, text = send_waiting_out_rate_limits(method, uri, headers, payload)
214
270
  return nil if allow_404 && status == 404
215
271
  unless status.between?(200, 299)
216
272
  raise ProviderActionError.new(@name, status, "#{@name} #{method} #{path} answered #{status}", error_code(text))
@@ -226,6 +282,21 @@ module NoResponse
226
282
 
227
283
  private
228
284
 
285
+ def send_waiting_out_rate_limits(method, uri, headers, payload)
286
+ retry_index = 0
287
+ loop do
288
+ status, text, response_headers = perform(method, uri, headers, payload)
289
+ return [status, text] unless status == 429 && @retry_rate_limits && retry_index < RATE_LIMIT_RETRIES
290
+
291
+ wait = Caller.rate_limit_wait(response_headers, retry_index)
292
+ return [status, text] if @waited + wait > RATE_LIMIT_BUDGET
293
+
294
+ @waited += wait
295
+ sleep(wait)
296
+ retry_index += 1
297
+ end
298
+ end
299
+
229
300
  # The error code in a provider's error body: Auth0's errorCode, or a top-level code.
230
301
  def error_code(text)
231
302
  body = JSON.parse(text.to_s)
@@ -247,7 +318,7 @@ module NoResponse
247
318
  request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri, headers)
248
319
  request.body = payload unless payload.nil?
249
320
  response = http.start { |connection| connection.request(request) }
250
- [response.code.to_i, response.body.to_s]
321
+ [response.code.to_i, response.body.to_s, response.to_hash]
251
322
  end
252
323
  end
253
324
 
@@ -345,10 +416,11 @@ module NoResponse
345
416
 
346
417
  def ban(user_id)
347
418
  user = Segment.encode(user_id)
348
- block(user)
419
+ call = @call.fresh
420
+ block(call, user)
349
421
  # The block already ends a session the plan cannot delete.
350
- delete_sessions(user)
351
- delete_refresh_tokens(user_id, user)
422
+ delete_sessions(call, user)
423
+ delete_refresh_tokens(call, user_id, user)
352
424
  nil
353
425
  end
354
426
 
@@ -366,10 +438,11 @@ module NoResponse
366
438
 
367
439
  def revoke(user_id)
368
440
  user = Segment.encode(user_id)
369
- ended = delete_sessions(user)
441
+ call = @call.fresh
442
+ ended = delete_sessions(call, user)
370
443
  # Without the sessions API a login session survives; blocking is the only way to end it.
371
- block(user) unless ended
372
- delete_refresh_tokens(user_id, user)
444
+ block(call, user) unless ended
445
+ delete_refresh_tokens(call, user_id, user)
373
446
  ended ? REVOKE_SESSIONS : BAN
374
447
  end
375
448
 
@@ -378,13 +451,13 @@ module NoResponse
378
451
  error.status == 403 && error.code == 'feature_not_enabled'
379
452
  end
380
453
 
381
- def block(user)
382
- @call.call('PATCH', "/users/#{user}", body: { 'blocked' => true }, allow_404: true)
454
+ def block(call, user)
455
+ call.call('PATCH', "/users/#{user}", body: { 'blocked' => true }, allow_404: true)
383
456
  end
384
457
 
385
458
  # Ends every session; false when the plan has no sessions API.
386
- def delete_sessions(user)
387
- @call.call('DELETE', "/users/#{user}/sessions", allow_404: true)
459
+ def delete_sessions(call, user)
460
+ call.call('DELETE', "/users/#{user}/sessions", allow_404: true)
388
461
  true
389
462
  rescue ProviderActionError => e
390
463
  raise unless not_on_plan?(e)
@@ -392,9 +465,9 @@ module NoResponse
392
465
  false
393
466
  end
394
467
 
395
- def delete_refresh_tokens(user_id, user)
468
+ def delete_refresh_tokens(call, user_id, user)
396
469
  begin
397
- @call.call('DELETE', "/users/#{user}/refresh-tokens", allow_404: true)
470
+ call.call('DELETE', "/users/#{user}/refresh-tokens", allow_404: true)
398
471
  return
399
472
  rescue ProviderActionError => e
400
473
  raise unless not_on_plan?(e)
@@ -403,15 +476,15 @@ module NoResponse
403
476
  ids = []
404
477
  %w[refresh_token rotating_refresh_token].each do |type|
405
478
  10.times do |page|
406
- body = @call.call('GET', '/device-credentials',
407
- query: [['user_id', user_id], ['type', type], ['page', page.to_s], ['per_page', PAGE.to_s]],
408
- allow_404: true)
479
+ body = call.call('GET', '/device-credentials',
480
+ query: [['user_id', user_id], ['type', type], ['page', page.to_s], ['per_page', PAGE.to_s]],
481
+ allow_404: true)
409
482
  listed = body.is_a?(Array) ? body.grep(Hash) : []
410
483
  ids.concat(listed.map { |c| c['id'] }.select { |id| id.is_a?(String) && !id.empty? })
411
484
  break if listed.length < PAGE
412
485
  end
413
486
  end
414
- ids.each { |id| @call.call('DELETE', "/device-credentials/#{ERB::Util.url_encode(id)}", allow_404: true) }
487
+ ids.each { |id| call.call('DELETE', "/device-credentials/#{ERB::Util.url_encode(id)}", allow_404: true) }
415
488
  end
416
489
  end
417
490
 
@@ -17,7 +17,7 @@ module NoResponse
17
17
  Models::Shared::AccountRisk,
18
18
  {'format_json': {'letter_case': ::NoResponse::Utils.field_name("risk"), required: true}}
19
19
  )
20
- # The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three equal risk.decision.
20
+ # The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three are equal.
21
21
  field(
22
22
  :decisions_by_flow,
23
23
  Models::Shared::AccountDecisionsByFlow,
@@ -4,7 +4,7 @@
4
4
  module NoResponse
5
5
  module Models
6
6
  module Shared
7
- # The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three equal risk.decision.
7
+ # The decision for each flow. Every flow uses the tenant's signup thresholds today, so all three are equal.
8
8
  class AccountDecisionsByFlow
9
9
  extend T::Sig
10
10
  include Crystalline::MetadataFields
@@ -4,23 +4,13 @@
4
4
  module NoResponse
5
5
  module Models
6
6
  module Shared
7
- # One assessment of one account, as POST /v1/assessments, GET /v1/assessments/{assessment_id} and the assessment.updated webhook return it. status says whether it is final (complete), usable with a source still running (partial) or not yet usable (pending). risk and recommendation are present for complete and partial; assessment_url is present for pending. Keep the highest assessment_version.
7
+ # One assessment, as POST /v1/assessments, GET /v1/assessments/{assessment_id} and the assessment.updated webhook return it. account is the account's one current risk: its score, the decision for this assessment's flow, the reasons and the decision for each flow. detail holds this assessment's own facts. status says whether the assessment is final (complete), usable with a source still running (partial) or not yet usable (pending). While the account's first assessment is pending, account is absent; wait for the webhook or poll the assessment. Keep the highest detail.assessment_version.
8
8
  class Assessment
9
9
  extend T::Sig
10
10
  include Crystalline::MetadataFields
11
11
 
12
- # Opaque, server-assigned assessment id: 'asm_' and a 26-character Crockford base32 ULID. Unique within the tenant; another tenant's assessment id answers 404.
13
- field(
14
- :assessment_id,
15
- ::String,
16
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("assessment_id"), required: true}}
17
- )
18
- # Your own opaque identifier for the account being checked, scoped to your tenant. Use an id you mint, with no direct identifier (email, phone, name or national id) in it. '|' is accepted so an Auth0 user id such as 'auth0|64abc' passes unchanged; '@', '+', '$' and whitespace are refused by the pattern, as is an exact-shape JWT; a hex digest such as a SHA-256 is allowed, because hashing an internal id is a common pseudonymous scheme. A value such as '07700900123' or 'jane.doe.gmail.com' cannot be told apart from an opaque id. It travels in bodies, and in a URL only as the path segment of GET /v1/accounts/{account_id} and its /assessments. An assessment requested by a provider hook at pre_registration, which has no account yet, carries the server-issued account_id pre: followed by its assessment_id.
19
- field(
20
- :account_id,
21
- ::String,
22
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("account_id"), required: true}}
23
- )
12
+ # This assessment's id, equal to detail.assessment_id.
13
+ field :id, ::String, {'format_json': {'letter_case': ::NoResponse::Utils.field_name("id"), required: true}}
24
14
  # complete: final; nothing is still running. A lane that failed or missed its 10 s deadline stays listed in incomplete_sources, and confidence is lower. partial: a recommendation exists and at least one lane is still running or did not settle (see incomplete_sources). pending: nothing usable before the deadline; no recommendation yet, poll or wait for the webhook.
25
15
  field(
26
16
  :status,
@@ -33,210 +23,85 @@ module NoResponse
33
23
  }
34
24
  }
35
25
  )
36
- # Ordinal confidence in the recommendation under the current policy: low, medium, high or unavailable.
26
+ # The customer event an assessment is made for: signup (a new account), login (a returning sign-in) or purchase (a payment or credit grant). The account's risk is reported with the decision for this flow.
37
27
  field(
38
- :confidence,
39
- Models::Shared::Confidence,
28
+ :flow,
29
+ Models::Shared::Flow,
40
30
  {
41
31
  'format_json': {
42
- 'letter_case': ::NoResponse::Utils.field_name("confidence"),
32
+ 'letter_case': ::NoResponse::Utils.field_name("flow"),
43
33
  required: true,
44
- 'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::Confidence, false)
34
+ 'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::Flow, false)
45
35
  }
46
36
  }
47
37
  )
48
- # The lanes that did not settle, each listed once with a reason; lanes, not sources. In a detailed view a lane is listed exactly when one of its signals did not settle; a standard view carries no signals to match.
38
+ # How long the synchronous answer waits for sources. quick waits only for the fast sources, those that answer within 500 ms of ingress (or deadline_ms when lower); full waits for the whole budget, up to 3000 ms. Sources still running at the cut continue asynchronously and update the assessment either way.
49
39
  field(
50
- :incomplete_sources,
51
- Crystalline::Array.new(Models::Shared::IncompleteSource),
52
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("incomplete_sources"), required: true}}
53
- )
54
- # Version of the hand-authored heuristic weights that produced the recommendation, for example 'risk-2026-10.7'. A tenant threshold override adds a +t<N> suffix, e.g. 'risk-2026-10.7+t3'.
55
- field(
56
- :policy_version,
57
- ::String,
58
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("policy_version"), required: true}}
59
- )
60
- # Monotonic revision of this assessment. 1 on the synchronous answer; each material change while the assessment is served (an async continuation settling a source, a change of recommendation, confidence or status) increments it. Expiry does not increment it: an expired assessment is gone, not revised, and the expiry webhook carries the last served version.
61
- field(
62
- :assessment_version,
63
- ::Integer,
64
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("assessment_version"), required: true}}
65
- )
66
- # When the assessment was created. Fixed for the life of the assessment: every revision carries the same created_at.
67
- field(
68
- :created_at,
69
- ::String,
70
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("created_at"), required: true}}
71
- )
72
-
73
- field(
74
- :updated_at,
75
- ::String,
76
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("updated_at"), required: true}}
77
- )
78
- # After this instant the assessment is no longer served: GET /v1/assessments/{assessment_id} answers 404 and it leaves the account history. It is created_at plus your retention period at creation (default and maximum 365 days) and is fixed per record: a later revision keeps it, and a new assessment for the same account starts its own clock. If you later shorten retention, the effective expiry becomes created_at plus the shorter of the two periods.
79
- field(
80
- :expires_at,
81
- ::String,
82
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("expires_at"), required: true}}
83
- )
84
-
85
- field(
86
- :timing,
87
- Models::Shared::AssessmentTiming,
88
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("timing"), required: true}}
89
- )
90
- # Version of the body shape.
91
- field(
92
- :schema_version,
93
- ::String,
94
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("schema_version"), required: true}}
95
- )
96
- # An optional provider correlation id, echoed back. It is not an idempotency key. Refuses an exact-shape JWT or a bare 40, 64 or 128 character hex digest; a UUID is accepted.
97
- field(
98
- :request_id,
99
- Crystalline::Nilable.new(::String),
100
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("request_id")}}
101
- )
102
- # The recommended action: allow, step_up or review. Your application applies it.
103
- field(
104
- :recommendation,
105
- Crystalline::Nilable.new(Models::Shared::Recommendation),
40
+ :depth,
41
+ Models::Shared::Depth,
106
42
  {
107
43
  'format_json': {
108
- 'letter_case': ::NoResponse::Utils.field_name("recommendation"),
109
- 'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::Recommendation, true)
44
+ 'letter_case': ::NoResponse::Utils.field_name("depth"),
45
+ required: true,
46
+ 'decoder': ::NoResponse::Utils.open_enum_from_string(Models::Shared::Depth, false)
110
47
  }
111
48
  }
112
49
  )
113
- # The one risk decision for this event. score is 0 (safe) to 100 (risky): the sum of the weights of the reasons that fired, clamped to 0 to 100, from hand-set versioned weights. Every answer with a recommendation carries it, and the top-level recommendation equals risk.recommendation. It names no source or provider.
114
- field(
115
- :risk,
116
- Crystalline::Nilable.new(Models::Shared::Risk),
117
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("risk")}}
118
- )
119
- # Detailed view only (response_mode=detailed, which needs the detailed scope): a standard answer omits signals[], and a detailed one always carries it, possibly empty. The body does not name its mode, so that rule is the serving gate's; the schema states the half it can: an answer with risk.details is a detailed view and carries signals[]. At most one signal per (type, source_id, identifier).
50
+ # One assessment's own facts: its score and recommendation, the signals behind them, the lanes still running and its timing. risk here is this assessment's own score; the account's current risk is the account member of the assessment. risk and recommendation are present for complete and partial; assessment_url is present for pending.
120
51
  field(
121
- :signals,
122
- Crystalline::Nilable.new(Crystalline::Array.new(Models::Shared::Signal)),
123
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("signals")}}
52
+ :detail,
53
+ Models::Shared::AssessmentDetail,
54
+ {'format_json': {'letter_case': ::NoResponse::Utils.field_name("detail"), required: true}}
124
55
  )
125
- # Labels the v0 policy as hand-authored and not statistically validated.
56
+ # Version of the body shape.
126
57
  field(
127
- :policy_basis,
58
+ :schema_version,
128
59
  ::String,
129
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("policy_basis"), required: true}}
130
- )
131
- # Server-relative path of the authoritative assessment, GET /v1/assessments/{assessment_id}.
132
- field(
133
- :assessment_url,
134
- Crystalline::Nilable.new(::String),
135
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("assessment_url")}}
60
+ {'format_json': {'letter_case': ::NoResponse::Utils.field_name("schema_version"), required: true}}
136
61
  )
137
- # The identity fields of the auth context the assessment was made with, echoed for the tenant's own correlation: the request's auth_context, or the one a hosted provider hook (POST /hooks/{integration_id}) built, with transport provider_hook and its hook_point. Present exactly when the request carried an auth_context, and the same on every revision of the assessment. Values are as the request or the hook sent them; tokens, timestamps, roles and scopes are not echoed, and the echo does not change the score.
62
+ # Body of the 200 answer to GET /v1/accounts/{account_id}: the one current risk of one of the calling tenant's accounts. It is the risk of the account's latest assessment that has a decision (status complete or partial), by created_at and then assessment_id, scored exactly as GET /v1/assessments/{assessment_id} scores that assessment. Its assessments are the account's history (GET /v1/accounts/{account_id}/assessments).
138
63
  field(
139
- :auth_context,
140
- Crystalline::Nilable.new(Models::Shared::AssessmentAuthContext),
141
- {'format_json': {'letter_case': ::NoResponse::Utils.field_name("auth_context")}}
64
+ :account,
65
+ Crystalline::Nilable.new(Models::Shared::Account),
66
+ {'format_json': {'letter_case': ::NoResponse::Utils.field_name("account")}}
142
67
  )
143
68
 
144
69
  sig {
145
70
  params(
146
- assessment_id: ::String,
147
- account_id: ::String,
71
+ id: ::String,
148
72
  status: Models::Shared::Status,
149
- confidence: Models::Shared::Confidence,
150
- incomplete_sources: T::Array[Models::Shared::IncompleteSource],
151
- policy_version: ::String,
152
- assessment_version: ::Integer,
153
- created_at: ::String,
154
- updated_at: ::String,
155
- expires_at: ::String,
156
- timing: Models::Shared::AssessmentTiming,
73
+ flow: Models::Shared::Flow,
74
+ depth: Models::Shared::Depth,
75
+ detail: Models::Shared::AssessmentDetail,
157
76
  schema_version: ::String,
158
- request_id: T.nilable(::String),
159
- recommendation: T.nilable(Models::Shared::Recommendation),
160
- risk: T.nilable(Models::Shared::Risk),
161
- signals: T.nilable(T::Array[Models::Shared::Signal]),
162
- policy_basis: ::String,
163
- assessment_url: T.nilable(::String),
164
- auth_context: T.nilable(Models::Shared::AssessmentAuthContext)
77
+ account: T.nilable(Models::Shared::Account)
165
78
  )
166
79
  .void
167
80
  }
168
- def initialize(
169
- assessment_id:,
170
- account_id:,
171
- status:,
172
- confidence:,
173
- incomplete_sources:,
174
- policy_version:,
175
- assessment_version:,
176
- created_at:,
177
- updated_at:,
178
- expires_at:,
179
- timing:,
180
- schema_version: "1",
181
- request_id: nil,
182
- recommendation: nil,
183
- risk: nil,
184
- signals: nil,
185
- policy_basis: "heuristic_unvalidated",
186
- assessment_url: nil,
187
- auth_context: nil
188
- )
189
- @assessment_id = assessment_id
190
- @account_id = account_id
81
+ def initialize(id:, status:, flow:, depth:, detail:, schema_version: "1", account: nil)
82
+ @id = id
191
83
  @status = status
192
- @confidence = confidence
193
- @incomplete_sources = incomplete_sources
194
- @policy_version = policy_version
195
- @assessment_version = assessment_version
196
- @created_at = created_at
197
- @updated_at = updated_at
198
- @expires_at = expires_at
199
- @timing = timing
84
+ @flow = flow
85
+ @depth = depth
86
+ @detail = detail
200
87
  unless schema_version == "1"
201
88
  raise ArgumentError, "Invalid value for schema_version"
202
89
  end
203
90
 
204
91
  @schema_version = "1"
205
- @request_id = request_id
206
- @recommendation = recommendation
207
- @risk = risk
208
- @signals = signals
209
- unless policy_basis == "heuristic_unvalidated"
210
- raise ArgumentError, "Invalid value for policy_basis"
211
- end
212
-
213
- @policy_basis = "heuristic_unvalidated"
214
- @assessment_url = assessment_url
215
- @auth_context = auth_context
92
+ @account = account
216
93
  end
217
94
 
218
95
  sig { params(other: T.untyped).returns(T::Boolean) }
219
96
  def ==(other)
220
97
  return false unless other.is_a?(self.class)
221
- return false unless @assessment_id == other.assessment_id
222
- return false unless @account_id == other.account_id
98
+ return false unless @id == other.id
223
99
  return false unless @status == other.status
224
- return false unless @confidence == other.confidence
225
- return false unless @incomplete_sources == other.incomplete_sources
226
- return false unless @policy_version == other.policy_version
227
- return false unless @assessment_version == other.assessment_version
228
- return false unless @created_at == other.created_at
229
- return false unless @updated_at == other.updated_at
230
- return false unless @expires_at == other.expires_at
231
- return false unless @timing == other.timing
100
+ return false unless @flow == other.flow
101
+ return false unless @depth == other.depth
102
+ return false unless @detail == other.detail
232
103
  return false unless @schema_version == other.schema_version
233
- return false unless @request_id == other.request_id
234
- return false unless @recommendation == other.recommendation
235
- return false unless @risk == other.risk
236
- return false unless @signals == other.signals
237
- return false unless @policy_basis == other.policy_basis
238
- return false unless @assessment_url == other.assessment_url
239
- return false unless @auth_context == other.auth_context
104
+ return false unless @account == other.account
240
105
  true
241
106
  end
242
107
  end