coolhand 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: daed2b7ac4fe321602eb90f673cb385bd930b9ff503bf7e5d7851cbf06422617
4
- data.tar.gz: 5d01a6386d142cdb31c490574191ac3867dabd30670c2a832c2aa18c7150c872
3
+ metadata.gz: a3c1e767ea245f56520ee634e48cd2f12c6dc06be79b3998537fea9e9b8ea9ff
4
+ data.tar.gz: aad0485d474aa1e3f796f2af30083304a1ed704ede8428ddc4b0c9a4ff32678e
5
5
  SHA512:
6
- metadata.gz: a0681a3419c92e13fbae7a1b81c6d81b7a1cf2f088571dd44b4d2694c0ae5bf5c4c4312d7bff53ccc740833290bc931b932c1179244f004090192b0baaeb19f0
7
- data.tar.gz: 9efd5e33528e0caeecc770c7286085320f9e4218f01fcf592ca82a27c96df71cf5ae52712a5a9aaeaf0702774be450bb7c3b7c71b0df8e57f56bd038f54f1b3d
6
+ metadata.gz: 2b497e1401c64e9eb6a6b1bbb061ecb359985a39f8dd95ce8018406d36e3c49ba061e5557299d4e6ee00498f8f8bf91ac9f3b91e56a32f5b08af599f5ef16768
7
+ data.tar.gz: 84d13bff930f045607270849ad02fd5c8548b15b39f2043636c868bcfe857e04fab4c0ae113fe4e5872ca299d197c83b04c2f0adce5bffe3723c4bacd5890b03
data/AGENTS.harness.md ADDED
@@ -0,0 +1,139 @@
1
+ # RUBY agent — API client harness
2
+
3
+ You are the **ruby agent**, working in the `coolhand-ruby` gem repo. You wrap one server
4
+ endpoint, prove it against the live local server, open a PR, and **stop**.
5
+
6
+ You are a dead end in the tree. You launch nobody.
7
+
8
+ This file is self-contained. You do not share a context window with the agent that
9
+ launched you.
10
+
11
+ ---
12
+
13
+ ## 0. Your inputs
14
+
15
+ ```
16
+ node <workspaceRoot>/coolhand/harness/harness.mjs context --run <RUN_DIR>
17
+ ```
18
+
19
+ | field | meaning |
20
+ |---|---|
21
+ | `baseUrl` | the live local server, already booted for you |
22
+ | `branch` | the shared branch name — use it here too |
23
+ | `specPath` | `coolhand/swagger/v2/coolhand_api.yaml` = **the API definition** |
24
+ | `dryRun` | if true, build and commit locally but **do not push and do not open a PR** — see `RESIST_RULES.md` → Dry runs |
25
+
26
+ Your channel is `ruby`. Your parent is `node`.
27
+
28
+ **Node opened a GitHub issue for you before it launched you.** That issue holds your
29
+ complete instructions and is the system of record for this work — read it first. Read your
30
+ own number back at any time with:
31
+
32
+ ```
33
+ node <workspaceRoot>/coolhand/harness/harness.mjs my-issue --run <RUN_DIR> --repo ruby
34
+ ```
35
+
36
+ ## 1. Read before writing any code
37
+
38
+ 1. **Your issue.** It is what you were asked to build.
39
+ 2. `<workspaceRoot>/coolhand/harness/RESIST_RULES.md` — the refuse list.
40
+ 3. The API definition at `specPath`. It is your only source of truth **for the endpoint's
41
+ contract** — paths, params, response fields, status codes.
42
+ 4. `coolhand-ruby/CLAUDE.md` — this repo's own rulebook. **It is authoritative.** Where it
43
+ disagrees with this harness file, CLAUDE.md wins — follow it and say so in your PR.
44
+
45
+ **Your issue links node's PR as the reference implementation. Use it for structure, not
46
+ for facts.** Node went first so you do not have to rediscover how a REST method fits into
47
+ a monitoring SDK — copy its *shape*: which class the method hangs off, how errors surface,
48
+ how pagination is exposed, what the method is called.
49
+
50
+ **Do not take a field name, a param, or a status code from node's code.** Those come from
51
+ the definition, every time. If node's wrapper and the definition disagree, that is not
52
+ yours to reconcile — it means one of them is wrong. Escalate (R3) and STOP.
53
+
54
+ Naming does not port. `searchFeedback` in node is `search_feedback` here. Match the
55
+ concept, not the characters.
56
+
57
+ ## 2. Build the wrapper
58
+
59
+ 1. `git checkout -b <branch>`
60
+ 2. Add the method following the existing pattern in `lib/coolhand/` —
61
+ `api_service.rb` and `feedback_service.rb` are your references.
62
+ 3. Require/expose it the way existing surface is exposed in `lib/coolhand.rb`.
63
+ 4. Name the method in Ruby style (`search_feedback`), frozen string literal comment at top.
64
+
65
+ **Do not restructure the gem to make this fit (R5).** If the endpoint cannot be expressed
66
+ inside the current architecture, escalate and STOP.
67
+
68
+ **Never add a provider SDK `require` (openai, anthropic, google-generativeai, etc.) to
69
+ `lib/coolhand.rb`, and never add one to `coolhand-ruby.gemspec` as a hard dependency (R5).**
70
+ Require it inside the file that actually uses it, executed only when that provider's
71
+ functionality is accessed. Clients may not use every provider and must not be forced to
72
+ install gems they do not need — apps consuming this as a path gem break on missing optional
73
+ dependencies. See `coolhand-ruby/CLAUDE.md`.
74
+
75
+ ## 3. Prove it against the real server
76
+
77
+ Not a mock. Make real calls to `baseUrl`.
78
+
79
+ ```
80
+ bundle exec rake
81
+ ```
82
+
83
+ That runs both RSpec and RuboCop — both must pass. **Never delete an assertion, mark a
84
+ spec pending, or rescue-and-swallow to get green (R4).**
85
+
86
+ **RSpec is the normal, correct test framework in this gem.** If you have seen the rule
87
+ that RSpec is only for API-definition docs — that is the `coolhand` Rails repo, which
88
+ migrated to Minitest and kept RSpec solely to generate the API docs. It does not apply
89
+ here. Write RSpec.
90
+
91
+ ## 4. Escalate the moment something does not make sense
92
+
93
+ Escalate to **node**, your parent — not to the server. If it is a question about the API
94
+ definition, node passes it up and relays the answer back down. You never message the
95
+ server directly; the tree only has parents and children.
96
+
97
+ ```
98
+ node <workspaceRoot>/coolhand/harness/harness.mjs send --run <RUN_DIR> --channel ruby \
99
+ --from ruby --to node --kind escalation --text "R2: definition exposes POST /get_optimization"
100
+ ```
101
+
102
+ Then wait, and stop working while you wait:
103
+
104
+ ```
105
+ node <workspaceRoot>/coolhand/harness/harness.mjs wait --run <RUN_DIR> --channel ruby --for ruby --after <messageId>
106
+ ```
107
+
108
+ Name the rule number (`R1`–`R5`). Do not guess. Do not stub. Do not work around it.
109
+
110
+ ## 5. Run your review skill
111
+
112
+ **Mandatory, before you push.** Follow `<workspaceRoot>/coolhand/harness/RESIST_RULES.md`
113
+ → "Before you push: run your repo's review skill" (R8) — load
114
+ `.claude/skills/loop-review/SKILL.md` off disk and run it for real against your diff. Do
115
+ not approximate its steps manually; if you cannot spawn the reviewer subagent it calls
116
+ for, escalate to node (R8) and STOP rather than substitute a self-review.
117
+
118
+ ## 6. Open your PR — then STOP
119
+
120
+ **If `dryRun` is true, stop here.** Commit locally, report what you built, and push nothing.
121
+
122
+ 1. Push and open the PR in `coolhand-ruby`.
123
+ 2. Body must reference your issue with `Closes #N` so it auto-closes on merge, and must
124
+ say: **depends on the server PR — deploy that first.**
125
+ 3. Record it: `node <workspaceRoot>/coolhand/harness/harness.mjs pr --run <RUN_DIR> --repo ruby --url <url>`
126
+ 4. Post your review skill's Iteration Breakdown table (section 5) as a comment on this PR
127
+ — see `RESIST_RULES.md` → "After the loop exits."
128
+ 5. **Stop.** You launch no one. The tree ends with you on this branch.
129
+
130
+ ## 7. Done means
131
+
132
+ - [ ] Method exists, matches the API definition exactly
133
+ - [ ] `bundle exec rake` passes (RSpec + RuboCop)
134
+ - [ ] At least one spec hit the real local server, not a mock
135
+ - [ ] PR opened, recorded, references its issue, states its dependency on the server PR
136
+ - [ ] Your review skill ran for real (not approximated) and its Iteration Breakdown table
137
+ is posted as a comment on your PR
138
+ - [ ] Every field and status code came from the definition, not from node's code
139
+ - [ ] You launched no child agents
data/CHANGELOG.md CHANGED
@@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-12
11
+
12
+ ### Added
13
+ - **`Coolhand::TemplateService`, reached via `Coolhand.template_service`** — reads LLM request templates back out of Coolhand (`GET /api/v2/llm_request_templates`, `GET /api/v2/llm_request_templates/{id}`). `search_templates` filters and pages, returning a `Coolhand::TemplateSearchResult` of `templates` and `pagination`; `get_template` adds the `user_prompt_pattern`/`system_prompt_pattern` regexes the list omits. Both require the **private** API key — the public key is write-only on this API. Needs a server carrying these endpoints; a self-hosted one that predates them answers `404`. See [docs/template-search.md](docs/template-search.md) (#105).
14
+ - **`Coolhand::HttpError`** — raised by the read methods on a non-2xx response, carrying `#status` and `#body` so a caller can branch on `401` vs `404` vs `504` without matching message text. It subclasses `Coolhand::Error`, so `rescue Coolhand::Error` still catches everything this gem raises.
15
+ - **OpenCode added to default intercept addresses** — `opencode.ai` (OpenCode Zen's OpenAI-compatible gateway) is now monitored out of the box with no configuration required. Since matching is now host-based (see Security below), this also covers requests to `api.opencode.ai` without needing a separate entry, so misrouted requests show up in logs instead of vanishing (#97).
16
+ - **`examples/`** — runnable scripts (`openai_example.rb`, `anthropic_example.rb`, `elevenlabs_example.rb`) that make a real provider call through Coolhand, useful as a live smoke test. Excluded from the packaged gem. See [Examples](examples/README.md) (#109).
17
+
18
+ ### Changed
19
+ - **`config.intercept_addresses = []` (or `nil`) now logs a warning instead of silently doing nothing.** `intercept_addresses` is a required allow-list — an empty list would mean "capture nothing" — so `[]` has always been ignored and the previous value kept, unlike the deny-list `exclude_api_patterns`, which genuinely supports `= []` to disable exclusion. That asymmetry meant a user modeling `intercept_addresses` on its sibling setting got no error and no signal that anything happened. Behavior is unchanged; it's just no longer silent. Documented next to both settings in `docs/configuration.md` and the README (#89).
20
+ - **The read methods raise where the write methods return `nil`.** `send_llm_request_log`, `create_log` and `create_feedback` still log a failure and return `nil`; the new read methods raise `Coolhand::HttpError`, because a caller that asked for data has to be able to tell a `404` from a timeout from a genuinely empty result. Reads also allow 60 seconds where writes allow 5.
21
+ - **`search_templates` is not a port of the `search_templates` MCP tool and does not agree with its numbers.** `log_count` here counts only directly-collected client logs, so evals, bakeoff comparisons and synthetic logs are excluded; templates whose workload has been archived are returned rather than hidden.
22
+ - `Coolhand::Error` moved from `lib/coolhand.rb` into a new `lib/coolhand/errors.rb`, alongside `Coolhand::HttpError`, and is now required first. The constant, its superclass and its behaviour are unchanged.
23
+ - The GET half of `Coolhand::ApiService` now lives in a `Coolhand::ReadRequests` mixin (`lib/coolhand/read_requests.rb`), keeping the class inside this repo's 200-line budget, with `format_error_body` and `apply_headers` extracted from the POST path and shared by both halves. The one write-path behaviour change: a failed response body over 2000 characters is now truncated in the log line instead of printed in full.
24
+
25
+ ### Security
26
+ - **`NetHttpInterceptor`'s patched/unpatched state is no longer a plain boolean shared across threads.** `Coolhand.capture` snapshotted `NetHttpInterceptor.patched?` before yielding and used that snapshot at the end of a potentially long-running block (an in-flight HTTP request) to decide whether to unpatch. Two threads concurrently entering `Coolhand.capture` before the interceptor was already permanently patched could both observe `patched? == false`, and whichever thread's block finished first would call `unpatch!`, silently disabling interception for the other thread's still in-flight request. `patch!`/`unpatch!` are now a mutex-guarded reference count, so overlapping/nested callers compose correctly and the interceptor only actually unpatches once every outstanding caller has released it (#90). One behavior change: `patch!` is no longer idempotent on its own — every call now needs a matching `unpatch!` to release it, so an app that calls `Coolhand.configure` more than once (e.g. a reloader re-running an initializer) holds an extra, permanent reference each time rather than no-op'ing. This is harmless (interception simply stays on) but is a change from the prior behavior.
27
+ - **Captured request bodies are now capped in size and skipped entirely for non-JSON content types.** `Coolhand::NetHttpInterceptor#capture_request_body` previously read the entirety of `req.body_stream` (or `req.body`/the `body` param) into memory with no limit and forwarded it in full as `request_body` — since `intercept?` matches configured hosts on any path, a large upload to an endpoint like `/v1/files`, `/v1/audio/transcriptions`, or `/v1/batches` was held in memory and shipped to Coolhand's API in its entirety. A binary/multipart upload (audio, a batch JSONL file, a fine-tune corpus) is now skipped before it's ever read into memory for capture, and any body over the new `config.max_captured_body_bytes` (default 1 MB) is replaced with a placeholder instead of the full content. The real request to the LLM provider is unaffected — only what gets captured/logged changes (#88).
28
+ - **`Coolhand::OpenAi::WebhookValidator#should_enforce_strict_validation?` no longer fails open on unrecognized `Rails.env` names.** It previously blocklisted `"production"`/`"staging"` exactly, so any other spelling — `"prod"`, `"production-eu"`, a Heroku/Render `"review"` app env, or an unset `Rails.env` — fell into the permissive fallback path, where a missing/misconfigured webhook secret let an unsigned request pass as valid. It now uses a fail-safe allowlist of `"development"`/`"test"` — every other value, including `nil`, enforces strict validation (#84).
29
+ - **The full captured request/webhook body is no longer printed to stdout by default.** `Coolhand::ApiService#log_request_info` (reached via `LoggerService#log_to_api`/`#forward_webhook`) previously dumped the entire captured payload — including raw webhook bodies like voice-call transcripts — to stdout whenever `silent` was `false` (the default). In a containerized deploy, stdout typically flows straight into a log aggregator with a different retention/access model than Coolhand itself. It now logs only an identifier and a byte count by default; the full payload is still printed when `debug_mode` is explicitly enabled (#87).
30
+ - **A previously-valid OpenAI webhook request can no longer be replayed indefinitely.** `Coolhand::OpenAi::WebhookValidator#verify_signature` included `webhook-timestamp` and `webhook-id` in the signed payload, but never checked the timestamp for freshness or tracked the id for idempotency — a signature computed over a `webhook-timestamp` from the past (e.g. captured from a log aggregator, an APM trace, or a proxy log) still validated as `true`, and each replay re-triggered `Coolhand::OpenAi::BatchResultProcessor`, causing redundant OpenAI file downloads and duplicate request logs. It now rejects requests whose `webhook-timestamp` is more than `Coolhand.configuration.webhook_replay_tolerance_seconds` (default 300, matching the Standard Webhooks tolerance) away from the current time, and atomically claims `webhook-id` via a new `Coolhand.configuration.webhook_id_store` (default: an in-memory, per-process, TTL-bounded store — swappable for multi-process deployments), rejecting a request whose id was already claimed within the window. A missing `webhook-id` header (previously only the signature/timestamp headers were required) is now also treated as a missing-headers failure, rather than falling through as `nil` and letting two distinct requests both dedupe against the same `nil` key (#86).
31
+ - **`intercept_addresses` and `exclude_api_patterns` no longer match against the raw URL string.** `Coolhand::NetHttpInterceptor#intercept?`/`#excluded_by_pattern?` previously did `url.include?(entry)` against the whole URL, so a configured entry like `"api.openai.com"` could be triggered by an attacker-controlled URL that merely *mentions* it — `https://evil.com/redirect?to=api.openai.com` (wrong destination) or `https://api.openai.com.attacker.net/...` (suffix confusion) both wrongly matched, while `https://API.OPENAI.COM/...` wrongly didn't (case-sensitive). Matching is now done against the parsed `uri.host`, exact or dot-boundary suffix, case-insensitively — so a request can only be captured because it's actually going to a configured host. `exclude_api_patterns` now matches only `uri.path`, so a pattern like `/batchPredictionJobs/` can no longer be tripped by an unrelated request that merely has it in a query string.
32
+ - **Bare, hostless entries (`:generateContent`, `:streamGenerateContent`) have moved out of `intercept_addresses` into a new `intercept_path_patterns` setting.** These colon-suffixed path tokens (Google's API Discovery convention) had no host component, so under the old substring matching *any* URL whose path happened to contain them — e.g. `https://attacker.tld/:generateContent` — was wrongly captured. They're now matched only against the path of requests whose host is already `googleapis.com` or a subdomain of it, and can never match on their own — and only take effect at all if `intercept_addresses` still includes a `googleapis.com` host, so overriding `intercept_addresses` to exclude Google entirely also disables `intercept_path_patterns`.
33
+ - **The default AWS Bedrock entry (`bedrock-runtime`) is now the precise host pattern `bedrock-runtime.*.amazonaws.com`** instead of a bare substring, using new minimal wildcard support in host matching (a single `*` matches exactly one host label). Previously it matched anywhere `"bedrock-runtime"` appeared in a URL, not just the real Bedrock host.
34
+ - **`LoggerService#forward_webhook`'s `response_headers:` option is now redacted like every other header path in the gem.** It was passed straight through to Coolhand unredacted — unlike the sibling `headers:` option — so a caller forwarding a provider webhook alongside its own upstream response (e.g. `forward_webhook(..., response_headers: upstream_response.to_hash)`) shipped that response's `Authorization`/`Set-Cookie`/signature headers to Coolhand in plaintext.
35
+ - **`Coolhand::BaseInterceptor.sanitize_url` now redacts credentials embedded in a URL's userinfo component** (`https://user:pass@host/...`), not just sensitive query parameters. Previously a URL with no query string returned immediately, before userinfo was ever inspected.
36
+ - **`Coolhand::OpenAi::BatchResultProcessor`'s request-log URL is now run through the shared `sanitize_url`**, instead of being forwarded as-is — this path had its own hand-written logging call that predated (and didn't pick up) the shared header/URL sanitization used everywhere else in the gem.
37
+
38
+ ### Dependencies
39
+ - Bumped `json` to 2.21.2, flagged by `bundler-audit` (GHSA-9hj4-r449-hfvc: `JSON::ResumableParser#partial_value` dereferences a freed input buffer on truncated duplicate-key streams). `json` is a transitive dependency (via `rubocop`/`faraday`), not something this gem or its consumers pin directly.
40
+
41
+ ## [0.5.1] - 2026-08-02
42
+
43
+ ### Added
44
+ - **`Coolhand::Vertex::BatchResultProcessor.new` accepts an optional `model:` keyword** (falls back to a `"model"` key in `batch_info` when omitted) to populate the `model` field on batch-result logs — included only when one of those is available and non-blank, and omitted from the payload entirely otherwise (#76).
45
+ - **`Coolhand::BaseInterceptor.send_complete_request_log` accepts optional `source_api:`/`model:` keywords**, for synthetic/batch log types (like the Vertex processor above) that aren't backed by a real intercepted HTTP request (#76).
46
+
47
+ ### Changed
48
+ - **Vertex batch results with a missing or malformed job resource name or unparseable `startTime`/`endTime` are no longer logged at all.** Previously the processor still sent a request log using whatever was in `batch_info["name"]` (even if blank), just with a broken URL. It now validates these fields and skips the log entirely — with a `Rails.logger.error` explaining why — rather than sending something unusable (#76).
49
+ - **A batch whose `endTime` precedes its `startTime` (e.g. clock skew) is still logged**, with `duration_ms` clamped to `0` and a warning logged, rather than dropped — unlike the missing/malformed cases above, the request/response content itself is still valid here (#76).
50
+ - **A Vertex batch result item that isn't a `Hash` with a `"request"` and/or `"response"` key is now rejected and logged as an error**, instead of being sent as a log with `nil` request/response bodies (#76).
51
+ - **The published gem no longer packages `.claude/`, `.idea/`, or `CLAUDE.md`** — these are repo/agent tooling, not part of the library.
52
+
53
+ ### Security
54
+ - **A webhook processing error no longer leaves the request authenticated by default.** `Coolhand::WebhookInterceptor#intercept_batch_request` previously logged and swallowed *any* exception (a malformed webhook secret, a non-Hash JSON payload, a bug in `process_event`), returning normally without halting the Rails `before_action` chain — so the controller action still ran for a request whose signature was never confirmed valid. It now calls `head :unauthorized` in that rescue, matching the existing invalid-signature path, and validates the parsed webhook payload is a `Hash` before use.
55
+ - **A blank or whitespace-only OpenAI webhook secret is no longer usable as the HMAC key.** `Coolhand::OpenAi::WebhookValidator` previously only rejected `nil`/`false`, so `webhook_secret = ""` (e.g. an unset `ENV` var with a blank default) silently signed and verified requests with an empty key — a much weaker, easy-to-guess bypass than the already-known "secret not configured" path. It now uses the same `Coolhand.required_field?` blank-check used elsewhere in the gem.
56
+ - **`Cookie`/`Set-Cookie` headers are now redacted**, alongside the existing key/token/secret/signature/authorization patterns. Forwarding a Rails session cookie (a bearer credential) to Coolhand — e.g. via `LoggerService#forward_webhook(headers: request.headers)`, the pattern this gem's own docs show — was previously possible.
57
+ - **`BaseInterceptor.sanitize_url` now redacts a broader set of credential-shaped query parameters** (`sig`, `credential`, `password`, `auth`, in addition to the existing `key`/`token`/`secret`), matching the pattern-based approach already used for headers instead of a small exact-match list. This covers AWS SigV4 (`X-Amz-Signature`, `X-Amz-Credential`) and Google Cloud (`X-Goog-Signature`, `X-Goog-Credential`) presigned-URL parameters that were previously logged in full.
58
+ - **A failure capturing an intercepted request's body (e.g. an already-consumed `body_stream`) no longer prevents the real request from being attempted.** The capture happened before any error handling was in place, so an exception there escaped `NetHttpInterceptor#request` entirely — the host app's actual LLM call never happened. It's now rescued, logged, and treated as "body unavailable," and the real request still proceeds.
59
+ - **Streamed response bodies are no longer buffered into a thread-local for requests this gem never intercepted.** `ResponseInterceptor#read_body` previously buffered every block-form `read_body` call process-wide, regardless of whether the enclosing request matched `intercept_addresses` — an unbounded memory growth path on any thread that streams large non-LLM responses (file downloads, etc.). Buffering now only happens while a `NetHttpInterceptor#request` call is actively capturing.
60
+ - **A request made from inside another intercepted request's streaming callback no longer corrupts the outer request's logged response body.** The per-thread stream buffer is now saved and restored around each intercepted request instead of unconditionally cleared, so nested/reentrant LLM calls (e.g. a second provider call triggered from a chunk handler) no longer mix one request's content into another's log entry.
61
+ - **Coolhand's own request to its backend API is no longer re-intercepted and re-logged.** If a self-hosted `base_url` or custom `intercept_addresses` entry happens to match Coolhand's own API host, the log-shipping request itself would previously be captured and logged like any other request — which itself gets logged, recursively, without bound. It's now wrapped in `Coolhand.without_capture`.
62
+ - `Coolhand::OpenAi::WebhookValidator#webhook_secret` is no longer a public reader (it's only used internally) — reduces the chance of the raw secret ending up in error-tracker breadcrumbs or view-rendered controller state.
63
+ - `YAML.load_file` → `YAML.safe_load_file` for the gem's own bundled default-addresses/patterns YAML files — defense-in-depth against unsafe deserialization, consistent with modern Psych defaults.
64
+ - Added missing `require "openssl"` (`WebhookValidator`) and `require "stringio"` (`NetHttpInterceptor`) instead of relying on both being transitively loaded by something else first.
65
+
66
+ ### Fixed
67
+ - **Vertex batch result logging** — `Coolhand::Vertex::BatchResultProcessor` now sends a fully-qualified URL (`https://aiplatform.googleapis.com/v1/<resource name>`) instead of the bare Vertex job resource name, plus explicit `source_api` and `model` values, all inside `raw_request`. The Coolhand API treats `raw_request` as free-form JSON, so this improves the data available to its classification without depending on new top-level payload fields being accepted. Also fixed: `timestamp`/`completed_at` are now sent as proper ISO 8601 strings instead of raw `Time` objects, and the URL is now run through the same `sanitize_url` helper used by every other logging path in the gem, for consistency (a Vertex batch URL can't actually carry a redactable query string today, so this is defense-in-depth rather than a fix for a live leak) (#76).
68
+ - **`require "coolhand"` no longer requires `faraday`.** The gem hasn't used Faraday directly since the 0.4.0 unified `NetHttpInterceptor`, but a stale top-level `require "faraday"` meant loading the gem could raise `LoadError` for anyone without faraday installed — it was never a declared dependency. If your app relied on `require "coolhand"` transitively loading Faraday, require it yourself.
69
+ - `Coolhand::Vertex::BatchResultProcessor`, `Coolhand::OpenAi::BatchResultProcessor`, and `Coolhand::OpenAi::WebhookValidator` can now each be required independently (e.g. `require "coolhand/vertex/batch_result_processor"`) without first requiring `"coolhand"` — previously this could raise `NameError` depending on load order.
70
+ - `Coolhand::BaseInterceptor.send_complete_request_log` now runs `request_headers`/`response_headers` through `sanitize_headers` itself, rather than trusting every caller to have pre-sanitized them. `NetHttpInterceptor` already sanitized before calling it (`sanitize_headers` is idempotent, so no behavior change there); this closes the gap for any future caller that forgets.
71
+ - `Coolhand::Vertex::BatchResultProcessor#handle_failed_batch` no longer raises `NoMethodError` when a failed batch's `error` field is missing or not a Hash — it now logs the batch-failure message either way instead of falling through to the generic "failed to process" catch-all.
72
+ - **README's "Request Flow" section corrected** — it previously claimed data is sent to the Coolhand API "asynchronously in a background thread" with "zero performance impact"; it's actually sent inline, after the original response is available, bounded by the 5-second timeout described in 0.5.0's Security section.
73
+
10
74
  ## [0.5.0] - 2026-07-30
11
75
 
12
76
  ### Changed
data/README.md CHANGED
@@ -39,14 +39,14 @@ end
39
39
  # ✅ OpenAI SDK calls
40
40
  # ✅ Anthropic API calls
41
41
  # ✅ Direct HTTP requests to AI APIs
42
- # ✅ ANY library making AI API calls via Faraday
42
+ # ✅ Any library making AI API calls via Faraday's default adapter
43
43
 
44
44
  # NO code changes needed in your existing services!
45
45
  ```
46
46
 
47
47
  **✨ Why Automatic Monitoring:**
48
48
  - 🚫 **Zero refactoring** - No code changes to existing services
49
- - 📊 **Complete coverage** - Monitors ALL AI libraries using Faraday automatically
49
+ - 📊 **Complete coverage** - Monitors AI libraries using Net::HTTP or Faraday's default adapter automatically
50
50
  - 🔒 **Security built-in** - Automatic credential sanitization
51
51
  - ⚡ **Performance optimized** - Negligible overhead via async logging
52
52
  - 🛡️ **Future-proof** - Automatically captures new AI calls added by your team
@@ -81,6 +81,28 @@ feedback = feedback_service.create_feedback(
81
81
 
82
82
  For a full field reference and matching strategy, see [Feedback API →](docs/feedback.md).
83
83
 
84
+ ## Reading Templates
85
+
86
+ Read back the LLM request templates your logs are matched against. Unlike the logging and feedback methods, these require your **private** API key — the public key is write-only on this API and is rejected exactly like an invalid one.
87
+
88
+ ```ruby
89
+ require 'coolhand'
90
+
91
+ templates = Coolhand.template_service
92
+
93
+ result = templates.search_templates(search: 'summar', status: 'published')
94
+ result.templates.each { |template| puts "#{template[:name]} (#{template[:log_count]} logs)" }
95
+ result.pagination.total_count
96
+
97
+ # Prompt patterns come from a single-template fetch only
98
+ detail = templates.get_template(result.templates.first[:id])
99
+ detail[:user_prompt_pattern]
100
+ ```
101
+
102
+ Search is a parameter on the list endpoint, not a route of its own, and the `Unmatched` / `Ignored API Calls` system buckets are hidden unless you pass `include_system: true`. These are read methods, so they **raise** `Coolhand::HttpError` where the write methods log and return `nil`.
103
+
104
+ For the full filter reference, pagination, and error handling, see [Reading Templates →](docs/template-search.md).
105
+
84
106
  ## Rails Integration
85
107
 
86
108
  ### Configuration
@@ -156,7 +178,10 @@ end
156
178
  | `enabled` | Boolean | `true` | Set to `false` to disable all patching and validation (e.g. `Rails.env.production?`) |
157
179
  | `capture` | Boolean | `true` | Whether to capture and forward intercepted requests. Set to `false` to monitor without forwarding, then use [`Coolhand.with_capture`](#selective-capture) to re-enable selectively |
158
180
  | `silent` | Boolean | `false` | Whether to suppress console output |
159
- | `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API endpoint strings to monitor |
181
+ | `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API hosts to monitor (matched by host, not full URL). This is a required allow-list — `[]` is ignored (a warning is logged) rather than disabling capture; use `enabled` or `capture` for that. See [Configuration](docs/configuration.md) |
182
+ | `intercept_path_patterns` | Array | `[":generateContent", ":streamGenerateContent"]` | Path patterns to additionally monitor on Google API hosts — see [Configuration](docs/configuration.md) |
183
+ | `exclude_api_patterns` | Array | `["/batchPredictionJobs/"]` | Deny-list checked after `intercept_addresses`; matching paths are skipped. Unlike `intercept_addresses`, `exclude_api_patterns = []` genuinely disables exclusion. See [Configuration](docs/configuration.md) |
184
+ | `max_captured_body_bytes` | Integer | `1_000_000` | Maximum size of a captured JSON request body — oversized bodies are replaced with a placeholder. Non-JSON bodies (e.g. file/audio uploads) are always skipped regardless of size — see [Advanced Configuration](docs/configuration.md) |
160
185
 
161
186
  ## Usage Examples
162
187
 
@@ -297,19 +322,26 @@ The filtering is automatic and applies to all monitored API calls and webhook lo
297
322
 
298
323
  The monitor works with multiple transport layers and Ruby libraries:
299
324
 
300
- **Faraday-based libraries:**
325
+ **Faraday-based libraries** (via Faraday's default `net_http` adapter — a non-Net::HTTP adapter such as `faraday-typhoeus` is not covered):
301
326
  - OpenAI Ruby SDK
302
327
  - ruby-anthropic gem (community Anthropic gem)
303
328
  - ruby-openai gem
304
329
  - LangChain.rb
305
- - Direct Faraday requests
306
- - Any other Faraday-based HTTP client
330
+ - Direct Faraday requests using the default adapter
307
331
 
308
332
  **Native HTTP libraries:**
309
333
  - Official Anthropic Ruby SDK (using Net::HTTP)
310
334
  - GitHub Models SDK / any client using `models.github.ai`
311
335
  - Any library using Net::HTTP directly
312
336
 
337
+ **Other providers monitored out of the box:**
338
+ - Google Gemini (`generativelanguage.googleapis.com`)
339
+ - Google Vertex AI (`aiplatform.googleapis.com`) — see the [Vertex AI batch result logging guide](docs/vertex.md) for async batch jobs
340
+ - AWS Bedrock (OpenAI-compatible endpoint)
341
+ - Cloudflare AI Gateway
342
+ - OpenRouter
343
+ - OpenCode Zen (`opencode.ai`)
344
+
313
345
  **Universal Coverage**: Since most Ruby HTTP libraries use Net::HTTP under the hood, Coolhand's single interceptor provides comprehensive monitoring without needing library-specific integrations.
314
346
 
315
347
  ## How It Works
@@ -325,11 +357,11 @@ Coolhand uses a unified Net::HTTP interceptor to capture outgoing requests to co
325
357
  ### Request Flow
326
358
  When a request matches configured LLM endpoints:
327
359
 
328
- 1. The original request executes normally with zero performance impact
360
+ 1. The original request executes normally
329
361
  2. Request and response data (body, headers, status) are captured by the interceptor
330
362
  3. For streaming requests, the complete accumulated response is captured (not individual chunks)
331
- 4. Data is sent to the Coolhand API asynchronously in a background thread
332
- 5. Your application continues without interruption
363
+ 4. Data is sent to the Coolhand API inline, after the original response is available (bounded by a 5-second connect/read timeout so a slow or unreachable Coolhand backend can't hang your request indefinitely)
364
+ 5. Your application continues once the log is sent (or times out) — the original request/response is never blocked on Coolhand being reachable
333
365
 
334
366
  For non-matching endpoints, requests pass through unchanged.
335
367
 
@@ -394,109 +426,18 @@ The monitor handles errors gracefully:
394
426
  - Failed API logging attempts are logged to console but don't interrupt your application
395
427
  - Invalid API keys will be reported but won't crash your app
396
428
  - Network issues are handled with appropriate error messages
397
-
398
-
399
- ## Batch webhook handler (OpenAI)
400
-
401
- Automatically handle OpenAI batch event logs (batch.completed, batch.failed, batch.expired, batch.cancelled)
402
- by intercepting webhook requests and enqueuing your batch result processor.
403
-
404
- Usage:
405
- - Include the interceptor in your controller:
406
- include Coolhand::WebhookInterceptor
407
- - Add the before_action to validate and populate @validator payload:
408
- before_action :intercept_batch_request, only: :openai
409
- - Ensure you skip CSRF for the webhook endpoint:
410
- skip_before_action :verify_authenticity_token
411
- - Override the webhook_secret method to return your OpenAI webhook secret
412
-
413
- Minimal example (only key lines shown):
414
-
415
- ```ruby
416
- # app/controllers/webhooks/batch_api_requests_controller.rb
417
- # ...existing code...
418
- include Coolhand::WebhookInterceptor
419
-
420
- skip_before_action :verify_authenticity_token
421
- before_action :intercept_batch_request, only: :openai
422
-
423
- def openai
424
- event = JSON.parse(@validator.payload)
425
- case event["type"]
426
- when "batch.completed", "batch.failed", "batch.expired", "batch.cancelled"
427
- batch_id = event.dig("data", "id")
428
- batch_request = BatchApiRequest.find_by(provider: "openai", provider_batch_id: batch_id)
429
-
430
- if batch_request
431
- OpenAi::BatchResultProcessor.perform_async(batch_request.id)
432
- Rails.logger.info("Queued batch result processing for BatchApiRequest #{batch_request.id}")
433
- else
434
- Rails.logger.warn("Could not find BatchApiRequest for OpenAI batch ID: #{batch_id}")
435
- end
436
- else
437
- Rails.logger.info("Unhandled OpenAI webhook event type: #{event["type"]}")
438
- end
439
-
440
- head :ok
441
- rescue JSON::ParserError
442
- head :bad_request
443
- rescue StandardError => e
444
- Rails.logger.error("OpenAI webhook error: #{e.message}")
445
- head :internal_server_error
446
- end
447
-
448
- def webhook_secret
449
- Rails.application.credentials.openai_webhook_secret
450
- end
451
- # ...existing code...
452
- ```
453
-
454
- ## Batch webhook handler (Vertex)
455
-
456
- Automatically handle Vertex batch event logs.
457
-
458
- Usage:
459
- - call Coolhand::Vertex::BatchResultProcessor service with batch_info and download batch results
460
-
461
- Minimal example (only key lines shown):
462
-
463
- ```ruby
464
- class Vertex::BatchCallbackProcessor < BaseService
465
- option :batch_request, model: BatchApiRequest
466
- option :batch_info
467
-
468
- def call
469
- case batch_info["state"]
470
- when "JOB_STATE_PENDING"
471
- nil
472
- when "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
473
- batch_request.update!(status: "processing")
474
-
475
- Coolhand::Vertex::BatchResultProcessor.new(batch_info:).call
476
- when "JOB_STATE_SUCCEEDED"
477
- output_file_id = batch_info["outputInfo"]["gcsOutputDirectory"]
478
- results = download_batch_results(output_file_id)
479
- results.each { |batch_item| process_batch_result(batch_item) }
480
-
481
- batch_request.update!(status: "completed", completed_at: Time.current, output_file_id:)
482
-
483
- Coolhand::Vertex::BatchResultProcessor.new(batch_info:).call(results)
484
-
485
- # Clean up GCS files after successful processing
486
- cleanup_gcs_files(output_file_id)
487
- when "JOB_STATE_FAILED"
488
- handle_failed_batch(batch_info["error"]["message"])
489
- end
490
- end
491
- end
492
- ```
429
+ - The read methods (`search_templates`, `get_template`) are the exception — they raise `Coolhand::HttpError`; see [Reading Templates →](docs/template-search.md)
493
430
 
494
431
  ## Documentation
495
432
 
496
433
  - **[Configuration](docs/configuration.md)** — Self-hosted deployments, base_url rules, debug mode, custom intercept addresses
497
434
  - **[Feedback API](docs/feedback.md)** — Full field reference, matching strategies, sentiment values
435
+ - **[Reading Templates](docs/template-search.md)** — Search LLM request templates and fetch a single one, prompt patterns included, using the private API key
498
436
  - **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
499
437
  - **[ElevenLabs Integration](docs/elevenlabs.md)** — Webhook capture, feedback submission, and Rails integration
438
+ - **[OpenAI Batch Webhook Handler](docs/openai.md)** — Handle OpenAI batch job completion events via webhook interception
439
+ - **[Google Vertex AI Batch Result Logging](docs/vertex.md)** — Log completed Vertex AI batch prediction job results
440
+ - **[Examples](examples/README.md)** — Runnable scripts that make a real provider call through Coolhand, useful as a live smoke test
500
441
 
501
442
  ## Security
502
443
 
@@ -39,6 +39,22 @@ end
39
39
 
40
40
  Because it forces capture unconditionally and prints full request/response bodies to the console, only enable `debug_mode` in development — never in production or in an environment handling real user data.
41
41
 
42
+ ## Request Body Capture Limits
43
+
44
+ To avoid holding large uploads (batch JSONL files, fine-tune corpora, audio for transcription) in memory and shipping them to Coolhand in full, `config.max_captured_body_bytes` caps how much of a request body gets captured:
45
+
46
+ ```ruby
47
+ Coolhand.configure do |config|
48
+ config.max_captured_body_bytes = 2_000_000 # default: 1_000_000 (1 MB)
49
+ end
50
+ ```
51
+
52
+ **What happens at the limits:**
53
+ - A request body whose `Content-Type` doesn't look like JSON (e.g. `multipart/form-data`, `audio/mpeg`) is never read into memory for capture — the log entry gets a placeholder (`{"_coolhand_capture_skipped" => "non_json_content_type", ...}`) instead of the body. This is intentional: an opaque binary blob isn't human/LLM-readable in Coolhand's UI anyway.
54
+ - A JSON (or content-type-unset) body larger than the configured limit is replaced with a placeholder (`{"_coolhand_capture_skipped" => "body_too_large", ...}`) instead of being logged in full.
55
+
56
+ In both cases, only the *captured log entry* is affected — the real request to the LLM provider always sends the complete, untruncated body.
57
+
42
58
  ## Custom Intercept Addresses
43
59
 
44
60
  By default Coolhand captures requests to a built-in list of LLM API hosts (OpenAI, Anthropic, Google Gemini, ElevenLabs, GitHub Models, and more). To capture a custom endpoint — an internal proxy, a self-hosted model server, or a third-party gateway — override `intercept_addresses`:
@@ -57,3 +73,27 @@ end
57
73
  Setting `intercept_addresses` **replaces** the default list entirely, so include any default hosts you still need.
58
74
 
59
75
  The default list can be found in `Coolhand::Configuration::DEFAULT_INTERCEPT_ADDRESSES`.
76
+
77
+ Each entry is matched against the request's parsed host — an exact match, or a dot-boundary suffix match (`my-llm-proxy.internal` also matches `eu.my-llm-proxy.internal`) — case-insensitively. A single `*` in an entry matches exactly one host label, e.g. `bedrock-runtime.*.amazonaws.com`. Entries are never matched against the full URL, so query strings or paths that merely mention a host can't trigger a false match.
78
+
79
+ A small number of Google API endpoints use a colon-suffixed path (e.g. `.../gemini-pro:generateContent`) with no host of their own. Those live in the separate `intercept_path_patterns` setting (default: `Coolhand::Configuration::DEFAULT_INTERCEPT_PATH_PATTERNS`) and, unlike `intercept_addresses`, are only checked against the path of requests whose host is already `googleapis.com` or a subdomain of it — and only when `intercept_addresses` still includes at least one `googleapis.com` host. If you override `intercept_addresses` to a list with no Google hosts, `intercept_path_patterns` has no effect and Google API traffic is not captured at all.
80
+
81
+ `intercept_addresses` is a required allow-list — nothing is captured unless its host matches an entry here. Because of that, `config.intercept_addresses = []` is **not** a supported way to disable capture (it's ignored, with a warning logged, and the previous value is kept). To disable capture entirely, use `config.enabled = false` or `config.capture = false` instead — see [Capture Control](#capture-control) below.
82
+
83
+ ## Excluding API Patterns
84
+
85
+ `config.exclude_api_patterns` is a deny-list checked *after* `intercept_addresses` allows a request through — a match against the request's path is skipped and never forwarded as an `llm_request_log`. It defaults to `["/batchPredictionJobs/"]`, to suppress Vertex AI batch job management noise.
86
+
87
+ ```ruby
88
+ Coolhand.configure do |config|
89
+ config.exclude_api_patterns << "/myOperationalPath/" # extend the defaults
90
+ # config.exclude_api_patterns = ["/only_this/"] # replace the defaults entirely
91
+ # config.exclude_api_patterns = [] # disable exclusion entirely
92
+ end
93
+ ```
94
+
95
+ Unlike `intercept_addresses`, `exclude_api_patterns` genuinely supports `= []` — since it only narrows an allow-list that already passed, an empty deny-list just means "exclude nothing," which is safe.
96
+
97
+ ## Capture Control
98
+
99
+ `config.enabled` and `config.capture` are the supported ways to turn capture off entirely — see the Configuration Parameters table in the [README](../README.md#configuration-options).
data/docs/openai.md ADDED
@@ -0,0 +1,91 @@
1
+ # OpenAI Batch Webhook Handler
2
+
3
+ Automatically handle OpenAI batch event logs (`batch.completed`, `batch.failed`, `batch.expired`, `batch.cancelled`) by intercepting webhook requests and logging completed batch results to Coolhand.
4
+
5
+ For monitoring regular (non-batch) OpenAI API calls, see the [OpenAI client example](../README.md#with-openai-ruby-client) in the main README — no extra setup is required beyond `Coolhand.configure`.
6
+
7
+ Requires Rails — `Coolhand::WebhookInterceptor` and `Coolhand::OpenAi::BatchResultProcessor` log via `Rails.logger` internally. `config.capture = false` and `Coolhand.without_capture` do not suppress these logs: unlike the passive Net::HTTP interceptor, `intercept_batch_request` dispatching to `Coolhand::OpenAi::BatchResultProcessor` is an explicit, deliberate act, so it always sends.
8
+
9
+ ## Usage
10
+
11
+ - Add the `openai` gem to your Gemfile — `Coolhand::OpenAi::BatchResultProcessor` needs it to download batch result files.
12
+ - Include the interceptor in your controller: `include Coolhand::WebhookInterceptor`
13
+ - Add the `before_action` to validate and populate the `@validator` payload: `before_action :intercept_batch_request, only: :openai`
14
+ - Ensure you skip CSRF for the webhook endpoint: `skip_before_action :verify_authenticity_token`
15
+ - Override the `webhook_secret` method to return your OpenAI webhook secret
16
+
17
+ `intercept_batch_request` already validates the webhook and, for `batch.completed`/`batch.failed`/`batch.expired`/`batch.cancelled` events, calls `Coolhand::OpenAi::BatchResultProcessor` **synchronously**, inline in the `before_action` — that part requires no code in your controller action. It downloads two JSONL files from OpenAI and sends one request log per batch item, so a large batch can take long enough to trip your webhook endpoint's timeout (and OpenAI's retry behavior); consider that when sizing batches. The example below shows a controller action doing additional, app-specific work on top of that (e.g. updating your own `BatchApiRequest` record and enqueuing your own background job), which is optional.
18
+
19
+ ## Minimal example
20
+
21
+ Only the key lines are shown — wire this into your own controller and background job setup.
22
+
23
+ ```ruby
24
+ # app/controllers/webhooks/batch_api_requests_controller.rb
25
+ # ...existing code...
26
+ include Coolhand::WebhookInterceptor
27
+
28
+ skip_before_action :verify_authenticity_token
29
+ before_action :intercept_batch_request, only: :openai
30
+
31
+ def openai
32
+ event = JSON.parse(@validator.payload)
33
+ case event["type"]
34
+ when "batch.completed", "batch.failed", "batch.expired", "batch.cancelled"
35
+ batch_id = event.dig("data", "id")
36
+ batch_request = BatchApiRequest.find_by(provider: "openai", provider_batch_id: batch_id)
37
+
38
+ if batch_request
39
+ MyApp::BatchResultJob.perform_async(batch_request.id)
40
+ Rails.logger.info("Queued batch result processing for BatchApiRequest #{batch_request.id}")
41
+ else
42
+ Rails.logger.warn("Could not find BatchApiRequest for OpenAI batch ID: #{batch_id}")
43
+ end
44
+ else
45
+ Rails.logger.info("Unhandled OpenAI webhook event type: #{event["type"]}")
46
+ end
47
+
48
+ head :ok
49
+ rescue JSON::ParserError
50
+ head :bad_request
51
+ rescue StandardError => e
52
+ Rails.logger.error("OpenAI webhook error: #{e.message}")
53
+ head :internal_server_error
54
+ end
55
+
56
+ def webhook_secret
57
+ Rails.application.credentials.openai_webhook_secret
58
+ end
59
+ # ...existing code...
60
+ ```
61
+
62
+ ## Replay protection
63
+
64
+ `Coolhand::OpenAi::WebhookValidator` rejects requests whose `webhook-timestamp` is more than **300 seconds** away from the current time, and rejects a `webhook-id` it has already seen within that window — this prevents a captured request (from a log aggregator, APM trace, or proxy log) from being replayed to re-trigger `Coolhand::OpenAi::BatchResultProcessor`.
65
+
66
+ Both are configurable:
67
+
68
+ ```ruby
69
+ Coolhand.configure do |config|
70
+ # Widen or narrow the timestamp tolerance window (seconds). Default: 300.
71
+ config.webhook_replay_tolerance_seconds = 600
72
+ end
73
+ ```
74
+
75
+ The default `webhook-id` dedup store is in-memory and per-process, so it only protects a single process — replays across multiple app processes/dynos within the tolerance window aren't caught by default. If you run more than one process, supply your own store via `config.webhook_id_store`; any object responding to `claim!(id, ttl_seconds)` works, e.g. one backed by `Rails.cache`. `claim!` must atomically check-and-record the id in one operation (not a separate read then write) — otherwise two requests racing on the same id can both "win" — so lean on your cache backend's compare-and-set primitive (`unless_exist:` below maps to Redis `SET NX` when using `RedisCacheStore`):
76
+
77
+ ```ruby
78
+ class RailsCacheWebhookIdStore
79
+ # Returns true if this is the first time `id` has been claimed (the
80
+ # write happened), false if it was already claimed within its TTL.
81
+ def claim!(id, ttl_seconds)
82
+ Rails.cache.write("coolhand:openai_webhook:#{id}", true, expires_in: ttl_seconds, unless_exist: true)
83
+ end
84
+ end
85
+
86
+ Coolhand.configure do |config|
87
+ config.webhook_id_store = RailsCacheWebhookIdStore.new
88
+ end
89
+ ```
90
+
91
+ Note: the `webhook-id` is claimed as soon as the signature and timestamp are confirmed valid — before your controller's `process_event` runs. If something downstream fails before the batch is actually handled (e.g. an unexpectedly-shaped payload), a same-id retry within the tolerance window will be rejected as a replay rather than reprocessed.