ordainer 1.0.0 → 1.2.4

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,280 @@
1
+ # Changelog: ordainer
2
+
3
+ Entries before 1.1.0 are written with the current names. Those versions used the product's former name for the
4
+ package, class, env vars, outage error code, User-Agent and key prefix: the full list is in 1.1.0 "Renamed".
5
+
6
+ ## 1.2.4 (2026-10-22)
7
+
8
+ The first release on npm: `npm install ordainer`. The code is the same as 1.2.3, byte for byte; only the install
9
+ instructions in this README changed. (1.0.0 on npm was an empty placeholder that reserved the name.)
10
+
11
+ ## 1.2.3 (2026-10-18)
12
+
13
+ Added (T18-P39): **the 429 `plan_limit_reached` names the agent** when the API's deny carries the key's `label`: "This
14
+ agent (agent-a) has reached its plan limit (0 of 3 left). …" and `error.label`. Also on `result.label` (denies only; null
15
+ on allows), on a sticky deny, and JSON-escaped in the shadow "would deny" log line. The label is cleaned (control,
16
+ invisible and bidi characters removed, whitespace collapsed) and cut to 64 characters. It is off by default on both
17
+ sides: the API sends `label` only for accounts that turned it on, and `exposeKeyLabel: true` opts the SDK in; without
18
+ either, the body and sentence are exactly as before. Without a label (an older server) nothing changes. Status, `error.code`, `Retry-After` and
19
+ whether the call runs are unchanged.
20
+
21
+ 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 Ruby 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)".
22
+ `429 test_limit_reached`: "This call wasn't run. This service's test calls for the month are used up. They reset on
23
+ November 1 (UTC). Nothing is wrong on your side, and trying again won't help until they reset. If you need it sooner, contact the
24
+ service's team. (test_limit_reached)". The reset day is `resets_at` in UTC as "<Month D>" (full English month, no leading
25
+ zero, no year; no locale APIs), and the "They reset on …" sentence appears only when `resets_at` is a valid ISO-8601
26
+ date-time string (`YYYY-MM-DDTHH:MM:SS`, optional fraction, `Z` or `±HH:MM`); missing, null, empty, numbers, garbage or
27
+ trailing text leave it out (closes P95R-N4 for this message; `plan_limit_reached` is unchanged). `403 plan_denied` with
28
+ `tool_not_in_plan`: "This call wasn't run because your plan doesn't include "<tool>". If you need it, ask whoever manages
29
+ your plan. (tool_not_in_plan)", where `<tool>` is the tool passed to `decide()` / `wrap()` / `middleware()` (no extra
30
+ request), quoted only when it matches `^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$` and has no `//` and no leading `scheme:` (`^[A-Za-z][A-Za-z0-9+.-]*:`, so nothing URL-like is echoed), otherwise "this action" without quotes. Any
31
+ other plan deny: "This call wasn't run because it isn't allowed on your account right now. If you need it, contact the
32
+ service's team. (<reason>)", with the bracket only when the reason matches `^[a-z][a-z_]{0,39}$`. Status, `error.code`,
33
+ `reason`, `resets_at`, `request_id`, headers (`Retry-After`, `X-Request-Id`) and whether the call runs are unchanged.
34
+
35
+ Changed (P95R-N3, Content & DevRel Addendum 3): **three more caller messages drop our name and our terms**, with the same
36
+ bytes as the Ruby SDK. `503 ordainer_unavailable` (outage under `failMode: 'closed'`): "This call wasn't run. The service
37
+ couldn't complete a check it needs before running it, and it's set to stop rather than go ahead without one. Please try again
38
+ in a moment. (ordainer_unavailable)". `429 test_limit_reached`: "This call wasn't run. The service's test allowance for this
39
+ month is used up. It resets at {resets_at}. It isn't something you did, and retrying won't help until the allowance resets.
40
+ (test_limit_reached)" (no "It resets at" sentence when `resets_at` is unknown). `403 plan_denied`: "This call wasn't run
41
+ because this agent's plan doesn't allow it. ({reason})", with the bracketed reason only when it looks like an API code
42
+ (`^[a-z][a-z_]{0,39}$`, the same check as the `plan_check_rejected` message); otherwise the text ends at "allow it.". Only
43
+ `error.message` (and `OrdainerDeniedError.message`) changes: status, `error.code` (still `ordainer_unavailable`,
44
+ `test_limit_reached`, `plan_denied`), `reason`, `resets_at`, `request_id`, headers (`Retry-After`, `X-Request-Id`), the
45
+ result flags and whether the call runs are unchanged. Logged warnings are unchanged.
46
+
47
+ Changed (T18-P40): **the caller's `500 plan_check_rejected` message says what kind of setup issue it is**, in plain words,
48
+ and ends with the API's own code for the service's team: "This call wasn't run. This is a setup issue on the service's side:
49
+ one of its keys isn't recognized. It isn't something you did, and retrying won't help until the service fixes the key. (For
50
+ the service's team: unknown_key.)" `old_key_format` and `too_many_failed_auth` get the same sentence. A revoked key ("one of
51
+ its keys has been turned off"), no key ("a key it needs is missing") and any other rejection ("This call wasn't run because
52
+ of a setup issue on the service's side. It isn't something you did, and retrying won't help until the service fixes it.")
53
+ each get their own sentence. The wording never names us or the plan check and makes no outage claim. Only `error.message`
54
+ (and `OrdainerDeniedError.message`) changes: the status, `error.code` (`plan_check_rejected`), `request_id`, headers (no
55
+ `Retry-After`), `result.allowed` / `result.rejected` and whether the call runs are all unchanged. The 403 billing message is
56
+ unchanged (Legal-approved, word for word).
57
+
58
+ Changed: **the caller's `429 rate_limited` message no longer names us.** It now reads: "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)" When the server answers
59
+ `429 concurrency_limited`, the message is "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)"
60
+ (the `code` field stays `rate_limited`). Only `error.message` changes: the status, `error.code` (`rate_limited`), `Retry-After`,
61
+ `retry_after`, `request_id` and `result.rateLimited` / `result.retryAfterSec` are unchanged.
62
+
63
+ ## 1.2.2 (2026-10-09)
64
+
65
+ Docs-site download only. Not published to npm. `ordainer-node-1.2.2.tgz` is the new download; the 1.2.1 and 1.2.0 files stay
66
+ on the docs site byte for byte, so existing installs and lockfiles keep working.
67
+
68
+ Changed: **`result.wouldDeny` is true only for a shadow deny**, as the docs say ("`d.wouldDeny` is true for shadow
69
+ denies"). 1.2.1 and earlier set it on every deny, enforce included. Now an enforce deny (and a sticky deny that blocks)
70
+ has `wouldDeny: false` with `decision: 'deny'` and `allowed: false`; a shadow deny (requested, the plan's mode, or a
71
+ shadow `test_limit_reached`) still has `wouldDeny: true` and `allowed: true`. Whether the handler runs is unchanged:
72
+ it still follows `allowed`. If your metrics count `wouldDeny` to mean "any deny", count `decision === 'deny'` instead.
73
+
74
+ Changed: **the README's install steps match the docs**: the docs-site tarball and how to check its SHA-256, instead of
75
+ `npm install ordainer` (which gets the empty 1.0.0 placeholder).
76
+
77
+ ## 1.2.1 (2026-10-08)
78
+
79
+ Docs-site download only. Not published to npm. The packed tarball's default `baseUrl` is `DOCS_SDK_DEFAULT_BASE_URL` or `API_URL` from the docs build (`https://api.ordainer.dev` on the custom-domain build). The filename is `ordainer-node-1.2.1.tgz`, so a client does not keep the 1.2.0 bytes. The source default in this repo stays `https://api.ordainer.dev`.
80
+
81
+ ## 1.2.0 (2026-10-06)
82
+
83
+ Shadow never blocks (Terms: "Shadow mode never blocks a call because of a plan or test limit. Calls past it are counted
84
+ and recorded as "would deny" and still run. Safety rate limits that protect the service still apply.").
85
+
86
+ Changed: **`test_limit_reached` in shadow runs the call.** The server now answers a shadow call past the account's
87
+ monthly test limit with `decision: 'deny'`, `reason: 'test_limit_reached'`, `mode: 'shadow'` (counted and recorded as a
88
+ would-deny). 1.2.0 treats that like any other shadow would-deny: `result.allowed: true`, `result.wouldDeny: true`,
89
+ `result.testLimitReached: true`, `onTestLimit(result)`, and its own "test limit reached (shadow)" warning. Enforce calls
90
+ are still blocked (`429 test_limit_reached`). An answer with `mode: 'enforce'` (enforce, or an older server) still blocks.
91
+ **1.1.0 blocks these shadow calls**: upgrade to get the Terms behaviour.
92
+
93
+ Added: **`429 safety_limit_reached` lets the call run in every `failMode`.** Past a safety ceiling of 10 times the
94
+ account's monthly test limit, the server answers shadow calls with `429 safety_limit_reached` (an error, not a
95
+ decision; nothing counted or recorded) until the start of next month (UTC). Before 1.2.0 any 429 other than a plan
96
+ limit was blocked in every `failMode`. Now this one runs, like `billingLapse: 'open'`: `result.allowed: true`, new
97
+ result field **`safetyLimited: true`** (`failOpen` and `rejected` stay `false`), `result.error.limitScope: 'org_safety'`,
98
+ `result.resetsAt`, `result.retryAfterSec`, a warning at most once per `warningIntervalMs`, and the new hook
99
+ **`onSafetyLimit(result)`** on every such call. New export `SAFETY_LIMIT_CODE`.
100
+
101
+ Added: **a loud line when the base URL's host doesn't resolve.** On `ENOTFOUND` / `EAI_AGAIN` for the configured base
102
+ URL, the first occurrence per host logs one error-level line (`logger.error`, else `logger.warn`; it bypasses the
103
+ `warningIntervalMs` throttle) naming the host and saying to set `ORDAINER_BASE_URL`. Behaviour is otherwise unchanged
104
+ (`failMode` applies, default open). New `result.error.dnsCode`. `logger` may now have an optional `error(msg)`.
105
+
106
+ Changed: **the `subscription inactive` warning's wording** (QA CC-L3, approved by UI/UX). It only appears after a
107
+ live key was refused, so it no longer says "if your live keys still work" or that the subscription has ended. Now: "Ordainer
108
+ refused this live key because of a billing problem on your account." then what the SDK did with the call (unchanged), then
109
+ "To fix this, sign in to the Ordainer console and open
110
+ Billing. If Billing shows an unpaid invoice, pay it and these same live keys turn back on. If your subscription has
111
+ ended, subscribe again under Billing, then issue new live keys under Keys."
112
+
113
+ Types: `DecideResult.safetyLimited`, `error.limitScope` `'org_safety'`, `error.dnsCode`, `OrdainerOptions.onSafetyLimit`,
114
+ `logger.error?`, `SAFETY_LIMIT_CODE`.
115
+
116
+ ## 1.1.0 (2026-10-01)
117
+
118
+ > **Upgrade note: `failMode: 'closed'` no longer blocks a billing lapse.** In 0.2.0 a `403 subscription_inactive`
119
+ > (your Ordainer subscription inactive) followed `failMode`, so `failMode: 'closed'` blocked those calls. From 1.1.0 it
120
+ > follows **`billingLapse`**, which defaults to `'open'`: with `failMode: 'closed'` alone, lapsed calls now **run,
121
+ > unchecked**. To keep blocking them, set **`billingLapse: 'closed'`** (details under "the billing-lapse setting" below).
122
+
123
+ Renamed: the product's former name is replaced by Ordainer everywhere in the SDK. Nothing else changed:
124
+ `decide()`, `middleware()`, `wrap()`, `failMode`, idempotency and every result field behave exactly as in
125
+ 0.2.0, except the items below (including the billing-lapse setting, which replaces 0.2.0 item 4).
126
+
127
+ Changed: **`retries` defaults to `1`** (was `0`) and retries only a timeout, a network error or a **503** (was: any
128
+ 5xx), with the same idempotency key. New **`retryBudgetMs`** (default 650): the total time for all attempts of one
129
+ call; a retry starts only with at least 50 ms left, with its timeout cut to the time left. 4xx answers are still never
130
+ retried, and `failMode` still defaults to `'open'`. Pass `retries: 0` for the old behaviour.
131
+
132
+ Added: **sticky deny** (`stickyDenyMs`, default 60000, max 300000, `0` = off). After a `quota_exceeded` deny for an
133
+ (agent key, tool), a call for the same pair that would fail open (timeout, network error, 5xx, malformed answer) is
134
+ denied instead (`429 plan_limit_reached`, `result.stickyDeny`), until the window or the plan's reset passes or an
135
+ allow arrives. Shadow still runs the call and reports it. New result field `stickyDeny` (types updated).
136
+
137
+ Changed (Terms 10.3(b), Tyler Oct 3, 2026: **the billing-lapse setting**): `403 subscription_inactive` (your
138
+ Ordainer subscription is inactive) no longer follows `failMode`. It follows the new option **`billingLapse:
139
+ 'open' | 'closed'`** (default `'open'`; on the client, or per call/route in `decide()`, `middleware()`, `wrap()`):
140
+ - `'open'` runs the call, unchecked and uncounted, **whatever `failMode` is**. `result.allowed: true`, and
141
+ `result.failOpen` is now `false` (it was `true`): `failMode` didn't decide it.
142
+ - `'closed'` blocks it with `403 plan_check_rejected`, no `Retry-After`, `result.rejected: true` (was:
143
+ `failClosed: true`).
144
+ - **If you used `failMode: 'closed'` to block lapsed calls, set `billingLapse: 'closed'` too**: with `failMode: 'closed'`
145
+ alone those calls now run.
146
+ - New result field `billingLapse` (`'open' | 'closed'` on a lapse, else `null`) and export `BILLING_BLOCKED_MESSAGE`.
147
+ - The billing warning ("let through, unchecked, because billingLapse is 'open'" / "blocked … because billingLapse is
148
+ 'closed'") and `onBillingInactive` happen in both modes, as before.
149
+ - The end-user message of every billing block (`subscription_required`, or the lapse under `'closed'`) is now neutral:
150
+ "This action isn't available right now. This is a setup issue on the service's side; retrying won't help." (was:
151
+ "…the service's plan-check account is not active…fixes its billing.").
152
+ - `403 subscription_required` is unchanged: blocked under both settings.
153
+
154
+ Changed: an idempotency key with a control character (U+0000–U+001F, U+007F) or an unpaired surrogate is sent as
155
+ `<tool>:<sha256>` (as a key over 255 characters already was), instead of reaching the server raw and getting a 400
156
+ (which blocked the call even in shadow mode).
157
+
158
+ Version 1.0.0 is an empty placeholder published to npm only to reserve the `ordainer` name. It contains no code and
159
+ was never sent to anyone; skip it. Install this version with `npm install ordainer@1.1.0`.
160
+
161
+ ### Renamed
162
+
163
+ `<former>` stands for the product's former name (`<Former>` and `<FORMER>` in the other cases). The old names are not
164
+ read any more and have no fallbacks: change them when you install 1.1.0.
165
+
166
+ | What | 0.2.0 and earlier | 1.1.0 |
167
+ | --- | --- | --- |
168
+ | Env var: agent key | `<FORMER>_KEY` | `ORDAINER_KEY` (`BRAND.envKey`) |
169
+ | Env var: base URL | `<FORMER>_BASE_URL` | `ORDAINER_BASE_URL` (`BRAND.envBaseUrl`) |
170
+ | Class | `<Former>` | `Ordainer` |
171
+ | Deny error class | `<Former>DeniedError` | `OrdainerDeniedError` |
172
+ | Result on the request (`middleware()`) | `req.<former>` | `req.ordainer` |
173
+ | Error code: real outage under `failMode: 'closed'` (503 + `Retry-After`) | `<former>_unavailable` | `ordainer_unavailable` (`BRAND.unavailableCode`) |
174
+ | Package (npm name) | `@<former>/node` | `ordainer` (`require('ordainer')`) |
175
+ | Tarball | `<former>-node-<version>.tgz` | `ordainer-<version>.tgz` |
176
+ | Default base URL | `https://api.<former>.dev` | `https://api.ordainer.dev` |
177
+ | User-Agent | `<former>-node/<version>` | `ordainer-node/1.1.0` |
178
+ | Log prefix | `[<former>]` | `[ordainer]` |
179
+ | Agent key prefix (issued by the server) | `sk_test_` / `sk_live_` | `ord_test_` / `ord_live_` |
180
+
181
+ Old agent keys (`sk_test_…` / `sk_live_…`) no longer work: the server answers `401 old_key_format` ("This key uses an
182
+ old format. Create a new key in the dashboard."), and the SDK blocks the call like any other 401 (`rejected: true`,
183
+ `500 plan_check_rejected`, in any `failMode`). Create new keys in the dashboard and replace the old ones.
184
+ `plan_check_rejected` (`BRAND.rejectedCode`) is unchanged.
185
+
186
+ ### Upgrade checklist: what to search your code for
187
+
188
+ - `<FORMER>_KEY` and `<FORMER>_BASE_URL` in your environment, deploy config and secrets: rename to `ORDAINER_KEY` /
189
+ `ORDAINER_BASE_URL`. Without that the SDK finds no key and blocks every call (`missing_key`).
190
+ - `require('@<former>/node')`, `new <Former>(`, `<Former>DeniedError`, `req.<former>`: the new names above.
191
+ - `<former>_unavailable` in `onDeny` code, agent prompts, monitors and load-balancer rules: now `ordainer_unavailable`.
192
+ - `<former>-node/` in log or proxy filters that match our User-Agent: now `ordainer-node/`.
193
+ - `[<former>]` in log alerts: now `[ordainer]`.
194
+ - `sk_test_` / `sk_live_` agent keys: create `ord_test_` / `ord_live_` keys in the dashboard.
195
+
196
+ ## 0.2.0 (2026-10-01)
197
+
198
+ Breaking. Read this before installing `ordainer-0.2.0.tgz` over 0.1.0.
199
+
200
+ ### Breaking changes
201
+
202
+ 1. **Ordainer's 4xx answers are blocked in every `failMode` (M1).** `failMode` now covers only real outages: a timeout,
203
+ a network error, a 5xx or a malformed 200. Any other 4xx (missing, unknown or revoked agent key,
204
+ `403 subscription_required`, `400`, `409 idempotency_conflict`, …), and a call with no agent key or no tool,
205
+ is blocked with `result.rejected: true` even under `failMode: 'open'`. In 0.1.0, `failMode: 'open'` let these
206
+ calls run. They answer `plan_check_rejected` with **no** `Retry-After` (retrying won't help until the setup is
207
+ fixed; see item 5); `429 rate_limited` answers `429` with the server's `Retry-After`.
208
+ The one exception is the lapse (item 4 below).
209
+ 2. **The caller's `Idempotency-Key` header is no longer forwarded.** `middleware()` sends a fresh random key for each
210
+ incoming request and ignores the `Idempotency-Key` header your caller (the agent) sent, so a caller can't replay
211
+ an earlier allow. If you want retries of the same job to count once, pass `idempotencyKey(req)` built from your
212
+ own trusted data (for example your own job id from your database), never from a field the caller controls
213
+ (`req.body.jobId`, a query parameter, a header). Ordainer replays a key for 24 hours, then counts it again.
214
+ 3. **`failClosed` and `rejected` mean different things.**
215
+ - `failOpen` / `failClosed`: `failMode` decided because there was no decision (an outage, or the lapse).
216
+ - `rejected`: Ordainer refused the call (any 4xx except the lapse), or it couldn't be sent.
217
+ At most one of the three is true. In 0.1.0 a rejected call also set `failClosed: true`; check `rejected` now.
218
+ Blocks a retry can't fix (rejected calls, the lapse under `failMode: 'closed'`) no longer send `Retry-After` or
219
+ say "Try again shortly"; real outages under `failMode: 'closed'` still send `Retry-After: 5`.
220
+ 4. **A lapsed subscription (`403 subscription_inactive`) follows `failMode`, pending a decision (Terms 10.3(b)).**
221
+ `failMode: 'open'` lets the call run (`failOpen: true`, warning "let through because failMode is 'open'",
222
+ `onBillingInactive` fires); `failMode: 'closed'` blocks it with `403 plan_check_rejected` and no `Retry-After`
223
+ (0.1.0: `503 ordainer_unavailable`).
224
+ This is the same lapse behaviour as 0.1.0, kept on purpose while the decision is open; a pre-release build
225
+ briefly blocked it in every `failMode`. It may change in a later version: read this changelog before
226
+ upgrading.
227
+ 5. **`503 ordainer_unavailable` now means only a real outage (Q2).** A block a retry can't fix answers the new code
228
+ `plan_check_rejected`, with no `Retry-After`: **`403`** for billing (`403 subscription_required`, or the lapse
229
+ under `failMode: 'closed'`) and **`500`** for everything else (missing, unknown or revoked agent key,
230
+ `429 too_many_failed_auth`, `400`, `409 idempotency_conflict`, other 4xx). `503 ordainer_unavailable` with
231
+ `Retry-After: 5` stays for timeouts, network errors, 5xx and malformed answers under `failMode: 'closed'`, so a
232
+ load balancer or agent that retries 503s no longer hammers a bad key. If your agents, monitors or `onDeny` code
233
+ look for `ordainer_unavailable` or a 503 from a blocked call, also handle `plan_check_rejected` (500/403).
234
+ `BRAND.rejectedCode` is the new code.
235
+
236
+ ### Other changes
237
+
238
+ - `result.limit` and `result.remaining` are `number | null` in `index.d.ts`. Plans with no cap answer `null` for both
239
+ (before, `1000000000`). Code like `result.remaining - 1` needs a null check to compile.
240
+ - New **`retries`** option (default `0`, max `5`; on the constructor, `decide()`, `middleware()` or `wrap()`): retries a
241
+ timeout, network error or 5xx with the **same** idempotency key and a short backoff, so a lost answer comes back
242
+ replayed instead of being counted twice. 4xx answers are never retried. Only after the last attempt does `failMode`
243
+ apply.
244
+ - New **`result.attempts`**: how many HTTP attempts were made (`1` normally, up to `1 + retries`; `0` when nothing was
245
+ sent). There is no `result.retries` field: retries made = `result.attempts - 1`.
246
+ - New **`result.idempotencyKey`**: the key actually sent (yours, the `<tool>:<sha256>` form of one over 255
247
+ characters, or the random UUID). Log it to match a retry to its replay.
248
+ - `result.replayed` is `true` when Ordainer answered from an earlier call with the same idempotency key
249
+ (`Idempotent-Replayed: true`); that call was not counted again.
250
+ - **What a `409 idempotency_conflict` means:** the idempotency key was already used, within the 24 h replay window,
251
+ by the same agent key with a different `tool` or `mode`. The original answer is not returned, nothing is counted,
252
+ and the call is blocked (`rejected: true`, `500 plan_check_rejected`) in any `failMode`. It's a bug in how your code
253
+ builds keys: use one key per real operation and include the tool in it. Retrying with the same key gets the same 409.
254
+ - `result.error.limitScope` on a 429 (the server's `error.limit_scope`; named so it can't be confused with
255
+ `result.limit`, the plan's cap): `'per_key'` (`rate_limited`) or `'per_address'` (`too_many_failed_auth`, a bad key;
256
+ rejected, `onRateLimited` not called).
257
+ - New server answer **`429 concurrency_limited`** (`error.limit_scope: 'per_key_concurrency'`, `Retry-After: 1`): this
258
+ agent key already has too many decides in flight at once on one Ordainer server (`DECIDE_KEY_MAX_IN_FLIGHT`, default 2;
259
+ up to 20 more wait in line within the server's deadline). It is answered fast (well inside the 300 ms timeout) and handled exactly like
260
+ `429 rate_limited`: blocked in any `failMode` (never a fail-open), `result.rateLimited: true`,
261
+ `result.reason: 'concurrency_limited'`, `result.error.limitScope: 'per_key_concurrency'`, `onRateLimited`, its own
262
+ "concurrency limited" warning, and the end user gets `429 rate_limited` with `Retry-After`. (0.1.0 also blocks it,
263
+ as a rejected 4xx.) If your agent fans out many tool calls at once for one customer, send fewer in parallel.
264
+ - The `User-Agent` is `ordainer-node/<version>` read from `package.json` (`ordainer-node/0.2.0`).
265
+
266
+ ### Upgrade checklist: what to search your code for
267
+
268
+ - `failClosed`: if you used it to detect refused calls, check `rejected` too (item 3).
269
+ - `failMode: 'open'` combined with handling of 401/403/400/409: those calls are now blocked (item 1).
270
+ - `Idempotency-Key` / `req.headers['idempotency-key']`: no longer forwarded; pass `idempotencyKey(req)` from your own
271
+ data if you need retries to count once (item 2).
272
+ - `ordainer_unavailable` and `503` (in `onDeny`, agent prompts, monitors, load-balancer retry rules): blocks a retry
273
+ can't fix are now `plan_check_rejected` with `500` or `403` (item 5).
274
+ - `remaining -`, `remaining <`, `limit -` and other arithmetic on `remaining` / `limit`: both can be `null` now.
275
+ - `subscription_inactive` / `onBillingInactive`: unchanged behaviour (follows `failMode`), but read item 4.
276
+ - `1000000000`: if you special-cased the old "no cap" number, check for `null` instead.
277
+
278
+ ## 0.1.0
279
+
280
+ First version sent to vendors.
package/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.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # ordainer
2
+
3
+ A small wrapper that asks Ordainer (`POST /v1/decide`) before your tool handler runs. Zero dependencies. Node 20+
4
+ (uses the built-in `fetch`, `AbortSignal.timeout` and `crypto.randomUUID`). MIT licence.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install ordainer
10
+ ```
11
+
12
+ Requires an Ordainer agent key (`ORDAINER_KEY`) from the console. The default API host is
13
+ `https://api.ordainer.dev`; pass `baseUrl` (or set `ORDAINER_BASE_URL`) if you are pointing at staging.
14
+
15
+ **1.1.0 renames the package, class, env vars (`ORDAINER_KEY`, `ORDAINER_BASE_URL`), outage error code
16
+ (`ordainer_unavailable`) and key prefix (`ord_test_`/`ord_live_`) with no fallbacks to the old names;
17
+ 0.2.0 had breaking changes from 0.1.0**: read the [changelog](CHANGELOG.md) before upgrading.
18
+
19
+ ```js
20
+ const { Ordainer } = require('ordainer');
21
+
22
+ const ordainer = new Ordainer({
23
+ apiKey: process.env.ORDAINER_KEY, // or (req) => keyForThisCustomer(req)
24
+ baseUrl: 'https://api.ordainer.dev',
25
+ timeoutMs: 300, // default 300
26
+ // mode: 'shadow' | 'enforce' // unset = follow the plan's mode in the console (no deploy to flip)
27
+ // failMode: 'open' | 'closed' // default 'open': outages only (timeout, network error, 5xx)
28
+ // billingLapse: 'open' | 'closed' // default 'open': if YOUR Ordainer subscription is inactive (Terms 10.3(b))
29
+ // denyStatus: 429 | 402 // status for quota denies; default 429. Anything else throws here.
30
+ onDecision: (d) => metrics.record(d), // every result, including would-deny and fail-open
31
+ onBillingInactive: (d) => pager.alert('billing', d.error.code, d.requestId), // 403 subscription_inactive OR subscription_required
32
+ onTestLimit: (d) => log.warn('Ordainer sandbox limit reached', d.requestId), // deny test_limit_reached (test keys)
33
+ onRateLimited: (d) => log.warn('Ordainer rate limit', d.retryAfterSec), // 429 rate_limited (per-key abuse limit)
34
+ onSafetyLimit: (d) => log.warn('Ordainer safety limit', d.resetsAt), // 429 safety_limit_reached (the call still runs)
35
+ });
36
+
37
+ // Express / Connect
38
+ app.post('/tools/research', ordainer.middleware({ tool: 'research.run' }), handler);
39
+
40
+ // Any function
41
+ const research = ordainer.wrap(runResearch, { tool: 'research.run', idempotencyKey: (job) => job.id });
42
+ await research(job); // throws OrdainerDeniedError (.status, .headers, .body) when blocked
43
+
44
+ // Raw call (never throws)
45
+ const d = await ordainer.decide({ tool: 'research.run', idempotencyKey: 'job-42-step-3' });
46
+ if (d.allowed) { /* run it */ }
47
+ ```
48
+
49
+ ## Behaviour
50
+
51
+ | Situation | Default result |
52
+ |---|---|
53
+ | Ordainer says allow | Handler runs. `req.ordainer` holds the decision (`remaining`, `limit`, `requestId`, …). `remaining` and `limit` are `number \| null`: `null` on a plan with no cap (and when Ordainer didn't answer), so check before doing arithmetic. |
54
+ | Ordainer says deny, **enforce** | Handler does not run. `429 plan_limit_reached` with `Retry-After` and a message you can show the agent as-is. `tool_not_in_plan` gives `403 plan_denied`. `result.decision` is `'deny'`, `result.allowed` is `false` and `result.wouldDeny` is `false` (1.2.2: it's a real deny, not a would-deny). |
55
+ | Ordainer says deny, **shadow** | Handler runs. A `[ordainer] shadow: would deny …` warning is logged, `onDecision` gets `wouldDeny: true`, and the console shows "would deny (call ran)". |
56
+ | Timeout (`timeoutMs`), network error, 5xx, or a 200 that isn't a decision | **Fail open**: the handler runs and a warning is logged. With `failMode: 'closed'` you get `503 ordainer_unavailable` instead. These are the **only** cases `failMode` covers (`403 subscription_inactive` follows `billingLapse`, below). |
57
+ | Any other 4xx (`400`, `401 unknown_key`/`revoked_key`, `403 subscription_required`, `409 idempotency_conflict`, `429` other than `safety_limit_reached`, …) or no agent key configured | **Blocked in any `failMode`** (QA S2): Ordainer answered, and the answer isn't "allow", so the call is never let through unchecked. `plan_check_rejected` with **no `Retry-After`** (a retry won't fix it): `500` for a key, config, `400` or `409` problem, `403` for `subscription_required` (Q2; never `503`, which means "outage, retry"); or `429 rate_limited` with `Retry-After` for a rate limit; `result.rejected: true` (`failClosed` stays `false`), `result.error` has the code and status. A "rejected (call blocked in any failMode): <code>: <message> X-Request-Id=req_… tool=…" warning is logged. The warning is logged at most once a minute per failure mode and error code (`warningIntervalMs`), with "(N more since the last warning)", so an outage doesn't flood your logs; `onDecision` still sees every result. |
58
+ | `403 subscription_inactive`: your own Ordainer subscription is inactive | A **billing state, not an outage**. Follows the **billing-lapse setting `billingLapse`**, never `failMode` (Terms 10.3(b)): `'open'` (default) runs the handler, unchecked and uncounted (`result.allowed: true`, `result.failOpen: false`); `'closed'` (opt-in) blocks with `403 plan_check_rejected`, **no `Retry-After`** and a neutral message (`result.rejected: true`). Either way: its own warning at most once a minute (not the generic one), `result.billingInactive: true`, `result.billingLapse`, `result.reason: 'subscription_inactive'` and `onBillingInactive(result)` on every call. See below. |
59
+ | `403 subscription_required`: your Ordainer account has never had a paid subscription (live key) | **Blocked in any `failMode`**, like other 4xx (`result.rejected: true`). It gets its own billing warning (`[ordainer] subscription required: … no paid subscription yet … Subscribe …`, never "subscription inactive"), `result.billingInactive: true`, `result.reason: 'subscription_required'` and `onBillingInactive(result)`. |
60
+ | `429 rate_limited`: this agent key sent more than the per-key limit (default: about 100 requests a second per key (each server counts separately)) | A **rate limit, not an outage**. Blocked in any `failMode` with `429 rate_limited` and the server's `Retry-After`. Never silent: its own warning at most once a minute, `result.rateLimited: true`, `result.error.limitScope: 'per_key'`, `result.retryAfterSec`, and `onRateLimited(result)` on every such call. |
61
+ | `429 concurrency_limited`: this agent key already has too many plan checks in flight at once on one Ordainer server (4 in flight at once per key on our hosted service; up to 20 more wait briefly, so small bursts just serialize) | Handled **exactly like `429 rate_limited`**: blocked in any `failMode` (never a fail-open; it is answered inside the timeout), `429 rate_limited` with `Retry-After` to the end user, `result.rateLimited: true`, `result.reason: 'concurrency_limited'`, `result.error.limitScope: 'per_key_concurrency'`, `onRateLimited(result)`, and its own "concurrency limited" warning at most once a minute. Send fewer calls in parallel per agent key. For abuse-prone tools consider `failMode: 'closed'`. |
62
+ | `429 too_many_failed_auth`: too many requests with a missing or unrecognized agent key came from your server's address (Ordainer's per-address limit; never sent for a valid key) | A **bad key, not a rate problem**: rejected like other 4xx (`result.rejected: true`, `result.rateLimited: false`, `result.error.limitScope: 'per_address'`; `onRateLimited` is not called). Its own warning: "too many requests with a missing or unrecognized agent key from this server's address (Ordainer's per-address limit …) Check the agent key …". Fix the key; retrying won't help. |
63
+ | Deny `test_limit_reached`: your account used its free test-key decisions for the month (test keys only) | **Enforce: blocks** regardless of `failMode`: `429 test_limit_reached` with `Retry-After` until the 1st (UTC). **Shadow (1.2.0): runs.** Shadow never blocks: the server counts the call and records a would-deny `test_limit_reached` (answer `mode: 'shadow'`), and the handler runs (`result.allowed: true`, `result.wouldDeny: true`). Not an outage and not your customer's plan. Both: `result.testLimitReached: true`, its own warning at most once a minute, and `onTestLimit(result)` on every such call. Live keys are never affected. |
64
+ | `429 safety_limit_reached` (1.2.0): shadow calls passed the safety ceiling, 10 times your account's monthly test limit | A **safety limit, not a deny**. The handler **runs in every `failMode`** (like `billingLapse: 'open'`), unchecked and not recorded, until your test usage resets at the start of next month (UTC). `result.allowed: true`, `result.safetyLimited: true`, `result.failOpen: false`, `result.rejected: false`, `result.error.limitScope: 'org_safety'`, `result.resetsAt`, `result.retryAfterSec`; its own warning at most once per `warningIntervalMs`, and `onSafetyLimit(result)` on every such call. |
65
+ | The base URL's host doesn't resolve (`ENOTFOUND` / `EAI_AGAIN`, e.g. a typo in `ORDAINER_BASE_URL`) (1.2.0) | Handled like any network error (`failMode` applies; default: the handler runs). The **first** time per host, one loud error-level line (`logger.error`, else `logger.warn`; never throttled) names the host and says to set `ORDAINER_BASE_URL`; `result.error.dnsCode` says which. |
66
+
67
+ > **Upgrading from 0.2.0 with `failMode: 'closed'`?** A billing lapse is no longer blocked by `failMode: 'closed'`;
68
+ > it now runs, unchecked, unless you also set **`billingLapse: 'closed'`** (CHANGELOG 1.1.0, upgrade note).
69
+
70
+ **Subscription inactive: the billing-lapse setting.** When your Ordainer subscription is inactive (your live keys
71
+ stopped at a payment cutoff, or after a cancellation), live keys get `403 subscription_inactive`. The SDK doesn't treat
72
+ that as an outage, and it doesn't use `failMode` for it: it follows its own **billing-lapse setting**, `billingLapse`
73
+ (Terms 10.3(b)), so a lapse on your side never becomes an outage for your users unless you choose that.
74
+
75
+ - `billingLapse: 'open'` (**default**, whatever `failMode` is): the call runs, **unchecked and uncounted**. Your caps
76
+ are not enforced and no Decision Record is written while your live keys are off (Terms 10.5).
77
+ - `billingLapse: 'closed'` (opt-in): the call is blocked with `403 plan_check_rejected`, no `Retry-After`, and this
78
+ neutral message for your end users (no mention of billing or of Ordainer): "This action isn't available right now.
79
+ This is a setup issue on the service's side; retrying won't help." (exported as `BILLING_BLOCKED_MESSAGE`).
80
+
81
+ Set it on the client, or per route / call (`middleware({ billingLapse })`, `wrap(fn, { billingLapse })`,
82
+ `decide({ billingLapse })`). In both modes the SDK logs one distinct warning, at most once per `warningIntervalMs`
83
+ (default 60000; the old name `billingWarningIntervalMs` still works) per `Ordainer` instance:
84
+
85
+ ```
86
+ [ordainer] subscription inactive: Ordainer refused this live key because of a billing problem on your account. Calls are being let through, unchecked, because billingLapse is 'open' (the default). To fix this, sign in to the Ordainer console and open Billing. If Billing shows an unpaid invoice, pay it and these same live keys turn back on. If your subscription has ended, subscribe again under Billing, then issue new live keys under Keys. X-Request-Id=req_… tool=research.run (3 more since the last warning) (next warning in 60s at most)
87
+ ```
88
+
89
+ Under `billingLapse: 'closed'` it says "Calls are being blocked (403 plan_check_rejected) because billingLapse is
90
+ 'closed'". Every other 4xx, including `403 subscription_required`, blocks whatever either setting says. To alert on it,
91
+ use `onBillingInactive(result)`, which is called on **every** such call in both modes (it's not rate-limited, so dedupe
92
+ in your alerting), or check `result.billingInactive` / `result.reason === 'subscription_inactive'` in `onDecision`.
93
+ We also email your account owner when live keys stop and again 7 days later if they are still stopped, and show a
94
+ console banner. The X-Request-Id comes from the error body, or from the response header if the body has none.
95
+
96
+ **Test limit.** Test keys are free up to `TEST_DECISIONS_PER_MONTH` (1,000) decisions per account per UTC month. Over
97
+ it the SDK blocks and warns:
98
+
99
+ ```
100
+ [ordainer] test limit reached: this Ordainer account has used its free test-key decisions for this month (UTC). Calls with test keys are being blocked (429 test_limit_reached) until 2026-11-01T00:00:00.000Z, in enforce mode and regardless of failMode (shadow calls still run). This is a sandbox limit, not an outage and not your customer's plan. Live keys are not affected. X-Request-Id=req_… tool=research.run (next warning in 60s at most)
101
+ ```
102
+
103
+ Mock the SDK in CI test suites instead of calling Ordainer on every run; they share the account's 1,000.
104
+
105
+ **Shadow at the test limit (1.2.0).** In shadow mode the test limit never blocks: past it, each shadow call is still
106
+ counted and recorded as a would-deny `test_limit_reached`, and the SDK runs it (a "test limit reached (shadow)"
107
+ warning, at most once per `warningIntervalMs`). Past a safety ceiling of 10 times the limit the server answers
108
+ `429 safety_limit_reached` until the start of next month (UTC); the SDK runs the call in every `failMode`
109
+ (`result.safetyLimited`, `onSafetyLimit`). If you call our API directly, we recommend letting the tool run and logging
110
+ the error. If you reach it, email support@ordainer.ai.
111
+
112
+ **Rate limit.** The server allows each agent key `DECIDE_RATE_LIMIT_PER_SEC` requests a second (default: about 100 requests a second per key (each server counts separately)). Over
113
+ it, the SDK logs (at most once a minute, with a suppressed count):
114
+
115
+ ```
116
+ [ordainer] rate limited: this agent key is over Ordainer's per-key request limit (429 rate_limited). This is a rate limit, not an outage. Calls are being blocked (429 rate_limited) in any failMode. Slow down: retry after 1s. X-Request-Id=req_… tool=research.run
117
+ ```
118
+
119
+ **Idempotency keys:** every call sends one. **The middleware never forwards the incoming request's `Idempotency-Key`
120
+ header.** The agent controls that header, and replaying one key would get one stored allow back many times without
121
+ being counted (QA S1). By default each incoming request gets a fresh random UUID. Pass `idempotencyKey: (req) => …`
122
+ (or a string) **only from your own trusted data** (your job id, your queue message id), and use **one key per real
123
+ operation**. The server replays a key for 24 hours; after that it's a new, counted decision.
124
+
125
+ `retries: n` (default **1**, max 5, `0` = off; on the client, `decide()`, `middleware()` or `wrap()`) retries a timeout,
126
+ a network error or a **503** (the server's fast `decide_timeout` / `db_timeout` / `db_unavailable` answers, or an outage) with the **same**
127
+ key, after a 50 ms pause, so a lost answer is replayed by the server, not counted twice. 4xx answers and other 5xx
128
+ (500, 502, …) are never retried. All attempts of one call share `retryBudgetMs` (default 650: a 300 ms attempt, the
129
+ pause, and one more 300 ms attempt): a retry starts only with at least 50 ms left and its timeout is cut to the time
130
+ left, so a call never takes much longer than the budget. Only after the last attempt does `failMode` apply (default
131
+ `'open'`). `result.attempts` says how many were made, and `result.replayed` is true when the
132
+ server answered from a stored decision (`Idempotent-Replayed: true`).
133
+
134
+ **Sticky deny:** once Ordainer has denied an (agent key, tool) pair with `quota_exceeded`, a later call for that
135
+ pair that would fail open (timeout, network error, 5xx, malformed answer) is **denied** instead, as a plan-limit deny
136
+ (`429 plan_limit_reached` + Retry-After, `result.stickyDeny: true`, `result.error` still says what failed). That stops
137
+ a customer who is already at its limit from running calls uncounted while Ordainer is slow or unreachable. It lasts
138
+ `stickyDenyMs` (default 60000, max 300000, `0` = off), ends early at the plan's reset or when an allow arrives for the
139
+ pair, and lives in memory per `Ordainer` instance. In shadow mode (requested, or the plan's mode on that deny) the call
140
+ still runs and is reported (`stickyDeny`, `wouldDeny`, a warning). `failMode: 'closed'` is unchanged.
141
+
142
+ **Sticky deny is per process, in memory (QA N9-4).** Only an `Ordainer` instance that has itself received the
143
+ `quota_exceeded` deny remembers it. It is not shared with other processes, replicas or serverless instances, and it is
144
+ gone after a restart. So a fresh process, a newly started replica, or a cold serverless start (AWS Lambda, Vercel,
145
+ Cloud Run scaled from zero, ...) that only sees timeouts or 5xx during an Ordainer outage **still fails open, even for
146
+ a customer already over its cap**, until it gets its own deny. The memory is also capped (10,000 (key, tool) pairs;
147
+ the oldest is evicted), and an allow for the pair clears it. If your caps must hold during an outage and you run on
148
+ serverless or many short-lived instances, use **`failMode: 'closed'`** for those routes (the call is blocked with
149
+ `503 ordainer_unavailable` whenever Ordainer gives no decision). Sticky deny narrows the fail-open gap; it does not
150
+ close it.
151
+
152
+ The server accepts 1–255 characters; if the key the SDK would send is longer, the SDK sends
153
+ `<tool>:<sha256 hex of your key>` instead, or the bare 64-character hash if the tool name is too long for that. The same happens to a key holding characters the
154
+ server refuses (control characters U+0000–U+001F and U+007F, or an unpaired surrogate; those are hashed from their
155
+ UTF-16 code units), so such a key never turns into a 400. The mapping is deterministic, so a
156
+ retry with the same long key is still replayed, not counted (`idempotencyKeyFor(tool, key)` is exported if you want
157
+ to see the key that was sent; it's also on `result.idempotencyKey`). Reusing a key with a
158
+ different `tool` or `mode` gets `409 idempotency_conflict` from the server (a 4xx, so the call is blocked), so derive
159
+ the key from the tool too.
160
+
161
+ **Blocked response** (enforce, quota):
162
+
163
+ ```http
164
+ HTTP/1.1 429 Too Many Requests
165
+ Retry-After: 3600
166
+ X-Request-Id: req_KdimJCxq8zbjfL4g
167
+
168
+ {"error":{"code":"plan_limit_reached","message":"This agent's plan limit is reached (0 of 5 left). It resets at 2026-11-01T00:00:00.000Z. Retrying before then will not succeed. This call was not run.","request_id":"req_KdimJCxq8zbjfL4g","reason":"quota_exceeded","remaining":0,"limit":5,"resets_at":"2026-11-01T00:00:00.000Z"}}
169
+ ```
170
+
171
+ **Naming the agent** (unreleased, DRAFT T18-P39): when the API sends the agent key's label on the deny, the sentence
172
+ names it and the body gets a `label` field: "This agent (agent-a) has reached its plan limit (0 of 3 left). It resets at
173
+ …. Retrying before then will not succeed. This call was not run." The label is the name you gave the key, cleaned and cut
174
+ to 64 characters; nothing else about the key is sent. This is off by default in two places: the API only sends `label`
175
+ when your account has it turned on (ask us; a console setting is coming), and the SDK only uses it when you pass
176
+ `exposeKeyLabel: true`. With either one off, the body and sentence stay exactly as above. Labels are yours to choose,
177
+ so only turn it on if your key names are fine for your callers to see.
178
+
179
+ `Retry-After` is always the real number of seconds from the moment of the call until `resets_at`, rounded up (at
180
+ least 1). This example was produced at 2026-10-31T23:00:00Z, one hour before the reset, so it's `3600`; on Oct 1 at
181
+ 00:00 UTC it would be `2678400` (31 days).
182
+
183
+ Blocked responses follow the API's own conventions: the `X-Request-Id` header (the decision's `request_id`, the same
184
+ id as the audit row) and `error.code` + `error.message` + `error.request_id`. The codes are `plan_limit_reached`
185
+ (quota, 429 or `denyStatus`), `plan_denied` (`tool_not_in_plan`, 403), `test_limit_reached` (429), `rate_limited`
186
+ (the server's per-key rate limit, 429), `plan_check_rejected` (a block a retry can't fix: 403 for billing, 500
187
+ otherwise) and `ordainer_unavailable` (503, a real outage under `failMode: 'closed'`; `X-Request-Id` only when a
188
+ decision id exists). Earlier builds used `Ordainer-Request-Id` and `error.type`; both are gone.
189
+
190
+ **`Retry-After` only when a retry can work** (G5):
191
+
192
+ | Block | Status and code | `Retry-After` | Message |
193
+ |---|---|---|---|
194
+ | Quota (`plan_limit_reached`) | 429 (or `denyStatus`) | seconds until the reset | says when it resets |
195
+ | `test_limit_reached` | 429 | seconds until the reset (none if `resets_at` is unknown) | "This call wasn't run. This service's test calls for the month are used up. They reset on November 1 (UTC). 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)" (the day is `resets_at` in UTC, as "<Month D>"; no "They reset on …" sentence unless `resets_at` is a valid ISO-8601 date-time) |
196
+ | Any other deny (today `tool_not_in_plan`) | 403 `plan_denied` | **none** | `tool_not_in_plan`: "This call wasn't run because your plan doesn't include "research.run". If you need it, ask whoever manages your plan. (tool_not_in_plan)" (the tool you passed to `decide()`, in quotes only when it matches `^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$` and has no `//` and no leading `scheme:` (`^[A-Za-z][A-Za-z0-9+.-]*:`, so nothing URL-like is echoed); otherwise "this action"). Any other reason: "This call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team. (reason)" (the reason in brackets only when it looks like an API code: lowercase letters and `_`, up to 40 characters) |
197
+ | `429 rate_limited` | 429 `rate_limited` | the server's value | "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)" (the seconds are in `Retry-After` and `retry_after`) |
198
+ | `429 concurrency_limited` | 429 `rate_limited` | the server's value | "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 seconds are in `Retry-After` and `retry_after`) |
199
+ | Outage under `failMode: 'closed'` (timeout, network error, 5xx, malformed answer) | **503 `ordainer_unavailable`** (only here) | `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)" |
200
+ | Billing: `subscription_required`, or the `subscription_inactive` lapse under `billingLapse: 'closed'` | **403 `plan_check_rejected`** | **none** | "This action isn't available right now. This is a setup issue on the service's side; retrying won't help." (neutral, Terms 10.3(b)) |
201
+ | Any other rejection: missing, unknown or revoked key, `too_many_failed_auth`, `400`, `409 idempotency_conflict`, other 4xx | **500 `plan_check_rejected`** | **none** | Plain words for the cause, then the API's code last, e.g. after `401 unknown_key`: "This call wasn't run. This is a 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 until the service fixes the key. (For the service's team: unknown_key.)" A revoked key says "one of its keys has been turned off", no key says "a key it needs is missing", anything else says "This call wasn't run because of a setup issue on the service's side." |
202
+
203
+ Q2: these blocks used to answer `503 ordainer_unavailable` too, which tells agents and load balancers "outage, retry".
204
+ Now `503` means only a real outage.
205
+
206
+ **Result flags** (distinct, never two of them true at once):
207
+
208
+ - `failOpen`: `failMode: 'open'` let the call run without a decision (an outage). Never set for the
209
+ `subscription_inactive` lapse: that ran because of `billingLapse: 'open'` (`result.billingLapse`).
210
+ - `failClosed`: `failMode: 'closed'` blocked the call because there was no decision (an outage). Only ever true
211
+ because of `failMode`.
212
+ - `rejected`: Ordainer refused the call (any 4xx, including the lapse under `billingLapse: 'closed'`), or it couldn't
213
+ be sent (no agent key, no tool). Blocked in **any** `failMode`; `failClosed` stays `false`. `result.error` has the
214
+ code and status.
215
+
216
+ 0.1.0 set `failClosed: true` on rejected calls too; check `rejected` now. All breaking changes since 0.1.0 are in
217
+ [`CHANGELOG.md`](CHANGELOG.md).
218
+
219
+ **Result fields** (`decide()`'s return value, `req.ordainer` in the middleware, the argument to `onDecision`; all in
220
+ `index.d.ts`):
221
+
222
+ | Field | Meaning |
223
+ |---|---|
224
+ | `allowed` | Run the tool? Already accounts for `mode`, `failMode` and `billingLapse`. |
225
+ | `decision` | `'allow'`, `'deny'`, or `null` when Ordainer gave no decision. |
226
+ | `reason` | Ordainer's reason (`quota_exceeded`, `tool_not_in_plan`, `test_limit_reached`, `subscription_inactive`, …) or `null`. |
227
+ | `remaining`, `limit` | `number \| null`: `null` on a plan with no cap, or when Ordainer didn't answer. |
228
+ | `resetsAt` | ISO time the period resets, or `null`. |
229
+ | `requestId` | Ordainer's `X-Request-Id` (the audit row), or `null`. |
230
+ | `mode` | Effective mode (`'enforce'` / `'shadow'`), or `null`. |
231
+ | `wouldDeny` | A **shadow** deny: Ordainer said deny, the mode is shadow, and the call ran. `false` on an enforce deny (check `decision === 'deny'` or `allowed`) and on an allow. 1.2.1 and earlier set it on every deny. |
232
+ | `failOpen`, `failClosed`, `rejected` | See "Result flags" above; at most one is true. |
233
+ | `replayed` | `true` when the server answered from a stored decision for this idempotency key (`Idempotent-Replayed: true`), so this call was **not** counted again. A retry (`retries`) after a lost answer comes back `replayed: true`. |
234
+ | `attempts` | HTTP attempts made: `1` normally, up to `1 + retries` after timeouts, network errors or 5xx; `0` when nothing was sent (no key, no tool). |
235
+ | `idempotencyKey` | The key actually sent (yours, the hashed form of a long one, or the random UUID). Log it to match a retry to its replay. |
236
+ | `billingInactive` | `403 subscription_inactive` or `subscription_required`; `reason` says which. |
237
+ | `billingLapse` | The billing-lapse setting applied to a `403 subscription_inactive` (`'open'`: ran unchecked; `'closed'`: blocked), else `null`. |
238
+ | `testLimitReached` | Denied with `test_limit_reached` (blocked in enforce; in shadow, 1.2.0 runs the call as a would-deny). |
239
+ | `rateLimited`, `retryAfterSec` | `429 rate_limited` (per key) or `429 concurrency_limited` (too many in flight for the key; `reason` says which) and its `Retry-After` in seconds. |
240
+ | `safetyLimited` | 1.2.0: `429 safety_limit_reached` (shadow calls past the safety ceiling, 10 times the account's monthly test limit). The call ran (`allowed: true`, `failOpen: false`); `retryAfterSec` and `resetsAt` say when it ends. |
241
+ | `error` | `{ code, message, status?, limitScope? }` when there was no decision; `limitScope` is `'per_key'`, `'per_key_concurrency'` or `'per_address'` on a 429 (the server's `error.limit_scope`; not `limit`, which is the plan's cap). |
242
+ | `label` | The agent key's label (its name) when the API sent it on a deny, cleaned and at most 64 characters; `null` on allows, errors, older servers, or unless `exposeKeyLabel: true`. Named in the 429 `plan_limit_reached` (unreleased, DRAFT T18-P39). |
243
+ | `stickyDeny` | A call that would have failed open was handled as a quota deny because this (key, tool) got `quota_exceeded` within `stickyDenyMs` (see "Sticky deny"); blocked in enforce, still run (and `failOpen`) in shadow. `error` says what failed. |
244
+ | `denyStatus` | Status a blocked quota deny answers with (429 or 402). |
245
+ | `tool`, `latencyMs` | The tool checked and how long the check took. |
246
+
247
+ Pass `onDeny(req, res, result)` to the middleware (or `onDeny(result, ...args)` to `wrap`) to render your own response.
248
+
249
+ **Quota-deny status (`denyStatus`).** Only `429` (Too Many Requests, the default) or `402` (Payment Required) are
250
+ accepted; anything else (`403`, `'429'` as a string, …) throws a `TypeError` when you construct the client, so a typo
251
+ can't ship. Override it per route: `ordainer.middleware({ tool: 'export.run', denyStatus: 402 })` answers that route's
252
+ quota denies with 402 while the rest keep the client's default (also validated, at `middleware()` time). It only
253
+ changes the status of `plan_limit_reached`; the body, `Retry-After` and the other codes are the same.
254
+
255
+ **`onBillingInactive`** fires for both billing answers, `403 subscription_inactive` (the subscription ended; follows
256
+ `failMode`) and `403 subscription_required` (a live key on an account that hasn't paid yet; always blocked);
257
+ `result.error.code` says which.
258
+ `denyResponse(result)` returns `{status, headers, body}` if you just want to change the shape.
259
+
260
+ ## Tests
261
+
262
+ `npm pack` in this folder (or `npm run sdk:pack` in the repo root) builds the installable tarball: `index.js`,
263
+ `index.d.ts`, `README.md`, `CHANGELOG.md`, `LICENSE` and `examples/`, no tests. `npm test` in this folder runs the unit tests against a fake HTTP server. The end-to-end tests against the real Ordainer
264
+ server and Postgres are in the app repo at `test/sdk-e2e.test.js`.
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+ // Quickstart example: one paid tool behind Ordainer.
3
+ // npm install express (in your own project)
4
+ // ORDAINER_KEY=ord_test_... ORDAINER_BASE_URL=https://api.ordainer.dev node express-server.js
5
+ // curl -X POST localhost:3001/tools/research
6
+ const express = require('express');
7
+ const { Ordainer } = require('ordainer');
8
+
9
+ const ordainer = new Ordainer(); // reads ORDAINER_KEY and ORDAINER_BASE_URL; mode follows the plan in the console
10
+
11
+ const app = express();
12
+ app.post('/tools/research', ordainer.middleware({ tool: 'research.run' }), (req, res) => {
13
+ // Only reached when Ordainer allows (or in shadow mode, or when Ordainer is unreachable).
14
+ res.json({ result: 'expensive research done', ordainer: req.ordainer.decision, remaining: req.ordainer.remaining });
15
+ });
16
+
17
+ const port = Number(process.env.PORT || 3001);
18
+ app.listen(port, () => console.log(`vendor tool on http://localhost:${port}/tools/research`));