Skip to content

Slice 3: Refinement routing via boundary proxy palette #36

@CuriouslyCory

Description

@CuriouslyCory

Parent

Tracker: #33
PRD: #2
Plan: docs/plans/flow-routed-connections.md

What 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 /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.

Schema work: none. Slice 2 already shipped FlowRoute.innerEdgeId?.

Service work:

  • 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:
    1. Loads the outer Edge; asserts the Flow's owner is one endpoint of it.
    2. 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).
    3. 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.
    4. Creates the FlowRoute { flowId, outerEdgeId, innerEdgeId: innerEdge.id }.
    5. 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).

Race-safety with #25

#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 context RouteFlowFromPaletteContext (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
    • restore brings them all back

Blocked by

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-humanRequires human interaction (design/architecture decision)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions