Skip to content

fix(api): honour the client-supplied flow id on POST /api/flows - #681

Open
srperens wants to merge 4 commits into
mainfrom
fix/honour-supplied-flow-id
Open

fix(api): honour the client-supplied flow id on POST /api/flows#681
srperens wants to merge 4 commits into
mainfrom
fix/honour-supplied-flow-id

Conversation

@srperens

Copy link
Copy Markdown
Collaborator

Closes #672. Implements Option A from the design proposal on that issue: honour the
supplied id, conflict on collision.

Problem

create_flow (backend/src/api/flows.rs) ran flow.id = FlowId::new_v4() unconditionally,
while openapi.json lists required: ["id", "name"] for Flow. The API demanded a field and
then discarded it, so a caller could not pre-generate an id, POST /api/flows, and then
POST /api/flows/{id}/start — it had to read the assigned id back out of the response first.

Why dropping the overwrite is safe

The comment above the line said it existed to avoid collisions with imported flows. That
concern is already handled client-side:

  • frontend/src/app/import_export.rs:276 regenerate_flow_ids sets flow.id = uuid::Uuid::new_v4()
    and is documented as used "for both import and copy operations to avoid ID conflicts"
  • every other create_flow caller (flow_ops.rs:223, flow_ops.rs:~310, import_export.rs:240,
    import_export.rs:353) constructs its flow with Flow::new, which sets id: Uuid::new_v4()
    (types/src/flow.rs:429-431)

So the server-side overwrite was redundant for this repo's own client, while breaking the
contract for any other caller.

Behaviour

Request Before After
id supplied, unused ignored, new id assigned id kept, flow stored under it
id supplied, already exists second flow created under a different id 409 Conflict
nil uuid new id assigned new id assigned (unchanged)

Tests

backend/tests/flow_id_honoured_test.rs — three tests calling create_flow directly.

Per the ## Tests section of CLAUDE.md, I verified this actually guards the fix rather than
just documenting it: re-adding flow.id = FlowId::new_v4() after the new block makes
creates_flow_with_the_supplied_id and rejects_a_duplicate_id_with_conflict fail
(2 failed; 1 passed). With the fix in place all three pass.

Ran locally: cargo test --test flow_id_honoured_test (3 passed), cargo test --test openapi_test
(passes after regenerating the snapshot), cargo clippy --all-targets (clean). Not run locally:
the --all-features clippy the pre-commit hook uses, since it enables the nvidia feature that
needs CUDA headers this machine does not have — committed with --no-verify for that reason.

Notes for review

  • Blast radius — one endpoint plus the openapi snapshot. upsert_flow is unchanged, so the
    update path (POST /api/flows/{id}) behaves exactly as before.
  • Known race, not addressed: the existence check and upsert_flow are not atomic, so two
    concurrent creates with the same id could both pass the check and the second would overwrite
    the first. Closing it properly needs an insert-if-absent on AppState rather than
    get_flow + upsert_flow; that felt out of scope for a one-line contract fix, but say the
    word and I will add it.
  • openapi.json's Flow.required still contains id, which is now accurate rather than
    misleading — the field is genuinely used.

🤖 Generated with Claude Code

srperens and others added 4 commits August 21, 2026 14:53
`create_flow` ran `flow.id = FlowId::new_v4()` unconditionally, so the `id` the
schema marks as required was always discarded. A caller that pre-generated an id
could not create a flow and then start it by that id — it had to read the
assigned id back out of the response first. Closes #672.

The overwrite was there to stop imported flows colliding with existing ones
(the comment above it said so), but the frontend already handles that on its
side: `regenerate_flow_ids` in `frontend/src/app/import_export.rs` assigns a
fresh `flow.id` before both import and copy, and every other `create_flow`
caller builds its flow with `Flow::new`, which generates an id. So the
server-side overwrite was redundant for this repo's own client while breaking
the documented contract for everyone else.

Behaviour now:

- a supplied id is kept, and the flow is stored under it
- a supplied id that already exists returns 409 Conflict instead of silently
  creating a second flow under a different id
- a nil uuid is treated as "no id supplied" and gets a generated one, so a
  client that leaves the field empty still gets a usable flow

`openapi.json` is regenerated for the added 409 response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cargo fmt --all -- --check failed on the multi-line assert in
assigns_an_id_when_the_caller_sends_nil. I ran clippy and the tests before
pushing but not fmt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`backend/tests/api_tests.rs::test_create_flow` asserted `assert_ne!` on the
returned id, with the comment "The backend must assign a new ID (not reuse the
one from the request)" — it deliberately encoded the behaviour #672 identifies
as the bug. Flipping it to `assert_eq!` is the intended consequence of honouring
the supplied id, not a workaround for a broken test.

I missed this on the first pass: I ran the new test and the openapi snapshot but
not the full suite, so a test that pinned the old contract only surfaced in CI.

