fix(control-plane-sdk): send the Idempotency-Key header on every required route (SPL-261) - #258
Conversation
…d route enforces (SPL-261) `worker-runtime/steps/idempotency.ts` reads the `Idempotency-Key` HEADER and nothing else, so a route declaring `idempotency: "required"` is unusable by a client that carries the key in the request body alone. #208 (SPL-150) flipped `flags_create`, `flags_delete`, `flag_variants_create` and `flag_variants_delete` to `required`, and every Control Panel Flag create started failing with `Flag not created: Idempotency-Key header is required for this route`. The four Flag routes that were already `required` before #208, plus `experiments_start`, were broken the same way and nothing reported it. `withIdempotencyHeader` moves out of `approvals-client.ts` — the one client that had it right — into its own module and is now applied at every required-route call site in `flags-client.ts` and `experiments-client.ts`. The key travels in BOTH the body and the header with the same value, so a JSON-only caller (MCP tools, the CLI) and an SDK caller name the same replay. The two DELETE routes have no body to carry it, so they take it from `ControlPlaneOperationOptions.idempotencyKey`, and a `required` route invoked with no key throws in the SDK rather than coming back as a far-end VALIDATION_ERROR the caller has to decode (ADR-0036). `idempotency-header.contract.test.ts` makes the class structurally impossible: it probes every `idempotency: "required"` route the Control Plane SDK owns through a capturing fetch and asserts the header is on the wire, and its coverage assertion fails when a required route has no probe — so a newly-flipped route cannot ship without a client that proves it sends the header. Refs SPL-261 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. To trigger a review, include ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
#248 (SPL-121/SPL-251) landed a second, private `withIdempotencyHeader` inside `experiments-client.ts` for the same defect. Two mechanisms for one job is the footgun; the private one is deleted and all three Experiment legs now route through the shared `idempotency-header.ts` helper. The shared helper takes the operationId and consults the route registry, which changes two of #248's legs: - `experiments_create` declares `optional` and its request schema carries `idempotency_key`, so it keeps mirroring the key onto the header. - `experiments_update` declares `idempotency: "none"` and `PatchExperimentRequestSchema` is `.strict()` with no `idempotency_key` field. The private helper's mirror there could never fire for a legal caller and the runtime guard returns before reading the header either way, so the call site goes back to plain `hcRequestOptions` and #248's update-header test is deleted: it only went green because it cast an illegal field through `as never`. #248's Start and create header tests are kept. They drive the PANEL client, whose mutations delegate to `createExperimentsClient` — a path the contract test does not walk — and `experiments_create` is `optional`, so the contract test's required-route sweep never reaches it. #248 changed no route contracts, so the contract test's coverage assertion has nothing new to report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
|
| leg | route idempotency |
outcome |
|---|---|---|
experiments_start |
required |
shared helper, key from body.idempotency_key. Unchanged behaviour. |
experiments_create |
optional |
shared helper, key from input.idempotency_key. Unchanged behaviour. |
experiments_update |
none |
reverted to plain hcRequestOptions. |
experiments_update declares idempotency: "none" and
PatchExperimentRequestSchema is .strict() with no idempotency_key
field, so #248's mirror there could never fire for a legal caller, and
checkIdempotency returns before reading the header regardless.
Test kept vs deleted:
- Kept SPL-121: guided Experiment creation into Run 1 (+ SPL-251 Run-start Idempotency-Key) #248's Start and create header tests. They drive the panel client
(createPanelExperimentsClient), whose mutations delegate to
createExperimentsClient— a pathidempotency-header.contract.test.tsdoes
not walk. Andexperiments_createisoptional, so the contract test's
required-route sweep never reaches it at all. Not redundant; their comment
updated to say which gap they cover. - Deleted SPL-121: guided Experiment creation into Run 1 (+ SPL-251 Run-start Idempotency-Key) #248's
"sends the update idempotency key as a header"test. It
only went green because it cast an illegal field throughas never; it
asserted a property no legal caller of that route can reach.
Contract test after the merge: still green, and its coverage assertion has
nothing new to report — #248 changed no route contracts
(git diff 1d1f3fe9 09bdd59a -- packages/contracts/src/routes/ is empty).
pnpm verify:ci (one plain run, post-merge): EXIT=0,
Tasks: 85 successful, 85 total, Cached: 51 cached, 85 total. Every package
touched was a cache miss: @splitch/control-plane-sdk:lint/typecheck/test/build,
@splitch/control-panel:lint/typecheck/test/build.
Not re-run: e2e. The resolution changes request building on exactly one leg,
experiments_update, and only by removing a header the route declares it does
not read. Flag e2e is untouched.
Noted, not changed: mcp-operation-adapter.ts also sets idempotency-key
directly. It is a different transport (raw Headers + fetch for MCP JSON
args, not the hc typed-client options shape), it already applies the identical
registry rule (route.idempotency !== "none"), and it predates both PRs. Forcing
it through the hc-shaped helper would mean an adapter for the shape, so I left it
and am flagging it rather than silently unifying it.
The break
packages/worker-runtime/src/steps/idempotency.tsreads theIdempotency-Keyheader and nothing else. A route declaring
idempotency: "required"istherefore unusable by any client that carries the key in the request body alone.
PR #208 (SPL-150) flipped four Flag routes to
required, and every ControlPanel Flag create began failing with:
Seven e2e specs fail byte-identically on clean
origin/main.Route audit (derived from the #208 diff, not from the ticket)
#208 flipped exactly four routes, all in
packages/contracts/src/routes/routes-flags.ts:flags_createnonerequiredflags_deletenonerequiredflag_variants_createoptionalrequiredflag_variants_deletenonerequiredBut five routes were already
requiredbefore #208 and were broken the sameway, silently:
flag_variants_update,flag_config_update,flag_targeting_rules_replace,flags_promote,experiments_start. Onlyapproval_request_reviews_createwas correct. The tworequireddata-planeroutes (
sdk_evaluate,sdk_cached_evaluation_telemetry) are owned byevaluation-apiand served by@splitch/sdk, out of scope here.The fix
withIdempotencyHeadermoves out ofapprovals-client.ts— the one client thathad it right — into
idempotency-header.tsand is applied at all ten requiredcall sites across
flags-client.ts,experiments-client.tsandapprovals-client.ts. One mechanism, not two.The key travels in both the body and the header with the same value, so a
JSON-only caller (MCP tools, CLI) and an SDK caller name the same replay. The two
DELETE routes have no request body, so they take the key from
ControlPlaneOperationOptions.idempotencyKey. Arequiredroute invoked with nokey throws in the SDK rather than coming back as a far-end
VALIDATION_ERRORthecaller has to decode (ADR-0036).
No Control Panel files needed changing: every panel mutation already routes
through the typed SDK.
The regression contract
idempotency-header.contract.test.tsdrives each required route through acapturing
fetchand asserts the header is on the wire. Its route list isderived from
routeRegistryfiltered onidempotency === "required" && owner === "control-plane-api",and a coverage assertion fails when a required route has no probe — so a
newly-flipped route cannot ship without a client that proves it sends the header.
Kill-proof, executed: removing the header from
flags_createalone gives1 failed | 10 passedwithAssertionError: expected null to be 'idem_contract_probe';restoring it gives
Tests 11 passed (11).Verification
pnpm verify:ci:EXIT=0,Tasks: 85 successful, 85 total,Cached: 64 cached, 85 total. Every package touched was a cache miss:@splitch/control-plane-sdk:lint/build/typecheck/test,@splitch/control-panel:typecheck/test.flags,onboarding,experiments-setup), machine locklocal-e2e-fleetheld,--workers=1,SPLITCH_PLATFORM_TARGET=pr-ci: 14 passed, 1 failed. All sixflags+onboardingspecs named in the ticket now pass.Not verified
experiments-setup.spec.tsstill fails onRun 2visibility. Instrumented runs show the Run start now returns 200 with the header on the wire, so idempotency is satisfied; the Experiment Setup screen simply never refetches Run history. That is a separate, previously-masked UI defect (surface adjacent to feat(control-panel): Flag editing with the Approval Request confirm gate (SPL-118) #245/SPL-121: guided Experiment creation into Run 1 (+ SPL-251 Run-start Idempotency-Key) #248), not this break.Refs SPL-261
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.