ordainer 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f012928c3aedf7e8eafb2b5f2fd6fcec671f467e4a44d6214bc55c3adf49ee1
4
- data.tar.gz: b748a4a31fa42620dc1fa045182147001f526fa789571ec9a1f2c112be85d416
3
+ metadata.gz: 53bc08e22508421e545a73555380d25329bc5c7cffb96f6476b479a416eb7586
4
+ data.tar.gz: 90543ec827c7accd06f17893821df1051ccd8b2faa5f6d4bcbc1351158518fab
5
5
  SHA512:
6
- metadata.gz: 7c4e07d10dfb6911bddc8cba631b5ec33cc52499a9dfd8e83dfd75ce5424870a04fa8f1b69c87e73f66a4a719f44e8e14eb0fe4ee7b2c29cebdbda029ef044b0
7
- data.tar.gz: 5fd5691ec62d7f837706fc46c6e4024cad416fa647dbac76357d74200446c1c077a58af50489752f8b6d8a4ced135bef3f88254ee3803082d50ba7ee9bc2fdf3
6
+ metadata.gz: 76c013a98e2d18e2d4306c975660b95a06c4aa1484a99311a3fd2f0a5175dfd394cde60915da49222026174cd56609f7b055f058b37d25396e0cdc8680b8b46c
7
+ data.tar.gz: c4deb82535a6f9cb7134358e609a966cf17a76b00d85257bbcd52b5e5470d9ade3a7884d686561c63e3736446837a3a430927ad31a7d59ca016d3588688e26de
data/CHANGELOG.md CHANGED
@@ -1,4 +1,40 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.1.1 (2026-10-18)
4
+ - Fixed (QA P95F-N4): `Ordainer.deny_response` no longer raises `FloatDomainError` when the server's `resets_at` parses but
5
+ gives a non-finite number of seconds (for example a fraction hundreds of digits long); there is no `Retry-After` header
6
+ then, as for an unknown reset time. Status, code and body are unchanged.
7
+ - Changed (PM final wording, Oct 18; replaces the P95R-N3 text below): Error messages reworded; codes, statuses and fields unchanged. Match on error.code, not the message. Same bytes as the Node SDK. `503 ordainer_unavailable`: "This call wasn't run. The service couldn't finish a safety check it does before each call, so it stopped instead of going ahead without it. Nothing is wrong on your side. Please try again in a minute. (ordainer_unavailable)".
8
+ `429 test_limit_reached`: "This call wasn't run. This service's test calls for the month are used up. They reset on
9
+ November 1 (UTC). Nothing is wrong on your side, and trying again won't help until they reset. If you need it sooner, contact
10
+ the service's team. (test_limit_reached)". The reset day is `resets_at` in UTC as "<Month D>" (full English month, no
11
+ leading zero, no year; no locale APIs), and the sentence appears only when `resets_at` is a valid ISO-8601 date-time
12
+ String (`YYYY-MM-DDTHH:MM:SS`, optional fraction, `Z` or `±HH:MM`); nil, empty, numbers, garbage or trailing text leave
13
+ it out (closes P95R-N4 for this message; `plan_limit_reached` is unchanged). `403 plan_denied` with `tool_not_in_plan`:
14
+ "This call wasn't run because your plan doesn't include "<tool>". If you need it, ask whoever manages your plan.
15
+ (tool_not_in_plan)", where `<tool>` is the tool passed to `decide` / `decide!` / the Rails guard (no extra request),
16
+ quoted only when it matches `\A[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}\z` and has no `//` and no leading `scheme:` (`\A[A-Za-z][A-Za-z0-9+.-]*:`, so nothing URL-like is echoed), otherwise "this action" without quotes. Any other
17
+ plan deny: "This call wasn't run because it isn't allowed on your account right now. If you need it, contact the
18
+ service's team. (<reason>)", with the bracket only when the reason matches `\A[a-z][a-z_]{0,39}\z`. Status, `code`,
19
+ `reason`, `resets_at`, `request_id`, headers and whether the call runs are unchanged.
20
+ - Changed (P95R-N3, Content & DevRel Addendum 3, same bytes as the Node SDK): three more caller messages drop our name
21
+ and our terms. `503 ordainer_unavailable`: "This call wasn't run. The service couldn't complete a check it needs before
22
+ running it, and it's set to stop rather than go ahead without one. Please try again in a moment. (ordainer_unavailable)".
23
+ `429 test_limit_reached`: "This call wasn't run. The service's test allowance for this month is used up. It resets at
24
+ {resets_at}. It isn't something you did, and retrying won't help until the allowance resets. (test_limit_reached)" (no
25
+ "It resets at" sentence when `resets_at` is nil or empty; an empty value used to render "They reset at ."). `403
26
+ plan_denied`: "This call wasn't run because this agent's plan doesn't allow it. ({reason})", with the bracketed reason
27
+ only when it matches `\A[a-z][a-z_]{0,39}\z` (the same check as the `plan_check_rejected` message); otherwise it ends at
28
+ "allow it.". Before, the Ruby 403 message never named the reason. Only `message` (and the raised errors' `message`)
29
+ changes: status, `code` (still `ordainer_unavailable`), `reason`, `resets_at`, `request_id`, headers and whether the call
30
+ runs are unchanged.
31
+ - Changed (T18-P40, same strings as the Node SDK): the caller's `500 plan_check_rejected` message says what kind of setup
32
+ issue it is (a key isn't recognized, has been turned off, or is missing; anything else) and ends with the API's code for
33
+ the service's team. The `429 rate_limited` message no longer names us: "The service is getting too many requests on this
34
+ key right now, so this call was held back. Please wait a moment and try again. (rate_limited)". A `429 concurrency_limited`
35
+ now gets its own: "The service is handling too many calls on this key at once, so this call was held back. Please wait a moment and try again. (concurrency_limited)" (the `code` field stays `rate_limited`). Only `message` changes:
36
+ status, `code`, headers (`Retry-After`, `X-Request-Id`), `retry_after`, `rejected?` / `rate_limited?` and whether the call
37
+ runs are unchanged. The 403 billing message is unchanged.
38
+
39
+ ## 0.1.0 (2026-10-14)
4
40
  - First version: Ordainer::Client#decide, Decision, typed errors, retries, fail_mode, optional Rails guard.
data/README.md CHANGED
@@ -4,7 +4,17 @@ Ask Ordainer before your tool runs: one `decide` call checks the caller's plan a
4
4
  Ruby 3.0+, standard library only (net/http, json), no runtime dependencies. Mirrors the Node SDK
5
5
  (`packages/ordainer-node`, 1.2.2).
6
6
 
7
- > 0.1.0, not on RubyGems yet. Install from the repo (see the Ruby quickstart in the docs).
7
+ Install from [RubyGems](https://rubygems.org/gems/ordainer):
8
+
9
+ ```sh
10
+ gem install ordainer
11
+ ```
12
+
13
+ or in your Gemfile:
14
+
15
+ ```ruby
16
+ gem "ordainer", "~> 0.1"
17
+ ```
8
18
 
9
19
  ```ruby
10
20
  require "ordainer"
@@ -47,6 +57,26 @@ end
47
57
  `fail_mode` (`rejected?`). 403 `subscription_inactive` follows `billing_lapse` instead. 429
48
58
  `safety_limit_reached` lets the call run (`safety_limited`).
49
59
  - Only timeouts, network errors and 503 are retried, never other 5xx or 4xx.
60
+ - What the caller reads (`Ordainer.deny_response`, same text as the Node SDK): a block a retry can't fix answers
61
+ `500 plan_check_rejected` with plain words for the cause and the API's code last, e.g. "This call wasn't run. This is a
62
+ setup issue on the service's side: one of its keys isn't recognized. It isn't something you did, and retrying won't help
63
+ until the service fixes the key. (For the service's team: unknown_key.)" A `429 rate_limited` answers "The service is
64
+ getting too many requests on this key right now, so this call was held back. Please wait a moment and try again.
65
+ (rate_limited)" with `Retry-After`; a `429 concurrency_limited` answers "The service is handling too many calls on this key at once, so this call was held back. Please wait a moment and try again. (concurrency_limited)" (the `code` field
66
+ stays `rate_limited`). Billing blocks (403) keep the Legal-approved text.
67
+ - The same text as the Node SDK for the other blocks too (PM final wording, Oct 18). Error messages can be reworded;
68
+ codes, statuses and fields stay the same, so match on `code`, not the message. An outage under `fail_mode: :closed`
69
+ answers `503 ordainer_unavailable` with `Retry-After: 5`: "This call wasn't run. The service couldn't finish a safety check it does before each call, so it stopped instead of going ahead without it. Nothing is wrong on your side. Please try again in a minute. (ordainer_unavailable)".
70
+ A used-up test allowance answers `429 test_limit_reached`: "This call wasn't run. This service's test calls for the
71
+ month are used up. They reset on November 1 (UTC). Nothing is wrong on your side, and trying again won't help until
72
+ they reset. If you need it sooner, contact the service's team. (test_limit_reached)" (the day is `resets_at` in UTC as
73
+ "<Month D>"; no "They reset on" sentence unless `resets_at` is a valid ISO-8601 date-time). A `tool_not_in_plan` deny
74
+ answers `403 plan_denied`: "This call wasn't run because your plan doesn't include "research.run". If you need it, ask
75
+ whoever manages your plan. (tool_not_in_plan)", naming the tool you passed to `decide` (or the Rails guard) in quotes
76
+ only when it matches `\A[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}\z` and has no `//` and no leading `scheme:` (`\A[A-Za-z][A-Za-z0-9+.-]*:`, so nothing URL-like is echoed), otherwise "this action". Any other deny answers "This
77
+ call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team.
78
+ (reason)", with the reason in brackets only when it looks like an API code (lowercase letters and `_`, up to 40
79
+ characters); the reason is also its own `reason` field.
50
80
 
51
81
  ## Decision
52
82
 
@@ -9,6 +9,34 @@ module Ordainer
9
9
  TEST_LIMIT_REASON = "test_limit_reached"
10
10
  # Legal-approved (Oct 3; tech/billing-lapse-scope.md item 2), verbatim, same as the Node SDK.
11
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
+ # Caller-facing text for a 429 rate_limited / concurrency_limited block, same as the Node SDK (Content & DevRel, Oct 18):
13
+ # no product name. Only the message differs; the code field stays rate_limited for both, and status, Retry-After and
14
+ # retry_after are unchanged.
15
+ RATE_LIMITED_MESSAGE = "The service is getting too many requests on this key right now, so this call was held back. Please wait a moment and try again. (rate_limited)"
16
+ CONCURRENCY_LIMITED_MESSAGE = "The service is handling too many calls on this key at once, so this call was held back. Please wait a moment and try again. (concurrency_limited)"
17
+ # T18-P40 (Content & DevRel final wording, Oct 18; same strings as the Node SDK's rejectedMessage()): the caller-facing
18
+ # sentence for a block a retry can't fix (500 plan_check_rejected). Plain words first, the API's own code last for the
19
+ # service's team; the code is echoed only if it looks like an API code (lowercase letters and _, <= 40 chars).
20
+ # too_many_failed_auth stays with "isn't recognized": the API's "couldn't check this key" variant has the same status,
21
+ # code, limit_scope and headers; only its English message differs.
22
+ KEY_NOT_RECOGNIZED_CODES = %w[unknown_key old_key_format too_many_failed_auth].freeze
23
+ REJECTED_TAIL = "It isn't something you did, and retrying won't help until the service"
24
+ # P95R-N3 (Content & DevRel, Oct 18, content/persona-2-2026-10-18/p40-sdk-key-messages.md Addendum 3; same bytes as
25
+ # the Node SDK): the 503 fail-closed outage, the 429 test allowance and the 403 plan deny. No product name and none of
26
+ # our terms. Only the message changes: status, code (ordainer_unavailable is kept), headers and fields are unchanged.
27
+ # Text replaced by the PM's final wording (Oct 18); codes and fields are as before.
28
+ UNAVAILABLE_MESSAGE = "This call wasn't run. The service couldn't finish a safety check it does before each call, so it stopped instead of going ahead without it. Nothing is wrong on your side. Please try again in a minute. (#{UNAVAILABLE_CODE})"
29
+ API_CODE_SHAPE = /\A[a-z][a-z_]{0,39}\z/
30
+ # PM final wording (Oct 18): tool_not_in_plan names the tool sent in decide(), in double quotes, only when it looks
31
+ # like a plain tool name; otherwise "this action". Same check and bytes as the Node SDK's TOOL_NAME_SHAPE.
32
+ TOOL_NAME_SHAPE = /\A[A-Za-z0-9][A-Za-z0-9_.:\/-]{0,63}\z/
33
+ # QA P95F-N3: nothing URL-like is echoed either: no "//" anywhere and no leading "scheme:" (RFC 3986 shape). Same as Node.
34
+ TOOL_SCHEME_PREFIX = /\A[A-Za-z][A-Za-z0-9+.-]*:/
35
+ # Addendum 3 amendment (Oct 18, CoS): the reset day as "<Month D>" in UTC (full English month, no leading zero, no
36
+ # year). Only for a String that is a valid ISO-8601 date-time (YYYY-MM-DDTHH:MM:SS, optional .fraction, then Z or
37
+ # +HH:MM/-HH:MM). Fixed month list and integer UTC math only (no Time.parse, no locale), same bytes as Node.
38
+ MONTHS = %w[January February March April May June July August September October November December].freeze
39
+ ISO_DATE_TIME = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|([+-])(\d{2}):(\d{2}))\z/
12
40
 
13
41
  module_function
14
42
 
@@ -21,28 +49,26 @@ module Ordainer
21
49
  retry_after = (d.retry_after_sec || 1).to_s
22
50
  return resp(429, { "Retry-After" => retry_after }.merge(rid),
23
51
  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.",
52
+ message: d.reason == "concurrency_limited" ? CONCURRENCY_LIMITED_MESSAGE : RATE_LIMITED_MESSAGE,
25
53
  request_id: d.request_id, retry_after: retry_after.to_i)
26
54
  end
27
55
  if d.rejected || (d.fail_closed && d.billing_inactive)
28
56
  billing = d.billing_inactive
29
57
  return resp(billing ? 403 : 500, rid,
30
58
  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.",
59
+ message: billing ? BILLING_BLOCKED_MESSAGE : rejected_message(d.error&.code),
32
60
  request_id: d.request_id)
33
61
  end
34
62
  if d.fail_closed
35
63
  return resp(503, { "Retry-After" => "5" }.merge(rid),
36
64
  code: UNAVAILABLE_CODE,
37
- message: "The plan check is unavailable, so this call was not run. Try again shortly.",
65
+ message: UNAVAILABLE_MESSAGE,
38
66
  request_id: d.request_id)
39
67
  end
40
68
  if d.reason == TEST_LIMIT_REASON
41
69
  return resp(429, retry_after_header(d.resets_at, now).merge(rid),
42
70
  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.",
71
+ message: test_limit_message(d.resets_at),
46
72
  request_id: d.request_id, reason: d.reason, resets_at: d.resets_at)
47
73
  end
48
74
  if d.reason == "quota_exceeded"
@@ -53,20 +79,103 @@ module Ordainer
53
79
  " Retrying before then will not succeed. This call was not run.",
54
80
  request_id: d.request_id, reason: d.reason, remaining: d.remaining, limit: d.limit, resets_at: d.resets_at)
