You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The delight slice: descend into a Component, see its connected externals as boundary proxies whose Flow palettes are dragable, drop a child Component, drag from the boundary proxy's POST /pets palette item onto the child — a single mutation creates the inner Edge and the FlowRoute that refines the parent pipe through. Going back up shows the parent Connection now tracking "1 / 14 routed."
This is HITL (ready-for-human) because it introduces the one and only exception to ADR-0005's same-Canvas rule and that exception needs an ADR. The exception is bounded, gated, and lives in exactly one function — but encoding it deserves human review.
routeFlow extended to accept { flowId, outerEdgeId, sourceNodeId?, targetNodeId? } where one of sourceNodeId / targetNodeId is the interior endpoint (a Component on the current Canvas) and the other side is the boundary (the outer Edge's other endpoint, which is the Flow's owner). The service:
Loads the outer Edge; asserts the Flow's owner is one endpoint of it.
Asserts the supplied interior endpoint sits on the Canvas whose interior is the outer Edge's other endpoint (the consumer for INBOUND, the producer for OUTBOUND).
Find-or-creates the inner Edge (canvasNodeId = the interior Canvas's scope, sourceId/targetId = interior endpoint + boundary endpoint) — see "Race-safety with Harden Edge de-dup with a partial unique index (close connectNodes TOCTOU) #25" below. This is the only place a service writes an Edge where target.parentId !== canvasNodeId (or source.parentId !== canvasNodeId). ADR-0010 documents the gated exception.
Creates the FlowRoute { flowId, outerEdgeId, innerEdgeId: innerEdge.id }.
All three writes in one Prisma transaction.
connectNodes is untouched. Its same-Canvas rule stays strict — Reviewable invariant: any future PR that loosens connectNodes regresses against ADR-0005.
Soft-delete cascade extended (forward-compat for Slice 2 was groundwork): sweeping the outer Edge sweeps its FlowRoutes; sweeping a FlowRoute sweeps its inner Edge only if no other active FlowRoute references it (because shared inner Edges are legal — see Harden Edge de-dup with a partial unique index (close connectNodes TOCTOU) #25 interaction).
#25 lands a partial unique index on (canvasNodeId, sourceId, targetId) where deletedAt IS NULL. Two concurrent routeFlow calls refining the same outer Edge with distinct Flows over the same (interiorSource, boundaryTarget) pair must converge on one shared inner Edge with two FlowRoutes, not two duplicate Edges. The model permits this — FlowRoute.innerEdgeId has no uniqueness; Edge is a pipe that carries many Flows. Therefore step 3 is find-or-create, not unconditional insert: look up the active Edge for the tuple first, fall through to create only when absent, and catch P2002 as a retry signal (re-query, attach FlowRoute to the existing Edge) rather than surfacing it to the client.
Canvas / UI:
Boundary proxy node type (M3 prerequisite). Renders read-only with the Component's kind icon, title, side handles, and a Flow palette popover/sidebar listing the proxied Component's Flows.
Palette drag dispatch: a drag that starts on a palette item targets a child Component's handle (for INBOUND Flows) or vice versa (for OUTBOUND). The canvas detects the palette-item source/target, synthesizes the equivalent connection, and dispatches routeFlow optimistically. Polarity validation lives in Slice 4 — this slice assumes the user routes in the structurally-correct direction; obvious mismatches are still surfaced as toast errors from the server.
New contextRouteFlowFromPaletteContext (or extend the Slice-2 RouteFlowContext) — inert by default, disabled when CanEditContext = false.
getCanvas returns flowPalettes: { [boundaryNodeId]: Flow[] } for boundary proxies on this scope. First 50 ops bundled; hasMore: true flag plus a separate getFlowPalette({ ownerNodeId, cursor }) procedure for the inspector when a Component owns ≥50 Flows.
ADRs to land in this PR (docs travel with the code slice — project convention):
ADR-0010 — Flows are first-class, owned by Components. Why Flow is its own row (not edge metadata, not Component Ports).
ADR-0011 — routeFlow is the sole cross-scope Edge writer. Documents the gated exception to ADR-0005. Encodes the reviewable invariant: connectNodes is strict; routeFlow is bounded-loose; no other service may write an Edge where an endpoint's parentId !== canvasNodeId.
Acceptance criteria
routeFlow extended with cross-scope inner-Edge writing. The same-Canvas rule on connectNodes is unchanged.
Inner-Edge write is find-or-create under the Harden Edge de-dup with a partial unique index (close connectNodes TOCTOU) #25 partial unique index; concurrent refines of the same outer Edge with distinct Flows over the same (interiorSource, boundaryTarget) pair converge on one shared inner Edge with two FlowRoutes (regression test required).
All three writes (inner Edge + FlowRoute + the cache-mirror lockstep) happen in one Prisma transaction at the service.
Service rejects:
inner endpoint not on the interior Canvas of the outer Edge's "other" endpoint
Flow's owner is not an endpoint of the outer Edge
duplicate active (outerEdgeId, flowId)
M3 boundary derivation in getCanvas lands or is consumed (whichever the M3 work has already completed).
getCanvas returns flowPalettes for boundary proxies in scope (bundled, paginated as described).
Boundary proxy node type renders palette popover/sidebar; drag from palette item to a child Component dispatches optimistic routeFlow.
Optimistic update: inner Edge appears with temp_ id; FlowRoute is implicit in the cache update; reconciliation on success swaps ids; rollback + toast on failure.
ADR-0010 written and committed in this PR. ADR-0011 written and committed in this PR.
CONTEXT.md updated: boundary proxy palette behavior, routeFlow's cross-scope exception called out by name.
MCP route-flow tool gains optional interiorEndpointId arg for cross-scope writes.
Vitest cases at the service seam:
happy-path: routeFlow with inner Edge creates Edge + FlowRoute atomically; subsequent getCanvas on the parent shows edgeFlows.routed += 1
two concurrent routeFlow calls over the same interior pair with distinct Flows converge on one shared inner Edge with two FlowRoutes (no P2002 surfaced)
inner endpoint not on the correct Canvas → rejected
Flow owner not on outer Edge → rejected
duplicate active (outerEdgeId, flowId) → rejected; soft-deleted prior is fine
connectNodes still rejects a cross-scope endpoint (regression guard for ADR-0005)
soft-deleting the outer Edge stamps the FlowRoute + inner Edge with one deletionId
soft-deleting a FlowRoute leaves a shared inner Edge alive when another active FlowRoute still references it
Harden Edge de-dup with a partial unique index (close connectNodes TOCTOU) #25 (Edge de-dup partial unique index) — routeFlow's inner-Edge writer must be race-safe under the unique index; this slice depends on the index existing so the find-or-create path is well-defined. See comment below for the concrete race.
Parent
Tracker: #33
PRD: #2
Plan:
docs/plans/flow-routed-connections.mdWhat to build
The delight slice: descend into a Component, see its connected externals as boundary proxies whose Flow palettes are dragable, drop a child Component, drag from the boundary proxy's
POST /petspalette item onto the child — a single mutation creates the inner Edge and the FlowRoute that refines the parent pipe through. Going back up shows the parent Connection now tracking "1 / 14 routed."This is HITL (ready-for-human) because it introduces the one and only exception to ADR-0005's same-Canvas rule and that exception needs an ADR. The exception is bounded, gated, and lives in exactly one function — but encoding it deserves human review.
Schema work: none. Slice 2 already shipped
FlowRoute.innerEdgeId?.Service work:
routeFlowextended to accept{ flowId, outerEdgeId, sourceNodeId?, targetNodeId? }where one ofsourceNodeId/targetNodeIdis the interior endpoint (a Component on the current Canvas) and the other side is the boundary (the outer Edge's other endpoint, which is the Flow's owner). The service:(canvasNodeId = the interior Canvas's scope, sourceId/targetId = interior endpoint + boundary endpoint)— see "Race-safety with Harden Edge de-dup with a partial unique index (close connectNodes TOCTOU) #25" below. This is the only place a service writes an Edge wheretarget.parentId !== canvasNodeId(orsource.parentId !== canvasNodeId). ADR-0010 documents the gated exception.FlowRoute { flowId, outerEdgeId, innerEdgeId: innerEdge.id }.connectNodesis untouched. Its same-Canvas rule stays strict — Reviewable invariant: any future PR that loosensconnectNodesregresses against ADR-0005.Race-safety with #25
#25 lands a partial unique index on
(canvasNodeId, sourceId, targetId) where deletedAt IS NULL. Two concurrentrouteFlowcalls refining the same outer Edge with distinct Flows over the same(interiorSource, boundaryTarget)pair must converge on one shared inner Edge with two FlowRoutes, not two duplicate Edges. The model permits this —FlowRoute.innerEdgeIdhas no uniqueness; Edge is a pipe that carries many Flows. Therefore step 3 is find-or-create, not unconditional insert: look up the active Edge for the tuple first, fall through tocreateonly when absent, and catchP2002as a retry signal (re-query, attach FlowRoute to the existing Edge) rather than surfacing it to the client.Canvas / UI:
routeFlowoptimistically. Polarity validation lives in Slice 4 — this slice assumes the user routes in the structurally-correct direction; obvious mismatches are still surfaced as toast errors from the server.RouteFlowFromPaletteContext(or extend the Slice-2RouteFlowContext) — inert by default, disabled whenCanEditContext = false.getCanvasreturnsflowPalettes: { [boundaryNodeId]: Flow[] }for boundary proxies on this scope. First 50 ops bundled;hasMore: trueflag plus a separategetFlowPalette({ ownerNodeId, cursor })procedure for the inspector when a Component owns ≥50 Flows.ADRs to land in this PR (docs travel with the code slice — project convention):
routeFlowis the sole cross-scope Edge writer. Documents the gated exception to ADR-0005. Encodes the reviewable invariant:connectNodesis strict;routeFlowis bounded-loose; no other service may write an Edge where an endpoint'sparentId !== canvasNodeId.Acceptance criteria
routeFlowextended with cross-scope inner-Edge writing. The same-Canvas rule onconnectNodesis unchanged.(interiorSource, boundaryTarget)pair converge on one shared inner Edge with two FlowRoutes (regression test required).(outerEdgeId, flowId)getCanvaslands or is consumed (whichever the M3 work has already completed).getCanvasreturnsflowPalettesfor boundary proxies in scope (bundled, paginated as described).routeFlow.temp_id; FlowRoute is implicit in the cache update; reconciliation on success swaps ids; rollback + toast on failure.routeFlow's cross-scope exception called out by name.route-flowtool gains optionalinteriorEndpointIdarg for cross-scope writes.getCanvason the parent showsedgeFlows.routed += 1routeFlowcalls over the same interior pair with distinct Flows converge on one shared inner Edge with two FlowRoutes (no P2002 surfaced)(outerEdgeId, flowId)→ rejected; soft-deleted prior is fineconnectNodesstill rejects a cross-scope endpoint (regression guard for ADR-0005)Blocked by
routeFlow's inner-Edge writer must be race-safe under the unique index; this slice depends on the index existing so the find-or-create path is well-defined. See comment below for the concrete race.