Skip to content

SPL-121: guided Experiment creation into Run 1 (+ SPL-251 Run-start Idempotency-Key) - #248

Merged
isuttell merged 2 commits into
mainfrom
spl-121-experiment-creation
Jul 31, 2026
Merged

isuttell merged 2 commits into
mainfrom
spl-121-experiment-creation

Conversation

@isuttell

@isuttell isuttell commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes SPL-121. Fixes SPL-251 (trunk-red: Panel could not Start a Run) and the SPL-242 class of gap in the Control Plane SDK.

What this ships

Guided Experiment creation (SPL-121). Step 1 writes a real draft Experiment, so the flow resumes from the Control Plane rather than from browser state. The last step opens Run 1 through the same draft → Start machinery every later Run uses: useExperimentRunStart is the Panel's only caller of stageAndStartControlPanelExperimentRun, and SPL-120's next-Run dialog now consumes the same extracted ExperimentRunDraftFields, useRunDraftState, and ExperimentRunStartConfirmation. There is no creation-only Start path.

SPL-251 / SPL-242 — the defect is in the shared client, not the Panel. Run-start was refused with Idempotency-Key header is required for this route because @splitch/control-plane-sdk put idempotency_key in the body only. withIdempotencyHeader in experiments-client.ts now mirrors it onto the header for experiment create, update, and start, and panel-experiments.test.ts asserts the header itself rather than only that the happy path passes. The Panel mints the key once per mounted form, so a retried Start replays instead of opening a second Run.

Fail loud (ADR-0036)

  • A Start whose Approval Request did not apply reports that no Run exists and does not offer a retry that would open a second request. runStartLanded is the single rule: null (allow) or applied means a Run exists; pending / declined / stale do not.
  • An unhonourable horizon is refused instead of coerced to sequential; a stored proposal with no horizon is refused instead of inferred.
  • StartRunInput requires horizon, so the column default can never silently stand in for a stopping rule nobody chose.
  • The locked decision spec is attributed to the live Run by id, not runs[0], and a live Run id absent from the Run history throws rather than rendering an editable form the Worker will refuse.

Approval gate seam

The Panel sends review: { action: "approve_and_apply" } inline, so the Approval Request is created and applied in one call and there is no pending request to gate. No confirm gate is authored here — SPL-118's components/approval/** remains the owner if a deferred-approval path is added later.

Verification

  • pnpm verify:ci: 84/85 tasks successful, 20/85 cached. The one failure is apps/control-plane-api test/live-update-do.test.ts > "closes a silent expired panel session after hibernation" — a WebSocket upgrade returning null under load; it passes in isolation and touches nothing in this diff. Same class as the known SPL-231 / SPL-238 load-sensitive failures.
  • e2e (fleet lock held, --workers=1, hard timeout): 3 passed, 1 failed. experiments-setup.spec.ts:13 — SPL-120's spec and the SPL-251 target — passes unmodified. The gated confirm-Environment creation path passes. The remaining failure is a local Miniflare D1 internal error thrown from D1DatabaseObject.queryExecute, hitting a different query on each run; not reproducible as an application defect.

🤖 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.

…SPL-251)

Experiment creation is a guided draft flow: step 1 writes a real `draft`
Experiment so the wizard resumes from the Control Plane rather than from browser
state, and the last step opens Run 1 through the SAME draft -> Start machinery
every later Run uses. `useExperimentRunStart` is the Panel's only caller of
`stageAndStartControlPanelExperimentRun`, and SPL-120's next-Run dialog now
consumes the same shared draft fields, Start confirmation, and hook.

SPL-251 / SPL-242: Run-start was refused with "Idempotency-Key header is
required for this route" because the Control Plane SDK put the key in the body
only. The fix is in the shared client, not in the Panel: `withIdempotencyHeader`
mirrors `idempotency_key` onto the header for experiment create, update, and
start, and a test asserts the header rather than only the happy path. The Panel
mints the key once per mounted form, so a retried Start replays instead of
opening a second Run.

Fail loud (ADR-0036):
- a Start whose Approval Request did not apply reports that no Run exists and
  does not offer a retry that would open a second request
- an unhonourable `horizon` is refused instead of coerced to sequential, and a
  proposal with no horizon is refused instead of inferred
- `StartRunInput` requires `horizon` so the column default can never stand in
- the locked decision spec is attributed to the live Run by id, and an absent
  live Run throws instead of rendering an editable form the Worker will refuse

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: 6efcf6f7-84c1-4f4b-a4a7-bcbc0b62e40a

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.

…identity

`horizon` and `sampleSizeLocked` were written into the Approval Request's
`proposed` record but were absent from `proposalInput`, so
`proposalRequestHash` did not cover them. A Start retried under the same
Idempotency-Key with a DIFFERENT stopping rule matched the stale hash,
replayed the earlier proposal and answered 200 while the Run froze the
original intent.

Both the hash input and the `proposed` record are now derived from one
`startProposalFields` call, so they cannot drift apart again.

Also defaults a proposal with no recorded `horizon` to `sequential` rather
than refusing it: refusing bricked every already-pending `experiment_start`
Approval Request with a remedy no operator can perform (ADR-0036).

Deletes the tautological `runStartLanded` arm and its factually wrong copy,
and adds per-call-site Idempotency-Key header tests for create and update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@isuttell

