ordainer 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6f012928c3aedf7e8eafb2b5f2fd6fcec671f467e4a44d6214bc55c3adf49ee1
4
+ data.tar.gz: b748a4a31fa42620dc1fa045182147001f526fa789571ec9a1f2c112be85d416
5
+ SHA512:
6
+ metadata.gz: 7c4e07d10dfb6911bddc8cba631b5ec33cc52499a9dfd8e83dfd75ce5424870a04fa8f1b69c87e73f66a4a719f44e8e14eb0fe4ee7b2c29cebdbda029ef044b0
7
+ data.tar.gz: 5fd5691ec62d7f837706fc46c6e4024cad416fa647dbac76357d74200446c1c077a58af50489752f8b6d8a4ced135bef3f88254ee3803082d50ba7ee9bc2fdf3
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+ - First version: Ordainer::Client#decide, Decision, typed errors, retries, fail_mode, optional Rails guard.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ordainer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # ordainer (Ruby)
2
+
3
+ Ask Ordainer before your tool runs: one `decide` call checks the caller's plan and counts the use.
4
+ Ruby 3.0+, standard library only (net/http, json), no runtime dependencies. Mirrors the Node SDK
5
+ (`packages/ordainer-node`, 1.2.2).
6
+
7
+ > 0.1.0, not on RubyGems yet. Install from the repo (see the Ruby quickstart in the docs).
8
+
9
+ ```ruby
10
+ require "ordainer"
11
+
12
+ client = Ordainer::Client.new(api_key: ENV["ORDAINER_API_KEY"]) # ord_test_... or ord_live_...
13
+ decision = client.decide(tool: "research.run")
14
+
15
+ if decision.allowed?
16
+ run_research
17
+ else
18
+ r = Ordainer.deny_response(decision) # { status:, headers:, body: { error: { code, message, ... } } }
19
+ end
20
+ ```
21
+
22
+ ## Options
23
+
24
+ | Option | Default | Meaning |
25
+ | --- | --- | --- |
26
+ | `api_key:` | `ENV["ORDAINER_API_KEY"]`, then `ENV["ORDAINER_KEY"]` | Agent key. Override per call: `decide(tool:, api_key:)`. |
27
+ | `base_url:` | `ENV["ORDAINER_BASE_URL"]` or `https://api.ordainer.dev` | |
28
+ | `timeout:` | `0.3` | Seconds per attempt. |
29
+ | `fail_mode:` | `:open` | `:open` lets the call run when Ordainer can't answer; `:closed` blocks it. |
30
+ | `retries:` | `1` | Extra attempts (0 to 5) after a timeout, network error or 503, with jittered backoff, same idempotency key. |
31
+ | `retry_budget:` | `0.65` | Seconds for all attempts of one `decide`. |
32
+ | `mode:` | `nil` | `nil` follows the plan's mode in the console; `:enforce` or `:shadow`. |
33
+ | `billing_lapse:` | `:open` | What to do on 403 `subscription_inactive`. |
34
+ | `deny_status:` | `429` | Status for quota denies: 429 or 402. |
35
+ | `sticky_deny:` | `60` | Seconds to keep denying would-be fail-opens for an (agent key, tool) after a quota deny. 0 = off. |
36
+ | `logger:` | warn to `$stderr` | A `Logger`, or `false`. |
37
+ | `on_decision:` | `nil` | Called with every `Decision`. |
38
+
39
+ ## Rules (same as the Node SDK)
40
+
41
+ - `decide` never raises for an Ordainer answer or an outage. A deny is a normal result. Bad options raise
42
+ `Ordainer::ConfigurationError`. `decide!` raises `DeniedError`, `RejectedError`, `RateLimitedError` or
43
+ `UnavailableError` (all `Ordainer::BlockedError`, with `#status`, `#headers`, `#body`, `#decision`).
44
+ - Only a deny from Ordainer blocks in enforce mode. Shadow never blocks (`would_deny?`).
45
+ - `fail_mode` covers only "Ordainer couldn't answer": timeout, network error, 5xx, or a 200 that isn't a decision.
46
+ - A 4xx (bad or missing key, 400, 409, 429 rate limit, ...) is Ordainer answering "no": blocked in any
47
+ `fail_mode` (`rejected?`). 403 `subscription_inactive` follows `billing_lapse` instead. 429
48
+ `safety_limit_reached` lets the call run (`safety_limited`).
49
+ - Only timeouts, network errors and 503 are retried, never other 5xx or 4xx.
50
+
51
+ ## Decision
52
+
53
+ `allowed?`, `denied?`, `decision` (`"allow"`/`"deny"`/`nil`), `reason`, `remaining`, `limit`, `resets_at`,
54
+ `request_id`, `mode`, `would_deny?`, `fail_open?`, `fail_closed?`, `unavailable?`, `rejected?`, `rate_limited?`,
55
+ `retry_after_sec`, `billing_inactive`, `test_limit_reached`, `safety_limited`, `sticky_deny`, `replayed?`,
56
+ `attempts`, `latency_ms`, `error` (`code`, `message`, `status`, `limit_scope`, `request_id`), `to_h`.
57
+
58
+ ## Rails
59
+
60
+ ```ruby
61
+ # config/initializers/ordainer.rb
62
+ Ordainer.configure do |c|
63
+ c.api_key = ENV["ORDAINER_API_KEY"]
64
+ end
65
+
66
+ class ResearchController < ApplicationController
67
+ include Ordainer::Rails::Guard
68
+ ordainer_check tool: "research.run", customer: ->(c) { c.current_account.ordainer_key }, only: :create
69
+ end
70
+ ```
71
+
72
+ `customer:` returns the agent key for the customer making the request (one agent key is one customer); leave it out
73
+ to use the configured key. On a blocked call the controller answers the JSON from `Ordainer.deny_response`
74
+ (403, 429, 500 or 503). `ordainer_decision` holds the `Decision`. Rails replaces `X-Request-Id` with its own id, so the guard also sends Ordainer's as `X-Ordainer-Request-Id` (and `request_id` in the body).
75
+
76
+ ## Tests
77
+
78
+ ```sh
79
+ cd packages/ordainer-ruby && bundle install && bundle exec rspec
80
+ ```
81
+
82
+ Set `ORDAINER_LIVE_TEST_KEY` (a test key) to also run one live call against `ORDAINER_BASE_URL`.
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "securerandom"
7
+ require "digest"
8
+ require "logger"
9
+
10
+ module Ordainer
11
+ DEFAULT_BASE_URL = "https://api.ordainer.dev"
12
+ DEFAULT_TIMEOUT = 0.3 # seconds per attempt (Node: 300 ms)
13
+ DEFAULT_RETRIES = 1 # Node: 1
14
+ MAX_RETRIES = 5
15
+ DEFAULT_RETRY_BUDGET = 0.65 # seconds for all attempts of one decide (Node: 650 ms)
16
+ RETRY_DELAY = 0.05 # first pause; doubles each retry, with +/-50% jitter
17
+ MIN_ATTEMPT = 0.05 # a retry with less time left than this isn't started
18
+ DEFAULT_STICKY_DENY = 60 # seconds (Node: 60 s; 0 = off, max 300)
19
+ MAX_STICKY_DENY = 300
20
+ MAX_STICKY_ENTRIES = 10_000
21
+ MAX_IDEMPOTENCY_LEN = 255
22
+ MODES = %i[enforce shadow].freeze
23
+ FAIL_MODES = %i[open closed].freeze
24
+ DENY_STATUSES = [429, 402].freeze
25
+ BILLING_CODES = %w[subscription_inactive subscription_required].freeze
26
+ CONCURRENCY_CODE = "concurrency_limited"
27
+ SAFETY_LIMIT_CODE = "safety_limit_reached"
28
+ LIMIT_SCOPES = %w[per_key per_address per_key_concurrency org_safety].freeze
29
+ BAD_KEY_TEXT = /[\u0000-\u001f\u007f]/
30
+
31
+ # The idempotency key actually sent for raw: the key itself, or "tool:" + sha256hex(raw) (or bare sha256hex) when it
32
+ # is over 255 characters or holds control characters the server refuses. Same rule as the Node SDK.
33
+ def self.idempotency_key_for(tool, raw, prefix_tool: false)
34
+ raw = raw.to_s
35
+ candidate = prefix_tool ? "#{tool}:#{raw}" : raw
36
+ return candidate if candidate.length <= MAX_IDEMPOTENCY_LEN && candidate !~ BAD_KEY_TEXT
37
+
38
+ digest = Digest::SHA256.hexdigest(raw.b)
39
+ with_tool = "#{tool}:#{digest}"
40
+ with_tool.length <= MAX_IDEMPOTENCY_LEN && with_tool !~ BAD_KEY_TEXT ? with_tool : digest
41
+ end
42
+
43
+ # Thread-safe; share one per process.
44
+ class Client
45
+ attr_reader :base_url, :timeout, :fail_mode, :retries, :retry_budget, :mode, :billing_lapse, :deny_status
46
+
47
+ # api_key: agent key (ord_test_/ord_live_). Default ENV["ORDAINER_API_KEY"] or ENV["ORDAINER_KEY"].
48
+ # base_url: default ENV["ORDAINER_BASE_URL"] or https://api.ordainer.dev
49
+ # timeout: seconds per attempt, default 0.3
50
+ # fail_mode: :open (default) or :closed. ONLY for timeouts, network errors, 5xx and malformed answers.
51
+ # Any 4xx and a missing key always block (decision.rejected?), except 403 subscription_inactive,
52
+ # which follows billing_lapse.
53
+ # retries: extra attempts after a timeout, network error or 503 (default 1, max 5, 0 = off). Same
54
+ # idempotency key, so a retry is replayed, never counted twice. 4xx and other 5xx never retry.
55
+ # retry_budget: seconds for all attempts of one decide (default 0.65).
56
+ # mode: nil (follow the plan's mode in the console), :enforce or :shadow
57
+ # billing_lapse: :open (default) or :closed, for 403 subscription_inactive (Terms 10.3(b)).
58
+ # deny_status: 429 (default) or 402 for quota denies.
59
+ # sticky_deny: seconds to keep denying would-be fail-opens for an (agent key, tool) after quota_exceeded.
60
+ # logger: a Logger (default: warn to $stderr), or false for silent.
61
+ # on_decision: callable, called with every Decision (its errors are swallowed).
62
+ def initialize(api_key: nil, base_url: nil, timeout: DEFAULT_TIMEOUT, fail_mode: :open, retries: DEFAULT_RETRIES,
63
+ retry_budget: DEFAULT_RETRY_BUDGET, mode: nil, billing_lapse: :open, deny_status: 429,
64
+ sticky_deny: DEFAULT_STICKY_DENY, logger: nil, on_decision: nil, sleeper: nil)
65
+ @api_key = api_key || ENV["ORDAINER_API_KEY"] || ENV["ORDAINER_KEY"]
66
+ @base_url = (base_url || ENV["ORDAINER_BASE_URL"] || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
67
+ @timeout = Float(timeout)
68
+ raise ConfigurationError, "timeout must be > 0" unless @timeout.positive?
69
+ @fail_mode = check_choice(fail_mode, FAIL_MODES, "fail_mode")
70
+ @billing_lapse = check_choice(billing_lapse, FAIL_MODES, "billing_lapse")
71
+ @mode = mode.nil? ? nil : check_choice(mode, MODES, "mode")
72
+ @retries = check_retries(retries)
73
+ @retry_budget = Float(retry_budget)
74
+ raise ConfigurationError, "deny_status must be 429 or 402" unless DENY_STATUSES.include?(deny_status)
75
+ @deny_status = deny_status
76
+ unless sticky_deny.is_a?(Numeric) && sticky_deny >= 0 && sticky_deny <= MAX_STICKY_DENY
77
+ raise ConfigurationError, "sticky_deny must be 0 (off) to #{MAX_STICKY_DENY} seconds"
78
+ end
79
+ @sticky_deny = sticky_deny
80
+ @sticky = {}
81
+ @lock = Mutex.new
82
+ @warned = {}
83
+ @logger = logger == false ? nil : (logger || Logger.new($stderr, progname: "ordainer"))
84
+ @on_decision = on_decision
85
+ @sleeper = sleeper || ->(s) { sleep(s) }
86
+ @uri = URI.join("#{@base_url}/", "v1/decide")
87
+ rescue ArgumentError, TypeError => e
88
+ raise ConfigurationError, e.message
89
+ end
90
+
91
+ # Never raises for an Ordainer answer or outage: a deny is a normal Decision. Bad arguments raise
92
+ # ConfigurationError. api_key: overrides the client's key for this call (one agent key per customer).
93
+ def decide(tool:, api_key: nil, idempotency_key: nil, mode: nil, fail_mode: nil, billing_lapse: nil, retries: nil)
94
+ started = monotonic
95
+ eff_fail = fail_mode.nil? ? @fail_mode : check_choice(fail_mode, FAIL_MODES, "fail_mode")
96
+ eff_lapse = billing_lapse.nil? ? @billing_lapse : check_choice(billing_lapse, FAIL_MODES, "billing_lapse")
97
+ req_mode = mode.nil? ? @mode : check_choice(mode, MODES, "mode")
98
+ max_attempts = 1 + (retries.nil? ? @retries : check_retries(retries))
99
+ key = api_key || @api_key
100
+ raw = idempotency_key.is_a?(Numeric) ? idempotency_key.to_s : idempotency_key
101
+ idem = raw.is_a?(String) && !raw.empty? ? Ordainer.idempotency_key_for(tool.is_a?(String) ? tool : "", raw) : SecureRandom.uuid
102
+ base = { tool: tool, idempotency_key: idem, mode: req_mode&.to_s, deny_status: @deny_status, attempts: 0,
103
+ would_deny: false, fail_open: false, fail_closed: false, rejected: false, billing_inactive: false,
104
+ test_limit_reached: false, rate_limited: false, safety_limited: false, sticky_deny: false, replayed: false }
105
+
106
+ result =
107
+ if key.nil? || key.to_s.empty?
108
+ rejected(base, ErrorInfo.new(code: "missing_key", message: "No #{BRAND_NAME} agent key (api_key / ORDAINER_API_KEY)."))
109
+ elsif !tool.is_a?(String) || tool.empty?
110
+ rejected(base, ErrorInfo.new(code: "invalid_request", message: "tool is required."))
111
+ else
112
+ budget_end = started + @retry_budget
113
+ attempt_timeout = @timeout
114
+ attempt = 0
115
+ loop do
116
+ attempt += 1
117
+ r, retryable = call(base, key, tool, idem, req_mode, eff_fail, eff_lapse, attempt_timeout)
118
+ r.attempts = attempt
119
+ result = r
120
+ break if !retryable || attempt >= max_attempts
121
+
122
+ pause = RETRY_DELAY * (2**(attempt - 1)) * (0.5 + rand) # jittered backoff
123
+ left = budget_end - monotonic - pause
124
+ break if left < MIN_ATTEMPT # out of budget: fail_mode applies now
125
+
126
+ attempt_timeout = [@timeout, left].min
127
+ @sleeper.call(pause)
128
+ end
129
+ apply_sticky_deny(result, key, tool, req_mode)
130
+ end
131
+ result.latency_ms = ((monotonic - started) * 1000).round
132
+ report(result)
133
+ result
134
+ end
135
+
136
+ # Like decide, but raises a BlockedError subclass when the call must not run. Returns the Decision otherwise.
137
+ def decide!(**kwargs)
138
+ d = decide(**kwargs)
139
+ return d if d.allowed?
140
+ raise RateLimitedError, d if d.rate_limited
141
+ raise RejectedError, d if d.rejected
142
+ raise UnavailableError, d if d.fail_closed
143
+ raise DeniedError, d
144
+ end
145
+
146
+ # Run the block only if allowed: client.guard(tool: "x") { do_work }. Raises like decide!.
147
+ def guard(**kwargs)
148
+ decision = decide!(**kwargs)
149
+ yield decision
150
+ end
151
+
152
+ def inspect = "#<Ordainer::Client base_url=#{@base_url} fail_mode=#{@fail_mode}>" # never shows the key
153
+
154
+ private
155
+
156
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
157
+
158
+ def check_choice(value, allowed, name)
159
+ sym = value.respond_to?(:to_sym) ? value.to_sym : value
160
+ return sym if allowed.include?(sym)
161
+
162
+ raise ConfigurationError, "#{name} must be #{allowed.map(&:inspect).join(' or ')} (got #{value.inspect})"
163
+ end
164
+
165
+ def check_retries(r)
166
+ return r if r.is_a?(Integer) && r >= 0 && r <= MAX_RETRIES
167
+
168
+ raise ConfigurationError, "retries must be an integer from 0 to #{MAX_RETRIES}"
169
+ end
170
+
171
+ # Returns [Decision, retryable].
172
+ def call(base, key, tool, idem, req_mode, fail_mode, lapse, timeout)
173
+ body = { tool: tool, idempotency_key: idem }
174
+ body[:mode] = req_mode.to_s if req_mode
175
+ begin
176
+ res = post(key, JSON.generate(body), timeout)
177
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, ::Timeout::Error
178
+ return [failure(base, fail_mode, ErrorInfo.new(code: "timeout", message: "#{BRAND_NAME} did not answer within #{(timeout * 1000).round}ms")), true]
179
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, EOFError => e
180
+ dns = e.is_a?(SocketError) && e.message =~ /getaddrinfo|name or service|not known|nodename/i
181
+ msg = dns ? "#{e.message} (host #{@uri.host} not found; check ORDAINER_BASE_URL)" : e.message
182
+ report_dns(fail_mode) if dns
183
+ return [failure(base, fail_mode, ErrorInfo.new(code: "network_error", message: msg)), true]
184
+ end
185
+
186
+ status = res.code.to_i
187
+ json = begin
188
+ JSON.parse(res.body.to_s)
189
+ rescue JSON::ParserError
190
+ nil
191
+ end
192
+ json = nil unless json.is_a?(Hash)
193
+
194
+ if status != 200 || json.nil? || !%w[allow deny].include?(json["decision"])
195
+ env = json && json["error"].is_a?(Hash) ? json["error"] : {}
196
+ code = env["code"]
197
+ err = ErrorInfo.new(code: code || (status >= 500 ? "server_error" : "bad_response"),
198
+ message: env["message"] || "#{BRAND_NAME} answered HTTP #{status}",
199
+ status: status, request_id: env["request_id"] || res["x-request-id"])
200
+ if status == 429
201
+ err.limit_scope = LIMIT_SCOPES.include?(env["limit_scope"]) ? env["limit_scope"] :
202
+ { "too_many_failed_auth" => "per_address", "rate_limited" => "per_key",
203
+ CONCURRENCY_CODE => "per_key_concurrency", SAFETY_LIMIT_CODE => "org_safety" }[code]
204
+ end
205
+ # 429 safety_limit_reached: a safety limit, not a deny. Runs in any fail_mode.
206
+ if status == 429 && code == SAFETY_LIMIT_CODE
207
+ secs = Integer(res["retry-after"], exception: false)
208
+ return [Decision.new(**base, allowed: true, safety_limited: true, reason: SAFETY_LIMIT_CODE,
209
+ resets_at: env["resets_at"].is_a?(String) ? env["resets_at"] : nil,
210
+ retry_after_sec: secs&.positive? ? secs : nil, request_id: err.request_id,
211
+ error: err.tap { |x| x.limit_scope = "org_safety" }), false]
212
+ end
213
+ is4xx = status >= 400 && status < 500
214
+ d = if status == 403 && code == "subscription_inactive" then lapse_decision(base, lapse, err)
215
+ elsif is4xx then rejected(base, err)
216
+ else failure(base, fail_mode, err)
217
+ end
218
+ if status == 403 && BILLING_CODES.include?(code)
219
+ d.billing_inactive = true
220
+ d.reason = code
221
+ end
222
+ if status == 429 && [CONCURRENCY_CODE, "rate_limited"].include?(code)
223
+ secs = Integer(res["retry-after"], exception: false)
224
+ d.rate_limited = true
225
+ d.reason = code
226
+ d.retry_after_sec = secs&.positive? ? secs : 1
227
+ end
228
+ return [d, status == 503] # only a 503 is retried (Node SDK: decide_timeout, db_unavailable, outage)
229
+ end
230
+
231
+ test_limit = json["decision"] == "deny" && json["reason"] == TEST_LIMIT_REASON
232
+ eff_mode = if test_limit then json["mode"] == "shadow" ? "shadow" : "enforce"
233
+ elsif %w[shadow enforce].include?(json["mode"]) then json["mode"]
234
+ else (req_mode || :enforce).to_s
235
+ end
236
+ denied = json["decision"] == "deny"
237
+ [Decision.new(**base, test_limit_reached: test_limit, allowed: !denied || eff_mode == "shadow",
238
+ decision: json["decision"], reason: json["reason"], remaining: json["remaining"],
239
+ limit: json["limit"], resets_at: json["resets_at"], request_id: json["request_id"],
240
+ mode: eff_mode, would_deny: denied && eff_mode == "shadow",
241
+ replayed: res["idempotent-replayed"] == "true"), false]
242
+ end
243
+
244
+ def post(key, payload, timeout)
245
+ http = Net::HTTP.new(@uri.host, @uri.port)
246
+ http.use_ssl = @uri.scheme == "https"
247
+ http.open_timeout = timeout
248
+ http.read_timeout = timeout
249
+ http.write_timeout = timeout
250
+ http.ssl_timeout = timeout if http.use_ssl?
251
+ http.max_retries = 0 # retries are ours (same idempotency key, budgeted)
252
+ req = Net::HTTP::Post.new(@uri)
253
+ req["Authorization"] = "Bearer #{key}"
254
+ req["Content-Type"] = "application/json"
255
+ req["User-Agent"] = "ordainer-ruby/#{VERSION}"
256
+ req.body = payload
257
+ ::Timeout.timeout(timeout * 2) { http.request(req) } # hard cap on top of the socket timeouts
258
+ end
259
+
260
+ def rejected(base, err)
261
+ Decision.new(**base, allowed: false, rejected: true, request_id: err.request_id, error: err)
262
+ end
263
+
264
+ def failure(base, fail_mode, err)
265
+ Decision.new(**base, allowed: fail_mode == :open, fail_open: fail_mode == :open,
266
+ fail_closed: fail_mode == :closed, request_id: err.request_id, error: err)
267
+ end
268
+
269
+ def lapse_decision(base, lapse, err)
270
+ d = lapse == :closed ? rejected(base, err) : Decision.new(**base, allowed: true, request_id: err.request_id, error: err)
271
+ d.billing_lapse = lapse.to_s
272
+ d
273
+ end
274
+
275
+ def sticky_id(key, tool) = "#{Digest::SHA256.hexdigest(key.to_s)[0, 32]}\0#{tool}"
276
+
277
+ def apply_sticky_deny(r, key, tool, req_mode)
278
+ return r if @sticky_deny.zero?
279
+
280
+ id = sticky_id(key, tool)
281
+ now = Time.now
282
+ @lock.synchronize do
283
+ if r.decision == "deny" && r.reason == "quota_exceeded"
284
+ resets = (Time.iso8601(r.resets_at.to_s) rescue nil)
285
+ until_t = [now + @sticky_deny, resets].compact.min
286
+ if until_t > now
287
+ @sticky.delete(id)
288
+ @sticky.shift if @sticky.size >= MAX_STICKY_ENTRIES
289
+ @sticky[id] = { until: until_t, mode: r.mode, limit: r.limit, resets_at: r.resets_at }
290
+ end
291
+ return r
292
+ end
293
+ if r.decision == "allow"
294
+ @sticky.delete(id)
295
+ return r
296
+ end
297
+ return r if !r.fail_open || r.billing_inactive
298
+
299
+ entry = @sticky[id]
300
+ return r unless entry
301
+ if entry[:until] <= now
302
+ @sticky.delete(id)
303
+ return r
304
+ end
305
+ shadow = req_mode == :shadow || (req_mode.nil? && entry[:mode] == "shadow")
306
+ return r.dup_with(sticky_deny: true, would_deny: true, mode: "shadow") if shadow
307
+
308
+ r.dup_with(allowed: false, fail_open: false, sticky_deny: true, would_deny: false, reason: "quota_exceeded",
309
+ remaining: 0, limit: entry[:limit], resets_at: entry[:resets_at], mode: "enforce")
310
+ end
311
+ end
312
+
313
+ def report(d)
314
+ if @logger && d.error && (!d.allowed? || d.fail_open)
315
+ kind = "#{d.rejected ? 'rejected' : d.fail_open ? 'fail_open' : 'fail_closed'}:#{d.error&.code}"
316
+ what = d.rejected ? "rejected (call blocked in any fail_mode)" : d.fail_open ? "fail-open (call allowed)" : "blocked"
317
+ throttled_warn(kind) { "[ordainer] #{what}: #{d.error&.code}: #{d.error&.message} X-Request-Id=#{d.request_id || 'none'} tool=#{d.tool}" }
318
+ elsif @logger && d.would_deny && d.mode == "shadow"
319
+ cap = d.limit.nil? ? "no cap" : "#{d.remaining}/#{d.limit}"
320
+ @logger.warn("[ordainer] shadow: would deny tool=#{d.tool} reason=#{d.reason} remaining=#{cap} request_id=#{d.request_id} (call allowed)")
321
+ end
322
+ begin
323
+ @on_decision&.call(d)
324
+ rescue StandardError
325
+ nil # hooks never break the request
326
+ end
327
+ end
328
+
329
+ # One warning per kind per minute, so an outage doesn't log once per call.
330
+ def throttled_warn(kind)
331
+ now = monotonic
332
+ @lock.synchronize do
333
+ return if @warned[kind] && now - @warned[kind] < 60
334
+
335
+ @warned[kind] = now
336
+ end
337
+ @logger.warn(yield)
338
+ end
339
+
340
+ def report_dns(fail_mode)
341
+ return if @dns_reported || !@logger
342
+
343
+ @dns_reported = true
344
+ @logger.error("[ordainer] ERROR: cannot resolve the #{BRAND_NAME} API host \"#{@uri.host}\" from base_url #{@base_url}. " \
345
+ "Set ORDAINER_BASE_URL (or base_url:) to your #{BRAND_NAME} API URL. Until then every plan check fails: calls are " \
346
+ "#{fail_mode == :closed ? 'blocked (fail_mode :closed)' : 'let through unchecked (fail_mode :open)'}.")
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ordainer
4
+ # Global settings for Ordainer.client and the Rails guard:
5
+ # Ordainer.configure { |c| c.api_key = ENV["ORDAINER_API_KEY"]; c.fail_mode = :open }
6
+ class Configuration
7
+ attr_accessor :api_key, :base_url, :timeout, :fail_mode, :retries, :retry_budget, :mode,
8
+ :billing_lapse, :deny_status, :sticky_deny, :logger, :on_decision
9
+
10
+ def initialize
11
+ @api_key = ENV["ORDAINER_API_KEY"] || ENV["ORDAINER_KEY"]
12
+ @base_url = nil
13
+ end
14
+
15
+ def to_client_options
16
+ { api_key: api_key, base_url: base_url, timeout: timeout, fail_mode: fail_mode, retries: retries,
17
+ retry_budget: retry_budget, mode: mode, billing_lapse: billing_lapse, deny_status: deny_status,
18
+ sticky_deny: sticky_deny, logger: logger, on_decision: on_decision }.compact
19
+ end
20
+ end
21
+
22
+ class << self
23
+ def configuration = (@configuration ||= Configuration.new)
24
+
25
+ def configure
26
+ yield configuration
27
+ @client = nil
28
+ configuration
29
+ end
30
+
31
+ # A shared client built from the configuration (thread-safe to share; one per process is fine).
32
+ def client = (@client ||= Client.new(**configuration.to_client_options))
33
+
34
+ def reset!
35
+ @configuration = nil
36
+ @client = nil
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ordainer
4
+ # What went wrong when there was no decision: code, message, HTTP status, limit scope, request id.
5
+ ErrorInfo = Struct.new(:code, :message, :status, :limit_scope, :request_id, keyword_init: true)
6
+
7
+ # The result of Client#decide. Field names follow the Node SDK's result (snake_case).
8
+ class Decision
9
+ FIELDS = %i[
10
+ tool idempotency_key decision reason remaining limit resets_at request_id mode
11
+ would_deny fail_open fail_closed rejected error deny_status billing_inactive billing_lapse
12
+ test_limit_reached rate_limited retry_after_sec safety_limited sticky_deny replayed attempts latency_ms
13
+ ].freeze
14
+ attr_accessor(*FIELDS)
15
+ attr_writer :allowed
16
+
17
+ def initialize(**fields)
18
+ @allowed = false
19
+ fields.each { |k, v| public_send("#{k}=", v) }
20
+ end
21
+
22
+ # true: run the call. Shadow denies, fail-open and billing_lapse :open are allowed.
23
+ def allowed? = @allowed == true
24
+ def denied? = !allowed?
25
+ def would_deny? = would_deny == true
26
+ def fail_open? = fail_open == true
27
+ def fail_closed? = fail_closed == true
28
+ def rejected? = rejected == true
29
+ def rate_limited? = rate_limited == true
30
+ def replayed? = replayed == true
31
+ # Ordainer couldn't answer (timeout, network error, 5xx, malformed answer).
32
+ def unavailable? = fail_open? || fail_closed?
33
+
34
+ def to_h
35
+ FIELDS.to_h { |f| [f, public_send(f)] }.merge(allowed: allowed?).tap { |h| h[:error] = error&.to_h }
36
+ end
37
+
38
+ def dup_with(**changes)
39
+ d = dup
40
+ changes.each { |k, v| d.public_send("#{k}=", v) }
41
+ d
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Ordainer
6
+ BRAND_NAME = "Ordainer"
7
+ UNAVAILABLE_CODE = "ordainer_unavailable"
8
+ REJECTED_CODE = "plan_check_rejected"
9
+ TEST_LIMIT_REASON = "test_limit_reached"
10
+ # Legal-approved (Oct 3; tech/billing-lapse-scope.md item 2), verbatim, same as the Node SDK.
11
+ BILLING_BLOCKED_MESSAGE = "This action isn't available right now. This is a setup issue on the service's side; retrying won't help."
12
+
13
+ module_function
14
+
15
+ # What a blocked call should answer: { status:, headers:, body: { error: { code, message, request_id, ... } } }.
16
+ # Same statuses, codes and headers as the Node SDK's denyResponse(). Safe to return verbatim to the agent.
17
+ # The message never starts with a raw code; the code is its own field.
18
+ def deny_response(d, now: Time.now)
19
+ rid = d.request_id ? { "X-Request-Id" => d.request_id } : {}
20
+ if !d.allowed? && d.rate_limited
21
+ retry_after = (d.retry_after_sec || 1).to_s
22
+ return resp(429, { "Retry-After" => retry_after }.merge(rid),
23
+ code: "rate_limited",
24
+ message: "Too many plan checks for this agent right now (#{BRAND_NAME}'s per-key rate limit). Retry after #{retry_after}s. This call was not run.",
25
+ request_id: d.request_id, retry_after: retry_after.to_i)
26
+ end
27
+ if d.rejected || (d.fail_closed && d.billing_inactive)
28
+ billing = d.billing_inactive
29
+ return resp(billing ? 403 : 500, rid,
30
+ code: REJECTED_CODE,
31
+ message: billing ? BILLING_BLOCKED_MESSAGE : "The plan check refused this call because of a setup problem on the service's side, so it was not run. This is not an outage: retrying will not help until whoever runs the service fixes its setup.",
32
+ request_id: d.request_id)
33
+ end
34
+ if d.fail_closed
35
+ return resp(503, { "Retry-After" => "5" }.merge(rid),
36
+ code: UNAVAILABLE_CODE,
37
+ message: "The plan check is unavailable, so this call was not run. Try again shortly.",
38
+ request_id: d.request_id)
39
+ end
40
+ if d.reason == TEST_LIMIT_REASON
41
+ return resp(429, retry_after_header(d.resets_at, now).merge(rid),
42
+ code: TEST_LIMIT_REASON,
43
+ message: "This #{BRAND_NAME} sandbox account has used its free test-key decisions for this month." +
44
+ (d.resets_at ? " They reset at #{d.resets_at}." : "") +
45
+ " This is a sandbox limit on test keys, not a plan limit and not an outage. Live keys are not affected. This call was not run.",
46
+ request_id: d.request_id, reason: d.reason, resets_at: d.resets_at)
47
+ end
48
+ if d.reason == "quota_exceeded"
49
+ return resp(d.deny_status || 429, retry_after_header(d.resets_at, now).merge(rid),
50
+ code: "plan_limit_reached",
51
+ message: "This agent's plan limit is reached (#{d.remaining || 0} of #{d.limit || "?"} left)." +
52
+ (d.resets_at ? " It resets at #{d.resets_at}." : "") +
53
+ " Retrying before then will not succeed. This call was not run.",
54
+ request_id: d.request_id, reason: d.reason, remaining: d.remaining, limit: d.limit, resets_at: d.resets_at)
55
+ end
56
+ # Node says "(<reason>)" in the message; here the reason is only a field (no raw codes in the message).
57
+ resp(403, rid,
58
+ code: "plan_denied",
59
+ message: "This agent's plan does not allow this call, so it was not run.",
60
+ request_id: d.request_id, reason: d.reason)
61
+ end
62
+
63
+ def resp(status, headers, **error)
64
+ { status: status, headers: headers, body: { error: error } }
65
+ end
66
+
67
+ def retry_after_header(resets_at, now)
68
+ t = Time.iso8601(resets_at.to_s) rescue nil
69
+ t ? { "Retry-After" => [1, (t - now).ceil].max.to_s } : {}
70
+ end
71
+ private_class_method :resp, :retry_after_header
72
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ordainer
4
+ # Base class for everything this gem raises.
5
+ class Error < StandardError; end
6
+
7
+ # A bad option (fail_mode, retries, mode, ...). Raised up front, never from a network call.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised only by Client#decide! (decide never raises). Carries the Decision and the response a
11
+ # blocked call should answer (status, headers, body).
12
+ class BlockedError < Error
13
+ attr_reader :decision, :status, :headers, :body
14
+
15
+ def initialize(decision)
16
+ @decision = decision
17
+ response = Ordainer.deny_response(decision)
18
+ @status = response[:status]
19
+ @headers = response[:headers]
20
+ @body = response[:body]
21
+ super(@body[:error][:message])
22
+ end
23
+ end
24
+
25
+ # Ordainer answered deny (quota_exceeded, tool_not_in_plan, test_limit_reached, ...) in enforce mode.
26
+ class DeniedError < BlockedError; end
27
+ # Ordainer refused the request (any 4xx, missing key): blocked in any fail_mode.
28
+ class RejectedError < BlockedError; end
29
+ # 429 rate_limited / concurrency_limited: slow down.
30
+ class RateLimitedError < RejectedError; end
31
+ # Ordainer couldn't answer (timeout, network, 5xx, malformed) and fail_mode is :closed.
32
+ class UnavailableError < BlockedError; end
33
+
34
+ # Raised inside the transport; turned into a Decision, never seen by callers of decide.
35
+ class TimeoutError < Error; end
36
+ class NetworkError < Error; end
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional Rails helper. Loaded by `require "ordainer"` only when Rails (or ActionController) is already defined,
4
+ # or explicitly with `require "ordainer/rails"`.
5
+ #
6
+ # # config/initializers/ordainer.rb
7
+ # Ordainer.configure do |c|
8
+ # c.api_key = ENV["ORDAINER_API_KEY"]
9
+ # c.fail_mode = :open
10
+ # end
11
+ #
12
+ # class ResearchController < ApplicationController
13
+ # include Ordainer::Rails::Guard
14
+ # ordainer_check tool: "research.run", customer: ->(c) { c.current_account.ordainer_key }, only: :create
15
+ # end
16
+ module Ordainer
17
+ module Rails
18
+ # Controller concern. Works with any controller that has before_action, render and request
19
+ # (ActionController::Base / ::API). No ActiveSupport needed.
20
+ module Guard
21
+ def self.included(base)
22
+ base.extend(ClassMethods)
23
+ end
24
+
25
+ module ClassMethods
26
+ # tool: the tool name, or ->(controller) { ... }
27
+ # customer: ->(controller) { agent key for this customer } (one agent key is one customer). Default: the
28
+ # configured api_key.
29
+ # idempotency_key: ->(controller) { your own trusted id } (never the incoming Idempotency-Key header).
30
+ # mode:, fail_mode:, billing_lapse:, retries:, client: per-route overrides.
31
+ # on_deny: ->(controller, decision) { ... } to answer a blocked call yourself.
32
+ # Any other options (only:, except:, if:, unless:) go to before_action.
33
+ def ordainer_check(tool:, customer: nil, idempotency_key: nil, mode: nil, fail_mode: nil, billing_lapse: nil,
34
+ retries: nil, client: nil, on_deny: nil, **filter_options)
35
+ raise Ordainer::ConfigurationError, "ordainer_check: tool is required" if tool.nil? || tool == ""
36
+
37
+ opts = { tool: tool, customer: customer, idempotency_key: idempotency_key, mode: mode, fail_mode: fail_mode,
38
+ billing_lapse: billing_lapse, retries: retries, client: client, on_deny: on_deny }
39
+ before_action(**filter_options) { |controller| controller.send(:ordainer_guard!, opts) }
40
+ end
41
+ end
42
+
43
+ # The Decision for this request (nil before the check runs).
44
+ def ordainer_decision = @ordainer_decision
45
+
46
+ private
47
+
48
+ def ordainer_guard!(opts)
49
+ resolve = ->(v) { v.respond_to?(:call) ? v.call(self) : v }
50
+ client = opts[:client] || Ordainer.client
51
+ decision = client.decide(tool: resolve.(opts[:tool]), api_key: resolve.(opts[:customer]),
52
+ idempotency_key: resolve.(opts[:idempotency_key]),
53
+ **opts.slice(:mode, :fail_mode, :billing_lapse, :retries).compact)
54
+ @ordainer_decision = decision
55
+ request.env["ordainer.decision"] = decision if respond_to?(:request, true) && request
56
+ return if decision.allowed?
57
+ return opts[:on_deny].call(self, decision) if opts[:on_deny]
58
+
59
+ r = Ordainer.deny_response(decision)
60
+ r[:headers].each { |k, v| response.headers[k] = v }
61
+ # Rails' RequestId middleware overwrites X-Request-Id with its own id; the body's request_id is Ordainer's.
62
+ response.headers["X-Ordainer-Request-Id"] = decision.request_id if decision.request_id
63
+ # Every block is a 403/429/500/503 JSON body: { error: { code, message, request_id, ... } }.
64
+ render json: r[:body], status: r[:status]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module Ordainer
2
+ VERSION = "0.1.0"
3
+ end
data/lib/ordainer.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ordainer: call Ordainer's POST /v1/decide before your tool runs. Ruby >= 3.0, stdlib only.
4
+ #
5
+ # client = Ordainer::Client.new(api_key: ENV["ORDAINER_API_KEY"])
6
+ # decision = client.decide(tool: "research.run")
7
+ # return render_blocked(decision) unless decision.allowed?
8
+ #
9
+ # Mirrors the Node SDK (packages/ordainer-node, 1.2.2). See README.md for the rules.
10
+ require_relative "ordainer/version"
11
+ require_relative "ordainer/errors"
12
+ require_relative "ordainer/decision"
13
+ require_relative "ordainer/deny_response"
14
+ require_relative "ordainer/client"
15
+ require_relative "ordainer/configuration"
16
+ require_relative "ordainer/rails" if defined?(::Rails) || defined?(::ActionController)
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ordainer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ordainer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ executables: []
13
+ extensions: []
14
+ extra_rdoc_files: []
15
+ files:
16
+ - CHANGELOG.md
17
+ - LICENSE
18
+ - README.md
19
+ - lib/ordainer.rb
20
+ - lib/ordainer/client.rb
21
+ - lib/ordainer/configuration.rb
22
+ - lib/ordainer/decision.rb
23
+ - lib/ordainer/deny_response.rb
24
+ - lib/ordainer/errors.rb
25
+ - lib/ordainer/rails.rb
26
+ - lib/ordainer/version.rb
27
+ homepage: https://docs.ordainer.dev
28
+ licenses:
29
+ - MIT
30
+ metadata: {}
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '3.0'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.6.7
46
+ specification_version: 4
47
+ summary: 'Ask Ordainer before your tool runs: one decide() call checks the caller''s
48
+ plan and counts the use.'
49
+ test_files: []