agentadmit 1.9.0 → 1.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 677087b98e731945e7485c16db8c8a858dabc54fa346565b3697948db26f9fcb
4
- data.tar.gz: 189ff20ab78520f0ae0ce4a7db1cd32ba8067153c5c5787f4cd04984730eece3
3
+ metadata.gz: 305d2a2680095a277efd613bbc48dc1c0e948109f05ce01aa995dbff99c172bc
4
+ data.tar.gz: 559cae430ea15ea9c66fb7e758efdab50fa652625d67f31bf7dab06d4606842d
5
5
  SHA512:
6
- metadata.gz: 4e374766292936272c50ca95fd2b63ae97f5cb044d194a879dc268009e453630406e090fb57227dcb3dc19f964a04ec3c12dd7b40500cbfd1f1f6b218771e7cc
7
- data.tar.gz: c18398441c0d63526cb6fce609907eea627830a7dc71803688542a29950d8b0cf7ed1bd9d30dc3c0be3c154f53ba4f568707b855c3e01e001f5cdbc7ed9a1ff6
6
+ metadata.gz: 145b55beeabfec1d0860f8d2bdf772b28e2a4a8ca0926c814aac49c3f516bee31f1a66f4dd26c894803452a178bfd296083ec89143185e4c3925aa8d4ae7ac07
7
+ data.tar.gz: f1e2e43b3ea29c1f59a67d2b8791050d8058cac7210edac2cab8b9ef5f6915b35e91479d30ea3cd44709b1ccaaab16c855dc38851f8aab0ce567e1a693b7914f
data/README.md CHANGED
@@ -368,3 +368,45 @@ issued = tokens.issue_token(
368
368
  The SDK sends it as `presence: {verified: true, uv: true, method, verified_at}` -- `verified`/`uv` are literal true by construction and the class cannot represent anything else; a raw Hash is rejected so the wire contract stays owned by the typed class. The hosted service validates freshness (10-minute window, 60 s future clock-skew slack) and stores the method provenance-marked `app:<method>` so app-attested facts stay distinct from ceremonies AgentAdmit witnessed itself. Introspection, the grant-event ledger, and the evidence API then carry `presence.verified: true` for the connection.
369
369
 
370
370
  Honesty ceiling: this is your app's attestation, recorded and provenance-marked. It is not witnessed by AgentAdmit and not independently verifiable. Only attest a ceremony that verified the user with UV (biometric or PIN user verification); a ceremony without UV carries no presence fact, so pass `nil` (the default). An out-of-contract method (`^[a-z0-9_]+$`, 1-60) raises `ArgumentError` at construction, before any request; Ruby `Time`/`DateTime` always carry an offset, so `verified_at` serializes RFC 3339 with an explicit offset by construction.
371
+
372
+ ## Per-Call Audit Telemetry
373
+
374
+ Every verified call reports the exercised scope, endpoint, and method to your app's tamper-evident audit log on the hosted service. The introspection request body carries three optional fields alongside the token:
375
+
376
+ - `scope_used` -- the single declared scope this call enforces (never a joined list)
377
+ - `endpoint` -- the request path only; the query string is stripped before sending (queries can carry PII) and the path is truncated to 500 characters
378
+ - `method` -- the HTTP method, uppercased, capped at 20 characters
379
+
380
+ Each field is sent whenever it is known and OMITTED when it is not -- never null, never an empty string. When a field is omitted, the audit row honestly records "not reported".
381
+
382
+ The Rack middleware always sends `endpoint` (from `PATH_INFO`) and `method`; declare the enforced scope at mount time to send `scope_used` too:
383
+
384
+ ```ruby
385
+ # Scope resolved per request (env -> scope String or nil) ...
386
+ use AgentAdmit::Middleware, scope_for: ->(env) { SCOPES[env["PATH_INFO"]] }
387
+ # ... or one static scope for everything behind this middleware.
388
+ use AgentAdmit::Middleware, scope_for: "read:orders"
389
+ ```
390
+
391
+ `AgentAdmit::CallerConsent` reports its configured scope too and sets the hosted `consent_first` guard automatically, so denied caller classes receive no scope-state disclosure. Direct client calls pass the same optional keyword arguments:
392
+
393
+ ```ruby
394
+ result = AgentAdmit::IntrospectionClient.new.verify(
395
+ token,
396
+ scope_used: "read:orders",
397
+ endpoint: request.path,
398
+ method: request.request_method
399
+ )
400
+ ```
401
+
402
+ The local `ScopeEnforcement` checks (`require_scope!`, `require_scope_if_agent!`) are unchanged -- defense in depth on top of the hosted decision.
403
+
404
+ ### Active-error responses are denials
405
+
406
+ An introspection response with `active: true` AND a string `error` field means the token itself is valid but the authorization service refused this call. The SDK treats every such response as a denial, never a pass-through -- the downstream app does not run:
407
+
408
+ - `insufficient_scope` -> `AgentAdmit::InsufficientScopeError`; middlewares return 403 with the step-up shape (`error`, `required_scope`, `granted_scopes`)
409
+ - `bound_exceeded` -> `AgentAdmit::BoundExceededError`; middlewares return 403 passing the hosted fields (`error_description`, `bound`, `renewal`) through verbatim
410
+ - any other error string -> `AgentAdmit::ActiveDenialError`; middlewares return 403 with `{error: <code>, error_description: "Call refused by the authorization service."}` -- unknown codes fail closed
411
+
412
+ All three inherit from `AgentAdmit::ActiveDenialError` and expose `#code`, `#data` (the parsed hosted response), and `#denial_body` (the ready-made 403 JSON body) for apps that call `verify` directly.
@@ -134,33 +134,33 @@ module AgentAdmit
134
134
  token = (env["HTTP_AUTHORIZATION"] || "").sub(/\Abearer /i, "")
135
135
 
136
136
  begin
137
- result = @client.verify(token)
137
+ # Declare the exact exercised scope in the same hosted round trip.
138
+ # consent_first guarantees a denied caller class cannot learn scope
139
+ # state before this middleware returns its consent 403.
140
+ result = @client.verify(token,
141
+ scope_used: @required_scope,
142
+ endpoint: env["PATH_INFO"],
143
+ method: env["REQUEST_METHOD"],
144
+ consent_first: true)
145
+ rescue InsufficientScopeError
146
+ # Hosted consent-first ordering guarantees this refusal is reachable
147
+ # only after consent was granted.
148
+ return [403, { "Content-Type" => "application/json" },
149
+ [{ error: "insufficient_scope",
150
+ message: "Call refused by the authorization service." }.to_json]]
151
+ rescue ActiveDenialError => e
152
+ # Token valid, call refused (bound_exceeded or an unknown error
153
+ # string on an active response). Fail closed: 403, app never runs.
154
+ return [403, { "Content-Type" => "application/json" },
155
+ [e.denial_body.to_json]]
138
156
  rescue InvalidTokenError => e
139
157
  return json_error(401, "invalid_token", e.message)
140
158
  rescue IntrospectionError => e
141
159
  return json_error(502, "introspection_failed", e.message)
142
160
  end
143
161
 
144
- consent = result.consent
145
- unless consent.is_a?(Hash) && [true, false].include?(consent["granted"])
146
- owner = result.user_id
147
- if !owner.is_a?(String) || owner.empty?
148
- return json_error(503, "consent_unavailable",
149
- "Introspection carried no consent verdict and no resolvable data owner")
150
- end
151
-
152
- begin
153
- consent = @client.check_consent(app_user_id: owner, caller_class: EXTERNAL_AGENT,
154
- scope_group: @scope_group)
155
- rescue StandardError
156
- return json_error(503, "consent_unavailable", "Consent check failed")
157
- end
158
- end
159
-
160
- unless consent.is_a?(Hash) && consent["granted"] == true
161
- return json_error(403, "consent_not_granted",
162
- "The data owner has not enabled external agent access.")
163
- end
162
+ status, consent = resolve_external_consent(result.consent, result.user_id)
163
+ return consent unless status == :granted
164
164
 
165
165
  if @required_scope && !(result.scopes || []).include?(@required_scope)
166
166
  return [403, { "Content-Type" => "application/json" },
@@ -181,6 +181,50 @@ module AgentAdmit
181
181
  @app.call(env)
182
182
  end
183
183
 
184
+ ##
185
+ # Resolve the external-agent consent verdict, fail-closed. An inline
186
+ # boolean verdict is authoritative; an absent or malformed one is
187
+ # resolved through the Consent Ledger (absence is never a grant), using
188
+ # the owner from the introspection payload.
189
+ #
190
+ # @return [Array] [:granted, verdict Hash] when the class is allowed,
191
+ # otherwise [:denied | :unavailable, Rack response triple] ready to
192
+ # return.
193
+ #
194
+ def resolve_external_consent(consent, owner)
195
+ unless consent.is_a?(Hash) && [true, false].include?(consent["granted"])
196
+ if !owner.is_a?(String) || owner.empty?
197
+ return [:unavailable,
198
+ json_error(503, "consent_unavailable",
199
+ "Introspection carried no consent verdict and no resolvable data owner")]
200
+ end
201
+
202
+ begin
203
+ consent = @client.check_consent(app_user_id: owner, caller_class: EXTERNAL_AGENT,
204
+ scope_group: @scope_group)
205
+ rescue StandardError
206
+ return [:unavailable, json_error(503, "consent_unavailable", "Consent check failed")]
207
+ end
208
+ end
209
+
210
+ unless consent.is_a?(Hash) && consent["granted"] == true
211
+ return [:denied,
212
+ json_error(403, "consent_not_granted",
213
+ "The data owner has not enabled external agent access.")]
214
+ end
215
+
216
+ [:granted, consent]
217
+ end
218
+
219
+ ##
220
+ # A hosted insufficient_scope refusal (active: true, valid token). The
221
+ # class consent decision still comes first: a caller whose class the
222
+ # owner denied must not learn scope state, so the verdict is resolved
223
+ # from the hosted payload (or the ledger, fail-closed) and a denied or
224
+ # unresolvable class gets its consent response, never the step-up shape.
225
+ # Only a consent-granted caller sees the 403 step-up body.
226
+ #
227
+
184
228
  ##
185
229
  # Token-less caller class (in_app_ai, or human_session under gate_human),
186
230
  # gated on the Consent Ledger. Fail closed: an unreachable or erroring
@@ -71,13 +71,27 @@ module AgentAdmit
71
71
  # Automatically retries on HTTP 429 with exponential backoff + jitter.
72
72
  # Raises {RateLimitError} when retries are exhausted.
73
73
  #
74
+ # Per-call audit telemetry (all optional, all omitted from the request
75
+ # body when unknown -- never sent as null or empty string):
76
+ #
74
77
  # @param token [String] The full token including ag_at_ prefix
78
+ # @param scope_used [String, nil] the single scope this call enforces
79
+ # (from the scope-enforcing integration point). Never a joined list;
80
+ # omit for bare auth resolution / presence-only gates.
81
+ # @param endpoint [String, nil] inbound request path. Sent path-only:
82
+ # the query string is stripped (queries can carry PII) and the path is
83
+ # truncated to 500 characters.
84
+ # @param method [String, nil] inbound HTTP method; sent uppercased,
85
+ # capped at 20 characters.
75
86
  # @return [IntrospectionResult]
76
87
  # @raise [InvalidTokenError] if validation fails
88
+ # @raise [ActiveDenialError] (incl. {InsufficientScopeError},
89
+ # {BoundExceededError}) if the response is active but carries an error
90
+ # string -- the service refused this call; always a denial
77
91
  # @raise [IntrospectionError] if the service is unreachable
78
92
  # @raise [RateLimitError] if rate-limited and retries exhausted
79
93
  #
80
- def verify(token)
94
+ def verify(token, scope_used: nil, endpoint: nil, method: nil, consent_first: false)
81
95
  unless token.start_with?(@config.token_prefix_access)
82
96
  raise InvalidTokenError, "Not an AgentAdmit access token"
83
97
  end
@@ -90,7 +104,9 @@ module AgentAdmit
90
104
  http = build_http(uri)
91
105
 
92
106
  (0..max_retries).each do |attempt|
93
- request = build_request(uri, token)
107
+ request = build_request(uri, token, scope_used: scope_used,
108
+ endpoint: endpoint, method: method,
109
+ consent_first: consent_first)
94
110
 
95
111
  begin
96
112
  response = http.request(request)
@@ -165,10 +181,13 @@ module AgentAdmit
165
181
  raise InvalidTokenError.new("Token is not active: #{reason}", code: reason)
166
182
  end
167
183
 
168
- # insufficient_scope arrives with active: true (token valid,
169
- # requested scope not granted).
170
- if data["error"] == "insufficient_scope"
171
- raise InsufficientScopeError, data["error_description"] || "Scope not granted"
184
+ # Active-error fail-closed: `active: true` with a string `error`
185
+ # means the token is valid but the authorization service refused
186
+ # THIS call (insufficient_scope, bound_exceeded, or anything the
187
+ # service may add later). Every such response is a DENIAL, never a
188
+ # pass-through -- unknown error strings included.
189
+ if data["error"].is_a?(String) && !data["error"].empty?
190
+ raise_active_denial!(data, scope_used)
172
191
  end
173
192
 
174
193
  # Validate that consumed fields have the expected types when present.
@@ -310,14 +329,86 @@ module AgentAdmit
310
329
  http
311
330
  end
312
331
 
313
- def build_request(uri, token)
332
+ ##
333
+ # Map an active-response error string to its typed denial (always raises).
334
+ #
335
+ # - insufficient_scope: token valid, enforced scope not granted. Carries
336
+ # the step-up fields: required_scope from the hosted response when
337
+ # present, else the scope this call enforced; granted_scopes from the
338
+ # hosted response when present.
339
+ # - bound_exceeded: the hosted bounded-capabilities layer refused the
340
+ # call; hosted fields ride along verbatim on the error's data.
341
+ # - anything else: unknown refusal -> generic typed denial. Fail closed.
342
+ #
343
+ def raise_active_denial!(data, scope_used)
344
+ case data["error"]
345
+ when "insufficient_scope"
346
+ required = data["required_scope"].is_a?(String) ? data["required_scope"] : scope_used
347
+ granted = data["granted_scopes"]
348
+ granted = data["scopes"] unless granted.is_a?(Array)
349
+ granted = nil unless granted.is_a?(Array)
350
+ raise InsufficientScopeError.new(
351
+ data["error_description"] || "Scope not granted",
352
+ required_scope: required, granted_scopes: granted, data: data
353
+ )
354
+ when "bound_exceeded"
355
+ raise BoundExceededError.new(
356
+ data["error_description"] || "Call refused by the authorization service.",
357
+ data: data
358
+ )
359
+ else
360
+ raise ActiveDenialError.new(
361
+ "Call refused by the authorization service.",
362
+ code: data["error"], data: data
363
+ )
364
+ end
365
+ end
366
+
367
+ ##
368
+ # Build the introspection POST. Beyond the token, the body carries the
369
+ # per-call audit telemetry when known: scope_used (the single scope this
370
+ # call enforces), endpoint (path only -- query stripped, queries can
371
+ # carry PII -- truncated to 500 chars), method (uppercase, capped at
372
+ # 20). Unknown fields are OMITTED, never sent as null or empty string --
373
+ # the hosted audit row then honestly records "not reported".
374
+ #
375
+ def build_request(uri, token, scope_used: nil, endpoint: nil, method: nil,
376
+ consent_first: false)
314
377
  req = Net::HTTP::Post.new(uri.path)
315
378
  req["Authorization"] = "Bearer #{@config.api_key}"
316
379
  req["Content-Type"] = "application/json"
317
- req.body = JSON.generate({ token: token })
380
+
381
+ body = { token: token }
382
+ scope = presence_of(scope_used)
383
+ body[:scope_used] = scope if scope
384
+ path = normalize_endpoint(endpoint)
385
+ body[:endpoint] = path if path
386
+ verb = presence_of(method)
387
+ body[:method] = verb.upcase[0, 20] if verb
388
+ body[:consent_first] = true if consent_first
389
+
390
+ req.body = JSON.generate(body)
318
391
  req
319
392
  end
320
393
 
394
+ # The value when it is a non-empty String, else nil (field omitted).
395
+ def presence_of(value)
396
+ value.is_a?(String) && !value.empty? ? value : nil
397
+ end
398
+
399
+ # Path only: strip everything from the first "?" (query strings can
400
+ # carry PII) and cap at 500 characters. nil when nothing usable remains
401
+ # so the field is omitted, never null.
402
+ def normalize_endpoint(endpoint)
403
+ path = presence_of(endpoint)
404
+ return nil unless path
405
+
406
+ path = path.split("?", 2).first
407
+ return nil if path.nil? || path.empty?
408
+
409
+ path[0, 500]
410
+ end
411
+
321
412
  ##
322
413
  # Parse a response header as Float, returning nil if absent or non-numeric.
323
414
  #
@@ -13,15 +13,34 @@ module AgentAdmit
13
13
  # env['agentadmit.agent_label'] -- agent display name
14
14
  # env['agentadmit.presence'] -- human-presence block (Hash) or nil
15
15
  #
16
+ # Every verify call carries per-call audit telemetry: the request path
17
+ # (PATH_INFO -- no query string) and the uppercase HTTP method, plus the
18
+ # scope the call enforces when the middleware is mounted with one:
19
+ #
20
+ # # scope resolved per request (env -> scope String or nil) ...
21
+ # use AgentAdmit::Middleware, scope_for: ->(env) { SCOPES[env["PATH_INFO"]] }
22
+ # # ... or one static scope for everything behind this middleware
23
+ # use AgentAdmit::Middleware, scope_for: "read:orders"
24
+ #
25
+ # When no scope is known the field is omitted (never null) and the hosted
26
+ # audit row records "not reported". The local ScopeEnforcement checks
27
+ # remain unchanged -- defense in depth.
28
+ #
29
+ # An introspection response with active: true AND an error string is a
30
+ # DENIAL: the token is valid but the authorization service refused this
31
+ # call (insufficient_scope, bound_exceeded, or an error code this SDK has
32
+ # never heard of). The middleware returns 403 and never calls the app.
33
+ #
16
34
  class Middleware
17
35
  # RFC 7235: the auth-scheme token is case-insensitive.
18
36
  # Match "bearer", "Bearer", "BEARER", etc. followed by the ag_at_ prefix.
19
37
  BEARER_AGENT_RE = /\Abearer ag_at_/i
20
38
 
21
- def initialize(app)
39
+ def initialize(app, scope_for: nil)
22
40
  @app = app
23
41
  @client = IntrospectionClient.new
24
42
  @config = AgentAdmit.configuration || Config.new
43
+ @scope_for = scope_for
25
44
  end
26
45
 
27
46
  def call(env)
@@ -32,13 +51,21 @@ module AgentAdmit
32
51
  token = auth.sub(/\Abearer /i, "")
33
52
 
34
53
  begin
35
- result = @client.verify(token)
54
+ result = @client.verify(token,
55
+ scope_used: resolve_scope(env),
56
+ endpoint: env["PATH_INFO"],
57
+ method: env["REQUEST_METHOD"])
36
58
  env["agentadmit.auth_type"] = "agent"
37
59
  env["agentadmit.user_id"] = result.user_id
38
60
  env["agentadmit.scopes"] = result.scopes
39
61
  env["agentadmit.connection_id"] = result.connection_id
40
62
  env["agentadmit.agent_label"] = result.agent_label
41
63
  env["agentadmit.presence"] = result.presence
64
+ rescue ActiveDenialError => e
65
+ # Token valid, call refused (active: true + error). Fail closed:
66
+ # 403 with the denial's contract shape; the app never runs.
67
+ return [403, { "Content-Type" => "application/json" },
68
+ [e.denial_body.to_json]]
42
69
  rescue InvalidTokenError => e
43
70
  return [401, { "Content-Type" => "application/json" },
44
71
  [{ error: "invalid_token", error_description: e.message }.to_json]]
@@ -50,5 +77,18 @@ module AgentAdmit
50
77
 
51
78
  @app.call(env)
52
79
  end
80
+
81
+ private
82
+
83
+ ##
84
+ # The scope this request enforces, when the app declared one at mount
85
+ # time. scope_for may be a Proc (env -> scope String or nil) or a
86
+ # static String; nil (the default) omits scope_used from the verify body.
87
+ #
88
+ def resolve_scope(env)
89
+ return @scope_for.call(env) if @scope_for.respond_to?(:call)
90
+
91
+ @scope_for
92
+ end
53
93
  end
54
94
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AgentAdmit
4
- VERSION = "1.9.0"
4
+ VERSION = "1.10.1"
5
5
  end
data/lib/agentadmit.rb CHANGED
@@ -20,7 +20,84 @@ module AgentAdmit
20
20
  end
21
21
  end
22
22
 
23
- class InsufficientScopeError < Error; end
23
+ ##
24
+ # Raised when the hosted /verify returns `active: true` together with a
25
+ # string `error` field: the token itself is valid, but the authorization
26
+ # service refused THIS call. Always a denial, never a pass-through --
27
+ # middlewares map it to HTTP 403, and unknown error strings fail closed
28
+ # (forward compatible: an error code this SDK version has never heard of
29
+ # must never be treated as a pass).
30
+ #
31
+ # {#code} carries the machine-readable error string; {#data} carries the
32
+ # parsed hosted response so denial responses can pass hosted fields
33
+ # through; {#denial_body} is the ready-made 403 JSON body.
34
+ #
35
+ class ActiveDenialError < Error
36
+ # @return [String] machine-readable error code from the active response
37
+ attr_reader :code
38
+ # @return [Hash] the parsed hosted introspection response
39
+ attr_reader :data
40
+
41
+ def initialize(message = "Call refused by the authorization service.",
42
+ code: "access_denied", data: {})
43
+ super(message)
44
+ @code = code
45
+ @data = data
46
+ end
47
+
48
+ # The HTTP 403 body for this denial. Unknown codes get the generic
49
+ # fail-closed shape; subclasses override with their contract shape.
50
+ # @return [Hash]
51
+ def denial_body
52
+ { "error" => code,
53
+ "error_description" => "Call refused by the authorization service." }
54
+ end
55
+ end
56
+
57
+ ##
58
+ # `active: true` + `error: "insufficient_scope"` -- token valid, the scope
59
+ # this call enforces not granted. {#denial_body} is the spec step-up shape
60
+ # (error, required_scope, granted_scopes).
61
+ #
62
+ class InsufficientScopeError < ActiveDenialError
63
+ # @return [String, nil] the scope the call enforced (hosted value when
64
+ # present, else the scope_used this SDK sent)
65
+ attr_reader :required_scope
66
+ # @return [Array<String>, nil] granted scopes from the hosted response
67
+ attr_reader :granted_scopes
68
+
69
+ def initialize(message = "Scope not granted", required_scope: nil,
70
+ granted_scopes: nil, data: {})
71
+ super(message, code: "insufficient_scope", data: data)
72
+ @required_scope = required_scope
73
+ @granted_scopes = granted_scopes
74
+ end
75
+
76
+ def denial_body
77
+ { "error" => "insufficient_scope",
78
+ "required_scope" => required_scope,
79
+ "granted_scopes" => granted_scopes || [] }
80
+ end
81
+ end
82
+
83
+ ##
84
+ # `active: true` + `error: "bound_exceeded"` -- the hosted bounded-
85
+ # capabilities layer refused the call. {#denial_body} passes the hosted
86
+ # fields (error_description, bound, renewal) through verbatim.
87
+ #
88
+ class BoundExceededError < ActiveDenialError
89
+ def initialize(message = "Call refused by the authorization service.", data: {})
90
+ super(message, code: "bound_exceeded", data: data)
91
+ end
92
+
93
+ def denial_body
94
+ body = { "error" => "bound_exceeded", "error_description" => message }
95
+ body["bound"] = data["bound"] if data.key?("bound")
96
+ body["renewal"] = data["renewal"] if data.key?("renewal")
97
+ body
98
+ end
99
+ end
100
+
24
101
  class IntrospectionError < Error; end
25
102
  class ConfigurationError < Error; end
26
103
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agentadmit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.0
4
+ version: 1.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Emerson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-09-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json