55
81
  end
56
- # Node says "(<reason>)" in the message; here the reason is only a field (no raw codes in the message).
82
+ # Same as Node: the reason in brackets at the end, only when it looks like an API code.
57
83
  resp(403, rid,
58
84
  code: "plan_denied",
59
- message: "This agent's plan does not allow this call, so it was not run.",
85
+ message: plan_denied_message(d.reason, d.tool),
60
86
  request_id: d.request_id, reason: d.reason)
61
87
  end
62
88
 
89
+ def rejected_message(code)
90
+ msg =
91
+ if code == "missing_key"
92
+ "This call wasn't run. This is a setup issue on the service's side: a key it needs is missing. #{REJECTED_TAIL} adds the key."
93
+ elsif code == "revoked_key"
94
+ "This call wasn't run. This is a setup issue on the service's side: one of its keys has been turned off. #{REJECTED_TAIL} switches to a new key."
95
+ elsif KEY_NOT_RECOGNIZED_CODES.include?(code)
96
+ "This call wasn't run. This is a setup issue on the service's side: one of its keys isn't recognized. #{REJECTED_TAIL} fixes the key."
97
+ else
98
+ "This call wasn't run because of a setup issue on the service's side. #{REJECTED_TAIL} fixes it."
99
+ end
100
+ code.is_a?(String) && code.match?(API_CODE_SHAPE) ? "#{msg} (For the service's team: #{code}.)" : msg
101
+ end
102
+
103
+ # Same rules as Node's resetDayUtc(): nil for anything that isn't a valid ISO-8601 date-time String.
104
+ def reset_day_utc(v)
105
+ return nil unless v.is_a?(String) && v.valid_encoding?
106
+
107
+ m = ISO_DATE_TIME.match(v)
108
+ return nil unless m
109
+
110
+ y, mo, d, h, mi, s = m.captures[0, 6].map(&:to_i)
111
+ leap = (y % 4).zero? && !(y % 100).zero? || (y % 400).zero?
112
+ dim = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
113
+ return nil if mo < 1 || mo > 12 || d < 1 || d > dim[mo - 1] || h > 23 || mi > 59 || s > 59
114
+
115
+ off = 0
116
+ if m[7]
117
+ oh = m[8].to_i
118
+ om = m[9].to_i
119
+ return nil if oh > 23 || om > 59
120
+
121
+ off = (m[7] == "-" ? -1 : 1) * (oh * 60 + om)
122
+ end
123
+ # Minutes from local midnight, shifted to UTC; at most one day either way.
124
+ mins = h * 60 + mi - off
125
+ uy, um, ud = y, mo, d
126
+ if mins.negative?
127
+ ud -= 1
128
+ if ud < 1
129
+ um -= 1
130
+ if um < 1
131
+ um = 12
132
+ uy -= 1
133
+ end
134
+ ud = dim[um - 1] # February only from March 1 (same year)
135
+ end
136
+ elsif mins >= 1440
137
+ ud += 1
138
+ if ud > dim[mo - 1]
139
+ ud = 1
140
+ um += 1
141
+ um = 1 if um > 12
142
+ end
143
+ end
144
+ "#{MONTHS[um - 1]} #{ud}"
145
+ end
146
+
147
+ def test_limit_message(resets_at)
148
+ day = reset_day_utc(resets_at)
149
+ "This call wasn't run. This service's test calls for the month are used up." +
150
+ (day ? " They reset on #{day} (UTC)." : "") +
151
+ " Nothing is wrong on your side, and trying again won't help until they reset. If you need it sooner, contact the service's team. (test_limit_reached)"
152
+ end
153
+
154
+ def showable_tool?(tool)
155
+ tool.is_a?(String) && tool.valid_encoding? && tool.match?(TOOL_NAME_SHAPE) && !tool.include?("//") &&
156
+ !tool.match?(TOOL_SCHEME_PREFIX)
157
+ end
158
+
159
+ def plan_denied_message(reason, tool = nil)
160
+ if reason == "tool_not_in_plan"
161
+ what = showable_tool?(tool) ? "\"#{tool}\"" : "this action"
162
+ return "This call wasn't run because your plan doesn't include #{what}. If you need it, ask whoever manages your plan. (tool_not_in_plan)"
163
+ end
164
+ msg = "This call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team."
165
+ reason.is_a?(String) && reason.valid_encoding? && reason.match?(API_CODE_SHAPE) ? "#{msg} (#{reason})" : msg
166
+ end
167
+
63
168
  def resp(status, headers, **error)
64
169
  { status: status, headers: headers, body: { error: error } }
65
170
  end
66
171
 
67
172
  def retry_after_header(resets_at, now)
68
173
  t = Time.iso8601(resets_at.to_s) rescue nil
69
- t ? { "Retry-After" => [1, (t - now).ceil].max.to_s } : {}
174
+ return {} unless t
175
+
176
+ # QA P95F-N4: a huge fraction can make the difference NaN/Infinity; no header then, never a FloatDomainError.
177
+ secs = (t - now).to_f
178
+ secs.finite? ? { "Retry-After" => [1, secs.ceil].max.to_s } : {}
70
179
  end
71
180
  private_class_method :resp, :retry_after_header
72
181
  end
@@ -1,3 +1,3 @@
1
1
  module Ordainer
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ordainer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ordainer
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-18 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  executables: []
13
13
  extensions: []