schematichq 1.5.1 → 1.5.3

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/.fern/metadata.json +3 -3
  3. data/.fern/replay.lock +14 -338
  4. data/.fernignore +6 -0
  5. data/README.md +141 -0
  6. data/WASM_VERSION +1 -1
  7. data/custom.gemspec.rb +6 -0
  8. data/lib/schematic/accounts/types/update_environment_request_body.rb +2 -0
  9. data/lib/schematic/billing/types/create_invoice_request_body.rb +2 -0
  10. data/lib/schematic/client.rb +1 -1
  11. data/lib/schematic/credits/client.rb +50 -6
  12. data/lib/schematic/credits/leases/check.rb +477 -0
  13. data/lib/schematic/credits/leases/lease_manager.rb +565 -0
  14. data/lib/schematic/credits/leases/lease_store.rb +240 -0
  15. data/lib/schematic/credits/leases/redis_lease_store.rb +358 -0
  16. data/lib/schematic/credits/leases/redis_reservation_store.rb +326 -0
  17. data/lib/schematic/credits/leases/reservation_store.rb +161 -0
  18. data/lib/schematic/credits/leases/server_check.rb +237 -0
  19. data/lib/schematic/credits/leases/track.rb +66 -0
  20. data/lib/schematic/credits/leases/types.rb +329 -0
  21. data/lib/schematic/credits/leases/wire_client.rb +83 -0
  22. data/lib/schematic/credits/types/acquire_credit_lease_request_body.rb +2 -0
  23. data/lib/schematic/credits/types/create_credit_spend_policy_request_body.rb +5 -1
  24. data/lib/schematic/credits/types/extend_credit_lease_request_body.rb +2 -0
  25. data/lib/schematic/credits/types/get_credit_spend_policy_usage_params.rb +16 -0
  26. data/lib/schematic/credits/types/get_credit_spend_policy_usage_request.rb +15 -0
  27. data/lib/schematic/credits/types/get_credit_spend_policy_usage_response.rb +13 -0
  28. data/lib/schematic/credits/types/update_credit_spend_policy_request_body.rb +4 -0
  29. data/lib/schematic/datastream/client.rb +32 -2
  30. data/lib/schematic/entitlements/client.rb +165 -0
  31. data/lib/schematic/entitlements/types/count_company_user_usage_params.rb +24 -0
  32. data/lib/schematic/entitlements/types/count_company_user_usage_request.rb +23 -0
  33. data/lib/schematic/entitlements/types/count_company_user_usage_response.rb +13 -0
  34. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_params.rb +16 -0
  35. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_request.rb +15 -0
  36. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_response.rb +13 -0
  37. data/lib/schematic/entitlements/types/list_company_user_usage_params.rb +24 -0
  38. data/lib/schematic/entitlements/types/list_company_user_usage_request.rb +23 -0
  39. data/lib/schematic/entitlements/types/list_company_user_usage_response.rb +13 -0
  40. data/lib/schematic/plangroups/client.rb +2 -0
  41. data/lib/schematic/plangroups/types/create_plan_group_request_body.rb +2 -0
  42. data/lib/schematic/plangroups/types/update_plan_group_request_body.rb +2 -0
  43. data/lib/schematic/planmigrations/client.rb +6 -0
  44. data/lib/schematic/planmigrations/types/count_migrations_params.rb +2 -0
  45. data/lib/schematic/planmigrations/types/count_migrations_request.rb +2 -0
  46. data/lib/schematic/planmigrations/types/create_migration_input.rb +2 -0
  47. data/lib/schematic/planmigrations/types/list_migrations_params.rb +2 -0
  48. data/lib/schematic/planmigrations/types/list_migrations_request.rb +2 -0
  49. data/lib/schematic/plans/types/publish_plan_version_request_body.rb +4 -0
  50. data/lib/schematic/plans/types/retry_custom_plan_billing_request_body.rb +2 -0
  51. data/lib/schematic/rules_engine.rb +37 -0
  52. data/lib/schematic/schematic_client.rb +830 -33
  53. data/lib/schematic/types/capture_raw_event.rb +4 -0
  54. data/lib/schematic/types/change_subscription_internal_request_body.rb +2 -0
  55. data/lib/schematic/types/change_subscription_request_body.rb +2 -0
  56. data/lib/schematic/types/check_flags_response_data.rb +2 -0
  57. data/lib/schematic/types/company_detail_response_data.rb +2 -0
  58. data/lib/schematic/types/company_feature_usage_export_metadata_visible_columns_item.rb +1 -0
  59. data/lib/schematic/types/company_plan_detail_response_data.rb +2 -0
  60. data/lib/schematic/types/company_user_usage_metrics_response_data.rb +15 -0
  61. data/lib/schematic/types/company_user_usage_response_data.rb +19 -0
  62. data/lib/schematic/types/company_user_usage_row_response_data.rb +17 -0
  63. data/lib/schematic/types/component_display_settings.rb +2 -0
  64. data/lib/schematic/types/component_settings_response_data.rb +2 -0
  65. data/lib/schematic/types/credit_event_ledger_response_data.rb +7 -1
  66. data/lib/schematic/types/credit_event_type.rb +3 -0
  67. data/lib/schematic/types/credit_ledger_entry_kind.rb +17 -0
  68. data/lib/schematic/types/credit_spend_policy.rb +25 -0
  69. data/lib/schematic/types/credit_spend_policy_response_data.rb +12 -0
  70. data/lib/schematic/types/credit_spend_window.rb +11 -0
  71. data/lib/schematic/types/credit_spend_window_unit.rb +13 -0
  72. data/lib/schematic/types/custom_plan_billing_response_data.rb +2 -0
  73. data/lib/schematic/types/environment_detail_response_data.rb +2 -0
  74. data/lib/schematic/types/environment_response_data.rb +2 -0
  75. data/lib/schematic/types/estimated_plan_total.rb +13 -0
  76. data/lib/schematic/types/invoice_request_body.rb +2 -0
  77. data/lib/schematic/types/invoice_response_data.rb +2 -0
  78. data/lib/schematic/types/manage_plan_request.rb +2 -0
  79. data/lib/schematic/types/pending_migration_response_data.rb +6 -0
  80. data/lib/schematic/types/plan_version_migration_response_data.rb +2 -0
  81. data/lib/schematic/types/plan_version_migration_strategy.rb +1 -0
  82. data/lib/schematic/types/preview_subscription_finance_response_data.rb +2 -0
  83. data/lib/schematic/types/rules_engine_schema_version.rb +1 -1
  84. data/lib/schematic/types/rulesengine_company.rb +2 -0
  85. data/lib/schematic/types/rulesengine_comparable_operator.rb +18 -0
  86. data/lib/schematic/types/rulesengine_condition.rb +1 -1
  87. data/lib/schematic/types/rulesengine_credit_spend_policy.rb +25 -0
  88. data/lib/schematic/types/rulesengine_credit_spend_policy_scope.rb +13 -0
  89. data/lib/schematic/types/rulesengine_credit_spend_window.rb +11 -0
  90. data/lib/schematic/types/rulesengine_user.rb +2 -0
  91. data/lib/schematic/types/upcoming_invoice_response_data.rb +2 -0
  92. data/lib/schematic/types/user_usage_metric.rb +12 -0
  93. data/lib/schematic/version.rb +1 -1
  94. data/lib/schematic/wasm/rulesengine.wasm +0 -0
  95. data/lib/schematic.rb +27 -2
  96. data/lib/schematichq.rb +10 -0
  97. data/reference.md +485 -12
  98. metadata +37 -2
