Skip to content

fix(control-plane-sdk): send the Idempotency-Key header on every required route (SPL-261) - #258

Merged
isuttell merged 2 commits into
mainfrom
spl-261-idempotency-header
Jul 31, 2026
Merged

isuttell merged 2 commits into
mainfrom
spl-261-idempotency-header

Conversation

@isuttell

@isuttell isuttell commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

The break

packages/worker-runtime/src/steps/idempotency.ts reads the Idempotency-Key
header and nothing else. A route declaring idempotency: "required" is
therefore 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 Control
Panel Flag create began failing with:

Flag not created: Idempotency-Key header is required for this route

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:

operationId before after
flags_create none required
flags_delete none required
flag_variants_create optional required
flag_variants_delete none required

But five routes were already required before #208 and were broken the same
way, silently: flag_variants_update, flag_config_update,
flag_targeting_rules_replace, flags_promote, experiments_start. Only
approval_request_reviews_create was correct. The two required data-plane
routes (sdk_evaluate, sdk_cached_evaluation_telemetry) are owned by
evaluation-api and served by @splitch/sdk, out of scope here.

The fix

withIdempotencyHeader moves out of approvals-client.ts — the one client that
had it right — into idempotency-header.ts and is applied at all ten required
call sites across flags-client.ts, experiments-client.ts and
approvals-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. 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).

No Control Panel files needed changing: every panel mutation already routes
through the typed SDK.

The regression contract

idempotency-header.contract.test.ts drives each required route through a
capturing fetch and asserts the header is on the wire. Its route list is
derived from routeRegistry filtered on idempotency === "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_create alone gives
1 failed | 10 passed with AssertionError: 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.
  • Local e2e (flags, onboarding, experiments-setup), machine lock local-e2e-fleet held, --workers=1, SPLITCH_PLATFORM_TARGET=pr-ci: 14 passed, 1 failed. All six flags + onboarding specs named in the ticket now pass.

Not verified

Refs SPL-261

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…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>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. To trigger a review, include @coderabbitai review in the PR description. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9da171bf-37ab-4e1b-bd0f-b71c2793eba4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

#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>
@isuttell

Copy link
Copy Markdown
Contributor Author

Merged origin/main (#248, 09bdd59a) — two mechanisms reconciled onto one

#248 shipped its own private withIdempotencyHeader(options, body) inside
experiments-client.ts for the same defect. Git auto-merged my import of the
shared helper alongside their local definition of the same name, so the tree
briefly had a shadowing collision as well as a duplicate mechanism.

Resolution: the private helper is deleted. All three Experiment legs now go
through idempotency-header.ts. Nothing was renamed to dodge the collision.

The shared helper takes an operationId and consults the route registry, which
changes two of #248's legs:

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:

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.

@isuttell
isuttell merged commit dfc2c90 into main Jul 31, 2026
4 checks passed
@isuttell
isuttell deleted the spl-261-idempotency-header branch July 31, 2026 06:27
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.

1 participant