schematichq 1.5.2 → 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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.fern/metadata.json +3 -3
  3. data/.fern/replay.lock +8 -338
  4. data/.fernignore +6 -0
  5. data/README.md +141 -0
  6. data/custom.gemspec.rb +6 -0
  7. data/lib/schematic/accounts/types/update_environment_request_body.rb +2 -0
  8. data/lib/schematic/client.rb +1 -1
  9. data/lib/schematic/credits/client.rb +50 -6
  10. data/lib/schematic/credits/leases/check.rb +477 -0
  11. data/lib/schematic/credits/leases/lease_manager.rb +565 -0
  12. data/lib/schematic/credits/leases/lease_store.rb +240 -0
  13. data/lib/schematic/credits/leases/redis_lease_store.rb +358 -0
  14. data/lib/schematic/credits/leases/redis_reservation_store.rb +326 -0
  15. data/lib/schematic/credits/leases/reservation_store.rb +161 -0
  16. data/lib/schematic/credits/leases/server_check.rb +237 -0
  17. data/lib/schematic/credits/leases/track.rb +66 -0
  18. data/lib/schematic/credits/leases/types.rb +329 -0
  19. data/lib/schematic/credits/leases/wire_client.rb +83 -0
  20. data/lib/schematic/credits/types/acquire_credit_lease_request_body.rb +2 -0
  21. data/lib/schematic/credits/types/create_credit_spend_policy_request_body.rb +5 -1
  22. data/lib/schematic/credits/types/extend_credit_lease_request_body.rb +2 -0
  23. data/lib/schematic/credits/types/get_credit_spend_policy_usage_params.rb +16 -0
  24. data/lib/schematic/credits/types/get_credit_spend_policy_usage_request.rb +15 -0
  25. data/lib/schematic/credits/types/get_credit_spend_policy_usage_response.rb +13 -0
  26. data/lib/schematic/credits/types/update_credit_spend_policy_request_body.rb +4 -0
  27. data/lib/schematic/datastream/client.rb +32 -2
  28. data/lib/schematic/entitlements/client.rb +165 -0
  29. data/lib/schematic/entitlements/types/count_company_user_usage_params.rb +24 -0
  30. data/lib/schematic/entitlements/types/count_company_user_usage_request.rb +23 -0
  31. data/lib/schematic/entitlements/types/count_company_user_usage_response.rb +13 -0
  32. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_params.rb +16 -0
  33. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_request.rb +15 -0
  34. data/lib/schematic/entitlements/types/get_company_user_usage_metrics_response.rb +13 -0
  35. data/lib/schematic/entitlements/types/list_company_user_usage_params.rb +24 -0
  36. data/lib/schematic/entitlements/types/list_company_user_usage_request.rb +23 -0
  37. data/lib/schematic/entitlements/types/list_company_user_usage_response.rb +13 -0
  38. data/lib/schematic/plangroups/client.rb +2 -0
  39. data/lib/schematic/plangroups/types/create_plan_group_request_body.rb +2 -0
  40. data/lib/schematic/plangroups/types/update_plan_group_request_body.rb +2 -0
  41. data/lib/schematic/planmigrations/client.rb +6 -0
  42. data/lib/schematic/planmigrations/types/count_migrations_params.rb +2 -0
  43. data/lib/schematic/planmigrations/types/count_migrations_request.rb +2 -0
  44. data/lib/schematic/planmigrations/types/create_migration_input.rb +2 -0
  45. data/lib/schematic/planmigrations/types/list_migrations_params.rb +2 -0
  46. data/lib/schematic/planmigrations/types/list_migrations_request.rb +2 -0
  47. data/lib/schematic/plans/types/publish_plan_version_request_body.rb +4 -0
  48. data/lib/schematic/plans/types/retry_custom_plan_billing_request_body.rb +2 -0
  49. data/lib/schematic/rules_engine.rb +37 -0
  50. data/lib/schematic/schematic_client.rb +830 -33
  51. data/lib/schematic/types/capture_raw_event.rb +4 -0
  52. data/lib/schematic/types/check_flags_response_data.rb +2 -0
  53. data/lib/schematic/types/company_detail_response_data.rb +2 -0
  54. data/lib/schematic/types/company_plan_detail_response_data.rb +2 -0
  55. data/lib/schematic/types/company_user_usage_metrics_response_data.rb +15 -0
  56. data/lib/schematic/types/company_user_usage_response_data.rb +19 -0
  57. data/lib/schematic/types/company_user_usage_row_response_data.rb +17 -0
  58. data/lib/schematic/types/component_display_settings.rb +2 -0
  59. data/lib/schematic/types/component_settings_response_data.rb +2 -0
  60. data/lib/schematic/types/credit_event_ledger_response_data.rb +5 -1
  61. data/lib/schematic/types/credit_event_type.rb +3 -0
  62. data/lib/schematic/types/credit_ledger_entry_kind.rb +17 -0
  63. data/lib/schematic/types/credit_spend_policy.rb +25 -0
  64. data/lib/schematic/types/credit_spend_policy_response_data.rb +12 -0
  65. data/lib/schematic/types/credit_spend_window.rb +11 -0
  66. data/lib/schematic/types/credit_spend_window_unit.rb +13 -0
  67. data/lib/schematic/types/custom_plan_billing_response_data.rb +2 -0
  68. data/lib/schematic/types/environment_detail_response_data.rb +2 -0
  69. data/lib/schematic/types/environment_response_data.rb +2 -0
  70. data/lib/schematic/types/estimated_plan_total.rb +13 -0
  71. data/lib/schematic/types/pending_migration_response_data.rb +6 -0
  72. data/lib/schematic/types/plan_version_migration_response_data.rb +2 -0
  73. data/lib/schematic/types/plan_version_migration_strategy.rb +1 -0
  74. data/lib/schematic/types/rules_engine_schema_version.rb +1 -1
  75. data/lib/schematic/types/rulesengine_company.rb +2 -0
  76. data/lib/schematic/types/rulesengine_credit_spend_policy.rb +25 -0
  77. data/lib/schematic/types/rulesengine_credit_spend_policy_scope.rb +13 -0
  78. data/lib/schematic/types/rulesengine_credit_spend_window.rb +11 -0
  79. data/lib/schematic/types/rulesengine_user.rb +2 -0
  80. data/lib/schematic/types/user_usage_metric.rb +12 -0
  81. data/lib/schematic/version.rb +1 -1
  82. data/lib/schematic.rb +26 -2
  83. data/lib/schematichq.rb +10 -0
  84. data/reference.md +474 -9
  85. metadata +36 -2
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Schematic
7
+ module Credits
8
+ module Leases
9
+ # Everything a server-mode check needs. reservation_ttl_ms is how far out
10
+ # the hold's expires_at is set, and default_value resolves the caller's
11
+ # default for the flag, which the fail-open branch returns because there
12
+ # is no local engine to re-run.
13
+ ServerCheckDeps = Struct.new(:features, :credits, :logger, :reservation_ttl_ms, :default_value, :clock,
14
+ keyword_init: true)
15
+
16
+ # Mirrors the reason the API returns on a 200 with value false for the
17
+ # same denial, so a caller matching on reason has one string to match
18
+ # either way.
19
+ INSUFFICIENT_CREDITS_REASON = "Insufficient credits"
20
+
21
+ # Drive a single check with usage set, in server mode.
22
+ #
23
+ # One check-and-reserve call does everything the client path spreads
24
+ # across a lease acquire, a local reserve, and a rules evaluation: the
25
+ # server evaluates the flag against the company's real balance, applies
26
+ # the preflight cost, and takes the hold in the same round trip. There is
27
+ # no lease, no local store, and no rules engine involved.
28
+ #
29
+ # The failure contract differs from client mode in one place. fail-open
30
+ # there means re-run the engine with the credit balance assumed
31
+ # sufficient, so plan targeting and every non-credit condition still
32
+ # apply. Server mode has no local engine to re-run, since the call that
33
+ # would have answered is the one that failed, so fail-open returns the
34
+ # caller's default value instead. fail-closed denies, same as client mode.
35
+ #
36
+ # No flag_check event is enqueued here: the server logs the flag check for
37
+ # check-and-reserve itself, the same way the REST check path does.
38
+ def self.check_with_server_reservation(deps, key, eval_ctx, options, &fallback)
39
+ ServerCheck.new(deps, key, eval_ctx, options, fallback).run
40
+ end
41
+
42
+ class ServerCheck
43
+ def initialize(deps, key, eval_ctx, options, fallback)
44
+ @deps = deps
45
+ @key = key
46
+ @eval_ctx = eval_ctx || {}
47
+ @options = options || {}
48
+ @fallback = fallback
49
+ @logger = deps.logger
50
+ @clock = deps.clock || DEFAULT_CLOCK
51
+ @on_failure = Leases.resolve_failure_mode(@options[:on_acquire_failure], @logger)
52
+ # One key for this check, minted before the call rather than per
53
+ # attempt: check-and-reserve takes a hold, so a 502 arriving after the
54
+ # API committed one would otherwise have the retry take a second and
55
+ # park the first until its TTL. Sharing the key across attempts makes
56
+ # the server return the hold it already took.
57
+ @idempotency_key = SecureRandom.uuid
58
+ end
59
+
60
+ def run
61
+ usage = @options[:usage]
62
+ # The same guard as the client path: a malformed usage must never
63
+ # reach the wire. NaN slips through every numeric comparison, so the
64
+ # server would size a hold off a value no comparison can reject.
65
+ unless Leases.valid_quantity?(usage)
66
+ @logger.error(
67
+ "Server reservation: invalid usage #{usage.inspect} for flag #{@key}, " \
68
+ "must be a finite non-negative number"
69
+ )
70
+ return failure_result("invalid_usage")
71
+ end
72
+
73
+ if usage.zero?
74
+ @logger.debug("Server reservation: usage is 0 for flag #{@key}, nothing to reserve, using plain check")
75
+ return @fallback.call
76
+ end
77
+
78
+ data = call_api
79
+ return data if data.is_a?(CheckResult)
80
+
81
+ build_result(data)
82
+ end
83
+
84
+ private
85
+
86
+ def call_api
87
+ response = @deps.features.check_and_reserve_flag(request_options: request_options, **request_body)
88
+ response.data
89
+ rescue StandardError => e
90
+ # A 402 is the server's definitive answer, not a can't-gate: it knows
91
+ # the credits are not there. Deny regardless of the failure mode,
92
+ # since failing open here would hand out credit the balance cannot
93
+ # cover. check-and-reserve itself answers 200 with value false for
94
+ # insufficient credits; this is defensive.
95
+ return payment_required_result(e) if payment_required?(e)
96
+
97
+ @logger.error("Server reservation: check-and-reserve for flag #{@key} failed: #{e.message}")
98
+ failure_result("server_reservation_failed")
99
+ end
100
+
101
+ def request_body
102
+ # The request body's quantity is an integer, so a fractional usage
103
+ # would truncate and the server would size the hold below the work
104
+ # about to run. Round up, and size the preflight from the same number
105
+ # so the flag is evaluated against the quantity actually held.
106
+ quantity = Leases.wire_quantity(@options[:usage])
107
+ body = {
108
+ key: @key,
109
+ quantity: quantity,
110
+ expires_at: (@clock.call + (@deps.reservation_ttl_ms / 1000.0)).utc.iso8601
111
+ }
112
+ company = @eval_ctx[:company] || @eval_ctx["company"]
113
+ user = @eval_ctx[:user] || @eval_ctx["user"]
114
+ body[:company] = company if company && !company.empty?
115
+ body[:user] = user if user && !user.empty?
116
+ preflight = Leases.build_preflight_options(@options.merge(usage: quantity))
117
+ body[:preflight] = preflight if preflight
118
+ body[:idempotency_key] = @idempotency_key
119
+ body
120
+ end
121
+
122
+ # Only the timeout is set here. The call keeps the client's default
123
+ # retry policy, which the idempotency key makes safe.
124
+ def request_options
125
+ return {} if @options[:timeout_ms].nil?
126
+
127
+ { timeout_in_seconds: @options[:timeout_ms] / 1000.0 }
128
+ end
129
+
130
+ def build_result(data)
131
+ base = CheckResult.new(
132
+ allowed: data.value, value: data.value, reason: data.reason, entitlement: data.entitlement,
133
+ flag_key: data.flag || @key, flag_id: data.flag_id, error: data.error
134
+ )
135
+ held = data.reservation
136
+ # No reservation comes back when the flag denied, the credits were
137
+ # insufficient (a 200 with value false), or the feature is not
138
+ # credit-metered. Nothing was held, so there is nothing to release.
139
+ return base if !data.value || held.nil?
140
+
141
+ # The settling track event is named by the event subtype; the caller's
142
+ # explicit one wins, otherwise the server names it on the hold. With
143
+ # neither, the hold could never be settled.
144
+ event_subtype = @options[:event_subtype] || held.event_subtype
145
+ return release_unsettleable(held, base) if event_subtype.nil? || event_subtype.empty?
146
+
147
+ CheckResult.new(
148
+ allowed: true, value: true, reason: data.reason, entitlement: data.entitlement,
149
+ flag_key: data.flag || @key, flag_id: data.flag_id,
150
+ reservation: reservation_from(held, event_subtype)
151
+ )
152
+ end
153
+
154
+ def reservation_from(held, event_subtype)
155
+ Reservation.new(
156
+ id: held.id,
157
+ # No lease exists in server mode; mirror the id so the field stays
158
+ # populated and a handle round-trips through code that reads it.
159
+ lease_id: held.id,
160
+ mode: :server,
161
+ company_id: held.company_id,
162
+ credit_type_id: held.credit_type_id,
163
+ event_subtype: event_subtype,
164
+ quantity_reserved: held.quantity_reserved,
165
+ credits_reserved: held.credits_reserved,
166
+ consumption_rate: held.consumption_rate,
167
+ expires_at: parse_time(held.expires_at),
168
+ eval_ctx: @eval_ctx
169
+ )
170
+ end
171
+
172
+ def release_unsettleable(held, base)
173
+ @logger.error(
174
+ "Server reservation: reservation #{held.id} for flag #{@key} has no event subtype, " \
175
+ "releasing, it could never be settled"
176
+ )
177
+ begin
178
+ @deps.credits.release_credit_reservation(reservation_id: held.id)
179
+ rescue StandardError => e
180
+ @logger.warn(
181
+ "Server reservation: failed to release #{held.id} (#{e.message}); its hold is refunded when it expires"
182
+ )
183
+ end
184
+ return failure_result("missing_event_subtype") if @on_failure == :fail_closed
185
+
186
+ # Fail-open means assume the credits are there, and the server has
187
+ # already evaluated the flag and allowed this check. Only the settle
188
+ # is impossible, so keep the server's verdict rather than falling back
189
+ # to the caller's default, which could deny what the server allowed.
190
+ CheckResult.new(
191
+ allowed: base.allowed, value: base.value, reason: base.reason, entitlement: base.entitlement,
192
+ flag_key: base.flag_key, flag_id: base.flag_id, error: "missing_event_subtype"
193
+ )
194
+ end
195
+
196
+ # Resolve a can't-gate outcome. fail-closed denies; fail-open returns
197
+ # the caller's default value, since there is no local engine to
198
+ # re-evaluate with an assumed-sufficient balance the way client mode
199
+ # does.
200
+ def failure_result(reason)
201
+ return CheckResult.new(allowed: false, value: false, reason: reason, flag_key: @key, error: reason) if @on_failure == :fail_closed
202
+
203
+ value = @deps.default_value.call
204
+ CheckResult.new(allowed: value, value: value, reason: "#{reason}_fail_open", flag_key: @key, error: reason)
205
+ end
206
+
207
+ # The generated client has no 402-specific error class, so a payment
208
+ # required arrives as a ClientError carrying the status code.
209
+ def payment_required?(error)
210
+ error.respond_to?(:code) && error.code.to_i == 402
211
+ end
212
+
213
+ def payment_required_result(error)
214
+ CheckResult.new(
215
+ allowed: false, value: false, reason: INSUFFICIENT_CREDITS_REASON, flag_key: @key,
216
+ error: api_error_message(error)
217
+ )
218
+ end
219
+
220
+ # A response error's message is the raw body, which carries the API's
221
+ # own error string when it is JSON.
222
+ def api_error_message(error)
223
+ parsed = JSON.parse(error.message.to_s)
224
+ parsed.is_a?(Hash) && parsed["error"] ? parsed["error"] : error.message
225
+ rescue StandardError
226
+ error.message
227
+ end
228
+
229
+ def parse_time(value)
230
+ Leases.parse_api_time(value)
231
+ rescue StandardError
232
+ @clock.call
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematic
4
+ module Credits
5
+ module Leases
6
+ # What a settle did locally, and what it owes the server.
7
+ #
8
+ # settled_locally is true when the hold was still open and this call
9
+ # debited the consumed slice and refunded the rest. False when it had
10
+ # already been swept at its TTL, already settled, or the store was
11
+ # unreachable: the lease balance was not touched here, so it reads high
12
+ # until the lease rolls over, and the event is a recovery emit.
13
+ SettleOutcome = Struct.new(:track, :settled_locally, keyword_init: true)
14
+
15
+ # Consume a reservation against its lease and build the event that bills
16
+ # it.
17
+ #
18
+ # The event is built from the caller-held handle rather than the store, so
19
+ # the usage is still billed once the hold has been swept. Only the local
20
+ # bookkeeping clamps to the reserved amount; the event carries the
21
+ # unclamped actual, because the server is the source of truth for real
22
+ # consumption.
23
+ def self.consume_reservation_and_build_event(reservations, reservation, actual_quantity, traits: nil)
24
+ # Rounded up for the same reason the hold is (see check_with_lease), and
25
+ # through the same helper the event's quantity uses: the debit has to
26
+ # move the local ledger by exactly what the Track event bills.
27
+ consumed = reservations.consume(
28
+ reservation.id, Leases.wire_quantity(actual_quantity) * reservation.consumption_rate
29
+ )
30
+ SettleOutcome.new(
31
+ track: build_reservation_track_event(reservation, actual_quantity, traits: traits),
32
+ settled_locally: !consumed.nil?
33
+ )
34
+ end
35
+
36
+ # Build the track event for a reservation from the handle alone, with no
37
+ # store access, so the client can still bill the usage when the local
38
+ # settle fails against an unreachable store.
39
+ def self.build_reservation_track_event(reservation, actual_quantity, traits: nil)
40
+ # Whole event units, the same rounding the hold and the settle debit
41
+ # use: the API rejects a non-integer quantity during processing, and
42
+ # the local ledger has to move by what this event bills.
43
+ body = { event: reservation.event_subtype, quantity: Leases.wire_quantity(actual_quantity) }
44
+ if reservation.server_mode?
45
+ # The hold lives on the server, so the event settles it by id. Never
46
+ # send lease_id too: the server prefers it when both are set, and
47
+ # there is no lease here for it to route through.
48
+ body[:reservation_id] = reservation.id
49
+ else
50
+ # Routes the server-side credit consumption through the lease's
51
+ # sub-ledger instead of decrementing the grant again, which the
52
+ # acquire already pre-debited. Without this the grant double-debits
53
+ # and eventually starves redemptions mid-session.
54
+ body[:lease_id] = reservation.lease_id
55
+ end
56
+ eval_ctx = reservation.eval_ctx || {}
57
+ company = eval_ctx[:company] || eval_ctx["company"]
58
+ user = eval_ctx[:user] || eval_ctx["user"]
59
+ body[:company] = company if company && !company.empty?
60
+ body[:user] = user if user && !user.empty?
61
+ body[:traits] = traits if traits
62
+ body
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Schematic
6
+ module Credits
7
+ # Client-side credit leases, reservations, and preflight checks.
8
+ #
9
+ # A credit-metered feature is gated without a wire call per check: the SDK
10
+ # leases a tranche of credits per (company, credit type), carves a
11
+ # reservation out of it at check time, and settles the reservation against
12
+ # the actual usage when the work finishes. See conformance/SPEC.md at the
13
+ # repo root for the full model; the vectors there pin the semantics every
14
+ # Schematic SDK shares.
15
+ module Leases
16
+ # Durations are milliseconds throughout, matching the names and units the
17
+ # other SDKs use so one set of numbers configures a mixed fleet.
18
+
19
+ DEFAULT_LEASE_DURATION_MS = 5 * 60 * 1000
20
+ DEFAULT_RESERVATION_TTL_MS = 60 * 1000
21
+ # The API rejects a hold whose expires_at is more than an hour out, so a
22
+ # larger reservation TTL would fail every server-mode check. The SDK
23
+ # clamps to this instead.
24
+ MAX_RESERVATION_TTL_MS = 60 * 60 * 1000
25
+ # The API measures that hour against its own clock while the SDK computes
26
+ # expires_at against the caller's, so a client running ahead would be
27
+ # rejected at exactly the cap. Hold this much back from it.
28
+ RESERVATION_TTL_SKEW_ALLOWANCE_MS = 60 * 1000
29
+ DEFAULT_LEASE_SIZE = 10_000
30
+ DEFAULT_LOW_WATER_MARK = 0.25
31
+ DEFAULT_SWEEP_INTERVAL_MS = 1000
32
+ # How long prewarm is willing to wait for a freshly identified company to
33
+ # surface in the datastream cache before giving up. Long enough to cover
34
+ # the buffer-flush, server-ingest, datastream-push round trip for a new
35
+ # company; short enough that a misconfigured caller does not hang.
36
+ DEFAULT_PREWARM_RESOLVE_TIMEOUT_MS = 5000
37
+ DEFAULT_PREWARM_POLL_INTERVAL_MS = 100
38
+ # How long close waits for in-flight lease work to land before giving up
39
+ # on it. Bounded on purpose: a shutdown that hangs is worse than a hold
40
+ # the server expires at DEFAULT_LEASE_DURATION_MS.
41
+ SHUTDOWN_DRAIN_TIMEOUT_MS = 5000
42
+
43
+ # How many in-flight extends one caller will wait out before issuing its
44
+ # own. Two covers the case the single flight was written for: the flight a
45
+ # caller joins, and the follow-up another caller registers while it was
46
+ # waiting.
47
+ MAX_EXTEND_JOINS = 2
48
+
49
+ # Balance substituted for a fail-open evaluation: large enough that the
50
+ # credit gate always passes, and the same figure the other SDKs use so a
51
+ # shared vector can name it.
52
+ FAIL_OPEN_BALANCE = (2**53) - 1
53
+
54
+ # Where a credit hold lives for a check that passes usage.
55
+ # :client - local leases over DataStream.
56
+ # :server - one check-and-reserve API call per check.
57
+ # :auto - client when DataStream is enabled, server otherwise.
58
+ MODES = %i[client server auto].freeze
59
+
60
+ # What to do when a lease cannot be acquired or reserved against.
61
+ # :fail_open - re-run the engine with the credit balance assumed
62
+ # sufficient, so non-credit rules still apply.
63
+ # :fail_closed - deny, because the gate cannot gate.
64
+ FAILURE_MODES = %i[fail_open fail_closed].freeze
65
+
66
+ # Reads the current time. Every store and the lease manager take one so
67
+ # tests and the conformance runner can drive a virtual clock instead of
68
+ # wall time.
69
+ DEFAULT_CLOCK = -> { Time.now }
70
+
71
+ # Convert a caller-supplied mode to a symbol, accepting the hyphenated
72
+ # spellings the other SDKs use ("fail-open") so one config shape travels.
73
+ def self.normalize_symbol(value)
74
+ return nil if value.nil?
75
+
76
+ value.to_s.tr("-", "_").downcase.to_sym
77
+ end
78
+
79
+ # How long stop waits for the sweeper to finish what it is doing before
80
+ # killing it. Long enough for a sweep already past its claim to land its
81
+ # refund, short enough that close stays prompt.
82
+ SWEEP_STOP_JOIN_MS = 100
83
+
84
+ # Resolve a caller's on_acquire_failure, or fail closed.
85
+ #
86
+ # An unrecognized value must not read as fail-open: the branches all test
87
+ # for :fail_closed, so a typo would quietly turn the safe default into the
88
+ # permissive one on every check. Name the value in the warning, since a
89
+ # silent downgrade of a gate is the thing worth telling someone about.
90
+ def self.resolve_failure_mode(value, logger = nil)
91
+ return :fail_closed if value.nil?
92
+
93
+ mode = normalize_symbol(value)
94
+ return mode if FAILURE_MODES.include?(mode)
95
+
96
+ logger&.warn(
97
+ "Unrecognized on_acquire_failure #{value.inspect}; expected one of " \
98
+ "#{FAILURE_MODES.join(" or ")}. Failing closed."
99
+ )
100
+ :fail_closed
101
+ end
102
+
103
+ # Whether a caller-supplied quantity can size a credit hold. NaN is the
104
+ # dangerous case: it slips through every numeric comparison, and a NaN
105
+ # balance would approve every later reserve on a possibly shared lease.
106
+ def self.valid_quantity?(value)
107
+ value.is_a?(Numeric) && !value.to_f.nan? && value.to_f.finite? && value >= 0
108
+ end
109
+
110
+ # The local view of the one lease a (company, credit type) slot holds.
111
+ class LeaseEntry
112
+ attr_accessor :lease_id, :company_id, :credit_type_id, :granted_amount,
113
+ :local_remaining_credits, :expires_at
114
+
115
+ def initialize(lease_id:, company_id:, credit_type_id:, granted_amount:, expires_at:,
116
+ local_remaining_credits: nil)
117
+ @lease_id = lease_id
118
+ @company_id = company_id
119
+ @credit_type_id = credit_type_id
120
+ @granted_amount = granted_amount.to_f
121
+ @local_remaining_credits = (local_remaining_credits || granted_amount).to_f
122
+ @expires_at = expires_at
123
+ end
124
+
125
+ def dup
126
+ LeaseEntry.new(
127
+ lease_id: @lease_id,
128
+ company_id: @company_id,
129
+ credit_type_id: @credit_type_id,
130
+ granted_amount: @granted_amount,
131
+ local_remaining_credits: @local_remaining_credits,
132
+ expires_at: @expires_at
133
+ )
134
+ end
135
+
136
+ def expired?(now)
137
+ @expires_at.to_f <= now.to_f
138
+ end
139
+ end
140
+
141
+ # The post-debit balance a successful try_reserve returns, plus the id of
142
+ # the lease the credits actually came out of. The debit is not keyed by
143
+ # lease id, so the caller pins its reservation to this id and never to the
144
+ # one its acquire handed back.
145
+ ReserveResult = Struct.new(:balance, :lease_id)
146
+
147
+ # One credit hold carved out of a lease by a check. Returned to the caller
148
+ # from check and handed back to track_with_reservation.
149
+ class Reservation
150
+ attr_reader :id, :lease_id, :mode, :company_id, :credit_type_id, :event_subtype,
151
+ :quantity_reserved, :credits_reserved, :consumption_rate, :expires_at, :eval_ctx
152
+
153
+ def initialize(id:, lease_id:, company_id:, credit_type_id:, event_subtype:,
154
+ quantity_reserved:, credits_reserved:, consumption_rate:, expires_at:,
155
+ eval_ctx: {}, mode: nil)
156
+ @id = id
157
+ @lease_id = lease_id
158
+ # :server means the API holds the credits and the settling track event
159
+ # routes by reservation_id; nil (or :client) means the hold is a local
160
+ # carve-out of a lease.
161
+ @mode = mode
162
+ @company_id = company_id
163
+ @credit_type_id = credit_type_id
164
+ @event_subtype = event_subtype
165
+ @quantity_reserved = quantity_reserved.to_f
166
+ @credits_reserved = credits_reserved.to_f
167
+ @consumption_rate = consumption_rate.to_f
168
+ @expires_at = expires_at
169
+ @eval_ctx = eval_ctx || {}
170
+ end
171
+
172
+ def server_mode?
173
+ @mode == :server
174
+ end
175
+
176
+ def to_h
177
+ {
178
+ id: @id,
179
+ lease_id: @lease_id,
180
+ mode: @mode,
181
+ company_id: @company_id,
182
+ credit_type_id: @credit_type_id,
183
+ event_subtype: @event_subtype,
184
+ quantity_reserved: @quantity_reserved,
185
+ credits_reserved: @credits_reserved,
186
+ consumption_rate: @consumption_rate,
187
+ expires_at: @expires_at,
188
+ eval_ctx: @eval_ctx
189
+ }
190
+ end
191
+ end
192
+
193
+ # Cast a usage onto the integer the wire carries. A hold can be sized from
194
+ # a fractional usage, but every quantity field on the API (the
195
+ # check-and-reserve ask, the preflight envelope, a track event) is an
196
+ # integer, and the generated models truncate a float onto it. A preflight
197
+ # asks an upper-bound question and a settle must not bill a partial unit
198
+ # as none, so a fraction rounds up in both directions.
199
+ # Float noise is shaved off before the rounding: (0.1 + 0.2) * 10 is
200
+ # 3.0000000000000004, and a bare ceil would bill that as 4.
201
+ ROUND_UP_TOLERANCE = 1e-9
202
+
203
+ def self.wire_quantity(value)
204
+ return value unless value.is_a?(Numeric)
205
+ return value if value.is_a?(Integer)
206
+ return value unless value.finite?
207
+
208
+ shaved = value - ROUND_UP_TOLERANCE
209
+ shaved.positive? ? shaved.ceil : value.ceil
210
+ end
211
+
212
+ # One shape for the matched entitlement whichever mode produced it.
213
+ #
214
+ # The WASM engine hands back a camelCase hash and the API hands back a
215
+ # generated model, so without this a caller reading result.entitlement
216
+ # would need one accessor for client mode and another for server mode.
217
+ # Both become a snake_case, symbol-keyed Hash matching the field names on
218
+ # Schematic::Types::FeatureEntitlement, and metric_reset_at is parsed to a
219
+ # Time so a caller can compare it without knowing which mode it came from.
220
+ def self.normalize_entitlement(raw)
221
+ return nil if raw.nil?
222
+
223
+ hash = raw.is_a?(Hash) ? raw : entitlement_to_h(raw)
224
+ return nil if hash.nil?
225
+
226
+ normalized = deep_snake_case(hash)
227
+ reset_at = normalized[:metric_reset_at]
228
+ normalized[:metric_reset_at] = parse_reset_at(reset_at) unless reset_at.nil?
229
+ normalized
230
+ end
231
+
232
+ def self.entitlement_to_h(raw)
233
+ return raw.to_h if raw.respond_to?(:to_h)
234
+
235
+ nil
236
+ end
237
+ private_class_method :entitlement_to_h
238
+
239
+ def self.parse_reset_at(value)
240
+ parse_api_time(value)
241
+ rescue StandardError
242
+ value
243
+ end
244
+ private_class_method :parse_reset_at
245
+
246
+ # The API's timestamps are UTC, but Time.iso8601 reads one that carries
247
+ # no offset as host-local time, which would shift a lease's expiry by the
248
+ # host's UTC offset. A timestamp with no zone is read as UTC instead.
249
+ def self.parse_api_time(value)
250
+ return value.getutc if value.is_a?(Time)
251
+
252
+ text = value.to_s
253
+ text += "Z" if text.include?("T") && !text.match?(/(?:Z|[+-]\d{2}(?::?\d{2})?)\z/i)
254
+ Time.iso8601(text).utc
255
+ end
256
+
257
+ def self.deep_snake_case(value)
258
+ case value
259
+ when Hash
260
+ value.each_with_object({}) do |(key, inner), out|
261
+ out[key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym] = deep_snake_case(inner)
262
+ end
263
+ when Array
264
+ value.map { |inner| deep_snake_case(inner) }
265
+ else
266
+ value
267
+ end
268
+ end
269
+ private_class_method :deep_snake_case
270
+
271
+ # What a lease-aware check decided. `allowed` is what the caller gates on;
272
+ # `reservation` is present only when a hold was taken.
273
+ class CheckResult
274
+ attr_reader :allowed, :value, :reservation, :reason, :entitlement, :flag_key, :flag_id, :error
275
+
276
+ def initialize(allowed:, value:, reason:, flag_key:, reservation: nil, entitlement: nil,
277
+ flag_id: nil, error: nil)
278
+ @allowed = allowed
279
+ @value = value
280
+ @reservation = reservation
281
+ @reason = reason
282
+ @entitlement = Leases.normalize_entitlement(entitlement)
283
+ @flag_key = flag_key
284
+ @flag_id = flag_id
285
+ @error = error
286
+ end
287
+
288
+ def allowed?
289
+ @allowed
290
+ end
291
+
292
+ def to_h
293
+ {
294
+ allowed: @allowed,
295
+ value: @value,
296
+ reservation: @reservation&.to_h,
297
+ reason: @reason,
298
+ entitlement: @entitlement,
299
+ flag_key: @flag_key,
300
+ flag_id: @flag_id,
301
+ error: @error
302
+ }.compact
303
+ end
304
+ end
305
+
306
+ # The four resolvable knobs for one credit type, after overrides and
307
+ # defaults.
308
+ ResolvedLeaseConfig = Struct.new(:lease_duration_ms, :reservation_ttl_ms, :lease_size, :low_water_mark,
309
+ keyword_init: true)
310
+
311
+ # Resolve the knobs for one credit type: the credit type's override wins,
312
+ # then the client-wide config, then the default.
313
+ def self.resolve_config(config, credit_type_id)
314
+ config ||= {}
315
+ overrides = config[:overrides] || {}
316
+ override = overrides[credit_type_id] || overrides[credit_type_id.to_s] ||
317
+ overrides[credit_type_id.to_sym] || {}
318
+ ResolvedLeaseConfig.new(
319
+ lease_duration_ms: override[:default_lease_duration] || config[:default_lease_duration] ||
320
+ DEFAULT_LEASE_DURATION_MS,
321
+ reservation_ttl_ms: override[:default_reservation_ttl] || config[:default_reservation_ttl] ||
322
+ DEFAULT_RESERVATION_TTL_MS,
323
+ lease_size: override[:default_lease_size] || config[:default_lease_size] || DEFAULT_LEASE_SIZE,
324
+ low_water_mark: override[:low_water_mark] || config[:low_water_mark] || DEFAULT_LOW_WATER_MARK
325
+ )
326
+ end
327
+ end
328
+ end
329
+ end