@@ -1399,10 +1399,7 @@ module Schematic
1399
1399
  # @option request_options [Integer] :timeout_in_seconds
1400
1400
  #
1401
1401
  # @example
1402
- # client.credits.create_credit_spend_policy(
1403
- # billing_credit_id: "billing_credit_id",
1404
- # max_per_draw: 1.1
1405
- # )
1402
+ # client.credits.create_credit_spend_policy(billing_credit_id: "billing_credit_id")
1406
1403
  #
1407
1404
  # @return [Schematic::Credits::Types::CreateCreditSpendPolicyResponse]
1408
1405
  def create_credit_spend_policy(request_options: {}, **params)
@@ -1597,6 +1594,53 @@ module Schematic
1597
1594
  end
1598
1595
  end
1599
1596
 
1597
+ # @param request_options [Hash]
1598
+ # @param params [Hash]
1599
+ # @option request_options [String] :base_url
1600
+ # @option request_options [Hash{String => Object}] :additional_headers
1601
+ # @option request_options [Hash{String => Object}] :additional_query_parameters
1602
+ # @option request_options [Hash{String => Object}] :additional_body_parameters
1603
+ # @option request_options [Integer] :timeout_in_seconds
1604
+ # @option params [String, nil] :billing_credit_id
1605
+ # @option params [String] :company_id
1606
+ # @option params [String, nil] :user_ids
1607
+ #
1608
+ # @example
1609
+ # client.credits.get_credit_spend_policy_usage(
1610
+ # billing_credit_id: "billing_credit_id",
1611
+ # company_id: "company_id",
1612
+ # user_ids: ["user_ids"]
1613
+ # )
1614
+ #
1615
+ # @return [Schematic::Credits::Types::GetCreditSpendPolicyUsageResponse]
1616
+ def get_credit_spend_policy_usage(request_options: {}, **params)
1617
+ params = Schematic::Internal::Types::Utils.normalize_keys(params)
1618
+ query_params = {}
1619
+ query_params["billing_credit_id"] = params[:billing_credit_id] if params.key?(:billing_credit_id)
1620
+ query_params["company_id"] = params[:company_id] if params.key?(:company_id)
1621
+ query_params["user_ids"] = params[:user_ids] if params.key?(:user_ids)
1622
+
1623
+ request = Schematic::Internal::JSON::Request.new(
1624
+ base_url: request_options[:base_url],
1625
+ method: "GET",
1626
+ path: "billing/credits/spend-policies/usage",
1627
+ query: query_params,
1628
+ request_options: request_options
1629
+ )
1630
+ begin
1631
+ response = @client.send(request)
1632
+ rescue Net::HTTPRequestTimeout
1633
+ raise Schematic::Errors::TimeoutError
1634
+ end
1635
+ code = response.code.to_i
1636
+ if code.between?(200, 299)
1637
+ (response.body.to_s.empty? ? nil : Schematic::Credits::Types::GetCreditSpendPolicyUsageResponse.load(response.body))
1638
+ else
1639
+ error_class = Schematic::Errors::ResponseError.subclass_for_code(code)
1640
+ raise error_class.new(response.body, code: code)
1641
+ end
1642
+ end
1643
+
1600
1644
  # @param request_options [Hash]
