feat: support 2026-09-01 gateway API - #378
Conversation
|
Claude-assisted review - I ran Checklist so we can track what gets picked up. Tick as they land, or push back on any of them. Edited after posting: the Blocking
Worth fixing
Tests
Not blocking
Open question on namingNot blocking, and I may be missing context from the API side. Verified while reviewing: I can put the non-blocking fixes and the test gaps into a PR against this branch if that's easier than folding them in yourself. |
Findings from reviewing #378. The `/projects` question is not addressed here - that is a deploy-ordering decision, not a code change. Only offer --delivery-group where the API accepts it. The flag was added to every metrics subcommand, but delivery_group exists only on the events, attempts and queue-depth filter schemas, and those filters are additionalProperties:false. So `metrics requests --delivery-group` and `metrics transformations --delivery-group` were a guaranteed 422, as were the two `metrics events` routes that land on events-pending-timeseries or events-by-issue. The flag is now omitted where it cannot work and rejected client-side on the two routes that share a command with routes where it can. metricsFlagOpts replaces the positional skipIssueID bool so a second exclusion does not turn every call site into unreadable booleans. Fall back to team_mode when team_product is absent. The cutover left no fallback: an empty product blanks ProjectProduct, ProjectMode and ProjectType at once, IsGatewayProject("") is false, and every gateway command then fails with an empty project type in the message. The live API does return team_product today - verified against prod with the type, mode and product stripped from a config - so this is insurance, not a repair. Stop ListProjects swallowing a shape mismatch. The unmarshal error was discarded, so a renamed field or a wrapped envelope would return an empty list and a nil error: "you have no projects" rather than a failure. Exactly the risk an endpoint rename introduces. Collapse five copies of the Type -> Product -> Mode ladder into Profile.ResolveProjectType. Same precedence, one place to get it wrong. Make the two case-sensitive mappers case-insensitive like their three siblings in the same file. Nothing documented which was which. Add ,omitempty to Event.DeliveryGroup so `event list --output json` does not start emitting "delivery_group": null on every event. Document --delivery-group in REFERENCE.md, including where it does not apply. Run gofmt on the files this change touches; profile.go and profile_credentials_test.go were unformatted on the branch. Tests. The upgrade path had none: a config written before this release has no project_product, and nothing asserted it gets derived from the legacy mode - which is what every existing user hits on first run. The mapping tests covered roughly a third of the matrix; they are now table-driven per function and include the empty-product case, case-insensitivity, and a round trip that pins the deliberate outbound-to-inbound flattening. The acceptance test now asserts project_product actually reaches config.toml, which nothing did before: every other test on this path reads a mock this repo also writes. Refs #378 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
|
Follow-up PR with the fixes from the review above: #379 (targets this branch, so merge / cherry-pick / close as you prefer). Covers everything on the checklist except the
Left alone: the 99 hardcoded version literals in tests, and the CLI-destination delivery-policy validation - both judgement calls that are yours rather than mine. |
core `origin/staging` now names the field `type` on GET /projects and
`team_type` on the CLI auth endpoints, with the same event_gateway | console |
outpost values. The CLI followed the wire rename and the internal vocabulary
with it, so it speaks the same word as the API it calls.
That word was already taken. The CLI used ProjectType for the display label -
"Gateway", "Console", "Outpost" - so the two meanings had to be separated:
ProjectType event_gateway | console | outpost what the API calls type
TypeLabel() Gateway | Console | Outpost derived at print time
The label is presentation and is no longer stored. project_type on disk now
holds the API value, project_product is gone entirely (it only ever existed on
this unreleased branch, so nothing has written it), and project_mode is still
written for older CLIs reading the same file.
NormalizeProjectType is the single door every value goes through. It accepts an
API type, a display label written by an older CLI, or a legacy mode, and returns
the API type - so the three vocabularies converge in one place instead of at
each call site.
The auth structs read team_type, then team_product, then team_mode. Prod
currently serves team_product while staging serves team_type, so without that
chain a CLI shipping ahead of the deploy would blank the project type and fail
every gateway command. Verified against prod, which still serves the old field:
whoami resolves Gateway from a config stripped of all type information.
Two things deliberately unchanged, both user-facing: `--output json` still emits
gateway | outpost | console, and the `--type` filter still accepts them. The
API type is not used there - changing it would break anyone parsing that output.
Tests caught two regressions worth naming: the gateway error message printed the
wire value ("current project type is outpost") instead of the label the user was
shown, and saving local config dropped an unrecognized mode instead of carrying
it through. Both fixed.
Refs #378
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
…prod The comments said the API rejects the unknown filter because the metrics filter schemas are additionalProperties:false. It does not. Tested against production with the pre-fix binary: `metrics requests --delivery-group` over a 14 day window returned count 87, identical to the same call with no filter, while a bogus --source-id on that call returned 0. So the filter key is recognized and applied when the endpoint supports it, and silently dropped when it does not. That makes withholding the flag more important than the original reasoning suggested, not less. An opaque 422 at least tells the caller something is wrong. Returning unfiltered totals under a flag that says they are filtered is the silent-wrong-answer shape, and the caller has no way to notice. No behaviour change: the flag was already withheld on requests and transformations, and rejected client-side on the two `metrics events` routes that cannot use it. Only the stated reason was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
|
Prod testing update, now that Confirmed working against prod
Correction to my earlier reviewI said Ran the pre-fix binary against prod, 14 day window: The API does not reject the unknown filter. It drops it and returns unfiltered totals. Debug logging confirms the CLI really did send This makes withholding the flag more important than I argued, not less. A 422 at least tells you something is wrong; unfiltered numbers under a flag that says they are filtered is the silent-wrong-answer shape, and there is no way for the caller to notice. No behaviour change needed - the flag was already withheld in the right places. I have pushed 4f6bfd3 correcting the comments and the test rationale, which previously asserted the 422 story. Gap worth knowing before releaseNothing in CI exercises Worth noting CI authenticates with |
The delivery-group fix was one instance of a general bug. metricsCommonFlags added six filter flags to all four metrics subcommands, but each endpoint declares its own filter schema and the API drops keys it does not recognize instead of rejecting them. So the flags that did not apply returned unfiltered totals under a flag saying they were filtered. Measured against production, 14 day window: attempts, no filter count: 120 attempts --source-id src_bogus count: 120 <- source_id not in schema attempts --destination-id des_bogus count: 0 <- destination_id is Nine flag/endpoint pairs were affected: requests offered destination-id, connection-id and issue-id; attempts offered source-id, connection-id and issue-id; transformations offered source-id, destination-id and status. All silently no-ops. Each subcommand now registers only the filters its endpoint honours, driven by a metricsFilters set per endpoint. `metrics events` is the exception: it fans out over four endpoints depending on measures and dimensions, so it offers the union and validates per route at run time, naming the flag and saying why it matters rather than just refusing it. TestMetricsFlagsMatchTheEndpointSchemas asserts the whole matrix, so adding a filter API-side fails the test rather than passing unnoticed. Also: replace the hardcoded API version in tests with hookdeck.APIPathPrefix, down from 99 occurrences to 4 - a genuine date value and three comments. The constant's own comment says to change the version in one place; it was being copied into 87 lines of pkg/gateway/mcp/server_test.go alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
TestMetricsAttemptsWithConnectionID ran `metrics attempts --connection-id web_placeholder`, asserted the command succeeded and stopped there. It did succeed - because the attempts endpoint has no webhook_id filter and the API drops keys it does not recognize, so the call returned unfiltered totals under a flag that said otherwise. The test was pinning the bug. Measured against production over 14 days, with a control to rule out the filter simply matching nothing: attempts, no filter count: 150 attempts --connection-id web_bogus count: 150 <- ignored events --connection-id web_bogus count: 0 <- honoured (145 unfiltered) Same flag, same filters[webhook_id] mapping, different endpoints. The test now asserts the flag is refused. It reads the message from stdout, because that is where cobra writes flag errors - stderr is empty and the error is only "exit status 1", so asserting on stderr would have passed regardless of what the CLI printed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
ProjectMode was threaded from config through listen, proxy, the renderers and the TUI, only ever to answer "is this a console project?". That is a project type question, and mode is the vocabulary the API dropped. Behaviour is identical: the only test anywhere was `== "console"`, and the console type is the string "console" in both vocabularies. The other values differ (inbound vs event_gateway) but neither is console, so every branch resolves the same way. The comparison now names config.ProjectTypeConsole rather than a bare string. Renamed through links, listen, printer, proxy.Config, RendererConfig, both renderers and the TUI model. What is left called mode is deliberate and now documented on the field: the pre-2026-09-01 config key, written so a CLI older than this one reading the same config file still resolves a project, and the team_mode wire field read as the last fallback in resolveType. Neither is vocabulary this codebase should reason in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The config file is shared between CLI versions - a repo-local .hookdeck/config.toml most obviously - and versions before 2026-09-01 only understand the display label. Writing the API type there broke every `hookdeck gateway ...` command for them: $ hookdeck gateway source list # older CLI, config written by this one this command requires a Gateway project; current project type is event_gateway Worse than one-off: both versions rewrite the file, so two people on different versions would ping-pong it and the older one would break again each time. Measured against production, not reasoned about. So project_type on disk stays exactly what v2.5.0 writes - Gateway, Console, Outpost - and project_mode keeps its legacy value. Internally nothing changes: ProjectType still holds the API type, and NormalizeProjectType turns the label back into it on read, which it already had to do for configs written before this release. Verified the full round trip with both binaries against production: new CLI selects a project, old CLI runs gateway commands against the same file, old CLI switches project, new CLI reads it back. Every step resolves correctly. The label and the API type are one-to-one, so persisting the label loses nothing. It also puts the two on-disk keys on the same footing: both are compatibility surfaces speaking the vocabulary the most versions understand, rather than one of them speaking the wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Nothing exercised this endpoint for real. Every other test on the path uses an httptest mock this repo also writes, so they verify our parsing and nothing about the endpoint: not that it exists, not that it authorizes the credential the CLI carries, not that its response still matches what we unmarshal. The tests that do need it sit behind //go:build manual, and they are manual because of the browser login flow, not because of the endpoint. That mattered here. /teams moved to /projects in 2026-09-01 and the type field was renamed twice during the release, and the mocks were updated alongside each rename, so they would have passed whatever the API did. On the credential: the acceptance runner bootstraps with a project API key, but `hookdeck ci` exchanges it at /cli-auth/ci for a CLI client key, and that is what lands in the config and goes on the wire. So this covers the CLI-key path a real user has. ListProjects also drops the project scoping header via clientForCLIAuthValidate, which is only observable against the real API. Asserts the three things worth pinning, all verified against production first: the endpoint answers a CLI key at all, `--output json` still reports gateway | outpost | console rather than following the API's event_gateway rename, and the --type filter speaks the same vocabulary as that output. Runs in the existing project_use tag, which is already in CI slice 0. No new secret needed - I had thought this needed one, which was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
My previous commit asserted the CI runner could list projects. CI proved
otherwise, with the clearest possible error:
403 GET /2026-09-01/projects
"This credential is scoped to a single project and cannot list all projects.
Keys from hookdeck ci are project-scoped. Run hookdeck login for
account-wide CLI access."
There are two kinds of CLI key. `hookdeck login` issues one bound to a user and
it can list projects; `hookdeck ci --api-key` issues a project-scoped key with
no user, and core rejects it:
if (!req.context.user?.id) { throw new APICLIProjectScopedError() }
I missed this because my own key comes from `hookdeck login`, so every manual
check against production passed. The runner authenticates with `ci`, which is
also the real reason the project-listing tests were behind //go:build manual -
not the browser login flow, as their comment implies.
Not a regression: the guard landed for GET /teams in core 90e38ee395 and the
/projects rename inherited it. So `project list` has never worked with ci
credentials.
The CI-runnable test now asserts the restriction itself, which is more useful
than asserting the happy path anyway: it checks the 403 surfaces with the reason
and names the command that fixes it, so a change to that rule fails a test
instead of arriving as a support ticket.
The vocabulary assertions - that --output json and --type still speak
gateway | outpost | console rather than following the API's rename - move to the
manual suite, where an account-wide key exists to run them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The coverage I said was missing already existed, in the same file I edited. TestProjectListFailsWithCIKeyAcceptance asserts the 403, and TestProjectListShowsType / TestProjectListJSONOutput / TestProjectListFilterByType assert the display labels and the gateway | outpost | console json vocabulary. I missed them because I grepped for ListProjects and /projects, and their bodies only say "project list". Their skip message already states the rule I reported as a discovery: "CLI key required for listing projects; API and CI keys cannot list or switch projects" So both halves were covered. Removed my duplicates in both the CI and manual files. What survives is one assertion the existing rejection test lacked: that the error names `hookdeck login`. Checking the reason is not enough, because the two kinds of CLI key are invisible to the user - without the command that fixes it, the message says what went wrong and not what to do. The real gap is not code: HOOKDECK_CLI_TESTING_CLI_KEY is not set in CI, so the five tests gated on it skip on every run. They are written and correct; they have never executed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Five project list/use tests are gated on this variable and skip on every run because it was never set, so they have been written and correct but never executed. That is why the /teams to /projects move and two renames of the project type field could all land without a test noticing. It has to be an account-wide CLI key from `hookdeck login`. The key the runner gets from `hookdeck ci` is project-scoped with no user attached, and core returns 403 for that on this endpoint by design, so it cannot stand in. The secret still needs adding to the repository; this only wires it through. Until then the tests skip exactly as they do now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
…sting Reverting c94662e. That commit passed HOOKDECK_CLI_TESTING_CLI_KEY to the acceptance jobs so five long-skipping tests would run. They did run, and they passed - but the key is account-wide, and an account-wide key reaches every org its owner belongs to, including Hookdeck Prod. This repository is public, so Actions logs are world-readable. Nothing leaked in the run that happened: I checked the log for org and project names and found none, because assertions only print on failure. But the failure path was one red build away from publishing the lot: RunExpectSuccess -> require.NoError(t, err, "...stdout: %s...", stdout) TestProjectListShowsType -> assert.Contains(t, stdout, "|") and `project list` on such a key returns every project in every org the owner can see. We have already had two unrelated 502 flakes in this slice today, so "only on failure" is not much of a guard. So the variable goes back out of the workflow, with a comment saying what has to be true before it returns: the key should belong to a test-only account in its own org, so that what a failure can disclose is worth nothing. Independently of that, the tests no longer put a listing in any failure message. All nine call sites move from RunExpectSuccess, which formats stdout into its error, to Run plus an assertion that carries no payload. Worth having whatever key is used later - the listing is the credential's reach made legible, and it does not belong in a public log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Re-applies what 8638838 reverted. The key behind the secret is no longer a person's: it belongs to a test-only account, so the project inventory a failing test could disclose is worth nothing, and it no longer reaches Hookdeck Prod. Five tests have been skipping since they were written because this variable was never passed through - which is how the /teams to /projects move and two renames of the project type field all landed with nothing checking them end to end. The assertion hardening from 8638838 stays: all nine project list call sites use Run rather than RunExpectSuccess, so no failure message carries a listing. The scoped account is the control that matters, but there is no reason to print the thing either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
`hookdeck login --api-key <rejected>` announced "Starting browser sign-in...", walked straight past the Enter prompt because there is nothing to read from, and then polled for a confirmation nobody could give. CI spent 248 seconds on a mistyped key before giving up; reproduced locally with stdin=/dev/null. This is the shape #337 fixed in v2.5.0 - commands hanging instead of failing where there is no terminal - and the same guard already exists a few lines below for a key that is valid but project-scoped. The rejected-key branch simply never got it. The error also says what to check, because "invalid or expired" is often untrue here. A project API key is a valid key that the CLI auth endpoints do not accept; an organization API key is not accepted by any of them. Both arrive at this branch, and telling their owner to re-authenticate a working key helps nobody: the API key was rejected, and browser sign-in needs an interactive terminal; check the key is a CLI key from hookdeck login rather than a project or organization API key, or use hookdeck ci --api-key with a project API key TestLogin_unauthorizedValidateStartsBrowserFlow had to start stubbing stdinIsTerminal to true. It passed before without doing so, which is the bug stated as a test: the browser flow ran whether or not anyone could complete it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
TestCIFailsFastWithInvalidAPIKeyAcceptance sends a deliberately invalid key and asserts the CLI answers with a friendly authentication failure. When POST /cli-auth/ci returns 502 instead, the assertion fails and the board goes red for something the CLI did not do. That happened three times today. A gateway error is not an authentication outcome, so the test cannot read it either way. It now retries on 502 the way CLIRunner.Run already does, and skips if every attempt is a 502 rather than reporting a product regression it has no evidence for. This test builds its own exec.Cmd rather than going through CLIRunner, which is why it inherited none of the existing retry behaviour. The context deadline goes from 60s to 150s so it actually covers the retries - sized for one attempt, it would cut them short and reintroduce the flake. Also drops a throwaway exec.Cmd that was only being used to carry args, dir and env into the loop. Refs #382 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The README distinguished a CLI key from a project API key and then said "within
the CLI both are stored and used the same way", which is not true of the thing
people hit: only a CLI key can list or switch projects. Nothing mentioned
organization API keys at all.
Verified against production rather than read off the code:
/cli-auth/validate CLI keys only - both API key types get 401
/cli-auth/ci project API key 200, organization API key 401
/projects project key -> 1 project; org key -> its organization's
projects given projects.read; CLI key -> every org the
user belongs to
So an organization API key cannot authenticate the CLI by any route, and
`hookdeck ci --api-key` needs a project key specifically. Neither was written
down anywhere, and the error you get is "your API key is invalid or expired" on
a key that is neither.
Refs #376
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The catch-all 401 handler replaced whatever the API said with "your API key is invalid or expired". That is an inference, and often a wrong one: a project API key is valid but not accepted by the CLI auth endpoints, and an organization API key is not accepted by any of them. Both land here, and both owners get told to re-authenticate a working key. We cannot do better by inspecting the key - validators.APIKey checks length and nothing else, so a bare 401 cannot tell an expired CLI key from an API key from a typo. So rather than guess more precisely, stop overriding the one source that does know. When the response carries an explanation, lead with it; otherwise fall back to the existing guidance. Worth being honest about the immediate effect: /cli-auth/validate and /cli-auth/ci currently answer with a bare "Unauthorized" body, which says nothing the status code did not, so most users will still see the fallback. The helper filters that case out deliberately rather than printing "Authentication failed: Unauthorized". What changes is that a message the server does send now reaches the user instead of being discarded - including from any endpoint that already returns one, and from these two if core ever improves them. Refs #283 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
b04a99f fixed this for `hookdeck gateway metrics` and touched pkg/gateway/mcp only to substitute a constant in its tests. The MCP tool kept building params from every argument and handing them to whichever endpoint the action routed to, so `hookdeck_metrics` still offered 22 argument/endpoint pairs the API silently drops - returning unfiltered totals to an agent with no way to notice. The release note would have been untrue for anyone using MCP. The matrix now lives in pkg/hookdeck beside the client, because both callers reach the same endpoints and a matrix only one of them consults is one the other drifts from. pkg/cmd and pkg/gateway/mcp both consult it; filter names are supplied per caller so a CLI user reads --source-id and an MCP client reads source_id. Three further places where MCP diverged from the CLI in the same routing function, all fixed here: - The pending timeseries route sent measures[]=pending. The API expects count there; "pending" only selects the route, which the CLI has always rewritten. - The by-issue route sent a request with no filters[issue_id]. The endpoint filters on it, so the route is meaningless without one; the CLI rejects this. - dimensions did not map connection_id to webhook_id, though the tool schema tells callers it does. True of the filter, not of the dimension. TestMetricsEvents_ByIssueRoute asserted a call with dimensions issue_id and no issue_id was a success, pinning the second of those. It now passes an issue_id, and a sibling covers the rejection. TestMetricsToolRejectsFiltersTheEndpointIgnores mirrors the pkg/cmd guard, so a filter added API-side now fails on both sides rather than being fixed for one. Refs #382 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Two places still told people the opposite of what the code now does. REFERENCE.md listed --source-id, --destination-id, --connection-id and --status under "Common flags (all metrics subcommands)". They are not common: each endpoint accepts a different set, which is the whole point of the change. The list now holds only the flags that really are shared, with a table for the rest. The MCP tool schema described every filter with no indication of where it applies, so a model reading the schema was invited to pass source_id to attempts and find out by being refused. Each argument now names the actions that honour it, and the tool description says why passing one elsewhere is an error rather than a no-op. The runtime guard is the safety net. The schema is the contract, and it was still wrong. Refs #382 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Closes #393. Bumping a delivery group's rate wiped the per-group overrides, silently. The API merges delivery_policy one level deep but replaces groups wholesale, so a groups object sent without overrides takes the stored ones with it. The CLI requires --delivery-group-key and --delivery-group-rate-period whenever --delivery-group-rate is given, so "just change the rate" always sends a full groups object -- the one command a user would reach for was the one that lost data, with no warning and nothing in --dry-run to reveal it. Carry the stored overrides forward when the caller did not supply their own. An explicit --delivery-group-overrides still wins, and '{}' still clears, so deliberately emptying them is unaffected. Three paths needed it: destination upsert, which now fetches the existing destination when a groups object would otherwise go out bare; and both connection upsert paths, which already hold the existing destination and so cost no extra request. This is a read-modify-write and races a concurrent edit of the same destination, which is the same exposure upsert already had for every other field it preserves. Verified against the live API: created a destination carrying overrides, bumped the group rate, and confirmed the overrides survived and the rate changed. The pre-fix binary run against the same destination drops them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Five pkg/ defects and the acceptance gap that let them through. Copilot's four findings, plus a fifth the new acceptance tests caught: 1. Mixed measures across routes silently dropped data. Routing on "pending" alone also captured --measures pending,failed_count, and the pending branch replaces the whole measure list with count -- exit 0, failed_count gone. RejectMixedMeasureRoutes now refuses the combination, shared by the CLI and MCP so the two cannot drift. 2. The CLI delivery-policy guard missed the common form. update and upsert normally omit --type, so destType was "" and the guard returned nil while the API silently discarded the policy. The stored type is now resolved, with the lookup skipped whenever it cannot change the outcome. 3. Overrides preservation failed open. A transient lookup error left a bare groups object going out, reintroducing the #393 data loss through the error path. It now refuses rather than proceeding. 4. The CLI-path fix did not fire for --destination-name plus --destination-type CLI, the ordinary idempotent form, because the existence lookup was skipped. Suppressing the default alone would have sent path:"" instead, trading one silent clobber for another, so the unconditional assignment went too. 5. destination update wiped delivery group overrides -- the same #393 loss on a third command, which neither the unit tests nor two code reviews caught. It reuses the lookup the type resolution already performs. Acceptance coverage: delivery groups had none at all, on the feature this release exists for. 13 test functions and 28 subtests now cover the standalone and inline forms, CLI-type rejection, partial and invalid flag sets, and -- the case that matters -- that bumping a group rate preserves the stored overrides on update, upsert and connection upsert. Defect 5 was found by writing them. Also adds the two metrics cases whose absence let real bugs ship: --measures pending with no --granularity, and --measures queue_depth. The existing tests were written around the broken behaviour, passing --granularity 1h and using max_depth, so they could never have failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Every gateway MCP response carried meta.active_project_name: "" whenever the CLI was authenticated with a project-scoped credential (hookdeck ci keys, dashboard/single-project API keys). The profile on disk stores only project_id, so fillProjectDisplayNameIfNeeded has to recover the name from the API, and its only source was GET /projects — which 403s for those credentials. The error was swallowed, so the meta block an LLM client shows the user had no readable project name, including before the pause/unpause write actions. Fall back to /cli-auth/validate, which does return team_name_no_org and organization_name for those keys (this is where whoami gets them). Its names are only applied when the key's project matches the active project id, mirroring resolveActiveProject in whoami, so the meta block can never name the wrong project. Refs #405 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Two ways `hookdeck login` failed a caller with no terminal, both in pkg/login. #400: with an EMPTY config, `hookdeck login </dev/null` printed "Press Enter to open the browser", read EOF instantly, opened a real browser window on the user's desktop, and then polled forever. Killed at 60s with no sign of stopping. The guard added in 9ff211a mirrors the browser branch of waitForLoginSession, but it sits inside `if config.Profile.APIKey != ""`. An empty config skips that block entirely, so the fresh-login path reached waitForLoginSession unguarded - the branch was known about and only the rejected-key case was covered. The same condition now runs before StartLogin, so no session is created either: no saved credentials, and browser sign-in needs an interactive terminal; use hookdeck ci --api-key with a project API key, hookdeck login --cli-key with a CLI key, or set HOOKDECK_API_KEY to a project API key The `isSSH() || !canOpenBrowser()` branch prints a URL and polls without reading stdin. That works headlessly and is still allowed; the shared condition is now browserSignInNeedsStdin() rather than two copies of it. The Enter prompt also had no trailing newline, so it ran straight into "Waiting for confirmation...". Fixed. Its "^C to quit" is now true rather than aspirational, because the branch is only reachable with a terminal. #401: `hookdeck login -i </dev/null` printed "Enter your CLI API key: " and then "operation not supported by device" - term.GetState's termios error, surfaced verbatim. It exited 1 immediately, so only the message was wrong. It now refuses before printing a prompt nobody can answer, and names the same ways in. Neither of these is a regression. #337's "unauthenticated commands no longer hang" is resolveAuthFallback in pkg/cmd/root.go, which covers a command that fails for want of credentials and would drop into login. Typing `hookdeck login` never went through it, so the claim was true as scoped and never covered this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
…n-in URL #373. The third copy of the Enter-then-browser branch, in waitForGuestUpgrade. A guest profile whose key still validates skipped every guard already added, so `hookdeck login </dev/null` printed the Enter prompt, read EOF, opened a browser window unasked, and then polled for four minutes (120 attempts, 2s apart). Same browserSignInNeedsStdin() guard as #400, placed before RefreshGuestSigninLink so no sign-up link is minted for a flow nobody can finish. The isSSH() || !canOpenBrowser() branch prints the URL and polls without reading stdin, and is still allowed. Same missing newline on its prompt, fixed. Unlike the other two this refusal has no headless equivalent - a permanent account is created in the browser - so it names signing in with one that exists: creating a permanent account needs browser sign-up, and browser sign-up needs an interactive terminal; run hookdeck login in a terminal to keep this sandbox's data, or sign in to an account you already have with hookdeck ci --api-key or hookdeck login --cli-key Both browser branches now print the sign-in URL on its own line before starting the spinner. It used to be carried only by the openBrowser error path, via a stop-spinner/restart-spinner dance, and that path is the detectable half only: open.Browser is exec.Command(...).Start(), which returns nil the moment the child is spawned. A browser that dies straight after - WSL, containers, VS Code Remote - reported success, so the user got a bare spinner and no link. Printing it unconditionally also lets the failure message shrink to one line that does not repeat the URL. Audit of every instance of this shape in pkg/login, since three surfaced separately. Two openBrowser call sites, both in client_login.go, both now guarded; two Fscanln reads, the same two branches; one echo-suppressed stdin read in InteractiveLogin, guarded by #401. No fourth. GuestLogin polls but prompts for nothing and opens nothing. The only other openBrowser in the repo, pkg/listen/tui/update.go:283, is an "o" keypress in the interactive renderer, which #333 already downgrades to compact without a terminal. Not touched: RefreshGuestSigninLink's stale-URL/TTL behaviour, which #373 also tracks and which is a larger behavioural question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Two commands reported success while answering a different question. #406: destination update/upsert build their config by switching on the destination type, and --type is normally omitted, so the switch fell to the empty-type default and --url and --cli-path were never copied into the request. The PUT went out without them and the command exited 0. PR #392 resolved the stored type for the delivery-policy guard but deliberately kept it out of config building, because wiring it there opportunistically would have made --url work only when a rate-limit flag happened to be present too. So resolve it generally instead: the lookup now fires for any flag whose handling depends on the type, and the resolved type is what config building gets. It reuses the memoised GetDestination the policy guard already pays for, so a typeless update still costs one GET. A type-specific flag the type has no field for is now refused rather than dropped, in both directions: --url on a stored CLI destination, and --url with no type to resolve at all (an upsert that is really a create). The empty-type default stays tolerant, because auth and delivery-policy flags mean the same thing whatever the type. #407: metrics events picks one endpoint from ordered conditions, so a queue-depth measure matched first and the issue_id dimension went to /metrics/queue-depth, which does not group by issue. "pending" shadowed it the same way, and the --issue-id filter was reported as an unsupported filter rather than as the second route it is. RejectCrossRouteEventQuery extends the #392 mixed-measure rule to measure/dimension conflicts and names both routes. It lives in pkg/hookdeck beside RejectMixedMeasureRoutes, which it subsumes; the MCP layer still calls the narrower one and needs the same one-line swap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Four output bugs found in v2.6.0 RC testing. #376 fixed readiness never being announced without a TTY; #399 is the same bug inverted, and is the one that matters most here. #399 — interactive mode looked connected before it was. The TUI drew its complete layout immediately — brand header, "Listening on …", "Requests to →", "Forwards to →" — and drew the status bar only once the websocket was up. A session that never connected was therefore identical to a working one apart from a line that was absent, for the whole 40-second attempt budget, before the alt-screen was torn down and an error printed. Absence is not a signal a user reads. The model now carries an explicit connection state whose zero value is "connecting", the status bar is drawn on every frame, and it leads with that state: "● Connecting…", "● Connecting… (attempt N)" while retries are in flight, "● Connected." on success, "● Reconnecting…" after a drop, and "● Connection failed: <reason>" when the CLI gives up — held briefly so it is visible inside the alt-screen rather than only after it. A failed attempt before the first connect is counted rather than reported as reconnecting, because the CLI cannot claim a connection it never had. #402 — compact output printed the bare preposition "Listening on". The counts the interactive header shows now live in pkg/listen/summary, a leaf shared by both renderers (as pkg/listen/links already is), so the two modes cannot drift apart again. Compact is the automatic no-TTY fallback, so this is the line most CI logs keep. #403 — OSC 8 hyperlinks were hand-rolled and emitted unconditionally, so redirected output carried the escape bytes and a real terminal — the only thing that can render them — carried a plain URL. They are now gated on the same check colour uses, and the plain-text fallback prints the full URL including team_id, which the hyperlink label deliberately omits. The CLI also now honours NO_COLOR, which it never had. #404 — --color off reached only pkg/ansi, and the TUI draws with lipgloss, so a controlling-pty run with the flag set still emitted 48 SGR sequences. The interactive renderer now threads the same answer into the TUI styles, which renders every frame with no SGR bytes at all. Verified across five output environments with a real openpty + TIOCSCTTY harness: the non-TTY readiness line of #376 is unchanged in compact and quiet, --color off on a TTY drops from 48 SGR sequences to 0, and compact on a TTY now emits the hyperlink while a pipe does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The upsert builder asserted the resolved type back onto the request. The type
resolution is needed -- it decides whether --url is even a valid field for this
destination -- but sending it makes the command a read-modify-write: if the
destination's type changes between the lookup and the PUT, we silently revert
it. Omitting the field leaves the stored type alone.
Verified against the live API: an upsert carrying a config and no type field is
accepted, applies the change, and keeps the stored type.
This also makes upsert agree with update, which already asserted exactly this
rule ("resolving the stored type must not start sending a type the user did not
pass") -- the two builders had opposite behaviour for the same situation.
An explicit --type is still sent, and is covered by a new case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
…ot honour Found by driving the MCP server over JSON-RPC against the live API. The shape throughout: MCP flattens several CLI subcommands into one tool with one flat schema, and the per-subcommand precision the CLI has is lost. hookdeck_requests accepted delivery_group on action "list" and the API silently ignored it -- a bogus value returned rows byte-identical to the unfiltered baseline, while every other filter narrows to zero. That is the same silent-wrong-answer hazard the metrics tool already guards against, unguarded one tool over, on the feature this release ships for. Arguments an action does not support are now refused, for hookdeck_requests and hookdeck_events alike. ignored_events was passing nil for limit/next/prev, so pagination was dropped. Dimensions received no client-side gating at all, unlike filters, so known-bad combinations fell through to raw 422s -- including the delivery_group dimension, which requires a destination_id filter. Gated per route from the shared matrix, in the CLI as well as MCP. The metrics schema advertised four "common" measures, three of which fail on most routes, and gave measures, dimensions and status no per-action values at all -- the three parameters that decide whether a call succeeds. All three are now accurate per action and derived from shared constants, so the CLI's hand-maintained lists and the MCP schema cannot drift again. 422 bodies surfaced verbatim with internal fields, burying the useful message. Also applies the #407 cross-route guard to the MCP routing, which the CLI-side change could not reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
hookdeck_requests action "events" dropped source_id before the request was
built, so "the events of req_X that came from source Y" answered with every
event of the request. That was briefed as "the API ignores it" and the previous
commit acted on it, refusing the argument. The brief was wrong:
GET /requests/{id}/events declares the whole /events filter set and honours it
-- verified live, a bogus source returns zero rows and the real one returns the
matching row.
So the refusal is replaced with forwarding for everything the route declares,
and `gateway request events` gains the matching flags. It offered five; it now
offers the flag set of `gateway event list`, same names, same wording, because
it queries the same collection narrowed to one request. --id is the one flag
left off: this command already takes the request ID as its argument, and a
second --id meaning "event IDs" beside it reads as the request's.
Still refused on events, and still tested: verified, rejection_cause and
ingested_* describe the edge decision, which the sub-resource has no parameter
for. Confirmed against the live route -- it answers 200 with unfiltered rows for
a parameter it does not declare, which is exactly the silent-wrong-answer the
guard exists for.
status was the one real design problem. It means ACCEPTED/REJECTED on list and
SCHEDULED/QUEUED/.../CANCELLED on events, and MCP has one flat property per
tool. The description now names both vocabularies, the shape hookdeck_metrics
already uses for the four its own status argument carries, rather than inventing
a second spelling the CLI has no equivalent of. The handler checks the value
against the action's own list: the API does 422 on an out-of-enum status, but
that message names only the enum of the route it was sent to, never the sibling
action that takes the value. Matching ignores case and sends the API's own
spelling, which the API itself will not do -- it 422s "successful" against the
upper-case enum.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
# Conflicts: # pkg/cmd/metrics_events.go
A revert audit of #392 found fixes that can be deleted with the whole suite green. The code is correct today; the problem is that nothing would notice if a refactor undid it, and several of these guard the silent-wrong-answer class the release exists to fix. Each test below was written first, then checked by reverting the fix it covers and confirming it fails. - MCP: gate the filters each events route ignores (queue depth, pending and by-issue). Only requests/attempts/transformations were covered, so the delivery_group family of bugs was unprotected on the tool side. - MCP: pin queue_depth -> max_depth at the request, mirroring the CLI's TestQueueDepthMeasureIsTranslatedOnTheWire. Without it the tool sends a 422 for a measure its own schema advertises. - MCP: gate the by-issue route's dimensions, the one events route missing from the dimension table. - `destination create`: extract buildCreateRequest, in the style of buildUpdateRequest and buildUpsertRequest, so both cliPathFromFlags call sites are covered through the command's own wiring. Testing the helper alone could not tell whether the command still called it, and raw dc.cliPath reinstates --config's path being overwritten with "/". - CLI: gate the dimensions of `metrics attempts`, `metrics requests` and `metrics transformations`, driven through each command's RunE. - Delete the default events route's filter guard in both layers: it can never fire, because DefaultEventRouteFilters differs from the union only by issue_id and any issue_id selects the by-issue route above it. A new hookdeck test pins that invariant, so a filter the route does not honour brings the guard back rather than passing unnoticed. - listen: cover Proxy.Run's give-up path end to end, asserting the renderer is told why before it is torn down (#399). The retry budget and its backoff become vars so the test runs in milliseconds rather than twenty seconds; the CLI never changes them. - listen: give InteractiveRenderer an injectable message sink and its first unit tests, covering a session-level OnError surfacing as a ConnectionFailedMsg the model acts on. Two tests that failed for the wrong reason: - TestSetColorEnabledKeepsTheWords asserted on the status bar, so a #399 regression failed as a #404 colour bug. It now asserts on the frame; position belongs to TestStatusBarAlwaysReportsConnectionState. - The #399 acceptance test passed with the status bar removed, because renderConnectingStatus writes the same words into the viewport body. It now matches the status bar line specifically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Eight findings from a review of the merged PR #392, most of them one layer fixed and the other missed. - RejectUnsupportedDimensions: say that the delivery_group/destination_id rule is the API's and applies on every route offering the dimension, not just events. Verified live against `metrics attempts`. - Report a refused dimension in the caller's spelling. Both callers rewrite connection_id to webhook_id before validating, so the refusal named a token the caller never typed, beside an allowed list that spelled it connection_id. The filter path was already correct. - hookdeck_events action "list" now canonicalises `status` like hookdeck_requests action "events" does. Same collection, same enum, and only one of them accepted "failed". - apiErrorMessage: hold "data" as a raw value. Decoding it straight into a []json.RawMessage failed the whole unmarshal on an object or string data and threw the top-level "message" away with it, which is the raw body dump the function exists to prevent. - The CLI canonicalises --status too: `request list` against the request-log enum, `event list` and `request events` against the event enum, and --help now names the vocabulary each one takes. MCP has done this since status.go landed; the CLI had not, so ACCEPTED worked through one surface and 422'd through the other. - --config/--config-file and the individual destination config flags are now a refused conflict on create, update and upsert alike. They disagreed three ways before, and `upsert --config ... --url ...` without --type dropped the URL and exited 0. - One routing table: hookdeck.RouteForMeasures replaces the CLI's map and MCP's containsAny list, and the route-name constants replace the hand-written strings that gave one route two names in adjacent errors. - REFERENCE.md documented `metrics queue-depth`, `metrics pending` and `metrics events-by-issue`, none of which exist; the per-route dimension gating was undocumented. Both fixed, and a test now pins every `gateway metrics <sub>` in the prose to a real subcommand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
faa742f made --config/--config-file a refused conflict with the individual destination config flags on `destination create`, `update` and `upsert`. That is a breaking change: before it, destination create --type HTTP --config '{"url":"https://from-config"}' \ --url https://from-flag succeeded and sent url=https://from-flag; after it, the command errors. 2.6.0 is not the release for that, so the change comes out here and will be re-proposed against 3.0.0 on its own. Restored from faa742f^, hunk for hunk: - rejectConfigJSONWithIndividualFlags and destinationIndividualConfigFlags in destination_common.go, and the three validateFlags call sites in destination_create.go, destination_update.go and destination_upsert.go. - The two overlays the rejection made unreachable: the --url overlay in buildCreateRequest (its applyCLIPath call was never removed and stays), and the --url/--cli-path overlay in buildUpsertRequest. - The four tests that pinned the refusal: TestDestinationConfigJSONRefusesIndividualFlags, TestDestinationConfigFileRefusesIndividualFlagsToo, TestDestinationConfigJSONAloneIsStillAccepted and TestDestinationUpsertAndUpdateAgreeOnConfigJSON, with the helpers added for them. The five builder tests that predate faa742f are untouched. - The --config/--config-file help text on all three commands, and REFERENCE.md regenerated to match. This restores the pre-faa742f behaviour as it was, three-way disagreement included: `create` and `upsert --type HTTP` let --url win, `update` and `upsert` without --type let --config win. Fixing that is the 3.0.0 proposal, not this commit. The other seven findings in faa742f stay: the RejectUnsupportedDimensions comment, hookdeck.DimensionName and the caller's-spelling refusal, canonicalEventsStatus, the apiErrorMessage raw-data fix, status_flag.go and its three callers, hookdeck.RouteForMeasures and the route-name constants, and REFERENCE.md's corrected metrics prose with its test. metrics_filters.go was picked over by hand rather than reverted, so the comment correction it shares with this file set survives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
fix: delivery-group data loss and metrics defects, plus the missing acceptance coverage
# Conflicts: # test-scripts/test-api-upsert-behavior.sh
…410) `gateway transformation run` printed "{}" and exited 0 for every kind of failure - a throwing handler, invalid JavaScript, a nonexistent --id. Testing a transformation before shipping it is the command's entire purpose, and it could not report that the code was broken. PUT /transformations/run answers HTTP 200 whether the code ran or threw; only the body differs. A throwing handler returns {"log_level":"fatal","console":[...]} with no "request" at all. TransformationRunResponse declared neither log_level nor console, so that body parsed into an empty struct - hence the "{}" and the nil error. Add LogLevel and Console to the response, a Failed() predicate and a ConsoleText() renderer, and check Failed() on both the human and the --output json paths. --output json still prints the payload before it fails, because the console output is the diagnosis a script wants. Failed() keys off log_level == "fatal" or a missing request, not off log_level != "info": log_level is the highest severity the run logged, not a completion flag, so a handler that calls console.error and then returns a transformed request succeeded. Console output is now printed on successful runs too - that handler is exactly the case someone is debugging, and printing the console only on failure hid it. Ported from release/v3.0.0. The unrelated apiPath() path-escaping refactor in that branch's version of transformations.go is deliberately left behind. Unit tests cover the predicate, the renderer, and all four command paths; acceptance tests (tag: transformation) pin that a throwing handler exits non-zero on both output paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
fix(transformation): report a failed transformation run as a failure
Merging a PR into a PR's head branch does not fire a pull_request synchronize event, so #378 has been showing a stale failed acceptance run from a commit two merges ago. A direct push does fire it. The run it is displaying (34910688627) failed on HTTP 429 rate limiting from two concurrent acceptance runs, not on any assertion -- zero test failures, every job hit the 12m timeout. A clean dispatch on the same commit (34940108286) passed: 411 passed, 0 failed, 17 pre-existing skips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The #410 fix returned "the transformation did not complete" as an ordinary error, and Execute prints those to stdout. So `--output json` emitted the payload followed by a prose line, and `| jq` failed to parse the stream -- trading a silent failure for an unparseable one. The reason now goes to stderr and stdout stays pure JSON; the exit code still carries the failure. Verified: stdout is 175 bytes of valid JSON, stderr is the 36-byte reason, exit 1. Adds alreadyReportedError, which Execute exits on without printing. Commands producing machine-readable output need a way to fail without writing prose to stdout, and Execute's default branch does exactly that. Narrowly scoped: the CLI-wide habit of printing errors to stdout is #394 and is not touched here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The suite has only ever triggered on pull_request -- zero runs in history with headBranch main -- so main's head is strictly a commit nothing ran acceptance against. The PR gate is inherited on merge, and that inheritance is only as current as the PR's last pull_request event. That is not hypothetical. Merging a PR into another PR's head branch fires no pull_request event, so #378 sat with 20 commits and 75 changed files against a stale run, and the code about to ship had never been through its own gate. test.yml already runs on push to main for exactly this reason, with the comment "so the merged result is tested, not just each PR branch in isolation. A PR based on stale main can merge into a combination that no PR run exercised." The argument was never extended to the stronger suite. Uses the same paths filter as the pull_request trigger, so a docs merge does not spend a full suite run -- which is what makes this affordable and is why it belongs in this PR rather than a separate one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Summary
update the CLI and cached OpenAPI source to API version
2026-09-01replace project
mode/team_modehandling withtype/team_type, while retaining local config compatibilitysend destination rate limits through
config.delivery_policyand add delivery-group flags for standalone and inline destinationsexpose delivery-group filters for events, request events, metrics, and MCP tools
update generated reference docs, examples, fixtures, and API-version expectations
Testing
env -u GOMODCACHE go test ./...env -u GOMODCACHE go run ./tools/generate-reference --checkCredentialed destination and connection acceptance tests were not run because
HOOKDECK_CLI_TESTING_API_KEYis not configured in this environment.