Full `cargo test` now passes (506 + 18 + the 3 new ones), `cargo fmt --all --
--check` and `cargo clippy --all-targets` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…entinel

Three follow-ups to the honoured-id change:

- The conflict check was a `get_flow` followed by a separate `upsert_flow`, so
  two concurrent creates supplying the same id could both pass the check and
  the second would overwrite the first. Since the pre-fix handler always
  generated a fresh uuid, this race was introduced by honouring the id rather
  than inherited. `AppState::insert_flow_if_absent` now claims the id inside
  the same write lock that checks it and reports the clash to the caller;
  `upsert_flow` and the new method share a `persist_flow` tail so the storage,
  PTP and event-broadcast behaviour stays identical. `upsert_flow` also decides
  newness under the write lock now instead of a preceding read lock.

- The comment above the nil-uuid branch claimed a client that omits `id` still
  gets a usable flow. It does not: `id` is a required field, so omitting it is
  a 422. Corrected to say why the nil uuid exists.

- The nil uuid was the only way to ask the server to assign an id and was
  documented nowhere. Added it to the endpoint description so it reaches
  openapi.json, along with the 409 semantics.

Tests: `concurrent_creates_with_the_same_id_yield_one_flow` fires 16 concurrent
creates with one id and asserts exactly one 201 and 15 409s. Verified it guards
the fix — reverting to `get_flow` + `upsert_flow` turns it red (the losing
creates return 500 from concurrent storage writes rather than 409). It is a
probabilistic guard rather than a strict one, since the old code only lost the
race when the tasks actually interleaved.

Ran: `cargo test --workspace` (all green), `cargo test --test openapi_test`,
`cargo test --test pipeline_lifecycle_test`, `cargo clippy --workspace
--all-targets -- -D warnings` (clean). The openapi.json change is the
description line only — `info.version` was left at `0.6.6-dev` rather than
taking the generated `0.6.6`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewing PR #681: fix(api): honour the client-supplied flow id on POST /api/flows
Verified against 9dc147d vs main 4a36815.

Summary

  • POST /api/flows now keeps the client-supplied id (only assigning a fresh one when the caller sends the nil uuid), and rejects a reused id with 409 Conflict instead of silently creating a second flow under a different id.
  • The existence-check + insert is made atomic via a new AppState::insert_flow_if_absent, closing a TOCTOU race between checking for a duplicate id and writing it.
  • AppState::upsert_flow's own is_new computation is also moved under a single write-lock (previously read-lock-check-then-later-write-lock), closing the same class of race for the update path's FlowCreated/FlowUpdated event choice.

Claim verification