1601
1645
  # @param params [Hash]
1602
1646
  # @option request_options [String] :base_url
@@ -1618,7 +1662,7 @@ module Schematic
1618
1662
  # billing_credit_id: "billing_credit_id",
1619
1663
  # company_id: "company_id",
1620
1664
  # end_time: "end_time",
1621
- # event_type: "grant",
1665
+ # event_type: "adjustment",
1622
1666
  # feature_id: "feature_id",
1623
1667
  # start_time: "start_time",
1624
1668
  # limit: 1000000,
@@ -1680,7 +1724,7 @@ module Schematic
1680
1724
  # billing_credit_id: "billing_credit_id",
1681
1725
  # company_id: "company_id",
1682
1726
  # end_time: "end_time",
1683
- # event_type: "grant",
1727
+ # event_type: "adjustment",
1684
1728
  # feature_id: "feature_id",
1685
1729
  # start_time: "start_time",
1686
1730
  # limit: 1000000,
@@ -0,0 +1,477 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Schematic
6
+ module Credits
7
+ module Leases
8
+ # Everything a lease-bearing check needs. enqueue_flag_check_event reports
9
+ # a flag_check event for a check the lease path resolved itself, mirroring
10
+ # the plain check paths so lease-gated checks stay visible to flag-check
11
+ # analytics and company last-seen. Fallback exits do not call it: the
12
+ # plain check they delegate to enqueues its own.
13
+ CheckDeps = Struct.new(:lease_store, :reservations, :manager, :datastream, :logger, :clock,
14
+ :enqueue_flag_check_event, keyword_init: true)
15
+
16
+ # Read a field from an engine payload, which carries camelCase keys inside
17
+ # the entitlement even though the top-level result is normalized.
18
+ def self.field(hash, *names)
19
+ return nil if hash.nil?
20
+
21
+ names.each do |name|
22
+ value = hash[name] || hash[name.to_s]
23
+ return value unless value.nil?
24
+ end
25
+ nil
26
+ end
27
+
28
+ # The preflight envelope for a client-side rules evaluation. With an
29
+ # event_subtype the quantity goes out as the event_usage pair so the
30
+ # engine matches it to that subtype's condition; without one it goes out
31
+ # as the generic usage knob. Public so the plain check path can thread the
32
+ # same preflight through when the lease path cannot run.
33
+ def self.build_preflight_options(options)
34
+ usage = options[:usage]
35
+ return nil if usage.nil?
36
+
37
+ # The preflight quantity is an integer on both seams (the engine
38
+ # envelope and the API's preflight body), and it asks an upper-bound
39
+ # question, so a fractional usage rounds up rather than gating on less
40
+ # usage than the operation is about to record.
41
+ quantity = Leases.wire_quantity(usage)
42
+ # A zero usage has no effect server-side, so sending a preflight for one
43
+ # would only cost the check its flag cache. A zero credit_cost would be
44
+ # different, saying free rather than absent, but this never emits one.
45
+ return nil if quantity.zero?
46
+
47
+ if options[:event_subtype]
48
+ { event_usage: { event_subtype: options[:event_subtype], quantity: quantity } }
49
+ else
50
+ { usage: quantity }
51
+ end
52
+ end
53
+
54
+ # Drive a single lease-gated check.
55
+ #
56
+ # 1. Probe the engine once against the company's real balance, with no
57
+ # substitution and no preflight, and read the matched entitlement. A
58
+ # non-credit entitlement means there is nothing to lease, so defer to
59
+ # the plain check.
60
+ # 2. Acquire (or reuse) a lease for (company, credit id).
61
+ # 3. Reserve ceil(usage) x consumption_rate from it, atomically.
62
+ # 4. Re-run the engine against a company snapshot whose balance for that
63
+ # credit is the PRE-reservation local balance, with credit_cost set, so
64
+ # the engine evaluates the same arithmetic try_reserve just enforced.
65
+ # The hold only sticks if the engine allows.
66
+ def self.check_with_lease(deps, key, eval_ctx, options, &fallback)
67
+ Check.new(deps, key, eval_ctx, options, fallback).run
68
+ end
69
+
70
+ # The check flow, as an object so its steps can pass state without
71
+ # threading a dozen arguments through every helper.
72
+ class Check
73
+ def initialize(deps, key, eval_ctx, options, fallback)
74
+ @deps = deps
75
+ @key = key
76
+ @eval_ctx = eval_ctx || {}
77
+ @options = options || {}
78
+ @fallback = fallback
79
+ @logger = deps.logger
80
+ @clock = deps.clock || DEFAULT_CLOCK
81
+ @on_failure = Leases.resolve_failure_mode(@options[:on_acquire_failure], @logger)
82
+ # Fixed now, not at each wait: a deadline taken when a join starts
83
+ # would let a check spend its acquire and reserve time and then its
84
+ # whole timeout again behind someone else's extend.
85
+ @deadline = Leases.join_deadline(request_options)
86
+ end
87
+
88
+ def run
89
+ guard = check_guards
90
+ return guard if guard
91
+
92
+ resolved = resolve_entitlement
93
+ return @fallback.call if resolved.nil?
94
+
95
+ @credit_id, @consumption_rate, @event_subtype = resolved
96
+ # Whole event units: a fraction of an event is not something the
97
+ # server bills, so the hold rounds up to what the settle will charge.
98
+ # Sizing it on the raw quantity would move the local ledger by less
99
+ # than the Track event, and the two would drift apart over a session.
100
+ # Rounded through wire_quantity, the one the Track event's quantity
101
+ # goes through, so the hold, the local debit and the billed figure
102
+ # cannot disagree over a float that is a hair above a whole unit.
103
+ @credit_cost = Leases.wire_quantity(@options[:usage]) * @consumption_rate
104
+
105
+ lease = @deps.manager.acquire_if_needed(@company[:id], @credit_id, request_options, deadline: @deadline)
106
+ return failure("lease_acquire_failed") if lease.nil?
107
+
108
+ reserve = reserve_credits
109
+ return reserve if reserve.is_a?(CheckResult)
110
+
111
+ reservation = register_reservation(reserve)
112
+ persisted = persist(reservation, reserve)
113
+ return persisted if persisted.is_a?(CheckResult)
114
+
115
+ gate(reservation, reserve)
116
+ end
117
+
118
+ private
119
+
120
+ # Guards, in order: a malformed usage never reaches the stores; zero
121
+ # usage has nothing to reserve; and without a datastream, a cached flag,
122
+ # or a resolvable company there is no local evaluation to gate with.
123
+ def check_guards
124
+ usage = @options[:usage]
125
+ # NaN slips through every numeric comparison, so a single NaN debit
126
+ # would poison the (possibly shared) lease balance into approving
127
+ # every later reserve. The stores guard too, but resolve it here
128
+ # through the caller's failure contract rather than letting it surface
129
+ # as an opaque reserve failure.
130
+ unless Leases.valid_quantity?(usage)
131
+ @logger.error(
132
+ "Lease check: invalid usage #{usage.inspect} for flag #{@key}, must be a finite non-negative number"
133
+ )
134
+ return emit(static_failure_result("invalid_usage", nil))
135
+ end
136
+
137
+ if usage.zero?
138
+ @logger.debug("Lease check: usage is 0 for flag #{@key}, nothing to reserve, using plain check")
139
+ return @fallback.call
140
+ end
141
+
142
+ return @fallback.call if datastream_unavailable?
143
+ return @fallback.call if load_flag.nil?
144
+ return @fallback.call unless entities_resolved?
145
+
146
+ nil
147
+ end
148
+
149
+ def datastream_unavailable?
150
+ return false if @deps.datastream
151
+
152
+ @logger.debug("Credit-lease check requested without datastream, falling back to plain check")
153
+ true
154
+ end
155
+
156
+ def load_flag
157
+ @flag = begin
158
+ @deps.datastream.get_flag(@key)
159
+ rescue StandardError => e
160
+ @logger.warn("Lease check: failed to load flag #{@key}: #{e.message}")
161
+ nil
162
+ end
163
+ @logger.debug("Lease check: no cached flag for #{@key}, falling back") if @flag.nil?
164
+ @flag
165
+ end
166
+
167
+ # Resolve company and user the way a plain datastream check does. An
168
+ # evaluation with a missing entity is not an option: a nil user would
169
+ # silently skip user-targeted rules and overrides, so a named entity
170
+ # that cannot be resolved falls back to the plain check, which has its
171
+ # own degradation story.
172
+ def entities_resolved?
173
+ company_keys = @eval_ctx[:company] || @eval_ctx["company"]
174
+ if company_keys.nil? || company_keys.empty?
175
+ @logger.debug("Lease check: no company on eval context, falling back")
176
+ return false
177
+ end
178
+ @company = fetch_entity("company") { @deps.datastream.get_company(company_keys) }
179
+ return false if @company.nil?
180
+
181
+ user_keys = @eval_ctx[:user] || @eval_ctx["user"]
182
+ return true if user_keys.nil? || user_keys.empty?
183
+
184
+ @user = fetch_entity("user") { @deps.datastream.get_user(user_keys) }
185
+ !@user.nil?
186
+ end
187
+
188
+ def fetch_entity(kind)
189
+ yield
190
+ rescue StandardError => e
191
+ @logger.debug("Lease check: #{kind} fetch failed (#{e.message}), falling back")
192
+ nil
193
+ end
194
+
195
+ # One probe against the real balance surfaces the matched entitlement,
196
+ # which names the credit directly and lets a non-credit grant skip the
197
+ # lease round trip entirely. The probe omits preflight on purpose:
198
+ # charging a cost against the lease-depleted server balance could fail
199
+ # the credit condition, drop the engine to a lower-priority rule, and
200
+ # hide the very entitlement being identified.
201
+ def resolve_entitlement
202
+ probe = probe_entitlement
203
+ return nil if probe.nil?
204
+
205
+ entitlement = probe[:entitlement]
206
+ value_type = Leases.field(entitlement, :valueType, :value_type)
207
+ unless value_type == "credit"
208
+ # A boolean or override grant, a numeric allocation, unlimited, or
209
+ # simply not entitled. The feature resolves without drawing a
210
+ # credit, so skip the lease round-trip and let the plain check,
211
+ # which is preflight-aware, decide.
212
+ @logger.debug(
213
+ "Lease check: flag #{@key} matched a non-credit entitlement " \
214
+ "(value_type=#{value_type || "<none>"}), falling back to plain check, no reservation"
215
+ )
216
+ return nil
217
+ end
218
+
219
+ credit_id = Leases.field(entitlement, :creditId, :credit_id)
220
+ consumption_rate = (Leases.field(entitlement, :consumptionRate, :consumption_rate) || 0).to_f
221
+ # The caller's explicit subtype wins; otherwise the entitlement names
222
+ # the metered event. The reservation settles into a track event named
223
+ # by this subtype, so a credit entitlement with neither a resolvable
224
+ # subtype nor a positive rate cannot be billed and is ungateable.
225
+ subtype = @options[:event_subtype] || Leases.field(entitlement, :eventSubtype, :event_subtype)
226
+ if credit_id.nil? || consumption_rate <= 0 || subtype.nil?
227
+ @logger.debug(
228
+ "Lease check: flag #{@key} credit entitlement is incomplete " \
229
+ "(credit_id=#{credit_id || "<none>"}, consumption_rate=#{consumption_rate}, " \
230
+ "subtype=#{subtype || "<none>"}), falling back"
231
+ )
232
+ return nil
233
+ end
234
+
235
+ [credit_id, consumption_rate, subtype]
236
+ end
237
+
238
+ def probe_entitlement
239
+ evaluate(@company, nil)
240
+ rescue StandardError => e
241
+ # The probe is a resolution step, not the gate, so a failure means the
242
+ # credit could not be resolved. Defer to the plain check rather than
243
+ # hard-denying. No reservation exists yet, so nothing to cancel.
244
+ @logger.warn("Lease check: entitlement probe failed for flag #{@key} (#{e.message}), falling back")
245
+ nil
246
+ end
247
+
248
+ # try_reserve is the atomic gate: check and debit in one step, returning
249
+ # the post-debit balance (so the pre-debit figure follows without a
250
+ # second store read) AND the id of the lease it charged. That id, not
251
+ # the acquired one, is what the reservation is pinned to: the debit is
252
+ # not keyed by lease, so the slot's lease may have been replaced since
253
+ # the acquire, and the window spans the extend awaited below.
254
+ def reserve_credits
255
+ reserve = @deps.lease_store.try_reserve(@company[:id], @credit_id, @credit_cost)
256
+ if reserve.nil?
257
+ # The lease has less than credit_cost left locally. Passing
258
+ # credit_cost extends even when the ratio is still above the low
259
+ # water mark, which a single large request needs.
260
+ @deps.manager.maybe_extend_in_background(@company[:id], @credit_id, @credit_cost,
261
+ request_options, deadline: @deadline)&.join
262
+ reserve = @deps.lease_store.try_reserve(@company[:id], @credit_id, @credit_cost)
263
+ end
264
+ return failure("insufficient_lease_balance") if reserve.nil?
265
+
266
+ reserve
267
+ rescue StandardError => e
268
+ @logger.error("Lease check: reserve against #{@company[:id]}/#{@credit_id} failed: #{e.message}")
269
+ failure("lease_store_error")
270
+ end
271
+
272
+ def register_reservation(reserve)
273
+ resolved = @deps.manager.resolve_config(@credit_id)
274
+ Reservation.new(
275
+ id: SecureRandom.uuid,
276
+ # The lease the debit actually landed on, which may not be the one
277
+ # the acquire handed back. Pinning the acquired id instead would
278
+ # send the settle refund, the sweep refund, and the track event's
279
+ # lease_id to a lease that was never charged.
280
+ lease_id: reserve.lease_id,
281
+ company_id: @company[:id],
282
+ credit_type_id: @credit_id,
283
+ event_subtype: @event_subtype,
284
+ quantity_reserved: @options[:usage],
285
+ credits_reserved: @credit_cost,
286
+ consumption_rate: @consumption_rate,
287
+ expires_at: @clock.call + (resolved.reservation_ttl_ms / 1000.0),
288
+ eval_ctx: @eval_ctx
289
+ )
290
+ end
291
+
292
+ # Recorded between the debit and the gate, so a crash leaves a sweepable
293
+ # reservation rather than credits stranded until the lease expires. The
294
+ # window it cannot cover is the gap before this call, which has no I/O
295
+ # in it and leaks at most credit_cost until that expiry.
296
+ def persist(reservation, reserve)
297
+ @deps.reservations.add(reservation)
298
+ nil
299
+ rescue StandardError => e
300
+ @logger.error("Lease check: failed to persist reservation #{reservation.id}: #{e.message}")
301
+ undo_debit(reservation, reserve)
302
+ failure("lease_store_error")
303
+ end
304
+
305
+ # Undo the local debit so the credits are not stranded until lease
306
+ # expiry. consume claims whatever slice of the add made it to the store
307
+ # and refunds it; if nothing was persisted, refund the debit directly.
308
+ # Both are pinned to the lease the debit landed on. If even the undo
309
+ # fails, accept the bounded leak: the slice is reclaimed when the lease
310
+ # expires server-side, which beats risking a double refund.
311
+ def undo_debit(reservation, reserve)
312
+ undone = @deps.reservations.consume(reservation.id, 0)
313
+ @deps.lease_store.refund(@company[:id], @credit_id, @credit_cost, reserve.lease_id) if undone.nil?
314
+ rescue StandardError => e
315
+ @logger.warn(
316
+ "Lease check: could not undo local debit for #{reservation.id} (#{e.message}); " \
317
+ "the slice is reclaimed at lease expiry"
318
+ )
319
+ end
320
+
321
+ # The engine gates against the lease's local view, not the server's
322
+ # balance, so it re-checks the arithmetic try_reserve just enforced plus
323
+ # every non-credit rule. The pre-reservation figure is the atomic
324
+ # reserve's own post-debit balance plus what it debited, so it is exact
325
+ # as of the debit with no read race.
326
+ def gate(reservation, reserve)
327
+ pre_reservation = reserve.balance + @credit_cost
328
+ substituted = substitute_credit_balance(@company, @credit_id, pre_reservation)
329
+ begin
330
+ result = evaluate(substituted, { credit_cost: { @credit_id => @credit_cost } })
331
+ rescue StandardError => e
332
+ @logger.error("Lease check: rules engine evaluation failed: #{e.message}")
333
+ # Cancel the hold, then resolve the mode statically: the engine
334
+ # itself just failed, so a fail-open re-evaluation is impossible.
335
+ cancel_reservation(reservation)
336
+ return emit(static_failure_result("wasm_error: #{e.message}", @flag), engine_ids(nil))
337
+ end
338
+
339
+ ids = engine_ids(result)
340
+ # A nil verdict denies here rather than standing in the caller's
341
+ # default, which is what the plain check path does with the same nil.
342
+ # The difference is deliberate: this branch holds credits, and the
343
+ # default exists to answer a flag nothing evaluated, not to release a
344
+ # hold the engine declined to approve.
345
+ unless result[:value]
346
+ cancel_reservation(reservation)
347
+ return emit(
348
+ CheckResult.new(
349
+ allowed: false, value: false, reason: result[:reason] || "denied_by_engine",
350
+ entitlement: result[:entitlement], flag_key: result[:flag_key] || @key, flag_id: result[:flag_id]
351
+ ), ids
352
+ )
353
+ end
354
+
355
+ # The engine allowed against the substituted lease balance, so the
356
+ # hold stays. No rule-match disambiguation is needed: the probe
357
+ # already established that this company's matched entitlement is the
358
+ # credit one, so an override or boolean grant would have skipped the
359
+ # reserve path. Bumping only the credit balance cannot make a
360
+ # different rule match here.
361
+
362
+ # Fire and forget the low-water-mark refresh now that we have debited.
363
+ @deps.manager.maybe_extend_in_background(@company[:id], @credit_id)
364
+
365
+ emit(
366
+ CheckResult.new(
367
+ allowed: true, value: true, reservation: reservation,
368
+ reason: result[:reason] || "lease_reserved", entitlement: result[:entitlement],
369
+ flag_key: result[:flag_key] || @key, flag_id: result[:flag_id]
370
+ ), ids
371
+ )
372
+ end
373
+
374
+ # Best-effort cancel: claims the record and refunds its full hold.
375
+ def cancel_reservation(reservation)
376
+ @deps.reservations.consume(reservation.id, 0)
377
+ rescue StandardError => e
378
+ @logger.warn(
379
+ "Lease check: failed to cancel reservation #{reservation.id} (#{e.message}); " \
380
+ "its hold is reclaimed by the sweeper or at lease expiry"
381
+ )
382
+ end
383
+
384
+ # Every can't-gate outcome funnels through here. fail-open means assume
385
+ # the credits are there, NOT skip evaluation: the engine still runs with
386
+ # the balance substituted to an effectively unlimited value, so a company
387
+ # that is not entitled stays denied even with the lease backend down.
388
+ # Only if that evaluation itself fails does this fall back to a blanket
389
+ # allow.
390
+ def failure(reason)
391
+ result =
392
+ if @on_failure == :fail_closed
393
+ static_failure_result(reason, @flag)
394
+ else
395
+ fail_open_result(reason)
396
+ end
397
+ emit(result, { company_id: @company&.dig(:id), user_id: @user&.dig(:id) })
398
+ end
399
+
400
+ def fail_open_result(reason)
401
+ substituted = substitute_credit_balance(@company, @credit_id, FAIL_OPEN_BALANCE)
402
+ result = evaluate(substituted, Leases.build_preflight_options(@options))
403
+ CheckResult.new(
404
+ allowed: result[:value], value: result[:value],
405
+ reason: "#{result[:reason] || "evaluated"} (#{reason}_fail_open)",
406
+ entitlement: result[:entitlement], flag_key: result[:flag_key] || @key,
407
+ flag_id: result[:flag_id] || @flag&.dig(:id), error: reason
408
+ )
409
+ rescue StandardError => e
410
+ @logger.warn("Lease check: fail-open evaluation failed (#{e.message}); allowing")
411
+ static_failure_result(reason, @flag)
412
+ end
413
+
414
+ # A mode resolved without an engine evaluation: deny for fail-closed,
415
+ # blanket allow for fail-open. Used when the engine itself is the thing
416
+ # that failed, and as the fallback when a fail-open evaluation errors.
417
+ def static_failure_result(reason, flag)
418
+ if @on_failure == :fail_closed
419
+ CheckResult.new(allowed: false, value: false, reason: reason, flag_key: @key,
420
+ flag_id: flag&.dig(:id), error: reason)
421
+ else
422
+ CheckResult.new(allowed: true, value: true, reason: "#{reason}_fail_open", flag_key: @key,
423
+ flag_id: flag&.dig(:id), error: reason)
424
+ end
425
+ end
426
+
427
+ def evaluate(company, options)
428
+ @deps.datastream.check_flag_with_options(@flag, company, @user, options)
429
+ end
430
+
431
+ def substitute_credit_balance(company, credit_id, balance)
432
+ substituted = company.dup
433
+ balances = (company[:credit_balances] || company["credit_balances"] || {}).dup
434
+ # The cache symbolizes keys, so a balance may be filed under either
435
+ # spelling. Replace whichever is there so the engine sees one value.
436
+ balances.delete(credit_id.to_sym)
437
+ balances.delete(credit_id.to_s)
438
+ balances[credit_id] = balance
439
+ substituted[:credit_balances] = balances
440
+ substituted
441
+ end
442
+
443
+ def engine_ids(result)
444
+ {
445
+ company_id: (result && result[:company_id]) || @company&.dig(:id),
446
+ user_id: (result && result[:user_id]) || @user&.dig(:id),
447
+ rule_id: result && result[:rule_id]
448
+ }
449
+ end
450
+
451
+ # Thread the caller's per-check timeout to the lease wire calls the same
452
+ # way the fallback path threads it to a plain check.
453
+ def request_options
454
+ return {} if @options[:timeout_ms].nil?
455
+
456
+ { timeout_in_seconds: @options[:timeout_ms] / 1000.0 }
457
+ end
458
+
459
+ def emit(result, ids = {})
460
+ @deps.enqueue_flag_check_event&.call(
461
+ flag_key: result.flag_key,
462
+ value: result.value,
463
+ reason: result.reason,
464
+ error: result.error,
465
+ flag_id: result.flag_id,
466
+ company_id: ids[:company_id],
467
+ user_id: ids[:user_id],
468
+ rule_id: ids[:rule_id],
469
+ req_company: @eval_ctx[:company] || @eval_ctx["company"],
470
+ req_user: @eval_ctx[:user] || @eval_ctx["user"]
471
+ )
472
+ result
473
+ end
474
+ end
475
+ end
476
+ end
477
+ end