Feature: Multi-Edit Safety for Flows¶
Generated on: 2026-09-08 Status: Implemented, pending merge Owner: Cristhian Zanforlin Related: Epic LE-2379 · Tasks LE-2399, LE-2400, LE-2401, LE-2402
Table of Contents¶
- Overview
- Ubiquitous Language Glossary
- Domain Model
- Behavior Specifications
- Architecture Decision Records
- Technical Specification
- Observability
- Deployment & Rollback
- Architecture Diagrams
- Platform Compatibility
1. Overview¶
Summary¶
A save that would overwrite somebody else's work is refused instead of silently landing. The person is told the version they are editing is out of date, shown what each side changed, and given three exits — update the flow with a merge they choose, duplicate into a flow of their own, or drop their changes and take the latest. Nobody loses work without deciding to.
Business Context¶
A flow is a shared artefact, and the editor autosaves the whole graph every few seconds. Before this, the last writer won and the loser's work vanished with no message. With an authorization plugin registered, several people already share one flow, so the exposure is not theoretical.
Bounded Context¶
Flow Persistence — optimistic concurrency on the flow graph.
Related Contexts¶
- Flow Version History (Partnership): stores the version an update replaces, so it stays recoverable.
- Authorization (Conformist): decides who may open a flow at all; in OSS the pass-through keeps flows owner-scoped.
- Flow Edit Audit Trail (Customer-Supplier): consumes the same version stamp to describe accepted writes.
Explicitly not in this release¶
Presence. Nobody is warned before a conflict happens — two people can work unaware of each other until one of them saves. The heartbeat/lock half of the epic was dropped, not deferred.
2. Ubiquitous Language Glossary¶
| Term | Definition | Code Reference |
|---|---|---|
| Version token | UUID identifying the flow's current graph. Rotated on every write that changes data, never accumulated. |
Flow.version_token |
| Precondition | The token a client sends to say "write only if the flow is still where I left it". | parse_if_match, If-Match header |
| Claim | Taking the right to write, as one indivisible conditional UPDATE. | claim_version_token |
| Conflict | The state a flow enters for one client when its save was refused. | useFlowConflictStore, FlowConflict |
| Change | One recognisable difference between two graphs, at component level. | FlowChange, diffGraphs |
| Change group | Every change to one component, offered as a single choice. | ChangeGroup, groupChangesByTarget |
| Contested component | A component both people changed; the copy can carry one version or the other, never a blend. | contestedTargetKeys |
| Update flow | Exit that writes the merge to the original and archives the replaced version. | POST /flows/{id}/overwrite |
| Duplicate | Exit that writes the merge to a new, inert flow. | POST /flows/{id}/fork, build_fork_payload |
| Take latest | Exit that drops the person's own changes and reloads the server's version. | onDiscard, fetchAndAdoptServerVersion |
| Draft | The refused work, kept in browser storage so a reload cannot lose it, together with the graph it was built on. Restoring it makes that graph the baseline again, so the dialog does not credit the other person's changes to the draft. | saveConflictDraft, lf_draft_{userId}_{flowId} |
| Abandoned flow | A flow duplicated out of; further writes to it are refused locally. | abandonedFlowIds |
3. Domain Model¶
Aggregate: Flow (root)¶
- Root entity:
Flow - Concurrency state:
version_token,last_modified_by - Child entities:
FlowVersion(snapshots)
Invariants
| Invariant | Guard |
|---|---|
Only a write that changes data rotates the token. |
graph_changed in _patch_flow / _update_existing_flow |
| At most one of N racing writers with the same precondition may win. | claim_version_token — conditional UPDATE, not read-compare |
| A NULL token is never a precondition failure. | ensure_version_precondition — legacy rows stay writable |
| A client cannot choose its own token. | version_token absent from _UPDATABLE_FLOW_FIELDS |
| An overwrite requires the token it reviewed. | overwrite_flow returns 428 without If-Match |
| The version an update replaces is recoverable. | create_flow_version_entry before the write |
| A duplicate is inert. | build_fork_payload clears endpoint, MCP, webhook, lock; access PRIVATE |
Domain events¶
| Event | Trigger | Payload | Consumers |
|---|---|---|---|
| Token rotated | Any accepted graph write | new version_token, last_modified_by |
open editors (on their next save or run) |
| Save refused | Precondition mismatch | flow_version_conflict + both tokens + author |
conflict banner, dialog, draft store |
4. Behavior Specifications¶
Feature: A save never silently overwrites somebody else
As a person editing a flow others can edit
I want a save built on a replaced version to be refused
So that nobody's work disappears without a decision
Background:
Given a flow that two people have open
Scenario: The second writer is refused, not overwritten
Given the first person has saved a change
When the second person's autosave is sent with the version they loaded
Then the write is refused with 409 and the code "flow_version_conflict"
And the refusal names who wrote last
Scenario: A refused flow stops writing
Given a person whose save was refused
When they keep editing for another ten seconds
Then no further save is attempted
Scenario: Renaming does not take the writer's turn
Given somebody else changed the graph after I opened the flow
When I rename the flow without touching the canvas
Then the rename is saved and the version token does not change
Scenario: A save that writes nothing never reports success
Given a flow in conflict
When I press Ctrl+S
Then no success message appears and the conflict dialog opens
Scenario Outline: Every exit resolves the conflict
Given a conflict with changes on both sides
When I choose "<exit>"
Then the banner clears and <outcome>
Examples:
| exit | outcome |
| Update flow | the merge is written and the replaced version is in history |
| Duplicate | the merge lands in a new flow and the original is untouched |
| Take latest | my changes are dropped and nothing is written to the flow |
Scenario: Taking a component takes it whole
Given both of us changed the same component
When I select their version of it
Then my change to that component is shown as replaced, not silently dropped
Scenario: Work survives a reload while the conflict is open
Given a conflict with unsaved work
When I reload the page
Then I am offered the work back, and restoring it raises the conflict again
Scenario: Running a stale flow is refused
Given a conflict on the flow
When I run it
Then the dialog opens instead of a build starting
Scenario: Running without editing is not a conflict
Given somebody else changed the flow and I changed nothing
When I run it
Then the canvas adopts their version with a notice and no dialog
Each scenario maps to a spec in src/frontend/tests/core/regression/ (multi-edit-conflict, multi-edit-stress, multi-edit-stress2, multi-edit-three-exits, multi-edit-two-people-exits, multi-edit-crowd) or to src/backend/tests/unit/api/v1/test_flow_conflict.py.
5. Architecture Decision Records¶
ADR-001 — A conditional UPDATE, not a read-then-compare¶
Status: Accepted
Context: Comparing the token in Python after SELECT ... FOR UPDATE looked sufficient. SQLite ignores FOR UPDATE entirely, so concurrent writers all read the same token, all passed the comparison, and all wrote — six simultaneous saves were accepted where one should have been.
Decision: Claim the write turn with a single UPDATE ... WHERE version_token = expected. Only the writer that moves the row off expected proceeds.
Consequences: Correct on every backend without backend-specific code. Costs one extra statement per graph write. Verified at 200 concurrent writers: exactly one 200, 199 refusals, no 5xx.
ADR-002 — The precondition is optional¶
Status: Accepted
Context: Requiring If-Match would break every existing API client and every pre-upgrade row on the day of the upgrade.
Decision: A request without the header behaves exactly as before. A malformed header is rejected with 400 rather than ignored — treating it as absent would silently downgrade the write to unconditional.
Consequences: Backward compatible. The guarantee holds only for clients that send the header; the editor always does.
ADR-003 — The graph travels only when the person edited it¶
Status: Accepted
Context: Opening a flow rewrites nodes (component refreshes, model inputs), and clicking a node writes selected into the graph. Comparing the payload against the baseline therefore called a plain rename a competing graph write — the rename was refused and silently discarded.
Decision: A save carries data only when the person edited the canvas, or when a caller supplies a graph that neither the canvas nor the baseline holds (applying a template). The graph as it was loaded is never such a graph, even when hydration has since rewritten the canvas. Canvas state that belongs to the viewer — viewport, selected, dragging, resizing — is excluded from the comparison. A save that sent no graph adopts only the metadata from its response: the graph and token stay the ones the client built on, so a graph change made by someone else in the meantime still surfaces as a conflict on the next edit.
Consequences: Renames, project moves and lock toggles never conflict. Automatic hydration changes are persisted by the next real edit rather than on their own.
ADR-004 — A blocked save fails loudly¶
Status: Accepted
Context: saveFlow returned quietly for a flow in conflict, so every caller's success path ran. Ctrl+S and the settings dialog both announced "saved" over a write that never happened.
Decision: Raise FlowSaveBlockedError; every caller that announces success calls handleBlockedSave first.
Consequences: No false reassurance. Every direct caller of saveFlow had to be reviewed.
ADR-005 — Component-level merge, one row per component¶
Status: Accepted Context: Interleaving two people's edits inside one component can produce a configuration neither wrote. An early UI listed each change separately, which gave one component several checkboxes that always moved together. Decision: The unit of choice is the component. One row per component, listing everything that changed inside it. Adopting a component's edge brings the endpoints it needs. Consequences: No blended component. A row is honest about what ticking it does.
ADR-006 — Autosave at 5s with a 15s ceiling¶
Status: Accepted
Context: At 300 ms, opening a flow could take the writer's turn from somebody actively editing. A plain trailing debounce has no ceiling: measured against a real server, a long interval held 23 seconds of continuous editing in memory and surfaced the conflict only once the person stopped.
Decision: AUTOSAVE_DEBOUNCE_TIME = 5000 with maxWait = 3 × interval.
Consequences: Fewer false conflicts and fewer writes; up to 15 seconds of work at risk in a crash, which the draft store covers.
6. Technical Specification¶
Dependencies¶
| Component | Role |
|---|---|
Flow.version_token, Flow.last_modified_by |
Concurrency state, migration 439697865628 |
flow_version |
Stores the version an update replaces |
localStorage |
Holds the refused draft, keyed lf_draft_{userId}_{flowId} |
API contracts¶
PATCH /api/v1/flows/{flow_id} — optional If-Match: <version_token>
| Response | Condition |
|---|---|
200 + version_token |
Accepted; the token is new when data changed |
409 |
Precondition failed — body below |
400 |
If-Match is present but blank or not a UUID |
{
"detail": {
"code": "flow_version_conflict",
"expected_version_token": "…", "current_version_token": "…",
"modified_by": { "id": "…", "username": "…" }, "modified_at": "…"
}
}
GET /api/v1/flows/{flow_id}/version-state — current token, last author (resolved to a name), updated_at.
POST /api/v1/flows/{flow_id}/overwrite — requires If-Match (the reviewed token). 428 without it, 409 if the flow moved again, 200 with the updated flow.
POST /api/v1/flows/{flow_id}/fork — read access is enough; returns 201 with an inert copy. 409 when a concurrent fork took the copy's name.
Error handling¶
| Code | Condition | User message | Recovery |
|---|---|---|---|
flow_version_conflict |
Precondition mismatch | "The version you're editing is out of date" | Update flow / Duplicate / Take latest |
428 |
Overwrite without If-Match |
— (client always sends it) | Reopen the dialog |
400 |
Malformed or blank If-Match |
Generic save error | Fix the client |
409 (fork) |
Copy name taken concurrently | "Could not duplicate the flow" | Retry |
503 |
Database busy on version restore | "The database is busy" | The client retries once |
7. Observability¶
| Signal | Where | Notes |
|---|---|---|
op=update_flow flow_id=… exhausted lock retries |
logger.awarning |
Existing lock-retry warning on the save path |
409 rate on PATCH /flows/{id} |
HTTP metrics | The conflict rate; a spike means people are colliding |
428 on /overwrite |
HTTP metrics | Should be zero — the client always sends the precondition |
No metric was added for this feature. TBD: whether the conflict rate deserves a counter of its own — worth deciding once the trail is enabled somewhere and there is real data.
8. Deployment & Rollback¶
Feature flags¶
None. The refusal is unconditional — it is the guarantee.
| Setting | Purpose | Default |
|---|---|---|
LANGFLOW_AUTO_SAVING_INTERVAL |
Debounce before a save; continuous editing is written at 3× this at the latest | 5000 |
Migrations¶
| Revision | Phase | Reversible |
|---|---|---|
439697865628 |
EXPAND | Yes — two nullable columns and one index, no backfill |
Rollback plan¶
- Deploy the previous release. Older services never read
version_token, and a NULL token is treated as "no precondition", so a mixed fleet degrades to today's behaviour rather than breaking. - The columns can stay;
alembic downgraderemoves them if required. - Drafts in
localStorageare per-browser and expire with the user clearing site data.
Smoke tests¶
- Two tabs on one flow: the second save is refused and the banner appears
- Each of the three exits resolves the conflict
- A rename with a stale token still saves
-
PATCHwithoutIf-Matchstill returns 200
9. Architecture Diagrams¶
C4Container
title Multi-edit safety — container view
Person(editor, "Editor", "Someone with the flow open")
Container_Boundary(fe, "Frontend") {
Container(canvas, "Flow canvas", "React", "Edits, autosave")
Container(save, "useSaveFlow", "TypeScript", "Builds the payload and its precondition")
Container(dialog, "Conflict dialog", "React", "Diff and the three exits")
ContainerDb(draft, "Draft store", "localStorage", "Refused work")
}
Container_Boundary(be, "Backend") {
Container(flows, "Flow routes", "FastAPI", "PATCH / PUT / upload")
Container(conflict, "flow_conflict", "Python", "Claim and refuse")
ContainerDb(db, "Database", "SQLite / PostgreSQL", "flow, flow_version")
}
Rel(editor, canvas, "Edits")
Rel(canvas, save, "Autosave")
Rel(save, flows, "PATCH with If-Match")
Rel(flows, conflict, "Claim the write turn")
Rel(conflict, db, "Conditional UPDATE")
Rel(flows, save, "409 with both tokens")
Rel(save, dialog, "Raise the conflict")
Rel(save, draft, "Persist the refused work")
sequenceDiagram
participant A as Alice
participant B as Bob
participant S as Server
A->>S: PATCH If-Match: T0
S-->>A: 200, token T1
B->>S: PATCH If-Match: T0
S-->>B: 409 (current T1, modified_by Alice)
Note over B: Banner, draft saved, autosave stops
B->>S: POST /overwrite If-Match: T1
S->>S: Archive T1 as a version, attributed to Alice
S-->>B: 200, token T2
Note over A: Learns at her next save or run
10. Platform Compatibility¶
| Platform | Supported | Notes |
|---|---|---|
| Linux / macOS / Windows | Yes | No platform-specific code; UTC at every boundary |
| SQLite | Yes | The conditional UPDATE is what makes it correct here |
| PostgreSQL | Yes | Verified on 17, including two replicas against one database |
| Docker | Yes | Same image; no new dependency |
| Kubernetes (multi-replica) | Yes | The write turn is decided in the database, so it holds across pods |
lfx serve |
Not applicable | Stateless, no database, no flow persistence |
Known limitation. In OSS, two different users cannot open the same flow — the pass-through authorization service reports supports_cross_user_fetch() == False, so flows stay owner-scoped. The multi-user path requires a registered authorization plugin; without one, the realistic case is one account across tabs or API clients.