Skip to content

feat: support 2026-09-01 gateway API - #378

Merged
leggetter merged 56 commits into
mainfrom
feat/api-2026-09-01
Sep 15, 2026
Merged

leggetter merged 56 commits into
mainfrom
feat/api-2026-09-01

Conversation

@alexbouchardd

@alexbouchardd alexbouchardd commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • update the CLI and cached OpenAPI source to API version 2026-09-01

  • replace project mode/team_mode handling with type/team_type, while retaining local config compatibility

    Note: this originally read product/team_product, which was the API's name for the field at the time this PR was opened. The API settled on type/team_type before release, and the code follows that. Config compatibility is retained by writing the display label (Gateway) that older CLIs understand, not the API value.

  • send destination rate limits through config.delivery_policy and add delivery-group flags for standalone and inline destinations

  • expose 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 --check
  • self-contained guest and login acceptance tests

Credentialed destination and connection acceptance tests were not run because HOOKDECK_CLI_TESTING_API_KEY is not configured in this environment.

@leggetter

leggetter commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Claude-assisted review - I ran /code-review over the diff, did a manual pass, then checked the findings against the live API and the published 2026-09-01 OpenAPI spec. Flagging the provenance up front: the verification below is real, the prose is generated.

Checklist so we can track what gets picked up. Tick as they land, or push back on any of them.

Edited after posting: the /projects finding is withdrawn once I checked core origin/staging. Details in the struck-through item below.

Blocking

  • /projects doesn't exist on prod. Withdrawn - this is a deploy-order dependency, not a CLI bug. origin/staging in core registers the list route on both paths:

    const path = '/projects';
    // The published CLI lists projects through GET /teams; that alias is kept
    // (and filtered out of the OpenAPI docs) until CLI versions move to /projects.
    const cli_legacy_list_paths = [path, '/teams'];

    So /projects is real and intended, it just isn't on prod yet - running this branch against prod today gives 404 Cannot GET /2026-09-01/projects, while /teams answers on both API versions. The one thing to settle is ordering: this PR can't ship before that core release. Since the /teams alias is deliberately kept for published CLIs, staying on /teams for now would also decouple the two releases, if that's preferable.

    Checked the scope declaration too (scope: 'projects.read') - withScopes passes CLI-authenticated callers through untouched, so that won't bite.

  • --delivery-group will 400 on four of the seven metrics endpoints. It's added in addMetricsCommonFlagsEx (pkg/cmd/metrics.go:86), so every metrics subcommand gets it, but per the spec only /metrics/attempts, /metrics/events and /metrics/queue-depth accept delivery_group. /metrics/requests, /metrics/transformations, /metrics/events-by-issue and /metrics/events-pending-timeseries don't, and their filters are additionalProperties: false, so it's a 400 rather than an ignored field. metrics events --measures pending routes to events-pending-timeseries, so that one breaks too. The skipIssueID mechanism in that file already solves this shape. Same gap in pkg/gateway/mcp/tool_metrics.go:58.

    Re-checked this against core origin/staging rather than the published spec, in case the spec was just stale: resolving each filter schema gives delivery_group on event_filters_schema, attempt_filters_schema and queue_depth_filters_schema only. request_filters_schema, transformation_filters_schema, events_pending_timeseries_filters_schema and events_by_issue_filters_schema don't carry it. So this one survives the deploy.

Worth fixing

  • ListProjects drops the unmarshal error (pkg/hookdeck/projects.go:20). postprocessJsonResponse(res, &projects) returns an error that isn't checked, so a 200 with an unexpected shape gives an empty slice and a nil error - zero projects listed, no failure. checkAndPrintError catches the non-2xx case, so this only bites on a shape mismatch, which is exactly what an endpoint rename risks.

  • No team_mode fallback on ValidateAPIKeyResponse / PollAPIKeyResponse / CIClient. If team_product is ever absent, ProjectProduct, ProjectMode and ProjectType all become "", IsGatewayProject("") is false, and every hookdeck gateway ... command fails with this command requires a Gateway project; current project type is (blank). To be fair to the change: I tested this against prod with project_type, project_mode and project_product stripped from my config, and whoami still printed Project type: Gateway - so the live API does return team_product and this isn't currently broken. Keeping the old field as a fallback through ModeToProduct is cheap insurance rather than a fix.

  • delivery_group is missing ,omitempty (pkg/hookdeck/events.go:18), unlike ResponseStatus two lines below. event list --output json now emits "delivery_group": null on every event, which changes output for anything diffing or schema-checking that JSON.

  • REFERENCE.md:1937 lists the metrics common flags by hand and doesn't mention --delivery-group. generate-reference --check passes because it emits no flag table for those subcommands.

  • gofmt regression. pkg/config/profile.go and pkg/config/profile_credentials_test.go are unformatted on the branch - the new ProjectProduct field breaks the struct alignment. profile.go was clean on main. Nothing in CI catches formatting, which is why it got through.

  • Five copies of the same fallback ladder. Type -> Product -> legacy Mode is open-coded at pkg/cmd/gateway.go:50, pkg/cmd/whoami.go:72, Profile.SaveProfile, Config.setProfileFieldsInViper and Config.constructConfig. One Profile.ResolveProjectType() would cover all five and stop the next caller getting the precedence subtly wrong.

  • Case handling splits down the middle of pkg/config/project_type.go. ProductToProjectType, ProductToLegacyMode and ModeToProduct all lowercase their input; ProjectTypeToProduct and IsGatewayProject are case-sensitive. Sibling functions, opposite contracts, nothing documenting which is which.