Copy link
Copy Markdown
Contributor Author

Review fixes — head 674e9511

Blocker: the Idempotency-Key identity did not cover the decision spec

proposalInput was built at the top of startExperiment from body.reason alone, before runDecisionSpecFromBody ran, so proposalRequestHash could never cover horizon / sampleSizeLocked even though both were written into proposed.

Reproduced then killed. apps/control-plane-api/test/experiment-decision-spec.test.ts → "refuses a same-key Start whose decision spec changed and replays an identical one", executed against a confirm-gated Environment:

  1. Start chore(deps): bump google/osv-scanner-action from 2.0.2 to 2.3.8 #1 {horizon: "fixed", sampleSizeLocked: 5000}409 APPROVAL_REVIEW_REQUIRED, proposal pending.
  2. Start chore(deps): bump actions/setup-node from 6.0.0 to 6.4.0 #2, same Idempotency-Key, {horizon: "sequential", sampleSizeLocked: null, review: approve_and_apply} → was 200 OK with a Run frozen at fixed/5000; now 409 IDEMPOTENCY_KEY_CONFLICT with details.scope === "approval_request", and listRunsForExperiment is still [].
  3. Start chore(deps): bump aquasecurity/trivy-action from 0.35.0 to 0.36.0 #3 with identical intent + review → still 200, Run frozen fixed/5000. Replay is preserved.

Fixed at the derivation point, not the call site. startProposalFields(body, decisionSpec) in apps/control-plane-api/src/experiment-start-decision-spec.ts is now the single producer: the hash input is proposalInput, and the Approval record is { ...current, status: "running", ...proposalInput }. There is no second literal to keep in sync, so the two cannot drift again.

Sweep of every other createApproval / replayApprovalIfExists call siteflag-definition-flag-delete.ts ({flagId}), flag-definition-variant-handlers.ts (name/value/description), flag-definition-variant-catalog.ts (name/value/description; delete {name}), handlers.ts (flag-config enabled/availableVariantNames/rollout, targeting {targetingRules}, promotion {fromEnvironmentId, select}). Every one already hashes exactly what it proposes. Experiment Start was the only drifted site.

Also fixed: decisionSpecFromProposal bricking pending Approval Requests

A missing horizon on a proposal now reads as sequential, the documented default, instead of returning null → malformedProposal. Refusing it was a refusal with no remedy: a frozen proposal cannot be edited to add the field. Proven by "applies a pending proposal with no recorded horizon as sequential", which strips horizon/sampleSizeLocked out of a real pending proposal in D1 and then reviews it → 200, live Run sequential/null.

Doc sites reconciled (5): docs/spec/control-plane/endpoints-experiment-run.md and docs/spec/contracts/request-response-envelopes-experiment-run.md edited to state the default applies on the request and on a proposal that recorded none, plus that horizon/sampleSizeLocked/reason are part of what idempotency_key identifies. The other three (storage-schemas-d1-experiment.md:85, stats/data-contracts.md:117, stats/inference-engine.md:29) already said sequential and are unchanged.

Should-fix outcomes

  1. runStartLanded was a tautology — the Panel always sends review: {action: "approve_and_apply"} and any non-applied outcome comes back as a 409 refusal, so result.ok is false and the arm was unreachable. Deleted, along with the approvalRequest state, the prop on three components, and the run-start-approval-request alert whose copy was factually wrong. The e2e assertion that referenced the dead testid now asserts on run-start-error.
  2. Per-call-site Idempotency-Key testspackages/control-plane-sdk/src/panel-experiments.test.ts now captures the real Request and asserts the idempotency-key header for start, create, and update individually.
  3. experiment-create.spec.ts:72 and :126 — not broken. Both pass, and so does :163, under acquireMachineLock("local-e2e-fleet") with --workers=1 and SPLITCH_PLATFORM_TARGET=pr-ci: 3 passed (26.6s). I then reverted the worktree to the untouched 8b9f27f8 tree and ran the two named specs the same way: 2 passed (26.3s). The 3/3 local failure was fleet contention from an unlocked run, not a broken spec or broken product code.

Mutation results (every fix proved by reverting it)

Mutation Result
Both server fixes reverted together 2 failed — expected 200 to be 409 (the exact repro) and expected 409 to be 200
Horizon default only (proposed.horizon ?? "sequential"proposed.horizon) 1 failed — expected 409 to be 200
Drift only (destructure horizon/sampleSizeLocked out of the hash input, keep them in proposed) 1 failed — expected 200 to be 409
Strip withIdempotencyHeader from the create and update legs 2 failed — expected null to be 'create-exp-1', expected null to be 'update-exp-1'
Remove { ignoreMismatch: true } from replayExperimentStart 5 passed — not load-bearing, so it was kept for consistency with the other four handlers

verify:ci (one run, from the worktree)

Tasks:    85 successful, 85 total
Cached:    56 cached, 85 total

@splitch/control-plane-api:test executed live: 90 test files, 480 tests passed. The lint/typecheck cache hits on the touched packages are legitimate — the pre-commit verify:commit had just executed them at these exact contents.

@isuttell
isuttell merged commit 09bdd59 into main Jul 31, 2026
4 checks passed
@isuttell
isuttell deleted the spl-121-experiment-creation branch July 31, 2026 06:08
useotto-dev Bot added a commit that referenced this pull request Jul 31, 2026
#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>
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