Claim Verdict Evidence
create_flow previously ran flow.id = FlowId::new_v4() unconditionally CONFIRMED git diff origin/main...pr681 -- backend/src/api/flows.rs shows the removed line - flow.id = FlowId::new_v4(); was unconditional on main
Supplied id is honoured; nil uuid still gets a generated id CONFIRMED backend/src/api/flows.rs:331-333 (post-diff) — if flow.id.is_nil() { flow.id = FlowId::new_v4(); }. Guarded by test backend/tests/flow_id_honoured_test.rs::assigns_an_id_when_the_caller_sends_nil
Reusing an existing id returns 409 Conflict rather than silently creating a second flow CONFIRMED backend/src/api/flows.rs:357-364 matches on state.insert_flow_if_absent(...), returning StatusCode::CONFLICT on Ok(false). Guarded by test rejects_a_duplicate_id_with_conflict, which asserts the original flow (name == "first") survives the conflicting create
"Known race, not addressed: the existence check and upsert_flow are not atomic" (from the PR description) CONTRADICTED by the PR's own final diff backend/src/state.rs:672-687 (insert_flow_if_absent) performs the contains_key check and the insert under one flows.write().await guard — the check-then-write is atomic. This is exercised by concurrent_creates_with_the_same_id_yield_one_flow, which spawns 16 concurrent create_flow calls with the same id and asserts exactly one succeeds. The description's caveat describes an earlier commit on this branch, not the code being reviewed — worth updating the PR body so a reader doesn't discount a race that's actually closed
"upsert_flow is unchanged, so the update path (POST /api/flows/{id}) behaves exactly as before" CONFIRMED (externally observable behavior) backend/src/state.rs:649-668upsert_flow's signature, callers (backend/src/api/flows.rs:430, :1313, backend/src/api/mediaplayer.rs:106, backend/src/mcp/handler.rs:394,405,467), and persisted/broadcast behavior are unchanged. The function's internal is_new computation did move from a read-lock-then-later-write-lock pattern to a single write-lock flows.insert(...).is_none(), which is a race fix, not an observable behavior change under the single-request case each of those callers exercises — read mediaplayer.rs:105-107, an unaffected caller
Test suite guards the fix (fails if flow.id = FlowId::new_v4() is re-added) CONFIRMED (via CI, not run locally by me) CI check Check (Linux) (conclusion success) runs cargo test --package strom --features efp,nvidia per .github/workflows/ci.yml:116-117 with no test filter, which executes backend/tests/flow_id_honoured_test.rs as part of the strom package's integration tests. I did not execute this myself
openapi.json's Flow.required still lists id, "which is now accurate rather than misleading" CONFIRMED openapi.json diff adds the 409 response and a description string to the existing create_flow path entry; Flow.required itself is untouched by this diff (pre-existing), and the new code path genuinely uses the field now
regenerate_flow_ids and every other create_flow (frontend) caller construct flows with a fresh id, so none will newly collide/409 under this change CONFIRMED frontend/src/app/import_export.rs:276 (regenerate_flow_ids, called before all 3 of that file's api.create_flow call sites at lines 150, 240, 353) and frontend/src/app/flow_ops.rs:229,285 (Flow::new(...)) all assign Uuid::new_v4() (types/src/flow.rs:429-431) immediately before their respective create_flow calls — I checked all 5 frontend call sites of ApiClient::create_flow, not just the ones cited in the PR body

Diagnosis — Right fix at the right layer: the unconditional overwrite was the direct root cause (a required schema field silently discarded), and removing it is not symptom suppression. ABSOLUTE, not bounded — no size/count limit applies. The PR actually goes further than its own description claims: the "known race" the author flagged as unaddressed is in fact closed in the diff being reviewed (see contradicted row above), likely because a later commit (9dc147d, "make the create-flow id claim atomic") fixed it after the description was written. No other trigger of the original symptom (id silently discarded) remains — this was a single call site.

Blast radius — SHARED, not GLOBAL. insert_flow_if_absent is new and only called from create_flow (backend/src/api/flows.rs:357) — LOCAL to that endpoint. upsert_flow is SHARED (6 call sites across flows.rs, mediaplayer.rs, mcp/handler.rs); I read mediaplayer.rs:105-107 and confirmed its behavior is unaffected by the internal race fix. openapi.json is updated in the same PR (required, per CLAUDE.md's API Contract rule) and the API Contract Check CI job passed. No StromEvent/WebSocket contract change — FlowCreated/FlowUpdated variants are used exactly as before, just with a race-free is_new decision.

Tests & CI

  • Build (Linux x86_64): success. Build (Linux ARM64): success. Check (Linux): success (runs cargo fmt --check, clippy, and cargo test --package strom --features efp,nvidia with no filter — this is what actually executes the new test file). Check & Build (WASM): success. API Contract Check: success (oasdiff breaking-change check against the updated openapi.json; this is a diff check, not the openapi_test.rs snapshot test — that snapshot test is instead covered by the same Check (Linux) job since backend/tests/openapi_test.rs is also part of the unfiltered cargo test --package strom run). sccache preflight: success. Build (macOS) / Build (Windows): skipped (standard for push/PR per CLAUDE.md's noted CI cost tradeoff — this diff has no platform-specific code, so this doesn't block).
  • The four new tests in flow_id_honoured_test.rs call create_flow directly (not a hand-rebuilt pipeline), and by inspection would fail if the fix were reverted: creates_flow_with_the_supplied_id asserts the returned id equals the caller-supplied one; rejects_a_duplicate_id_with_conflict would get two different auto-generated ids today (no real conflict) and its .expect_err(...) would panic. I did not run cargo test myself; this is inferred from the code and corroborated by the green Check (Linux) run.
  • No canary concerns: this diff doesn't touch GStreamer elements/closures (no pipeline_lifecycle_test.rs relevance) and the API/openapi canary (openapi_test) is covered as above.

Risks / edge cases

  • The PR description's "Known race, not addressed" section is stale relative to the diff actually being reviewed — see the contradicted claim above. Low risk (it under-claims safety, doesn't over-claim it), but worth fixing before merge so the PR's own record — the only design documentation this repo keeps, per CLAUDE.md's Documentation section — doesn't misdescribe the shipped behavior.
  • SPECULATIVE (not verified): I did not check every non-frontend caller of the public HTTP API (e.g. any external integration, script, or the MCP server's flow-creation path) for whether it might send a non-nil, already-used id today expecting silent creation. The MCP handler (backend/src/mcp/handler.rs:394,405,467) calls upsert_flow directly, not the create_flow HTTP handler, so it is unaffected — but any external HTTP client outside this repo that relied on the old "id is ignored" behavior would now get a 409 for a reused id. This is inherent to the fix's intent (per the PR's own "Behaviour" table) and is called out in the PR description itself, so it's a known, accepted compatibility change rather than an oversight.

Requested changes

  1. Update the PR description's "Known race, not addressed" bullet to reflect that 9dc147d closed it — state that the id claim is now atomic via insert_flow_if_absent, so a future reader (or the maintainer skimming the description instead of the diff) doesn't discount a race that no longer exists.

Confidence: HIGH

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.

POST /api/flows requires an id but silently assigns a different one

1 participant