Tests

  • The upgrade path has no test. pkg/config/config_test.go isn't touched, but constructConfig gained the back-fill that every existing user hits on first run after upgrading (ProjectProduct derived from a legacy project_mode). Nothing asserts project_mode = "inbound" yields event_gateway, and the existing "use project" test still passes only a legacy mode and never checks project_product was written.

  • TestProductMappings covers about a third of the matrix. Seven flat assertions in a file where every neighbour is table-driven. Missing: ProductToProjectType("") (the wipe case above), ProjectTypeToProduct for Console/Outpost/unknown, ProductToLegacyMode for console/outpost/empty, ModeToProduct for inbound/console/outpost/unknown, anything case-related, and a Type -> Product -> Type round trip that would pin the deliberate outbound-to-inbound flattening currently described only in a comment.

  • No acceptance test asserts project_product reaches disk. product shows up in the ATs only inside three mock responses. Nothing checks the field is persisted to config.toml after login or project use, which is the one end-to-end guarantee the rename needs.

Not blocking

  • 99 hardcoded 2026-09-01 literals across test files, 87 of them in pkg/gateway/mcp/server_test.go. APIPathPrefix says "Change in one place when the API version is updated" and 23 test sites already use it. Mechanical, but the next bump repeats all of this churn.

  • buildDeliveryPolicy (pkg/cmd/destination_common.go:81) runs regardless of destination type, and DestinationTypeConfigCLI in the spec has no delivery_policy and is additionalProperties: false. So --destination-type CLI plus the new delivery-group flags gives an opaque API 400 instead of a client-side error. Same shape as the pre-existing rate_limit behaviour, so this widens an existing surface rather than adding a new break.

Open question on naming

Not blocking, and I may be missing context from the API side. product reads odd to me next to a display type of Gateway / Console / Outpost, and the CLI now carries three vocabularies at once: product on the wire, legacy mode in config, and type in output. The public spec documents no project resource at all, so product is internal naming rather than something the public API commits to. Is product the settled term API-side, or is this the moment to align on one word? --output json still emits type, so nothing user-facing changes either way today.


Verified while reviewing: go build ./..., go test ./... and generate-reference --check all pass on the branch, and the delivery-policy payload the CLI builds (rate, period, groups.{key,rate,rate_period,overrides}) matches DestinationDeliveryPolicy in the spec exactly, including the second|minute|hour group-period enum.

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
@leggetter

Copy link
Copy Markdown
Collaborator

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 /projects deploy-ordering question, which is yours to call. Ticking the items it addresses:

  • --delivery-group restricted to the endpoints that accept it, plus client-side errors on the two metrics events routes that can't use it
  • ListProjects unmarshal error propagated
  • team_mode fallback
  • ,omitempty on delivery_group
  • REFERENCE.md
  • gofmt on the two regressed files
  • five fallback ladders collapsed into Profile.ResolveProjectType()
  • case handling aligned
  • upgrade-path test, mapping matrix, acceptance assertion on project_product

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.

leggetter and others added 6 commits September 10, 2026 11:33
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
@leggetter

Copy link
Copy Markdown
Collaborator

Prod testing update, now that /projects and the delivery-group work are deployed. Two things confirmed, one correction.

Confirmed working against prod

  • GET /2026-09-01/projects is live - project list and project use both work against it (they 404d a few days ago).
  • whoami resolves the type from team_type, so prod is on the new field name. The team_product fallback in auth.go / ci.go is now dead weight and can be dropped whenever we are sure nothing rolls back.
  • project use writes the new shape: project_type = 'event_gateway', project_mode = 'inbound', no project_product.
  • metrics events --delivery-group works; metrics requests --delivery-group is an unknown flag; metrics events --measures pending --delivery-group is rejected client-side with a readable message.

Correction to my earlier review

I said --delivery-group on the unsupported endpoints would be "a guaranteed 422" because those filter schemas are additionalProperties: false. That is wrong, and I should have tested it rather than reading it off the spec.

Ran the pre-fix binary against prod, 14 day window:

requests, no filter                      count: 87
requests + --delivery-group dg_bogus     count: 87   <- identical
requests + --source-id src_bogus         count: 0    <- a real filter key does filter

The API does not reject the unknown filter. It drops it and returns unfiltered totals. Debug logging confirms the CLI really did send filters[delivery_group]=dg_bogus.

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 release

Nothing in CI exercises /projects against the real API. project_use_test.go (which does run, in slice 0) contains only tests that make no API calls - its own header says so - and everything that needs "/projects endpoint access" sits in project_use_manual_test.go behind //go:build manual. Every other test on this path uses an httptest mock, so they cover our parsing, not the API contract or its auth.

Worth noting CI authenticates with HOOKDECK_CLI_TESTING_API_KEY, a project API key, whereas ListProjects goes through clientForCLIAuthValidate and in normal use carries a CLI key. So the CLI-key path against /projects is not covered by any automated test. I verified it by hand today; it works. Happy to add real coverage if we want it gated rather than manual.

leggetter and others added 16 commits September 14, 2026 10:32
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
leggetter and others added 18 commits September 14, 2026 17:47
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
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
leggetter and others added 2 commits September 15, 2026 00:46
# 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
leggetter and others added 3 commits September 15, 2026 09:08
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
@leggetter
leggetter merged commit a12c591 into main Sep 15, 2026
13 checks passed
@leggetter
leggetter deleted the feat/api-2026-09-01 branch September 15, 2026 09:04
leggetter added a commit that referenced this pull request Sep 15, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants