Skip to content

fix: delivery-group data loss and metrics defects, plus the missing acceptance coverage - #392

Merged
leggetter merged 20 commits into
feat/api-2026-09-01from
fix/rc-exercise-findings
Sep 14, 2026
Merged

leggetter merged 20 commits into
feat/api-2026-09-01from
fix/rc-exercise-findings

Conversation

@leggetter

@leggetter leggetter commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #378 (base: feat/api-2026-09-01). 20 commits, 75 files, +8848/−460. Defects found by building #378 and exercising it against the live API — plus the acceptance coverage whose absence let them through.

It opened as a 96-line PR fixing five defects. It is much larger now, because every round of checking found more, and the last round found the thing worth the whole exercise.

How it grew

Round Found
Exercising the RC build against the live API 5 defects in delivery groups and metrics
Code review of those fixes 4 must-fix, 3 of them in the fixes themselves
Copilot review 4 more, incl. one where the first fix was incomplete
Writing the acceptance tests a 5th data-loss path nobody had found
Adversarial review of the merged result 1 must-fix, 5 should-fixes

That fourth row is the argument for the whole PR: gateway destination update destroyed delivery group overrides, and neither the unit tests nor two code reviews caught it. The first acceptance test written for the feature caught it immediately.

Delivery groups and destinations

Defect Symptom
upsert destroyed groups.overrides (#393) Bumping a group's rate wiped per-group overrides the user never mentioned
update destroyed them too Same loss on a sibling command; found by the new ATs
Preservation failed open A transient lookup error sent a bare groups object anyway, reintroducing the loss through the error path
Policy silently discarded on --type CLI --rate-limit / --delivery-group-* exited 0 and applied nothing
…and the guard missed the common form update/upsert omit --type, so the type was "" and it never fired
…and connection upsert bypassed it entirely A second destination-building path had no guard at all
--config path overwritten (#392) --config '{"path":"/webhooks"}' on a CLI destination stored /
connection upsert reset a stored CLI path Any upsert omitting --destination-cli-path rewrote it to /
update/upsert dropped --url (#406) Silently ignored whenever --type was omitted — the normal way to run an update
upsert asserted a type nobody asked for Made the command a read-modify-write that could revert a concurrent type change

Two of these — the --config clobber and the connection upsert path reset — are byte-identical in v2.5.0, so they are pre-existing shipped bugs, not regressions from #378.

Metrics

Defect Symptom
Mixed measures dropped data --measures pending,failed_count exited 0 having silently discarded failed_count
Cross-route measure↔dimension (#407) A queue-depth measure shadowed the issue_id dimension. The audit found three such pairs, not the one reported
--measures pending needed --granularity Otherwise routed to an endpoint that does not accept the measure
--measures queue_depth never worked Advertised in --help, absent from the endpoint's enum
MCP carried both bugs Contradicting the "shared so they cannot drift apart" comment above the filter matrix
--dimensions/--status help wrong on 3 of 4 subcommands Following the help produced a 422
No per-route dimension gating Known-bad combinations fell through to raw 422s
--measures was optional Omitting it gave a raw API 422; the documented example in gateway --help did not run

Vocabularies are cross-checked against the API's OpenAPI document route by route, and verified live — the six event statuses all query, and PAUSED, which an earlier commit in this PR wrongly advertised, 422s.

MCP

  • hookdeck_requests accepted delivery_group on list and the API silently ignored it — a bogus value returned rows identical to unfiltered, while every other filter narrows to zero. The same hazard the metrics tool already guarded against, one tool over, on the feature this release ships for.
  • requests events filters now forwarded, not dropped — ~22 args, with matching CLI flags on gateway request events (5 → the full event list set). Correction to an earlier claim in this PR: that route genuinely honours source_id; MCP was dropping it, not the API ignoring it.
  • Per-action argument gating, ignored_events pagination, per-action measure/dimension/status vocabularies derived from shared constants, status canonicalisation, meta.active_project_name for project-scoped keys (#405), and 422 bodies reduced to the useful line.

listen and login

  • listen looked connected before it was (#399) — the full TUI rendered with a blank status bar for ~40s while nothing was connected. This is listen connects but never prints "Connected": readiness line is dropped when output is piped or --color off #376 inverted: piped output under-reported readiness, interactive over-reported it. Now Connecting…Connected.Connection failed: <reason>, affirmative in every mode.
  • Compact-mode dangling Listening on (#402), OSC-8 emitted only where it cannot render (#403), --color off ignored by the TUI (#404), plus NO_COLOR support the CLI never had.
  • hookdeck login opened a browser and hung forever with no terminal (#400), and the same on the guest-upgrade path (#373). The sign-in URL is now always printed, so a browser that fails after spawning is visible rather than silent. login -i explains itself instead of leaking a termios error (#401).

Test coverage

Delivery groups had none (grep -rn "delivery-group" test/acceptance/ → 0 hits) on the feature this release exists for.

27 new test files, 16 acceptance test functions, 115 unit test functions.

Every fix here was verified by reverting it and confirming the relevant test fails. A later audit did that mechanically across 80 individual reverts on the merged tree and found 9 fixes that were not actually pinned — all since closed, along with two guards that could never fire.

The two metrics ATs added are the ones whose absence let real bugs ship: TestMetricsEventsPending passed --granularity 1h and TestMetricsEventsQueueDepth used max_depth, so both were written around the broken behaviour and could never have failed.

Verification of the merged result

Check Result
Full acceptance suite — 391 tests, all 22 tags green (one intermittent test, #409, passes on re-run)
CLI surface vs pre-merge — 1,348 command/flag pairs nothing removed; 21 added, all intended
Read-command differential, 13 commands identical shape and exit codes
Revert audit — did the merge drop a fix? nothing dropped, verified against both merge parents
Adversarial review of the merged diff 1 must-fix (fixed), 5 should-fixes (fixed)
Untouched areas: source, connection, transformation, issue, event, attempt, request no regressions — every bug found reproduces on the pre-merge baseline

Also clean: go build, go test ./... (17 packages), go vet untagged and under basic/listen/destination/connection_upsert/metrics, generate-reference --check, and no gofmt regression.

Delivery-group and metrics behaviour was verified against a project with real grouped production traffic — 4.68M events across five delivery groups — because the CI test project has no delivery groups at all. Real group returned 19,688; bogus group returned 0.

Deliberately not included

  • The --config mutual-exclusion fix is reverted and deferred to 3.0.0 (#415) — it is a breaking change and this is a minor release. So 2.6.0 still ships the existing wart: two fields overlay --config, about ten flags are silently discarded, and create/update/upsert disagree. Unchanged from today, now tracked.
  • #393 stays open for the API-side merge. The CLI no longer loses data, but any other consumer sending a bare groups object still will, and the client-side fix is a read-modify-write that races a concurrent edit.
  • connection upsert carries overrides forward from the connection's current destination even when --destination-name names a different one. Pre-existing; forcing the lookup widens its reach. A semantic call about what that flag means.

Filed rather than fixed: #394, #396, #397, #410#414 — all pre-existing, none introduced here.

⚠️ CI caveat

This PR gets no automatic checkstest.yml only triggers on PRs targeting main (#395). It was triggered manually and passes. Acceptance tests cannot run on this branch at all, so the new delivery-group ATs will first execute in CI when this lands in #378. They pass locally and in a full 391-test local run.


🤖 Generated with Claude Code

https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9

leggetter and others added 2 commits September 14, 2026 17:13
Two defects found by exercising a release-candidate build against the API.

A CLI destination carries no delivery_policy in the API schema. Passing
--rate-limit or --delivery-group-* with --type CLI was accepted, sent, and
discarded server-side: exit 0, and a read-back showing no policy at all. The
flags looked applied and never took effect. Reject the combination instead,
naming the --destination- prefixed spelling when it came from a connection.

Separately, --cli-path is declared with a default of "/" on create (but "" on
update and upsert), so the "explicit flag wins" branch fired even when the user
never passed it — overwriting a path supplied via --config. Compare against
cobra's Changed rather than "", so the advertised default stays in --help and
only the --config case changes. The path defaulting is now one helper shared by
create and upsert, since the asymmetry between them is what produced the bug.

Verified on the wire by pointing the build at a local capture server via
--api-base and reading the request bytes, rather than inferring from read-backs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Three defects found by exercising a release-candidate build against the API.

--measures pending only routed to the pending-timeseries endpoint when
--granularity was also set; otherwise it fell through to the default events
route, which rejects the measure. The API treats granularity as optional on
that route, so gating the routing on it was wrong. Route on the measure alone.

--measures queue_depth is advertised in --help but is not in the endpoint's
enum, which accepts max_depth and max_age only, so it could never succeed.
Translate it to max_depth on the wire, mirroring the existing pending -> count
translation, so the documented flag works. (If the intent was to drop the
spelling instead, removing it from the measures list is the one-line
alternative.)

--dimensions and --status advertised one generic vocabulary on all four
subcommands, and it is wrong on three: passing a suggested dimension returned a
raw 422. Each route now advertises the set it accepts, alongside the filter
matrix that already gates which flags exist. The metrics example in
`gateway --help` was also missing the required --measures, so copying it
verbatim failed.

Vocabularies cross-checked against the API's OpenAPI document per route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Four defects the review caught in the first two commits, plus tests that
actually pin the fixes.

connection upsert has a second destination-building path, taken when the
connection already exists, which built a delivery policy with no CLI guard. The
bug this PR set out to fix was still reachable through it.

The status vocabulary added for --help was itself wrong. Events accept
SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED and CANCELLED; the constant
advertised PAUSED, which the API rejects, and omitted three real values. The
repo already had the right list in event_list.go and the MCP tools. Attempts
accept SUCCESSFUL and FAILED only, so they now get their own constant rather
than sharing the events one — a single shared vocabulary across routes with
different enums is the defect this commit series is meant to remove.
Transformations register no --status flag at all, so they are passed none.
Verified against the live API: all six event values query, PAUSED 422s.

The MCP layer carried both metrics bugs the CLI had just fixed, contradicting
the "shared so they cannot drift apart" comment above the filter matrix. The
queue-depth translation moves to pkg/hookdeck and both callers use it, and the
granularity gate is gone from the MCP routing too.

Also: metrics events printed two conflicting dimension lists on one help page,
so the local constant now derives from the shared one; --measures is marked
required, since every endpoint rejects a request without it and MCP already
enforced it; and connection upsert no longer resets an existing CLI
destination's path to "/", which its own comment said it should not do.

Every fix in this PR was checked by reverting it and confirming the relevant
test fails. Five previously had no such coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
@leggetter

Copy link
Copy Markdown
Collaborator Author

Review round 1 addressed

A coding agent reviewed this PR and found four must-fix defects, three of them in the fixes themselves. All are now fixed in 6827c44, plus the test gap it identified.

Must-fix

  • connection upsert bypassed the new CLI guard. There are two destination-building paths; the one taken when the connection already exists built a delivery policy with no guard, so --destination-rate-limit against an existing CLI destination still exited 0 and applied nothing — the exact bug this PR set out to fix, still reachable one command over.

  • The status vocabulary I added was wrong. EventStatusValues advertised PAUSED, which the API rejects, and omitted CANCELLED, HOLD and SCHEDULED. Corrected to SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED — which the repo already had right in event_list.go and the MCP tools. Verified against the live API: all six query successfully, PAUSED 422s. A wrong vocabulary shipped in the commit meant to fix wrong vocabularies, which is worth naming plainly.

  • metrics attempts was handed the events vocabulary. Attempts accept SUCCESSFUL, FAILED only. They now have their own constant — sharing one list across routes with different enums is the defect this series exists to remove. transformations register no --status flag at all, so they are passed none.

  • The MCP layer carried both metrics bugs the CLI had just fixed, contradicting the "shared so they cannot drift apart" comment above the filter matrix. TranslateQueueDepthMeasures moved to pkg/hookdeck with both callers using it, and the granularity gate is gone from the MCP routing too.

Also fixed

  • metrics events --help printed two conflicting dimension lists (differing by status); the local constant now derives from the shared one.
  • --measures is marked required — every endpoint rejects a request without it, and MCP already enforced it, so this turns an API 422 into a Cobra error.
  • connection upsert no longer resets an existing CLI destination's path to /. Its own comment said "for new CLI destinations" but the condition never checked that, so any upsert omitting the flag rewrote /webhooks to / — the same silent-clobber class this PR fixes elsewhere.

The test gap

The review reverted each fix and re-ran the suite. Five of them still passed — the tests pinned pure helpers, not behaviour anyone can observe.

Now corrected: the routing tests drive queryEventMetricsConsolidated against an httptest server and assert the actual path and measures[] on the wire, the --cli-path gate is extracted into a testable helper, and both connection guards are covered. I re-ran the revert check on all five and confirmed each now fails when its fix is removed.

Not changed

The reviewer noted destination upsert --type CLI without --cli-path cannot create a new destination, since the API requires path. That exactly preserves prior behaviour rather than regressing it, so I have left it alone rather than widen this PR.

Verification: go build, go test ./... (15 packages), go vet untagged and -tags basic, gofmt, generate-reference --check all clean. Metrics fixes re-checked against the live API.


Review and fixes by Claude, on Phil's behalf.

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

Copy link
Copy Markdown
Collaborator Author

Added: the #393 data-loss fix (f05ca9e)

Scope change — this now also fixes #393, the upsert path that destroyed delivery_policy.groups.overrides. Ship-blocking rather than follow-up work: delivery groups are the headline feature of 2.6.0, and shipping them with a known silent data-loss path with a patch queued behind it isn't the right trade.

Stored overrides are carried forward when the caller doesn't supply their own. Explicit --delivery-group-overrides still wins; '{}' still clears. 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 it, so no extra request).

Verified live, A/B on the same destination: fixed binary preserves the overrides while bumping the rate; the pre-fix binary drops them.

Known caveat, recorded on #393: this is a read-modify-write and races a concurrent edit — the same exposure upsert already has for every field it preserves. The API-side merge remains the better long-term fix, since any other consumer sending a bare groups object still loses overrides.

Note on CI for this PR

None of the normal checks run here — test.yml only triggers on PRs targeting main, so a stacked PR gets nothing (filed as #395). I triggered test.yml manually on the branch and it passes: unit-test, govulncheck, build-mac/linux/windows. Acceptance tests cannot be triggered on this branch at all (test-acceptance.yml has no workflow_dispatch), so they'll first run when this lands in #378. Worth merging this into #378 and letting that CI be the real gate before #378 is reviewed as a whole.

(Fixes and verification by Claude, on Phil's behalf.)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several update paths can still discard policies or stored configuration, and mixed metrics can fail or silently omit requested measures.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes release-candidate defects in destination configuration and metrics routing. It also implements #393 override preservation despite the PR description stating it was deferred.

Changes:

  • Validates CLI destination policies and preserves CLI paths.
  • Preserves delivery-group overrides during upserts.
  • Corrects metrics routing, translations, help text, and tests.
File summaries
File Description
REFERENCE.md Adds the required metrics measure to the example.
pkg/hookdeck/metrics_filters.go Adds route vocabularies and queue-depth translation.
pkg/gateway/mcp/tool_metrics.go Updates MCP event metrics routing.
pkg/cmd/metrics.go Adds route-specific help and requires measures.
pkg/cmd/metrics_transformations.go Supplies transformation dimensions.
pkg/cmd/metrics_requests.go Supplies request dimensions and statuses.
pkg/cmd/metrics_events.go Fixes pending and queue-depth routing.
pkg/cmd/metrics_events_routing_test.go Tests event routing and translation.
pkg/cmd/metrics_attempts.go Supplies attempt dimensions and statuses.
pkg/cmd/gateway.go Corrects the gateway metrics example.
pkg/cmd/destination_upsert.go Preserves paths and delivery-group overrides.
pkg/cmd/destination_create.go Prevents config paths from being overwritten.
pkg/cmd/destination_common.go Adds shared validation and preservation helpers.
pkg/cmd/destination_cli_type_test.go Tests CLI destination behavior.
pkg/cmd/delivery_group_overrides_test.go Tests override preservation.
pkg/cmd/connection_upsert.go Preserves destination settings during upserts.
pkg/cmd/connection_create.go Rejects CLI delivery policies.
Review details

Suppressed comments (1)

pkg/cmd/connection_upsert.go:488

  • This still misses full declarative upserts: when both destination name and type are supplied, the earlier needsExisting expression stays false, so an existing connection reaches here with isUpdate == false. The stored destination is therefore unavailable and a group-rate update still sends no overrides, reproducing the silent data loss. Ensure any groups update without explicit overrides fetches the existing connection first.
		if isUpdate && existing != nil && existing.Destination != nil {
			preserveDeliveryGroupOverrides(destinationInput.Config, existing.Destination.Config)
  • Files reviewed: 17/17 changed files
  • Comments generated: 7
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/cmd/metrics_events.go Outdated
Comment on lines +85 to +87
// Granularity is optional on this route, so it must not gate the routing:
// gating it sent "pending" to the default endpoint, which rejects the measure.
if hasMeasure(params, map[string]bool{"pending": true}) {
Comment thread pkg/gateway/mcp/tool_metrics.go Outdated
queueParams := params
queueParams.Measures = hookdeck.TranslateQueueDepthMeasures(params.Measures)
result, err = client.QueryQueueDepth(ctx, queueParams)
case containsAny(params.Measures, "pending"):
Comment on lines +475 to +477
existingCLIDest := isUpdate && existing != nil && existing.Destination != nil &&
strings.ToUpper(existing.Destination.Type) == "CLI"
if strings.ToUpper(cu.destinationType) == "CLI" && cu.destinationCliPath == "" && !existingCLIDest {
Comment on lines +326 to +327
if err := rejectDeliveryPolicyForCLI(destType, policy, ""); err != nil {
return nil, err
Comment thread pkg/cmd/destination_upsert.go Outdated
Comment on lines 135 to 140
if req.Config == nil || len(req.Config) == 0 || needsOverrides {
params := map[string]string{"name": dc.name}
listResp, err := client.ListDestinations(ctx, params)
if err == nil && listResp.Models != nil && len(listResp.Models) > 0 {
existing, err := client.GetDestination(ctx, listResp.Models[0].ID, nil)
if err == nil && existing.Config != nil {
Comment on lines +117 to +121
if seen[m] {
continue
}
seen[m] = true
out = append(out, m)
Comment thread pkg/cmd/destination_upsert.go Outdated
Comment on lines +132 to +134
// A groups object sent without overrides also needs the stored config, because
// the API replaces groups wholesale and would drop the overrides with it.
needsOverrides := deliveryGroupsNeedOverrides(req.Config)
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
@leggetter leggetter changed the title fix: five defects found exercising the 2.6.0 release candidate fix: delivery-group data loss and metrics defects, plus the missing acceptance coverage Sep 14, 2026
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
leggetter and others added 7 commits September 14, 2026 19:40
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
@leggetter
leggetter merged commit 00f3340 into feat/api-2026-09-01 Sep 14, 2026
7 checks passed
@leggetter
leggetter deleted the fix/rc-exercise-findings branch September 14, 2026 23:25
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.

2 participants