From 8264a7ce1ea2cfaafe17612ddfa7f0dbe1b8a3b0 Mon Sep 17 00:00:00 2001 From: CuriouslyCory Date: Mon, 1 Jun 2026 15:54:14 -0700 Subject: [PATCH 1/2] feat: retire Flow model; typed cross-scope Connections (#62) Re-found the connection model: a Connection is now a directed, typed Edge that may link any two Components at any scope. Retires the Flow capability model. Model - Drop Flow / FlowRoute; rename the 1:1 spec row FlowSpec -> Spec and point it at derived child Components via Node.sourceSpecId + Node.specKey. - FlowInteraction -> Interaction enum, gaining ASSOCIATION (the default). - Edge drops canvasNodeId (scope is derived from endpoint ancestry, #63) and gains interaction. De-dupe is two partial unique indexes keyed on projectId: directional (projectId, source, target, interaction) and ASSOCIATION-only unordered (projectId, LEAST, GREATEST). One hand-edited migration with the Postgres enum-swap ordering and a guarded dedup pre-flight. Service - connectNodes accepts cross-scope and lineal (ingress) endpoints; rejects only the self-link; interaction is an input; dedup findFirst branches on interaction. - moveNode drops the orphan-reject (keeps the cycle-reject). - deleteNode/restoreNode drop the Flow arms and gain a Spec sweep; deleteEdge reverts to a lone soft-delete. Delete flow.service / flow-route.service / flow-parser and the routeFlow cross-scope writer. - getCanvas reduced to { interiorNodes, interiorEdges, breadcrumbs } via a single both-endpoints relation filter (no waterfall); cross-scope rendering is #63. - prisma-errors: matcher accepts both new index names; drop dead Flow matchers. - export.service / markdown: minimal seam for the dropped canvasNodeId (subtree + boundary CTEs become endpoint-membership; golden re-baselined). #67 owns the full typed-export rewrite. MCP / API - connect_components + apply_graph drop canvasNode, gain interaction, accept cross-scope endpoints. Remove the 9 flow/flowSpec/flowRoute tRPC procedures. Client - Remove dead Flow UI (boundary-group, boundary-proxy, route-flow popover, flow palette, attach-spec, flow pill, flow: handle branch, flow-direction, flow-interaction-display, spec-kinds). Connections render as plain lines; typed arrowheads are #65. Docs (travel with the slice) - Write ADR-0027 (Connection carries its own interaction), ADR-0028 (cross-scope + lineal=ingress; same-Canvas retired), ADR-0030 (cascade/undo without FlowRoutes). Amend ADR-0005/0010/0023/0024/0026. Amend/tombstone CONTEXT.md. pnpm check green; db:check clean; service tests cover cross-scope, lineal, self-link reject, dedup (both indexes), move-no-orphan, deleteEdge lone, and the Spec cascade sweep. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 551 +++---- ...e-scope-and-service-enforced-invariants.md | 10 +- .../0010-edge-dedup-partial-unique-index.md | 15 +- ...connection-direction-derived-from-flows.md | 10 + ...-connection-carries-its-own-interaction.md | 97 ++ ...-cross-scope-connections-lineal-ingress.md | 99 ++ .../0030-cascade-undo-without-flowroutes.md | 83 ++ .../migration.sql | 126 ++ prisma/schema.prisma | 283 ++-- .../p/[slug]/_canvas/boundary-group-node.tsx | 106 -- .../p/[slug]/_canvas/boundary-proxy-node.tsx | 195 --- src/app/p/[slug]/_canvas/canvas.tsx | 948 +++--------- .../[slug]/_canvas/component-detail-panel.tsx | 240 +-- src/app/p/[slug]/_canvas/component-node.tsx | 24 - src/app/p/[slug]/_canvas/connection-edge.tsx | 153 +- .../p/[slug]/_canvas/route-flow-popover.tsx | 204 --- src/lib/flow-direction.ts | 38 - src/lib/flow-interaction-display.ts | 35 - src/lib/schemas.ts | 334 +---- src/lib/spec-kinds.ts | 98 -- src/lib/types.ts | 29 +- src/server/api/routers/architecture.ts | 178 +-- .../__tests__/apply-graph.service.test.ts | 56 +- .../__tests__/edge.service.test.ts | 280 ++-- .../__tests__/fixtures/export-project-full.md | 6 +- .../__tests__/fixtures/export-subtree-full.md | 2 +- .../__tests__/flow-parser.test.ts | 410 ------ .../__tests__/flow-route.service.test.ts | 1302 ----------------- .../__tests__/flow.service.test.ts | 605 -------- .../architecture/__tests__/helpers/test-db.ts | 2 +- .../__tests__/markdown-export.test.ts | 11 +- .../__tests__/node.service.test.ts | 373 +---- .../architecture/apply-graph.service.ts | 18 +- src/server/architecture/edge.service.ts | 376 ++--- src/server/architecture/errors.ts | 28 +- src/server/architecture/export.service.ts | 71 +- src/server/architecture/flow-parser/index.ts | 63 - .../flow-parser/parsers/asyncapi.ts | 133 -- .../flow-parser/parsers/graphql.ts | 117 -- .../flow-parser/parsers/openapi.ts | 94 -- .../flow-parser/parsers/sql-ddl.ts | 192 --- .../flow-parser/parsers/ts-signature.ts | 111 -- src/server/architecture/flow-parser/shared.ts | 110 -- src/server/architecture/flow-route.service.ts | 433 ------ src/server/architecture/flow.service.ts | 545 ------- src/server/architecture/markdown.ts | 25 +- src/server/architecture/node.service.ts | 906 ++---------- src/server/architecture/prisma-errors.ts | 138 +- src/server/mcp/tool-catalog.ts | 23 +- 49 files changed, 1697 insertions(+), 8589 deletions(-) create mode 100644 docs/adr/0027-connection-carries-its-own-interaction.md create mode 100644 docs/adr/0028-cross-scope-connections-lineal-ingress.md create mode 100644 docs/adr/0030-cascade-undo-without-flowroutes.md create mode 100644 prisma/migrations/20260601120000_retire_flow_model/migration.sql delete mode 100644 src/app/p/[slug]/_canvas/boundary-group-node.tsx delete mode 100644 src/app/p/[slug]/_canvas/boundary-proxy-node.tsx delete mode 100644 src/app/p/[slug]/_canvas/route-flow-popover.tsx delete mode 100644 src/lib/flow-direction.ts delete mode 100644 src/lib/flow-interaction-display.ts delete mode 100644 src/lib/spec-kinds.ts delete mode 100644 src/server/architecture/__tests__/flow-parser.test.ts delete mode 100644 src/server/architecture/__tests__/flow-route.service.test.ts delete mode 100644 src/server/architecture/__tests__/flow.service.test.ts delete mode 100644 src/server/architecture/flow-parser/index.ts delete mode 100644 src/server/architecture/flow-parser/parsers/asyncapi.ts delete mode 100644 src/server/architecture/flow-parser/parsers/graphql.ts delete mode 100644 src/server/architecture/flow-parser/parsers/openapi.ts delete mode 100644 src/server/architecture/flow-parser/parsers/sql-ddl.ts delete mode 100644 src/server/architecture/flow-parser/parsers/ts-signature.ts delete mode 100644 src/server/architecture/flow-parser/shared.ts delete mode 100644 src/server/architecture/flow-route.service.ts delete mode 100644 src/server/architecture/flow.service.ts diff --git a/CONTEXT.md b/CONTEXT.md index 875f39d..a4795c7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,15 +26,16 @@ Rule of thumb: anything a human reads or an MCP agent calls says **Component** / **Port**; anything in the Prisma schema, React Flow code, or graph algorithms says **Node** / **Edge** / **handle**. -**Exception — Flow has no user/code split.** Unlike Component/Node and Connection/Edge, the -Flow vocabulary — **Flow** / `Flow`, **FlowSpec** / `FlowSpec`, **Interaction** / `FlowInteraction` — -uses the same word in user-facing and code surfaces. The split exists because "Node" collides -with Node.js and the canvas library's node primitive; "Flow" carries no such overload (React -Flow names the *library*, not a graph primitive we model), and users genuinely say "flow" when -they mean the same thing engineers do. The discipline is not weakened; the conditions that -motivated it do not apply here. When a future term arrives, default to applying the split; -deviate only when both conditions hold — the word is the natural user word AND it carries no -overload pressure. +**Exception — some terms have no user/code split.** Unlike Component/Node and Connection/Edge, +a few terms — **Interaction** / `Interaction` (a **Connection** attribute), **Spec** / `Spec` — +use the same word in user-facing and code surfaces. The split exists because "Node" collides +with Node.js and the canvas library's node primitive; these words carry no such overload (React +Flow names the *library*, not a graph primitive we model), and users genuinely say "interaction" +/ "spec" when they mean the same thing engineers do. The discipline is not weakened; the +conditions that motivated it do not apply here. When a future term arrives, default to applying +the split; deviate only when both conditions hold — the word is the natural user word AND it +carries no overload pressure. *(The retired **Flow** / **FlowSpec** / **FlowRoute** vocabulary +formerly rode this exception — see those tombstones.)* ## Terms @@ -50,16 +51,19 @@ the markdown formatted and toggles to an editable surface with debounced, optimi ### Node The data-model representation of a Component: the stored graph vertex with `parentId` (its containing Component, or null at the **Project** root), plus -`kind` (see **Component kind**), position (`posX`, `posY`), `documentation`, and a -soft-delete column (`deletedAt`). Never surfaced to users by this name. +`kind` (see **Component kind**), position (`posX`, `posY`), `documentation`, the +generated-component provenance columns `sourceSpecId` + `specKey` (set when a +Component is derived from a **Spec** — the generation that populates them is #64; +#62 lands only the columns and their cascade), and a soft-delete column +(`deletedAt`). Never surfaced to users by this name. *(The `Node` model and the operations on it — `createNode` (root or child under a validated parent), `getCanvas` (with **breadcrumbs**), `updateNode` (title only), `updateNodeDocumentation` (the narrow owner-only autosave feeding the detail-panel markdown editor — ADR-0015), `updatePositions` (batched on drag-stop), `moveNode` (reparent to a new Canvas scope; cycle-creating moves -reject as `ValidationError`, moves that would orphan an incident Connection -reject as `ConflictError` with `details.conflictingEdgeIds` — non-cascading by -design, ADR-0024), and the cascading `deleteNode` / `restoreNode` pair (see +reject as `ValidationError` — the orphan-reject is retired now that Connections +may span scopes, so a reparent never strands an incident Connection, ADR-0024 as +amended by #62), and the cascading `deleteNode` / `restoreNode` pair (see **Deletion id**) — are realized now. `moveNode` ships via the MCP `move_component` **MCP tool** (#19); the web/tRPC reparent surface is later work. **Connection**/**Edge** wiring is its own entry.)* @@ -116,8 +120,9 @@ and the remainder under "All kinds" below, preserving the invariant that every kind is always reachable (search spans both groups). It is the only kind-selection surface in the canvas: the "Add Component" control opens it, and — since Slice 2 — the **Component-detail panel** reopens the same palette to change -a Component's kind. Applies the same prose/UI pattern **Flow palette** does — -*palette* names the surface, not the library. Never "kind picker" (too generic — +a Component's kind. Follows the *palette* convention — the word names the +surface, not the library (cf. the **Command-palette** primitive it is built on). +Never "kind picker" (too generic — it could name a `` it replaces is retired. The canonical-command-palette ADR is deferred until a second palette adopter, per @@ -127,124 +132,117 @@ docs-travel-with-code-slices.)* The user-facing link between two Components, drawn on a **Canvas** by dragging between their **Ports** (in either direction — Ports are non-directional). Carries an optional **label** (untrusted user content — stored verbatim, never interpreted; see the prompt-injection standing -note). Backed by an **Edge**. A Connection is **undirected**: it has no stored direction, and -its rendered arrowheads are **derived** from the **Flows** routed on it — none → a plain line, -one direction → one arrowhead, both → arrowheads at both ends (a WebSocket is ONE Connection, -not two). The derivation reads each routed Flow's **Interaction** verb (REQUEST/SUBSCRIBE point -at the owner, PUSH away, DUPLEX both), so the arrow follows the traffic and cannot lie -(ADR-0023, superseding ADR-0009). *(Drawing, labeling, and removing a Connection are -realized now — see **Edge** for the same-Canvas, no-self-link, and no-duplicate-active rules. -A Connection that carries one or more **FlowRoutes** wears a routed-count pill (**"N / M -routed"**) and exposes a **"+ flow"** affordance when selected by the owner, listing the -unrouted Flows from either endpoint. The **refinement Connection** — the inner Edge that -resolves a **boundary proxy** to a real Component one scope deeper — is realized now via the -gated cross-scope `routeFlow` writer (Slice 3 / ADR-0012); see **FlowRoute** and **Boundary -proxy**. A refinement route leaves the *parent* Connection's routed-count pill stale until the -viewer ascends (a fresh **getCanvas**) — a deliberate no-cross-scope-round-trip trade-off, see -ADR-0012 Consequences. The "+ flow" affordance offers **every** unrouted Flow from either -endpoint — a Connection is undirected, so any owner-endpoint Flow can ride it; the Flow's -**Interaction** verb decides which way its arrow points, not whether the route is legal (the -former polarity filter and reverse-Connection offer are retired — ADR-0023, superseding -ADR-0013). The full undirected-arrow rendering — and the rewrite of the structural-arrow -language just above — lands in a later slice of the same rollout.)* +note). Backed by an **Edge**. A Connection is a **directed, typed edge** that may link **any two +Components at any scope** — same-Canvas, cross-scope, or **lineal** (an ancestor and a +descendant; a parent→child Connection expresses **ingress**). It carries its own **Interaction** +(default `ASSOCIATION` — a plain undirected line with no arrowheads); the four directional +interactions (`REQUEST`/`PUSH`/`SUBSCRIBE`/`DUPLEX`) live ON the Connection rather than being +derived from routed traffic. Drawing order (`source`/`target`) is preserved, and arrowheads are +derived from `(interaction, source, target)` — a WebSocket is ONE Connection (`DUPLEX`), not two +(ADR-0027). The only endpoint the service rejects is the **true self-link** (A === B); +cross-scope and lineal endpoints are accepted (ADR-0028, retiring the same-Canvas invariant of +ADR-0005). *(Drawing, labeling, and removing a Connection are realized now — see **Edge** for the +self-link, cross-scope/lineal, and no-duplicate-active rules. A Connection's `interaction` is set +at creation (`connectNodes`) and defaults to `ASSOCIATION`. **Arrowhead rendering from +`interaction` lands in a later slice (#65); cross-scope rendering — the redefined boundary proxy — +lands in #63.** As of #62 every Connection still renders as a plain line regardless of +`interaction`.)* ### Edge The data-model representation of a **Connection**: the stored graph edge with `sourceId` and -`targetId` (both **Nodes**), an optional `label`, and a soft-delete column (`deletedAt`). -`sourceId`/`targetId` are just the two endpoints in arbitrary draw order — they carry **no -direction** and there is no stored `direction` field; a Connection is undirected and its -arrowheads are derived from the **Flows** routed on it (ADR-0023; the flow-derived rendering -lands in a later slice of that rollout). Scoped to the Canvas it is drawn on -by an **explicit `canvasNodeId`** (the Component whose interior Canvas owns the Edge; null = the -**Project** root), rather than being inferred from its endpoints — endpoints can later span -scope levels (the M5 refinement Connection), so scope is recorded, not derived (ADR-0005). -Three invariants hold and are enforced **in the service, not the database** (ADR-0005): both -endpoints sit on the **same Canvas** as the Edge, an Edge never links a Node to itself, and no -two *active* (non-soft-deleted) Edges share the same scope and **unordered** endpoint pair -(A→B and B→A are the same Connection; ADR-0023). The same-Canvas -invariant has exactly **one gated exception**: the **inner Edge** of a cross-scope **FlowRoute**, -whose **boundary endpoint** legitimately sits at a higher scope. Only `routeFlow` may write it, -and only when that endpoint is the Flow's owner; `connectNodes` stays strict (Slice 3 / -ADR-0012). Never surfaced to users by this name. *(The `Edge` model, `connectNodes`/`updateEdge`/`deleteEdge`, and the -**getCanvas** `interiorEdges` read are realized now; Connection removal as part of a Component -delete is undoable now (see **Deletion id**); partial-unique-index hardening of the de-dupe -rule landed via ADR-0010 — service-primary with a DB backstop that translates to the same -`ConflictError` — while undo of a standalone single-Connection `deleteEdge` remains a later -refinement.)* +`targetId` (both **Nodes**), an `interaction` (`Interaction`, default `ASSOCIATION`), an optional +`label`, and a soft-delete column (`deletedAt`). **An Edge stores no scope** — there is no +`canvasNodeId` column; an Edge's scope is *derived from its endpoints' ancestry* at read time +(the derivation lands in #63), so an Edge may freely span scope levels. `sourceId`/`targetId` +preserve **draw order**, and arrowheads are derived from `(interaction, source, target)` at +render time (#65); the pair is not a stored `direction` field. Two invariants hold and are +enforced **in the service, not the database** (ADR-0028, retiring ADR-0005's same-Canvas +invariant): an Edge never links a Node to itself (the **true self-link**, +`sourceId === targetId`), and no two *active* (non-soft-deleted) Edges duplicate per the de-dupe +rule below. **Cross-scope and lineal** (ancestor↔descendant) endpoints are accepted; +`connectNodes` is the writer and there is no longer a gated cross-scope exception (the `routeFlow` +inner-Edge writer is deleted with the Flow model). + +**De-dupe is now two partial unique indexes** (ADR-0010 named pattern, re-keyed because scope is +no longer stored): a **directional** index over the ordered tuple +`(projectId, sourceId, targetId, interaction)` for the four directional interactions, and an +**`ASSOCIATION`-only unordered** index over `(projectId, LEAST(sourceId, targetId), +GREATEST(sourceId, targetId))` (`A↔B` and `B↔A` are one Association). Both are +`WHERE "deletedAt" IS NULL`; `interaction` is in the directional key (so `A→B REQUEST` and +`A→B PUSH` coexist) but **`label` is not** (re-labeling edits the existing Connection). +Service-primary `findFirst` with the index as backstop, both translating to `ConflictError`. + +Never surfaced to users by this name. *(The `Edge` model with `interaction`, `connectNodes` +(cross-scope + typed) / `updateEdge` / `deleteEdge`, and the **getCanvas** `interiorEdges` read +are realized now; Connection removal as part of a Component delete is undoable now (see **Deletion +id**). `deleteEdge` is a plain lone soft-delete (no cascade — the FlowRoute cascade is gone). The +two partial unique indexes land via the #62 migration (ADR-0010 pattern). Full cross-scope +rendering of an Edge whose endpoints span scopes lands in #63.)* ### Port A Component's connection point — the user-facing name for a React Flow **handle**. **Non-directional** (ADR-0023): a Component is not directional, so a Port carries no input/output role. Every Component exposes two (rendered left and right) purely for drag-discoverability; under React Flow's `ConnectionMode.Loose` either can start *or* end a -**Connection**, in either direction. Which way a Connection is drawn carries no meaning — its -arrowheads are derived from the **Flows** routed on it (see **Connection**, **Interaction**). +**Connection**, in either direction. Which way a Connection is drawn is *preserved* as +`source`/`target` and feeds the derived arrowheads together with the Connection's **Interaction** +(see **Connection**, **Interaction**); the Port a drag starts from carries no input/output role. Both Ports are **unbounded**: a Port can feed many Connections and receive from many (fan-out -and fan-in), with no connection-count cap; the only limit is the de-dupe rule (no two *active* -Connections between the same **unordered** Component pair on a scope; see **Edge** and ADR-0023). -The word in prose and UI is **Port**; the React Flow code word is **handle** (the same -user-vs-code split as Component/Node) — never "connector", "socket", "anchor", or "terminal". +and fan-in), with no connection-count cap; the only limit is the de-dupe rule (see **Edge** — +directional rows de-dupe on `(projectId, source, target, interaction)`, `ASSOCIATION` rows on the +unordered pair). The word in prose and UI is **Port**; the React Flow code word is **handle** (the +same user-vs-code split as Component/Node) — never "connector", "socket", "anchor", or "terminal". *(The two non-directional handles render on every Component now; the former input/output -framing retired with ADR-0023. Typed, named, or per-protocol Ports remain out of scope.)* +framing retired with ADR-0023 and stays retired under ADR-0027. Typed, named, or per-protocol +Ports remain out of scope.)* -### Edge direction — retired (twice) -Direction has never been a stored field on the Edge. It was first a cosmetic `EdgeDirection` +### Edge direction — retired (thrice) +A *stored* arrow direction has never lived on the Edge. It was first a cosmetic `EdgeDirection` enum (`NONE` / `FORWARD` / `BIDIRECTIONAL`) the user cycled by hand (removed by ADR-0009, which -made the arrow *structural* — derived from the `sourceId`→`targetId` ordering). ADR-0023 then -removed even that structural meaning: `sourceId`/`targetId` are just the two endpoints in -arbitrary draw order, the de-dupe pair is **unordered**, and a Connection's arrowheads are -**derived from the Flows routed on it** (see **Connection**, **Interaction**). Re-introducing a -stored `direction` (or a `polarity`-on-Edge) field regresses both ADRs. See **Connection**, -**Port**, **Interaction**, and ADR-0023. +made the arrow *structural* — derived from the `sourceId`→`targetId` ordering). ADR-0023 removed +even that structural meaning, deriving arrows from the **Flows** routed on a Connection. ADR-0027 +retires the Flow-derivation in turn: a Connection now carries its own **Interaction**, and +arrowheads are derived from `(interaction, source, target)` — `source`/`target` preserve draw +order but are not themselves a direction field, and `interaction` is a *type*, not a stored arrow. +Re-introducing a stored `direction` (or a `polarity`-on-Edge) field regresses all three ADRs. +Note draw order is now **preserved** (not arbitrary): it feeds the directional de-dupe key and the +derived arrow. See **Connection**, +**Port**, **Interaction**, and ADR-0027. ### Canvas A **derived view, not a stored entity.** The Canvas of a Component `N` is -`{ Nodes where parentId = N } ∪ { Edges where canvasNodeId = N }`. The Project root has its own -top-level Canvas (the Nodes with `parentId = null`). Because it is derived, a Canvas is never -written directly — you mutate Nodes and Edges, and the Canvas falls out. *(The Node half of -the derivation is realized now via **getCanvas**, and the Edge half is realized now too -(`{ Edges where canvasNodeId = N }`); reading a non-root scope is realized now via -**getCanvas**, and user-facing navigation into it is realized now via **Descent**.)* +`{ Nodes where parentId = N } ∪ { Edges whose BOTH endpoints have parentId = N }` — an Edge no +longer stores its scope (`canvasNodeId` is dropped; ADR-0028), so the same-Canvas Connections fall +out of endpoint ancestry, not a stored column. The Project root has its own top-level Canvas (the +Nodes with `parentId = null`). Because it is derived, a Canvas is never written directly — you +mutate Nodes and Edges, and the Canvas falls out. *(The Node half of the derivation is realized now +via **getCanvas**, and the same-Canvas Edge half is realized now too (both endpoints' `parentId = +N`); reading a non-root scope is realized now via **getCanvas**, and user-facing navigation into it +is realized now via **Descent**. Rendering an Edge whose endpoints span Canvases — cross-scope — is +#63.)* ### getCanvas The single service read that materializes a **Canvas** for a given **Canvas -scope** in one round trip. Its full result is -`{ interiorNodes, interiorEdges, edgeFlows, boundaryProxies, flowPalettes, breadcrumbs }`, +scope** in one round trip. Its result is +`{ interiorNodes, interiorEdges, breadcrumbs }` (the cross-scope shape — re-derived +boundary proxies and the rest — is redefined in #63), derived without a per-level query walk. Because a Canvas is a *derived view*, `getCanvas` returns the **Nodes** and **Edges** that fall out of the scope — it is the read half of the Component/Node split, so its result is named in **Node**/**Edge** terms in code and tests even though the feature is described -to users as "the interior **Components**". The `edgeFlows` field is the -per-Edge Flow aggregation that drives the routed-count pill AND the derived -arrowheads on a Connection (see **FlowRoute**, **Interaction**): for each -interior Edge, an entry -`{ edgeId, total, routed, unrouted, orphan, byKind, arrowAtSource, arrowAtTarget }` -where `total` is the active **Flows** owned by either endpoint (loose — any -owner-endpoint Flow can ride the Connection; ADR-0023), `routed` is the active -**FlowRoutes** whose `outerEdgeId` is this Edge with a still-live Flow, `orphan` -covers FlowRoutes whose Flow was soft-deleted by a re-parse (the wiring hangs -visibly rather than vanishing), `byKind` is the per-`FlowKind` count of the -routed set, and `arrowAtSource` / `arrowAtTarget` count how many live routed -Flows point their arrow at the Edge's stored `source` / `target` endpoint -(derived per Flow from `(owner, interaction)` — the canonical rule lives in -`~/lib/flow-direction`; the client renders a `markerStart`/`markerEnd` from -them, both → a two-way Connection, neither → an undirected line; ADR-0023). The -`boundaryProxies` field is the transitively-derived **boundary proxy** list for -the scope (each `{ nodeId, title, kind, origin, outerEdgeId }`, where -`outerEdgeId` is the single incident outer Connection a palette drag refines — -a Connection is undirected, so any Flow rides it regardless of interaction -(ADR-0023); see **Boundary proxy**), and -`flowPalettes` maps each in-scope proxy's `nodeId` to the first page of its -owner's **Flows** (`{ flows, hasMore }`) so the boundary-proxy **Flow palette** -renders without a second round trip — the overflow pages in through -`getFlowPalette`. *(Realized now — `getCanvas` returns all six keys for a -scope; `boundaryProxies` + `flowPalettes` landed with Slice 3 (#36) via one -recursive CTE folded into the existing `Promise.all`. A non-null scope that -resolves to no live Node in the Project is a not-found. See ADR-0001 for the -single-round-trip service contract, ADR-0004 for how the payload reaches the -client island, ADR-0005 for the explicit `canvasNodeId` Edge scope, ADR-0006 -for the single recursive breadcrumb query, and ADR-0012 for the boundary -derivation + cross-scope refinement.)* +to users as "the interior **Components**". `getCanvas` no longer aggregates Flows +(the `edgeFlows` / `boundaryProxies` / `flowPalettes` fields are gone with the +Flow model). It returns the interior **Nodes** and the **Edges** whose BOTH +endpoints sit on the scope (a single relation-filtered query — `source.parentId +=== scope AND target.parentId === scope` — since an Edge stores no scope, ADR-0028); +rendering Edges whose endpoints span scopes — the redefined **boundary proxy** and +the per-Edge interaction-derived arrows — is #63. *(Realized now — `getCanvas` +returns `interiorNodes`, `interiorEdges`, and `breadcrumbs` for a scope. A non-null +scope that resolves to no live Node in the Project is a not-found. See ADR-0001 for +the single-round-trip service contract, ADR-0004 for how the payload reaches the +client island, ADR-0006 for the single recursive breadcrumb query. The cross-scope +read shape — Edges spanning scopes, the redefined boundary proxy — is #63 / +ADR-0031.)* ### Canvas scope Which **Canvas** an operation is acting on. A Canvas has **no id of its own** (it @@ -297,50 +295,33 @@ UX — inherited proxies fold away into a single **boundary group** (see entry) Canvases uncluttered — and gates refinement: only a direct proxy is **routable** here (it carries the outer Connection a palette drag refines), because the cross-scope `routeFlow` writer binds an outer Edge incident to the current scope. -*(Realized now — derivation in **getCanvas** (`boundaryProxies`), read-only rendering as the -`boundary-proxy` Canvas node with its **Flow palette**, and the refinement drag all landed with -Slice 3 (#36 / ADR-0012, absorbing the M3 boundary work #13 + #14).)* +*(The same-Canvas-era derivation and its refinement drag are retired with the Flow model (#62); +the boundary proxy is **redefined** as the far-end stand-in for a cross-scope Connection in #63 / +ADR-0031, where its #62-accurate definition and rendering are written. Until then no boundary +proxy renders. The conceptual definition above is left for #63 to rewrite.)* ### Boundary endpoint -The endpoint of a cross-scope refinement **inner Edge** that is the **boundary proxy** — i.e. the -**Flow**'s owner, which lives at a higher **Canvas scope** than the inner Edge sits on. It is the -*one* endpoint allowed to violate the same-Canvas rule, and only inside `routeFlow`: the service -derives it from the Flow's owner and pins it against the supplied endpoints rather than trusting -an input, so an arbitrary foreign Node can never be smuggled in as a cross-scope endpoint (the -gated exception to ADR-0005; ADR-0012). The other endpoint — the interior Component on the -current Canvas — is the *interior endpoint*. +Retired (#62): with no cross-scope **FlowRoute** and no `routeFlow` writer, there is no "one +endpoint allowed to violate same-Canvas" concept — *all* endpoints may now span scope (ADR-0028). +The far-end stand-in that replaces it is the **boundary proxy**, redefined in #63 / ADR-0031. ### Boundary group -The single read-only Canvas node a **Canvas** renders in place of its inherited **boundary -proxies** — bundled so a deep Canvas with many ancestors is not buried under N stand-ins for -externals routed at scopes the viewer cannot act on here. Collapsed by default; expanding reveals -each inherited proxy by title and **Component kind** but no **Flow palette** (inherited proxies -are context, not a work surface — only **direct** proxies are routable at this scope; see -**Boundary proxy** and ADR-0012). Like the proxies it contains, a Boundary group is **derived, -never persisted**: it has no **Node** row, no **Edge**, and no interior **Canvas scope** of its -own — it is a render-layer regrouping of the `boundaryProxies` whose `origin = "inherited"` at -the scope it appears on. Read-only in the same sense as a boundary proxy: not draggable, -selectable, deletable, or descendable — i.e. a **passive node** (see entry). The code term is -**`BoundaryGroupNode`** (React Flow node type `"boundary-group"`), mirroring the -**Component**/**Node** split — and distinct from React Flow's own built-in `"group"` node type (a -parent-of-children layout primitive this is not). Renders even for a single inherited proxy, so a -refetch flipping the inherited count never reshuffles the Canvas surface (ADR-0016). *(Realized as -the #14 grouping follow-up on top of Slice 3's per-proxy rendering — same derivation -(`deriveBoundaryProxies`), no service change.)* +Retired (#62): the client no longer renders an inherited-proxy group; cross-scope rendering is +redesigned in #63 / ADR-0031. Historical: ADR-0016. ### Passive node -A derived, read-only React Flow node on a **Canvas** — currently a **boundary proxy** or a -**boundary group** — excluded from the three interactive surfaces a **Component** participates -in: the **Component-detail panel** (no editable record exists), **Descent** (no interior -**Canvas scope** to open into), and hover-prefetch (nothing to warm). Passive nodes carry no -**Node** row, are never `draggable`, `selectable`, or `deletable`, and are partitioned out of -every interactive pointer handler by a single discriminator (`isPassiveNode` in `canvas.tsx`) -so a new passive kind composes by extension rather than by sprinkling fresh guards through the -click / double-click / hover paths (ADR-0016). The term is **passive** — not "read-only" (which -is overloaded with the capability-URL viewer surface, owner-edit vs viewer-read) and not -"non-interactive" (which over-claims — passive nodes still expand and collapse their own -internals; they are inert *with respect to the Canvas's interactive surfaces*, not globally -inert). +A derived, read-only React Flow node on a **Canvas** — the **boundary proxy** (redefined in #63) — +excluded from the three interactive surfaces a **Component** participates in: the +**Component-detail panel** (no editable record exists), **Descent** (no interior **Canvas scope** +to open into), and hover-prefetch (nothing to warm). Passive nodes carry no **Node** row, are +never `draggable`, `selectable`, or `deletable`, and are partitioned out of every interactive +pointer handler by a single discriminator so a new passive kind composes by extension rather than +by sprinkling fresh guards through the click / double-click / hover paths (ADR-0016). The term is +**passive** — not "read-only" (which is overloaded with the capability-URL viewer surface, +owner-edit vs viewer-read) and not "non-interactive" (which over-claims — passive nodes still +expand and collapse their own internals; they are inert *with respect to the Canvas's interactive +surfaces*, not globally inert). *(#62 removed the boundary-group passive kind; #63 re-populates the +passive set when it rebuilds boundary-proxy rendering.)* ### Project The root container of one architecture graph. Owned by a single user (`ownerId`) and addressed @@ -484,8 +465,7 @@ map, addressed by URI). Addressed under the `architecture://` scheme by internal `resources/list` enumerates only those (reusing the owner-scoped `listProjects`). The word is **resource** — the MCP-spec native term, so no Component/Node split applies (the overload that motivates the split is absent). Never "tool" (a **tool** invokes or mutates — see **MCP tool**), -"endpoint" (that names the route), or "query". *(Realized now via #18; #38's Flow resources -(`flow/:id`, `flow-route/:id`) append additively. See ADR-0017, ADR-0022.)* +"endpoint" (that names the route), or "query". *(Realized now via #18. See ADR-0017, ADR-0022.)* ### MCP tool A write-addressable unit an **agent** invokes over the **MCP path** to mutate the architecture. A @@ -494,7 +474,7 @@ and surfaces the result as a short text confirmation that includes the affected agent can chain calls without an intermediate read. Authorization, invariants, and de-dupe live in the service — the tool registers, parses, and translates errors only. The word is **tool** — the MCP-spec native term, so no split applies. Never "action", "command", "mutation" (collides with -tRPC), or "verb". Today's surface is the **MCP write tools** — the four single-op tools (`create_component`, `connect_components`, `update_component_docs`, `move_component` — #19) plus the **`apply_graph`** batch tool (#20) for constructing many Components and Connections in one transaction, chained by **client id** (the in-batch reference handle the agent picks). **No destructive tool is exposed at this version** (acceptance criterion). The catalog is plain data (`WRITE_TOOLS` in `~/server/mcp/tool-catalog.ts`), so the registration loop, `tools/list`, and `/llms.txt` all render from one source — additional tools (Flow / FlowRoute writers in issues #40 / #42, plus the additive `flows: []` / `routes: []` arms on `apply_graph` in #38) plug in without touching the adapter, the auth gate, or the route. *(Realized now via #19 + #20; see ADR-0001, ADR-0010, ADR-0022, ADR-0024, ADR-0026.)* +tRPC), or "verb". Today's surface is the **MCP write tools** — the four single-op tools (`create_component`, `connect_components`, `update_component_docs`, `move_component` — #19) plus the **`apply_graph`** batch tool (#20) for constructing many Components and Connections in one transaction, chained by **client id** (the in-batch reference handle the agent picks). **No destructive tool is exposed at this version** (acceptance criterion). The catalog is plain data (`WRITE_TOOLS` in `~/server/mcp/tool-catalog.ts`), so the registration loop, `tools/list`, and `/llms.txt` all render from one source — additional tools plug in without touching the adapter, the auth gate, or the route. `connect_components` and the `apply_graph` `connections` arm gain an `interaction` input and accept cross-scope endpoints (#62; the `canvasNode` ref is dropped — see **Client id**); a spec-attach tool (generating Components) lands in #67. *(Realized now via #19 + #20; see ADR-0001, ADR-0010, ADR-0022, ADR-0026, ADR-0027, ADR-0028.)* ### llms.txt The served discovery document at `/llms.txt` that tells an **agent** how to reach the **MCP path**, @@ -505,194 +485,131 @@ the grant (ADR-0021): it describes capability ("a token acts on behalf of the mi "read-only scope" the token does not carry — the MCP surface is read-only *at this version*, not the token. Carries the **prompt-injection standing note** that graph content is **data, not instructions**. Never "manifest", "sitemap", or "robots.txt for AI". *(Realized now via #18; #34/#38 -extend its vocabulary as they add Flow tools / resources. See ADR-0022.)* +extend its vocabulary as they grow. See ADR-0022.)* ### Client id -The agent-chosen string an **apply-graph batch** uses to chain references between rows it is about to create in one **MCP tool** call — `parent` on a new Component, or `source` / `target` / `canvasNode` on a new Connection — without an intermediate round trip to learn the server-minted ids. Each Component in the batch carries a `clientId` the agent picks (any non-empty string ≤ 64 chars); the response returns an `idMap: { [clientId: string]: serverId }` the agent uses for subsequent calls. Per-batch scope: a `clientId` means nothing outside the one transaction that materializes the map, and **carries no authorization** — it is a lookup key, not a bearer credential (writes still authorize through the **API token**-resolved **Actor**, ADR-0002). Each field that accepts a Component endpoint or a Component parent accepts EITHER a `clientId` from the same batch (`{ref:"client", clientId:"..."}`) OR an existing server id (`{ref:"server", id:"..."}`); the discriminator is explicit so a typo surfaces as "no such clientId in this batch" instead of silently rebinding to an unrelated server row. The word is **client id** in prose and `clientId` in code — never "ref id" / "batch id" / "local id" / "temp id". Clientids must be unique batch-wide so the flat `idMap` shape Slice 5 (#38) extends with `flows: []` / `routes: []` arms cannot collide. *(Realized now via #20 / `apply_graph`. The id-map type is `Record` in code; the outer service result is `ApplyGraphOutput`. See ADR-0026.)* +The agent-chosen string an **apply-graph batch** uses to chain references between rows it is about to create in one **MCP tool** call — `parent` on a new Component, or `source` / `target` on a new Connection (the `canvasNode` ref is dropped — Edges no longer store scope; #62 / ADR-0026 amendment) — without an intermediate round trip to learn the server-minted ids. Each Component in the batch carries a `clientId` the agent picks (any non-empty string ≤ 64 chars); the response returns an `idMap: { [clientId: string]: serverId }` the agent uses for subsequent calls. Per-batch scope: a `clientId` means nothing outside the one transaction that materializes the map, and **carries no authorization** — it is a lookup key, not a bearer credential (writes still authorize through the **API token**-resolved **Actor**, ADR-0002). Each field that accepts a Component endpoint or a Component parent accepts EITHER a `clientId` from the same batch (`{ref:"client", clientId:"..."}`) OR an existing server id (`{ref:"server", id:"..."}`); the discriminator is explicit so a typo surfaces as "no such clientId in this batch" instead of silently rebinding to an unrelated server row. The word is **client id** in prose and `clientId` in code — never "ref id" / "batch id" / "local id" / "temp id". Clientids must be unique batch-wide so the flat `idMap` shape stays collision-free across any future additive arm. *(Realized now via #20 / `apply_graph`. The id-map type is `Record` in code; the outer service result is `ApplyGraphOutput`. See ADR-0026.)* ### Deletion id The handle that ties together one cascading soft-delete so it can be undone as a unit. A single `deleteNode` mints one id and stamps it (`deletionId`) on every row it transitions to -deleted — the target **Node**, its subtree, every incident or interior **Edge**, every owned -**Flow** + owned **FlowSpec**, and every incident **FlowRoute** — and `restoreNode` clears -`deletedAt` for *exactly* the rows bearing that id, so an undo restores the operation's set and -nothing outside it. A `deleteEdge` that sweeps one or more incident FlowRoutes also mints a -`deletionId` and stamps both the Edge and the swept FlowRoutes, restored as one batch by -`restoreEdge`; a `deleteEdge` on an Edge with no incident FlowRoutes still mints **no** -`deletionId` (the "lone delete" carve-out preserved). A row removed by some other operation -never carries this id and is never revived by undoing a later one — a lone `deleteFlow` / -`unrouteFlow` / `deleteEdge`-without-routes sets `deletedAt` with no `deletionId`, and an -earlier delete carries its own id. It is a *grouping of soft-deleted rows*, not a stored -history: do not call it a "transaction" (the database mechanism that writes it), a "version" -or "snapshot" (nothing is copied — rows are flagged in place), or an "audit log". Named in -**Node**/**Edge**/**Flow**/**FlowRoute** terms in code; users see only "delete" and "undo". +deleted — the target **Node**, its subtree (including any spec-derived child Components, which +ride the ordinary subtree cascade), every incident or interior **Edge**, and the owned **Spec** — +and `restoreNode` clears `deletedAt` for *exactly* the rows bearing that id, so an undo restores +the operation's set and nothing outside it. A `deleteEdge` is a **plain lone soft-delete**: it +sets `deletedAt` on the one Edge with **no** `deletionId` (there is no longer a FlowRoute cascade +to group). `restoreEdge`'s batch role narrows to the cascade restore driven by `restoreNode`. +A row removed by some other operation never carries this id and is never revived by undoing a +later one — a lone `deleteEdge` sets `deletedAt` with no `deletionId`, and an earlier delete +carries its own id. It is a *grouping of soft-deleted rows*, not a stored history: do not call it +a "transaction" (the database mechanism that writes it), a "version" or "snapshot" (nothing is +copied — rows are flagged in place), or an "audit log". Named in **Node**/**Edge**/**Spec** terms +in code; users see only "delete" and "undo". *(Realized now via `deleteNode`/`restoreNode` and `deleteEdge`/`restoreEdge` for cascaded -edges; see ADR-0008, ADR-0014 (the `deleteEdge`/`restoreEdge` cascade), and ADR-0011. The -id is a bare stamped column today — a durable `Deletion` entity and an MCP undo tool are -deferred, additive future work.)* +edges; see ADR-0008, ADR-0030 (cascade/undo without FlowRoutes, superseding ADR-0014), and +ADR-0011. The id is a bare stamped column today — a durable `Deletion` entity and an MCP undo +tool are deferred, additive future work.)* ### Soft-delete + undo Deletes set a `deletedAt` timestamp rather than removing rows; reads filter out soft-deleted records; the operation is reversible. This matters specifically because AI agents mutate the graph, and a recoverable delete is the safety net for an automated change gone wrong. *(Realized now for a Component: `deleteNode` cascades a soft-delete across the Node, its subtree, every -incident or interior **Edge**, and every owned **Flow** + owned **FlowSpec** as one -**Deletion id**, and `restoreNode` reverses exactly that set (ADR-0008 + ADR-0011). Both are +incident or interior **Edge**, and the owned **Spec** as one +**Deletion id**, and `restoreNode` reverses exactly that set (ADR-0008 + ADR-0030). Both are **writes** — owner-only, never slug-granted (ADR-0002). The `Project` model also carries `deletedAt` and all reads filter it; Project-level cascade remains future.)* ### Flow -A named, directional unit of data movement a **Component** exposes — an OpenAPI operation, a -WebSocket channel, an SSE stream, a function call, an event. Owned by its Component -(`ownerNodeId` on the data side) and exists on the owner whether or not anything is calling it: -an API exposes `GET /pets` whether or not a client is wired up. A first-class row, individually -addressable and individually soft-deletable, so every named capability is something an MCP -agent can list, edit, or remove without touching the **Connection** that carries it. Carries a -stable `key` (e.g. `"GET /pets"`), an UNTRUSTED `title` (display label), an optional -`signature` (the parsed contract fragment as `Json?`), a **kind** (see **Flow kind**), and an -**interaction** (see **Interaction**). A Flow's `key` is unique among active rows of the same owner -— the de-dupe rule `(ownerNodeId, key)`, ADR-0005 style with the ADR-0010 partial-unique -backstop (`idx_flow_dedup`). A Flow may be **derived** from a **FlowSpec** (`sourceSpecId != null`) -or **user-authored** (`sourceSpecId = null`). *(Realized now via `attachFlowSpec` / `addFlow` / -`updateFlow` / `deleteFlow`, listed in the Component's **Flow palette**, and surfaced as the -"N flows" pill on the Component body. Same-Canvas baseline binding of a Flow to a **Connection** -— the `FlowRoute` — is realized now via `routeFlow` / `unrouteFlow`, surfaced as the routed-count -pill on the Connection and the "+ flow" affordance when a Connection is selected by the owner. -Cross-scope refinement routing and palette rendering on **boundary proxies** land in subsequent -slices. See ADR-0011.)* - -### FlowSpec -The imported contract — an OpenAPI document, an AsyncAPI document, a GraphQL schema, a SQL DDL -script, a TypeScript signature, or hand-authored `CUSTOM` prose — that materializes a set of -**Flows** on its owner **Component**. 1:1 with a Component (`ownerNodeId @unique`): exactly one current -FlowSpec per Component. The spec is the source of truth; Flow rows are its parsed projection, -regenerated by re-parse. `source` is **UNTRUSTED user-pasted content** — stored verbatim, -parsed only by a bounded loader (size + depth caps so a hostile spec cannot OOM), and never -interpolated (prompt-injection standing note, parse-time clause). A malformed spec stores -`parseError`, creates zero Flows, and never throws to the caller. Re-pasting is -**non-destructive**: matching keys preserved, dropped keys soft-deleted with a fresh -**Deletion id** per re-parse batch (the same handle the cascade uses, minted by a different -operation — `restoreNode` does not unwind a re-parse batch; orphan ids are harmless), so -downstream wiring orphans visibly rather than vanishing silently. The parser is selected by -**spec kind** through a registry (`src/server/architecture/flow-parser/`), each entry a pure, -bounded loader; adding a format is a localized, type-checked change (a parser module + one -registry line). *(Realized now for OPENAPI, ASYNCAPI, GRAPHQL, SQL_DDL, and TS_SIGNATURE; -CUSTOM is hand-authored prose with no parser — source persists with a `parseError` note. Which -spec kinds a Component is *offered* is a presentation-only affinity keyed by **Component kind** -(`~/lib/spec-kinds`, ADR-0019 precedent), never a constraint — the service accepts any kind. -See ADR-0011, ADR-0025.)* - -### Interaction (`FlowInteraction`) -How a **Flow**'s **owner** **Component** participates in the interaction — the owner-relative -verb from which a **Connection**'s arrowheads are *derived* (never a stored direction; ADR-0023): - -- `REQUEST` — the owner is *called* in request/response (REST, RPC, a GraphQL field it serves); - the caller depends on it, so the arrow points **at** the owner. -- `PUSH` — the owner *emits* unprompted (SSE, a webhook it sends, an event it publishes); the - arrow points **away** from the owner. -- `SUBSCRIBE` — the owner *consumes* an external stream/feed; the arrow points **at** the owner. -- `DUPLEX` — the owner both sends and receives (a WebSocket); arrows at **both** ends. - -It answers "from the owner's perspective, what is this interaction and which way does data -move?". A Connection renders the union of its routed Flows' arrows: none → a plain undirected -line, one direction → one arrowhead, both → arrowheads at both ends (a WebSocket is *one* -Connection, not two). The derivation rule lives in one place, `~/lib/flow-direction.ts`. The -word in prose and UI is **interaction**; the type name in code is `FlowInteraction` — the same -prose/type-name pattern **Component kind** / `NodeKind` uses. Never "direction" (it is derived, -not stored — re-introducing a stored direction or a `polarity`-on-edge field regresses ADR-0023). -`REQUEST`/`SUBSCRIBE` and `PUSH` are the arrow-preserving successors of the retired `INBOUND` / -`OUTBOUND` polarity; `SUBSCRIBE`/`DUPLEX` broaden a Flow from "a capability the owner exposes" to -"an interaction the owner participates in". *(Realized now as a per-Flow field, editable via -`addFlow`/`updateFlow`. `routeFlow` enforces only that the Flow's owner is an endpoint of the -Connection — there is no interaction-vs-arrow gate, so any owner-endpoint Flow routes onto the -single undirected Connection (ADR-0023, superseding ADR-0009/0013). The flow-derived arrowhead -rendering lands in a later slice of the same rollout.)* +Retired with the Flow capability model (#62 / ADR-0030). A **Connection** is now a directed, +typed edge carrying its own **Interaction**; the named data-movement units a Component exposed +are no longer modeled. The 1:1 import row formerly `FlowSpec` became **Spec** (see entry). +Historical: ADR-0011. + +### Spec (`Spec`) +The imported contract — an OpenAPI/AsyncAPI/GraphQL/SQL-DDL/TypeScript document or hand-authored +`CUSTOM` prose — that materializes a set of derived child **Components** on its owner Component. +1:1 with a Component (`ownerNodeId @unique`); renamed from the retired `FlowSpec`. Where the old +FlowSpec projected **Flows**, a Spec now points at derived child **Components** via their +`Node.sourceSpecId` + `Node.specKey` provenance columns. `source` is **UNTRUSTED user-pasted +content** — stored verbatim, parsed only by a bounded loader (prompt-injection standing note, +parse-time clause). No user/code split (it rides the exception — "Spec" / `Spec`). *(#62 lands the +renamed row, the provenance columns, and the cascade (`deleteNode` sweeps the owned Spec — +ADR-0030). The actual spec→Component **generation** — re-parse, `ParsedComponent`, non-destructive +key matching — is #64. Its source format is a **spec kind** (`SpecKind`); the parser registry and +per-Component affinity are revisited in #64. Historical: ADR-0011, ADR-0025.)* + +### Interaction (`Interaction`) +A **Connection**'s type, stored on its **Edge** as `interaction: Interaction` (default +`ASSOCIATION`). Five values: a default undirected `ASSOCIATION` plus four **directional** +interactions that describe, from the perspective of the **`source`** endpoint, how it +participates — the verb from which the Connection's arrowheads are *derived* together with draw +order (#65; not a stored arrow): + +- `ASSOCIATION` — a plain undirected relationship; **no arrowheads** (the default a freshly drawn + Connection carries). De-dupes as an unordered pair. +- `REQUEST` — `source` is *called* in request/response (REST, RPC, a served GraphQL field); the + dependent end points **at** `target`. +- `PUSH` — `source` *emits* unprompted (SSE, a webhook it sends, an event it publishes); the arrow + points **away** from `source` (at `target`). +- `SUBSCRIBE` — `source` *consumes* an external stream/feed; the arrow points **at** `source`. +- `DUPLEX` — `source` both sends and receives (a WebSocket); arrows at **both** ends. + +It answers "what kind of relationship is this, and (for the four directional values) which way +does data move relative to `source`?". `interaction` is in the directional de-dupe key — `A→B +REQUEST` and `A→B PUSH` are distinct Connections — but `ASSOCIATION` de-dupes on the unordered +pair (one Association per Component pair). The canonical `(interaction, source, target) → markers` +mapping lives in one helper (the successor to `~/lib/flow-direction`): `REQUEST`/`PUSH` → arrow at +`target`; `SUBSCRIBE` → arrow at `source`; `DUPLEX` → both; `ASSOCIATION` → neither. The word in +prose and UI is **interaction**; the type name in code is `Interaction` (no user/code split — see +the exception block) — the same prose/type-name pattern **Component kind** / `NodeKind` uses, +minus the prefix. Never "direction" (the arrow is derived from `(interaction, source, target)`, +not a stored field — re-introducing a stored `direction`/`polarity`-on-edge regresses ADR-0027) +and never "edge type" / "kind" (there is no `EdgeKind`; `interaction` is the only typing axis). +`REQUEST`/`PUSH`/`SUBSCRIBE`/`DUPLEX` are the four directional successors carried over from the +retired Flow model (where they were owner-relative); `ASSOCIATION` is the new default for an +untyped plain-line Connection. *(Realized now as a per-Connection field set at `connectNodes` +(default `ASSOCIATION`). Arrowhead rendering from `interaction` lands in #65; until then every +Connection renders as a plain line.)* ### Component-detail panel The slide-in surface that opens when a **Component** is selected on the **Canvas** — a sidebar, not a modal, so panning and zooming continue behind it (performance). It hosts the Component's -**kind** row, its **FlowSpec** paste field, its **Flow palette**, and the markdown -**documentation** editor (ADR-0015). **Dual-audience:** the owner sees the full edit surface; a -**viewer** (a non-owner holding the capability slug) sees the *same panel read-only* — rendered -documentation (Plate `readOnly`) and the read-only Flow palette, with no kind picker, no paste -field, and no docs Edit toggle. The read-only affordances are *omitted, not disabled*, so the -viewer panel never signals an edit it cannot perform; read-only mode is presentation only — writes -remain owner-only at the service layer (ADR-0002). The word is **Component-detail panel** — never -"inspector", "sidebar" (names the layout, not the surface), or "properties panel". *(Realized now; -the read-only viewer variant landed with issue #16. See ADR-0002, ADR-0011, ADR-0015.)* +**kind** row and the markdown **documentation** editor (ADR-0015). **Dual-audience:** the owner +sees the full edit surface; a **viewer** (a non-owner holding the capability slug) sees the *same +panel read-only* — rendered documentation (Plate `readOnly`), with no kind picker and no docs Edit +toggle. The read-only affordances are *omitted, not disabled*, so the viewer panel never signals +an edit it cannot perform; read-only mode is presentation only — writes remain owner-only at the +service layer (ADR-0002). The word is **Component-detail panel** — never "inspector", "sidebar" +(names the layout, not the surface), or "properties panel". *(Realized now; the read-only viewer +variant landed with issue #16. The **Spec** paste field and the **Flow palette** it formerly +hosted were removed with the Flow model (#62); the spec → Component generation surface returns in +#64. See ADR-0002, ADR-0015.)* ### Flow palette -The read-only UX surface listing a **Component**'s **Flows**. Surfaces on the -**Component-detail panel** that opens when the owner selects a Component on the **Canvas** — -alongside the paste field for its **FlowSpec** — and inside the **"+ flow"** popover that -opens when the owner selects a **Connection** (so the unrouted Flows on either endpoint are -pickable in place). Each item shows the Flow's `title`, `kind`, and `interaction`. When the -Component owns at least one Flow, its node body wears a **"N flows" pill** to signal the -palette is non-empty. *(Realized now on the Component-detail panel of a Component (editable for -the owner, read-only for a **viewer** — issue #16), inside the per-Connection "+ flow" popover, -and — since Slice 3 (#36) — on the **boundary -proxy**: the same surface projected inward, where each item carries a refinement Port so a -child Component can route the external Flow onto its interior pipe (ADR-0012). The first page -ships in **getCanvas** `flowPalettes`; the overflow pages in via `getFlowPalette`.)* +Retired with the Flow model (#62). The read-only list of a Component's **Flows** is gone with the +Flows it listed; the **Component-detail panel** keeps only the **kind** row and the documentation +editor. (The *palette* prose/UI convention — the word names the surface, not the library — lives +on in the **Kind palette**.) ### FlowRoute -The binding that says *"this **Connection** carries this **Flow**"* — a first-class row that -attaches a **Flow** to an **Edge** at a **Canvas scope**. Names exactly one `outerEdgeId` -(the Connection at this scope that carries the Flow) and zero-or-one `innerEdgeId` (the -**refinement Connection** one scope deeper — the inner **Edge** that resolves a **boundary -proxy** to the real Component, written by the gated cross-scope `routeFlow` since Slice 3 / -ADR-0012). An inner Edge is a **shared pipe**: one inner Edge can carry **many FlowRoutes** -(`innerEdgeId` has no uniqueness), so two Flows refined over the same interior pair converge -on one Edge, and the soft-delete sweep is reference-counted — an inner Edge dies only with its -last active FlowRoute. Same word user-facing and in code — applies the **Flow** no-split -convention (the -"Node" overload that motivated the Component/Node split does not apply). Carries `projectId` -for authz and cascade-index friendliness, soft-delete columns (`deletedAt`, `deletionId`), -and is owner-only writable via `routeFlow` / `unrouteFlow`. A FlowRoute's `flowId` must -reference an active **Flow** whose `ownerNodeId` is one endpoint of the outer **Edge** — -the *touches-endpoint* invariant, which is the WHOLE of `routeFlow`'s direction check: a -Connection is undirected, so any owner-endpoint Flow routes onto it and its **Interaction** -verb decides which way the derived arrow points (ADR-0023, retiring ADR-0013's -polarity-vs-arrow rejection and the reverse-Connection offer). De-dupe is `(outerEdgeId, flowId)` among active rows -— the **ADR-0010 named pattern**, third adopter (`idx_flow_route_dedup`, partial unique -backstop; service-primary `findFirst` is the readable fast path; both translate to -`ConflictError` with `details.conflictingFlowRouteIds`). The inner-Edge and FlowRoute writes -use `createMany({ skipDuplicates })` (`ON CONFLICT DO NOTHING`) so a concurrent racer never -aborts the route's transaction — convergence on a shared inner Edge, not a retry loop -(ADR-0012). *(Realized now for same-Canvas baseline routing via `routeFlow` / `unrouteFlow`, -surfaced as the `edgeFlows` aggregation in **getCanvas**, the routed-count pill on the -Connection, and the "+ flow" popover on a selected Connection — and, since Slice 3 (#36), -cross-scope refinement (the `innerEdgeId` writer) and palette rendering on **boundary -proxies**, with the drag-from-palette gesture. As of ADR-0023 a Connection is undirected: -`routeFlow` enforces only touches-endpoint, the per-Edge `arrowAtSource`/`arrowAtTarget` -aggregation in **getCanvas** drives the derived arrowheads, and the polarity gate + -reverse-Connection reconciliation (the old Slice 4 / ADR-0013) are retired. See ADR-0011 -(Flow foundation), ADR-0012 (cross-scope writer), ADR-0023 (undirected Connection, derived -direction), and the master plan at `docs/plans/flow-routed-connections.md`.)* +Retired with the Flow model (#62 / ADR-0030). Connections no longer carry routed Flows; a +Connection's **Interaction** is intrinsic, not derived from routes. The cross-scope inner-Edge +writer (`routeFlow`) is deleted. Historical: ADR-0011, ADR-0012, ADR-0023. ### Flow kind (`FlowKind`) -A **Flow**'s category, stored on it as `kind: FlowKind`. One of nine values: `GENERIC` (the -default — a hand-authored Flow with no formal contract), `OPENAPI_OPERATION`, `GRAPHQL_FIELD`, -`ASYNCAPI_CHANNEL`, `SSE_STREAM`, `WEBSOCKET`, `FUNCTION_CALL`, `EVENT`, `DB_TABLE`. The word in -prose and the enum name in code are **kind** / `FlowKind` — the same pattern as **Component -kind** / `NodeKind`. **Kind is cosmetic**: it drives palette icons and how an inspector formats -the `signature` payload; it does not change authorization, routing, or de-dupe. New kinds are an -additive change. (A `DB_TABLE` Flow routes onto a Connection exactly like an `OPENAPI_OPERATION` -— routing is kind-agnostic; only the icon and `signature` shape differ.) - -### Flow spec kind (`FlowSpecKind`) -A **FlowSpec**'s source format, stored on it as `kind: FlowSpecKind`. One of six values: -`OPENAPI`, `ASYNCAPI`, `TS_SIGNATURE`, `GRAPHQL`, `SQL_DDL`, `CUSTOM`. The value selects which -parser in the registry (`src/server/architecture/flow-parser/`) materializes Flows from -`source`; `CUSTOM` is hand-authored prose the canonical parsers do not cover (no parser). The -word in prose and the enum name in code are **spec kind** / `FlowSpecKind`. Which spec kinds a -Component is *offered* in the **Component-detail panel** is a presentation-only affinity keyed -by **Component kind** (`~/lib/spec-kinds` — e.g. a `DATABASE` is offered `SQL_DDL`, a `TOPIC` -`ASYNCAPI`, an `EXTERNAL_API` `OPENAPI`/`GRAPHQL`/`ASYNCAPI`); an empty affinity hides the paste -field entirely. The affinity *ranks*, it never *constrains* — the service accepts any spec kind -on any Component (ADR-0019 precedent; spec kind is orthogonal to the cosmetic Component kind). -*(All five structured parsers are realized now; `CUSTOM` persists source with a `parseError` -note. See ADR-0011, ADR-0025.)* +Retired with the Flow model (#62). It was the cosmetic categorization of Flows; no successor — a +Connection's only typing axis is **Interaction**, and there is no `EdgeKind`. + +### Spec kind (`SpecKind`) +A **Spec**'s source format, stored on it as `kind: SpecKind`. One of six values: `OPENAPI`, +`ASYNCAPI`, `TS_SIGNATURE`, `GRAPHQL`, `SQL_DDL`, `CUSTOM`. Renamed from `FlowSpecKind` with the +Flow model's retirement (#62). The value selects which parser materializes derived child +**Components** from `source` (#64); `CUSTOM` is hand-authored prose the canonical parsers do not +cover. The word in prose and the enum name in code are **spec kind** / `SpecKind`. *(The enum + +column land in #62; the parser registry and the per-Component affinity that ranks which spec kinds +a Component is offered — presentation-only, ADR-0019 precedent — are (re)built with the +spec→Component generator in #64. See ADR-0011, ADR-0025.)* ### Markdown export The byte-stable serialization of a **Project** — or one of its subtrees — to markdown for human @@ -723,9 +640,11 @@ actor, input)` (slug-readable, web) and `exportMarkdownForActor(db, actor, input `projectId`, the MCP read path #18 / ADR-0022) — both depth-independent, honouring the single-round-trip posture (ADR-0001), and both delegating to the pure `serializeGraph` in `src/server/architecture/markdown.ts`. The "Copy as markdown" toolbar action and the breadcrumb-bar -scope-anchored copy ship the client-side surface. **Flow** / **FlowRoute** sections are out of scope -here — Slice 5 / #38 extends the format additively (`### Flows` Component subsection, `flows:` -Connection subsection) without re-baselining the #15 golden file. See ADR-0017.)* +scope-anchored copy ship the client-side surface. The export rewrite for the typed cross-scope +model — the **Connections** section gaining the `interaction` glyph and a **Spec** subsection — is +#67 (which amends ADR-0017). #62 only adjusts the serializer for the dropped `Edge.canvasNodeId` +(Connections render `source → target` without a per-canvas scope suffix; subtree boundary +derivation is endpoint-membership based) and re-baselines the golden once. See ADR-0017.)* ## Standing notes diff --git a/docs/adr/0005-edge-scope-and-service-enforced-invariants.md b/docs/adr/0005-edge-scope-and-service-enforced-invariants.md index af0778e..597db2e 100644 --- a/docs/adr/0005-edge-scope-and-service-enforced-invariants.md +++ b/docs/adr/0005-edge-scope-and-service-enforced-invariants.md @@ -14,7 +14,15 @@ Amended further by [ADR-0023](0023-connection-direction-derived-from-flows.md) de-dupe key changes from the **ordered** triple to the **unordered** pair `(canvasNodeId, {sourceId, targetId})`: a Connection is undirected, so A→B and B→A are the same Connection. The explicit-scope and service-enforced-invariant decisions are -untouched; only the ordered-pair distinctness flips.)* +untouched; only the ordered-pair distinctness flips. +Amended further by [ADR-0028](0028-cross-scope-connections-lineal-ingress.md) +(#62) — the **same-Canvas endpoint invariant is RETIRED** and the explicit +`canvasNodeId` scope is **dropped**: scope is now derived from endpoint ancestry +(#63), `connectNodes` accepts cross-scope + **lineal** endpoints and rejects only +the self-link, and the "scope is explicit" reviewable invariant is **inverted** +(a future stored `canvasNodeId` is the regression). The service-enforced-invariant +*posture* survives; the Edge gains an `interaction` column +([ADR-0027](0027-connection-carries-its-own-interaction.md)).)* ## Context diff --git a/docs/adr/0010-edge-dedup-partial-unique-index.md b/docs/adr/0010-edge-dedup-partial-unique-index.md index c55af05..aa79d3e 100644 --- a/docs/adr/0010-edge-dedup-partial-unique-index.md +++ b/docs/adr/0010-edge-dedup-partial-unique-index.md @@ -8,7 +8,20 @@ deferred future work; amends ADR-0005's consequences §3 by name. Amended by becomes an EXPRESSION index over `(canvasNodeId, LEAST(sourceId, targetId), GREATEST(sourceId, targetId))` so it enforces the **unordered** pair; the name, the partial `WHERE deletedAt IS NULL` clause, `NULLS NOT DISTINCT`, and the -service-primary / index-backstop doctrine are unchanged.)* +service-primary / index-backstop doctrine are unchanged. +Amended further by +[ADR-0027](0027-connection-carries-its-own-interaction.md) / +[ADR-0028](0028-cross-scope-connections-lineal-ingress.md) (#62) — with +`canvasNodeId` dropped, `idx_edge_dedup` is **replaced by two partial unique +indexes keyed on `projectId`**: a **directional** index over +`(projectId, sourceId, targetId, interaction)` (`WHERE interaction <> +'ASSOCIATION'`) and an **`ASSOCIATION`-only unordered** index over +`(projectId, LEAST(sourceId,targetId), GREATEST(sourceId,targetId))`. Both keep +`WHERE deletedAt IS NULL`; `NULLS NOT DISTINCT` is dropped (`projectId` is +non-null). The named pattern and the service-primary / index-backstop doctrine +are unchanged; the collision matcher (`isEdgeDedupCollision`) now accepts both +index names. The migration pre-flights an unordered-duplicate guard before +creating the indexes.)* ## Context diff --git a/docs/adr/0023-connection-direction-derived-from-flows.md b/docs/adr/0023-connection-direction-derived-from-flows.md index 37c1a13..324f0c3 100644 --- a/docs/adr/0023-connection-direction-derived-from-flows.md +++ b/docs/adr/0023-connection-direction-derived-from-flows.md @@ -4,6 +4,16 @@ Accepted (rollout across four slices on `feat/flow-derived-direction`). +**Superseded by [ADR-0027](0027-connection-carries-its-own-interaction.md) +(#62):** the Flow model this ADR derived direction from is retired. A Connection +now carries its own **Interaction** (an intrinsic Edge column, default +`ASSOCIATION`) and arrowheads derive from `(interaction, source, target)`, not +from routed Flows. The core insight — direction has a single un-lying source of +truth — is preserved; ADR-0027 relocates it from routed Flows to the Connection's +own `interaction`. The unordered de-dupe survives only for `ASSOCIATION`; the four +directional interactions de-dupe on the *ordered* key with `interaction` included +([ADR-0010](0010-edge-dedup-partial-unique-index.md) amendment). + **Supersedes** [ADR-0009](0009-connection-direction-is-structural.md) (a Connection's arrow is the structural `sourceId→targetId` ordering) and [ADR-0013](0013-polarity-not-stored-direction.md) (a Flow's polarity selects diff --git a/docs/adr/0027-connection-carries-its-own-interaction.md b/docs/adr/0027-connection-carries-its-own-interaction.md new file mode 100644 index 0000000..6ba027d --- /dev/null +++ b/docs/adr/0027-connection-carries-its-own-interaction.md @@ -0,0 +1,97 @@ +# 27. A Connection carries its own Interaction; arrowheads derive from `(interaction, source, target)` + +## Status + +Accepted (#62, the re-founding slice). + +**Supersedes** [ADR-0023](0023-connection-direction-derived-from-flows.md) +(a Connection's arrowheads are derived from the Flows routed on it). The +single-source-of-truth-for-direction insight is preserved; #62 relocates that +source of truth from routed Flows (now deleted with the Flow model) to an +intrinsic `interaction` column on the Edge. + +**Supersedes** (transitively) [ADR-0009](0009-connection-direction-is-structural.md) +(structural `source`→`target` arrow) and +[ADR-0013](0013-polarity-not-stored-direction.md) (polarity-vs-arrow gate) — +both were already superseded by ADR-0023; named here so the chain is explicit +and the retired Flow model that 0013 depended on is gone. + +**Amends** [ADR-0005](0005-edge-scope-and-service-enforced-invariants.md) (the +Edge gains an `interaction` column) and +[ADR-0010](0010-edge-dedup-partial-unique-index.md) (`interaction` enters the +directional de-dupe key). The same-Canvas invariant of ADR-0005 is retired by +[ADR-0028](0028-cross-scope-connections-lineal-ingress.md). + +## Context + +ADR-0023 made a Connection undirected and derived its arrow from the +**Interaction** verbs of the **Flows** routed on it. #62 retires the entire +Flow model — there are no routed Flows to derive direction from. + +Direction was relocated to Flows because that is where it lived (ADR-0023). With +Flows gone, direction has nowhere to live unless the Connection itself carries +it. Re-deriving from absent rows is not an option; re-introducing a *stored +arrow* (the ADR-0009 column the project twice removed) would recreate the lie +ADR-0009/0023 fought — an arrow a user could set independently of meaning. + +A WebSocket, an SSE stream, a request/response call, and a plain "these relate" +line must all be expressible as ONE Connection. + +## Decision + +### Interaction is an intrinsic Edge column, default `ASSOCIATION` + +`Edge.interaction` is an `Interaction` enum with five values: `ASSOCIATION` +(the default), `REQUEST`, `PUSH`, `SUBSCRIBE`, `DUPLEX`. Renamed from +`FlowInteraction`; the four directional values carry over arrow-preserving. A +freshly drawn Connection is an `ASSOCIATION` — a plain undirected line — until +the user types it (the honest successor to ADR-0023's "no flows yet, no arrow"). + +### Draw order is preserved and load-bearing + +`sourceId` / `targetId` are no longer "arbitrary" (the ADR-0023 framing): they +record which Port the drag started from and anchor the derived arrow. The +arrow is *derived* — never stored — from `(interaction, source, target)` at +render time. This keeps the un-lying property (the arrow cannot be set +independently of meaning) while giving meaning a home. + +### The canonical marker mapping + +One pure helper (the successor to `~/lib/flow-direction`) is the single source +of truth for `(interaction, source, target) → { markerStart, markerEnd }`, +shared by the canvas renderer (#65) and the exporter (#67): + +- `REQUEST` / `PUSH` → arrow at **target**; +- `SUBSCRIBE` → arrow at **source**; +- `DUPLEX` → arrows at **both** ends; +- `ASSOCIATION` → **neither** (a plain line). + +This mapping matches the rendering spec in #65, which is its first consumer. + +### `ASSOCIATION` is the untyped default and de-dupes unordered + +A plain undirected relationship; the value a freshly drawn Connection gets when +the user expresses no direction. It alone uses the unordered de-dupe pair +(ADR-0010 amendment); the four directional values use the ordered key with +`interaction` included. + +### Rendering is deferred to #65; #62 lands the column + semantics only + +Every Connection renders as a plain line in #62; the marker map is wired into +the canvas in #65 and the exporter in #67. The semantics are recorded now so +those slices have a spec. + +## Consequences + +- **Reviewable invariant:** *A Connection's rendered direction is derived — + never stored — from `(interaction, source, target)`. `interaction` is a type + with one undirected value (`ASSOCIATION`); re-introducing a stored + `direction`/`polarity` column, or deriving the arrow from anything other than + the canonical helper, regresses this ADR.* +- `interaction` enters the directional de-dupe key (ADR-0010 amendment): + `A→B REQUEST` and `A→B PUSH` coexist as distinct Connections; `label` stays + out of every key. +- #65 (arrowhead rendering) and #67 (export prints the interaction glyph) + consume the canonical helper; one place owns the mapping. +- The ADR-0023 four-case arrow test matrix is repurposed: cases now assert the + arrow derived from the Connection's own `interaction`, not from a routed Flow. diff --git a/docs/adr/0028-cross-scope-connections-lineal-ingress.md b/docs/adr/0028-cross-scope-connections-lineal-ingress.md new file mode 100644 index 0000000..bb84b79 --- /dev/null +++ b/docs/adr/0028-cross-scope-connections-lineal-ingress.md @@ -0,0 +1,99 @@ +# 28. A Connection may link any two Components at any scope; the same-Canvas invariant is retired + +## Status + +Accepted (#62, the re-founding slice). + +**Supersedes** [ADR-0005](0005-edge-scope-and-service-enforced-invariants.md)'s +same-Canvas endpoint invariant and its explicit-`canvasNodeId`-scope decision: +an Edge no longer stores its scope; scope is *derived* from endpoint ancestry +(the derivation lands in #63). The service-enforced-invariant *posture* +(correctness lives in the service, not the DB) survives — only the same-Canvas +and explicit-scope sub-decisions are superseded. + +**Supersedes** [ADR-0012](0012-routeflow-sole-cross-scope-edge-writer.md) +(`routeFlow` as the sole bounded cross-scope Edge writer) — the gated exception +is obsolete because *all* `connectNodes` writes may now be cross-scope; +`routeFlow` and the boundary-endpoint derivation are deleted with the Flow model. + +**Amends** [ADR-0024](0024-movenode-reparent-reject-orphaning.md) (`moveNode` +drops its orphan-reject — see below). **Relates to** +[ADR-0027](0027-connection-carries-its-own-interaction.md) (the typed-Connection +half of the same re-founding). + +## Context + +ADR-0005 enforced "both endpoints sit on the same Canvas as the Edge," recording +scope in an explicit `canvasNodeId` and anticipating exactly one gated loosening +(the M5 refinement Connection, realized as `routeFlow` in ADR-0012). + +The re-founding model makes cross-scope the *common* case, not a gated +exception: a Connection should link a top-level external API directly to a deep +internal handler, or a parent Component to a child it contains. The same-Canvas +rule and its single-exception writer are now friction, not safety. + +A parent→child (**lineal**) Connection has real meaning: it records **ingress** +— traffic entering the parent and continuing to the child. The model must accept +lineal endpoints, not just sibling-cross-scope ones. + +Storing scope (`canvasNodeId`) is now actively wrong: an Edge spanning scopes has +no single owning Canvas. Scope becomes a *derived* property of endpoint ancestry +(the derivation, and the boundary-proxy rendering it feeds, is #63). + +## Decision + +### `connectNodes` accepts cross-scope and lineal endpoints; rejects only the true self-link + +The only endpoint integrity rule left is `sourceId !== targetId`. +Sibling-cross-scope, ancestor↔descendant (lineal), and same-Canvas endpoints are +all valid. The endpoint Nodes are still confirmed live and in the owned Project +(cross-project smuggling stays closed), but their `parentId`s are not +constrained. + +### Lineal connections are allowed and express ingress + +A parent→child Connection (an ancestor and one of its descendants) is explicitly +legal and means **ingress**: traffic entering the parent and continuing to the +descendant. There is no lineal-reject. *(This is the load-bearing record the +issue mandates — "lineal connections = ingress, recorded explicitly.")* + +### Edge scope is derived from endpoint ancestry, not stored + +Drop `canvasNodeId`. The derivation that answers "which scope(s) does this Edge +appear on" — and the boundary-proxy rendering it feeds — is #63 / ADR-0031. #62 +lands the column drop and the loosened writer; until #63, a cross-scope Edge +renders on neither endpoint's Canvas (only same-Canvas Connections render). + +### The cross-scope gated writer is deleted, not generalized + +`routeFlow`, the boundary-endpoint derivation, the `FOR UPDATE` inner-Edge race +lock, and find-or-create inner-Edge convergence (all ADR-0012) are removed with +the Flow model. `connectNodes` is the single Edge writer and is now +unconditionally scope-agnostic. + +### `moveNode` drops its orphan-reject + +ADR-0024's orphan-reject existed solely because the same-Canvas invariant pinned +a Component's incident Edges to its Canvas, so a reparent would strand them. With +Connections allowed to span scopes, a reparented Component's incident Connections +simply become cross-scope — there is nothing to orphan. The orphan-reject and its +`conflictingEdgeIds` channel are removed. **The cycle-reject stays** — it is +independent of edge scope and remains `moveNode`'s sole rejection. + +## Consequences + +- **Reviewable invariant:** *`connectNodes` rejects only `sourceId === targetId`. + Re-introducing a same-Canvas check, a stored `canvasNodeId`, a lineal-reject, + or a separate gated cross-scope writer regresses this ADR.* +- ADR-0005's "scope is explicit, not inferred" reviewable invariant is + **inverted**: scope is now *derived*, and a future stored `canvasNodeId` is the + regression. Called out so a reviewer reading ADR-0005 in isolation is not + misled. +- The Edge cascade sweep that unioned `canvasNodeId` (ADR-0008) loses that column + from its predicate — endpoint membership (`sourceId ∈ S ∨ targetId ∈ S`) is now + the whole rule, and stays complete precisely because scope is gone (ADR-0030). +- Net deletion: the boundary-endpoint derivation, the inner-Edge race lock, and + find-or-create convergence (all ADR-0012) are gone with `routeFlow`. +- **Deferred to #63:** deriving an Edge's scope from ancestry; rendering an Edge + whose endpoints span scopes (the redefined boundary proxy / ADR-0031). #62 can + *create* cross-scope Edges but does not yet *render* them cross-scope. diff --git a/docs/adr/0030-cascade-undo-without-flowroutes.md b/docs/adr/0030-cascade-undo-without-flowroutes.md new file mode 100644 index 0000000..b065ad5 --- /dev/null +++ b/docs/adr/0030-cascade-undo-without-flowroutes.md @@ -0,0 +1,83 @@ +# 30. Component cascade and undo without FlowRoutes; `deleteEdge` reverts to a lone soft-delete; a `Spec` sweep arm is added + +## Status + +Accepted (#62, the re-founding slice). + +**Supersedes** [ADR-0014](0014-deleteedge-restoreedge-cascade.md) (the +`deleteEdge` / `restoreEdge` FlowRoute cascade) — the FlowRoute cascade is +deleted; `deleteEdge` reverts to the ADR-0008 lone soft-delete. + +**Amends** [ADR-0008](0008-cascading-soft-delete-stamped-batch.md) (the +stamped-batch cascade): the `deleteNode` / `restoreNode` Flow + FlowSpec + +FlowRoute arms are removed; a `Spec` sweep arm is added; spec-derived child +Components ride the existing subtree descent. **Relates to** +[ADR-0011](0011-flows-as-first-class-component-owned.md) (the Flow / FlowSpec +foundation being retired) and +[ADR-0028](0028-cross-scope-connections-lineal-ingress.md) (the dropped +`canvasNodeId` that simplifies the Edge sweep). + +## Context + +ADR-0008 established the stamped-`deletionId` batch: `deleteNode` cascades the +Node, its subtree, incident/interior Edges, and (later, via ADR-0011/0014) owned +Flows + FlowSpec + incident FlowRoutes. ADR-0014 extended the same mechanism to +`deleteEdge` so sweeping a Connection swept its FlowRoutes. + +#62 deletes Flow / FlowSpec / FlowRoute. Every cascade arm that touched them is +now dead code referencing dropped tables. The 1:1 spec row survives renamed as +**Spec** and must be swept with its owner Component. + +The Edge sweep predicate in ADR-0008 unioned `canvasNodeId` (to catch incident +Connections living on a parent Canvas). With `canvasNodeId` dropped (ADR-0028), +the predicate simplifies to endpoint membership. + +## Decision + +### `deleteNode` / `restoreNode` drop the Flow/FlowSpec/FlowRoute arms; gain a `Spec` sweep arm + +Stamp the owned `Spec` (1:1, `ownerNodeId @unique`) with the same `deletionId`; +`restoreNode` revives it in lockstep and pre-checks the `ownerNodeId @unique` +collision (a fresh Spec attached to the same Component since the delete blocks +the revival with a readable `ConflictError` carrying `conflictingSpecIds`). +Spec-derived child Components carry **no special arm** — they are ordinary +children swept by the existing subtree `parentId` descent. + +*(In #62 nothing writes a `Spec` yet — the spec→Component generator is #64 — so +the Spec sweep and its restore pre-check are forward-compat, exercised by tests +that seed a `Spec` row directly. The same posture ADR-0012's inner-Edge +pre-check used before its writer landed.)* + +### The Edge sweep predicate loses `canvasNodeId` + +Now `sourceId ∈ S ∨ targetId ∈ S` over the subtree S. A Connection incident to +any swept Component — same-Canvas, cross-scope, or an "incident" one up to a +surviving sibling — touches a swept endpoint and is caught. With scope no longer +stored (ADR-0028), endpoint membership is the whole predicate, and it stays +complete precisely because there is no scope column to miss. + +### `deleteEdge` reverts to a lone soft-delete + +No FlowRoute cascade, no conditional `deletionId`, no shared-inner-Edge reference +counting. `deleteEdge` sets `deletedAt` on one Edge and mints **no** `deletionId` +— the ADR-0008 lone-delete carve-out, now the *only* `deleteEdge` path. +`restoreEdge` survives only as the cascade-restore helper driven by +`restoreNode` (it restores the Edges a `deleteNode` batch stamped, pre-checking +the two new de-dupe indexes — ADR-0027/0028). + +## Consequences + +- **Reviewable invariant:** *`deleteNode` stamps exactly {target Node, subtree, + incident/interior Edges (by `sourceId`/`targetId` ∈ subtree), owned Spec} under + one `deletionId`; `deleteEdge` is always a lone soft-delete with no + `deletionId`. Re-introducing a FlowRoute arm, a conditional `deleteEdge` + `deletionId`, or a shared-inner-Edge sweep references dropped tables and + regresses this ADR.* +- The ADR-0012 `FOR UPDATE` inner-Edge race lock and reference-counted inner-Edge + sweep are deleted — no shared pipe survives, so no last-referer race exists. +- The fail-loud post-stamp orphan guard (ADR-0008's `assertNoOrphanedChildren`) + and the no-depth-cap subtree descent are **unchanged** — they protect the Node + subtree, untouched by Flow retirement. +- Correctness still rests on service tests against real Postgres (ADR-0003): the + cascade tests lose their Flow/FlowRoute cases and gain a Spec-sweep case and a + cross-scope-incident-Edge sweep case. diff --git a/prisma/migrations/20260601120000_retire_flow_model/migration.sql b/prisma/migrations/20260601120000_retire_flow_model/migration.sql new file mode 100644 index 0000000..221ee1a --- /dev/null +++ b/prisma/migrations/20260601120000_retire_flow_model/migration.sql @@ -0,0 +1,126 @@ +-- Retire the Flow capability model; make a Connection a directed, typed Edge +-- that may link any two Components at any scope (#62 / ADR-0027, ADR-0028, +-- ADR-0030). Drops Flow / FlowRoute and the cross-scope routing machinery, +-- renames the 1:1 spec row's enum, drops the stored Edge scope (`canvasNodeId`), +-- and re-keys Edge de-dupe onto two partial unique indexes. +-- +-- Node + plain Edge data are preserved; Flow-model data (Flow / FlowRoute / +-- FlowSpec rows) is droppable per the clean-redesign mandate. Every preserved +-- Edge backfills to `interaction = 'ASSOCIATION'`. + +-- CreateEnum. Fresh `Interaction` type with all five values (incl. the new +-- ASSOCIATION default) — a fresh CREATE TYPE avoids the Postgres "cannot use a +-- just-ADDed enum value in the same transaction" footgun a rename+ADD would hit. +CREATE TYPE "SpecKind" AS ENUM ('OPENAPI', 'ASYNCAPI', 'TS_SIGNATURE', 'GRAPHQL', 'SQL_DDL', 'CUSTOM'); +CREATE TYPE "Interaction" AS ENUM ('ASSOCIATION', 'REQUEST', 'PUSH', 'SUBSCRIBE', 'DUPLEX'); + +-- Add the Edge interaction column. The DEFAULT backfills every existing Edge to +-- ASSOCIATION (a plain undirected line) — the structural successor to the old +-- undirected Connection. +ALTER TABLE "Edge" ADD COLUMN "interaction" "Interaction" NOT NULL DEFAULT 'ASSOCIATION'; + +-- Residual-duplicate guard (ADR-0010 pattern). De-dupe re-keys from the dropped +-- `canvasNodeId` to `projectId`. The old same-Canvas rule forced any endpoint +-- pair onto exactly one Canvas (a Node has a single `parentId`), so this is a +-- guarded no-op for clean data — but the re-key is not provably collision-free +-- under hand-crafted rows, so refuse loudly rather than let CREATE UNIQUE INDEX +-- fail with a bare Postgres error. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "Edge" + WHERE "deletedAt" IS NULL AND "interaction" = 'ASSOCIATION' + GROUP BY "projectId", LEAST("sourceId", "targetId"), GREATEST("sourceId", "targetId") + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION + 'Refusing to create idx_edge_assoc_dedup: active ASSOCIATION Edges share a (projectId, unordered {source, target}) pair. Soft-delete the duplicates first.'; + END IF; +END$$; + +-- Drop the old scope-keyed dedup index (raw SQL from 20260601000252) before +-- dropping the `canvasNodeId` column it references. +DROP INDEX "idx_edge_dedup"; + +-- DropForeignKey (Flow model + the Edge canvas-scope FK). +ALTER TABLE "Edge" DROP CONSTRAINT "Edge_canvasNodeId_fkey"; +ALTER TABLE "Flow" DROP CONSTRAINT "Flow_ownerNodeId_fkey"; +ALTER TABLE "Flow" DROP CONSTRAINT "Flow_projectId_fkey"; +ALTER TABLE "Flow" DROP CONSTRAINT "Flow_sourceSpecId_fkey"; +ALTER TABLE "FlowRoute" DROP CONSTRAINT "FlowRoute_flowId_fkey"; +ALTER TABLE "FlowRoute" DROP CONSTRAINT "FlowRoute_innerEdgeId_fkey"; +ALTER TABLE "FlowRoute" DROP CONSTRAINT "FlowRoute_outerEdgeId_fkey"; +ALTER TABLE "FlowRoute" DROP CONSTRAINT "FlowRoute_projectId_fkey"; +ALTER TABLE "FlowSpec" DROP CONSTRAINT "FlowSpec_ownerNodeId_fkey"; +ALTER TABLE "FlowSpec" DROP CONSTRAINT "FlowSpec_projectId_fkey"; + +-- DropIndex (Prisma-managed scope index). +DROP INDEX "Edge_projectId_canvasNodeId_idx"; + +-- Edge: drop the stored Canvas scope. Scope is now derived from endpoint +-- ancestry (#63 / ADR-0028). +ALTER TABLE "Edge" DROP COLUMN "canvasNodeId"; + +-- Node: generated-component provenance columns (#64 populates them; #62 lands +-- the columns + cascade). +ALTER TABLE "Node" ADD COLUMN "sourceSpecId" TEXT, +ADD COLUMN "specKey" TEXT; + +-- DropTable. FlowRoute first (it FKs Flow/Edge), then Flow (it FKs FlowSpec), +-- then FlowSpec — and before the enum drops below, since Flow.kind / +-- Flow.interaction / FlowSpec.kind are their last consumers. +DROP TABLE "FlowRoute"; +DROP TABLE "Flow"; +DROP TABLE "FlowSpec"; + +-- DropEnum (after their tables are gone). +DROP TYPE "FlowInteraction"; +DROP TYPE "FlowKind"; +DROP TYPE "FlowSpecKind"; + +-- CreateTable Spec (the renamed 1:1 import row; Prisma-canonical names so the +-- drift gate stays green). Flow-model FlowSpec data is dropped per the +-- clean-redesign mandate; the spec→Component generator is rebuilt in #64. +CREATE TABLE "Spec" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "ownerNodeId" TEXT NOT NULL, + "kind" "SpecKind" NOT NULL, + "source" TEXT NOT NULL, + "parsedAt" TIMESTAMP(3), + "parseError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + "deletionId" TEXT, + + CONSTRAINT "Spec_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Spec_ownerNodeId_key" ON "Spec"("ownerNodeId"); +CREATE INDEX "Spec_projectId_idx" ON "Spec"("projectId"); +CREATE INDEX "Spec_deletionId_idx" ON "Spec"("deletionId"); +CREATE INDEX "Node_sourceSpecId_idx" ON "Node"("sourceSpecId"); + +-- AddForeignKey +ALTER TABLE "Node" ADD CONSTRAINT "Node_sourceSpecId_fkey" FOREIGN KEY ("sourceSpecId") REFERENCES "Spec"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Spec" ADD CONSTRAINT "Spec_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Spec" ADD CONSTRAINT "Spec_ownerNodeId_fkey" FOREIGN KEY ("ownerNodeId") REFERENCES "Node"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The two partial unique indexes that replace the single scope-keyed +-- `idx_edge_dedup` (ADR-0010 named pattern, re-keyed on `projectId`). Raw SQL — +-- Prisma's schema cannot express partial predicates or LEAST/GREATEST +-- expressions. `NULLS NOT DISTINCT` is no longer needed: every key column is NOT +-- NULL now that the nullable `canvasNodeId` is gone. +-- +-- directional: `interaction` is in the key, so A→B REQUEST and A→B PUSH are +-- distinct Connections, and the pair is ORDERED (A→B REQUEST ≠ B→A REQUEST). +CREATE UNIQUE INDEX "idx_edge_dedup" + ON "Edge" ("projectId", "sourceId", "targetId", "interaction") + WHERE "deletedAt" IS NULL AND "interaction" <> 'ASSOCIATION'; + +-- association: the UNORDERED pair, so A↔B and B↔A are one Association. +CREATE UNIQUE INDEX "idx_edge_assoc_dedup" + ON "Edge" ("projectId", LEAST("sourceId", "targetId"), GREATEST("sourceId", "targetId")) + WHERE "deletedAt" IS NULL AND "interaction" = 'ASSOCIATION'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 248d04d..e57af4e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,9 +79,7 @@ model Project { deletedAt DateTime? nodes Node[] edges Edge[] - flowSpecs FlowSpec[] - flows Flow[] - flowRoutes FlowRoute[] + specs Spec[] @@index([ownerId]) } @@ -155,31 +153,14 @@ enum NodeKind { PRODUCER } -// A Flow's category — drives palette icons and how a renderer formats the -// `signature` payload, never authorization or routing. Mirrored by the Zod enum -// `flowKind` in ~/lib/schemas (the client-safe source of truth); a compile-time -// parity guard in the service layer keeps the two in lockstep (CONTEXT.md -// "Flow kind"; ADR-0011). -enum FlowKind { - GENERIC - OPENAPI_OPERATION - GRAPHQL_FIELD - ASYNCAPI_CHANNEL - SSE_STREAM - WEBSOCKET - FUNCTION_CALL - EVENT - DB_TABLE -} - -// A FlowSpec's source format — selects which parser materializes Flows from -// `source`. OPENAPI / ASYNCAPI / GRAPHQL / SQL_DDL / TS_SIGNATURE each have a -// bounded parser in flow-parser/parsers; CUSTOM is hand-authored prose (no -// parser — source persists with a `parseError` note). Which kinds a Component -// is OFFERED is presentation-only, keyed by NodeKind in ~/lib/spec-kinds -// (ADR-0019 precedent: affinity ranks, never constrains) (CONTEXT.md -// "Flow spec kind"; ADR-0011). -enum FlowSpecKind { +// A Spec's source format — selects which parser materializes derived child +// Components from `source` (#64). OPENAPI / ASYNCAPI / GRAPHQL / SQL_DDL / +// TS_SIGNATURE each have a bounded parser; CUSTOM is hand-authored prose (no +// parser). Which kinds a Component is OFFERED is presentation-only, keyed by +// NodeKind (ADR-0019 precedent: affinity ranks, never constrains) (CONTEXT.md +// "Spec"; ADR-0011, ADR-0025). Renamed from `FlowSpecKind` with the Flow +// model's retirement (#62 / ADR-0030). +enum SpecKind { OPENAPI ASYNCAPI TS_SIGNATURE @@ -188,16 +169,20 @@ enum FlowSpecKind { CUSTOM } -// How a Flow's owner Component participates in the interaction (CONTEXT.md -// "Interaction"; ADR-0023). The owner-relative encoder from which a Connection's -// arrowheads are DERIVED — never a stored direction on the Edge: -// REQUEST — owner is called in request/response (REST, RPC) → arrow at owner -// PUSH — owner emits unprompted (SSE, webhook out, event) → arrow away -// SUBSCRIBE — owner consumes an external stream/feed → arrow at owner -// DUPLEX — owner both sends and receives (WebSocket) → arrows both ends -// Replaces the former binary FlowPolarity (INBOUND→REQUEST, OUTBOUND→PUSH); -// see ADR-0023, which supersedes ADR-0009/0013. -enum FlowInteraction { +// A Connection's type, stored on its Edge as `interaction` (CONTEXT.md +// "Interaction"; ADR-0027). Five values: a default undirected ASSOCIATION plus +// four directional interactions describing, relative to the Edge's `source` +// endpoint, how it participates — the verb from which a Connection's arrowheads +// are DERIVED together with draw order (rendering lands in #65): +// ASSOCIATION — a plain undirected relationship; no arrowheads (the default) +// REQUEST — source is called in request/response → arrow at target +// PUSH — source emits unprompted (SSE, webhook out) → arrow at target +// SUBSCRIBE — source consumes an external stream/feed → arrow at source +// DUPLEX — source both sends and receives (WebSocket) → arrows both ends +// Renamed from `FlowInteraction` and gained ASSOCIATION with the Flow model's +// retirement (#62 / ADR-0027, superseding ADR-0023). +enum Interaction { + ASSOCIATION REQUEST PUSH SUBSCRIBE @@ -233,201 +218,111 @@ model Node { // sets `deletedAt` with NO `deletionId`. deletionId String? - // Edges are scoped to a Canvas by an explicit `canvasNodeId` and reference - // their endpoints by `sourceId`/`targetId`; three relations to one model - // force Prisma's named-relation disambiguation (see ADR-0005). - edgesOnCanvas Edge[] @relation("EdgesOnCanvas") + // Generated-component provenance (CONTEXT.md "Spec"; #64). A Component + // derived from a Spec carries `sourceSpecId` (the Spec it came from) and a + // stable `specKey` (the per-format identity the parser anchors on). #62 + // lands the columns + cascade; the generation that populates them is #64. + sourceSpecId String? + spec Spec? @relation("SpecDerivedComponents", fields: [sourceSpecId], references: [id], onDelete: SetNull) + specKey String? + + // An Edge references its endpoints by `sourceId`/`targetId`; two relations + // to one model force Prisma's named-relation disambiguation (ADR-0005). An + // Edge no longer stores a Canvas scope — scope is derived from endpoint + // ancestry (#63 / ADR-0028). outgoingEdges Edge[] @relation("EdgesFromNode") incomingEdges Edge[] @relation("EdgesToNode") - flowSpec FlowSpec? @relation("FlowSpecOnNode") - flows Flow[] @relation("FlowOnNode") + // The imported contract owned by this Component (1:1). Renamed from + // `flowSpec` with the Flow model's retirement (#62). + ownedSpec Spec? @relation("SpecOnNode") @@index([projectId, parentId]) @@index([parentId]) + @@index([sourceSpecId]) @@index([deletionId]) } // The data-model representation of a Connection (see CONTEXT.md "Connection / -// Edge split"); never surfaced to users by this name. Its Canvas is the -// EXPLICIT `canvasNodeId` (the Component whose interior Canvas owns it; null = -// the Project root) — recorded, NOT inferred from the endpoints, so a future -// refinement Connection can legitimately span scope levels (ADR-0005). The -// three invariants — same-Canvas, no self-link, no duplicate ACTIVE Edge for -// the UNORDERED endpoint pair within a scope — are primarily enforced in -// `connectNodes`. The de-dupe invariant additionally has the partial unique -// index `idx_edge_dedup` (in prisma/migrations; ADR-0010) as a TOCTOU backstop -// — the service emits the readable `ConflictError`, the index catches the -// concurrent racer the service can't. As of ADR-0023 the index is an EXPRESSION -// index over `(canvasNodeId, LEAST(sourceId, targetId), GREATEST(sourceId, -// targetId))` so A→B and B→A collide as ONE Connection regardless of which way -// it was drawn. It uses `NULLS NOT DISTINCT` so two root-Canvas edges -// (canvasNodeId = NULL) on the same pair are correctly rejected (Postgres' -// default would let them both pass). Do NOT add a plain `@@unique` here — it -// would wrongly forbid re-creating a Connection after soft-delete, and could -// not express the unordered pair anyway; the partial expression index lives in -// raw SQL. The FK cascades are the hard-delete backstop only — the normal -// removal path is soft-delete via `deletedAt`. `label` is untrusted user -// content. `sourceId`/`targetId` are just the two endpoints in arbitrary draw -// order — they carry NO direction; a Connection is undirected and its -// arrowheads are derived from the Flows routed on it (CONTEXT.md "Interaction"; -// ADR-0023). +// Edge split"); never surfaced to users by this name. A directed, typed edge +// that may link any two Components at any scope — same-Canvas, cross-scope, or +// lineal (ancestor↔descendant; ADR-0028). It stores NO Canvas scope: scope is +// derived from endpoint ancestry at read time (#63), so an Edge may freely span +// scope levels. It carries its own `interaction` (default ASSOCIATION); the +// arrowheads are DERIVED from `(interaction, source, target)` at render time +// (#65), never a stored direction. `sourceId`/`targetId` preserve draw order. +// The two invariants — no self-link, no duplicate ACTIVE Edge per the de-dupe +// rule — are primarily enforced in `connectNodes` (ADR-0028, retiring the +// same-Canvas invariant of ADR-0005). +// +// De-dupe is TWO partial unique indexes (in prisma/migrations; ADR-0010 named +// pattern, re-keyed on `projectId` now that scope is not stored), service- +// primary with the index as a TOCTOU backstop: +// - `idx_edge_dedup` — directional, over `(projectId, sourceId, targetId, +// interaction)` WHERE deletedAt IS NULL AND interaction <> 'ASSOCIATION'. +// `interaction` is in the key, so A→B REQUEST and A→B PUSH coexist. +// - `idx_edge_assoc_dedup` — ASSOCIATION-only, over `(projectId, +// LEAST(sourceId,targetId), GREATEST(sourceId,targetId))` WHERE deletedAt +// IS NULL AND interaction = 'ASSOCIATION', so A↔B and B↔A are one +// Association. `NULLS NOT DISTINCT` is no longer needed (projectId is NOT +// NULL, unlike the retired nullable `canvasNodeId`). +// Do NOT add a plain `@@unique` here — it would forbid re-creating a Connection +// after soft-delete and cannot express the unordered/partial predicates; the +// raw SQL lives in the migration. `label` is untrusted user content. model Edge { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - canvasNodeId String? - canvasNode Node? @relation("EdgesOnCanvas", fields: [canvasNodeId], references: [id], onDelete: Cascade) sourceId String source Node @relation("EdgesFromNode", fields: [sourceId], references: [id], onDelete: Cascade) targetId String target Node @relation("EdgesToNode", fields: [targetId], references: [id], onDelete: Cascade) + interaction Interaction @default(ASSOCIATION) label String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? // Batch id of the cascading soft-delete that swept this Edge (see // Node.deletionId / ADR-0008). Set by `deleteNode`'s cascade across the - // subtree, and by `deleteEdge` when at least one incident FlowRoute is - // swept alongside the Edge (Slice 2). A lone `deleteEdge` on an Edge that - // carries no FlowRoutes still mints no `deletionId` — the "lone delete" - // carve-out ADR-0008 codified for the dominant case. + // subtree. A `deleteEdge` is always a lone soft-delete — it sets + // `deletedAt` with NO `deletionId` (the FlowRoute cascade is gone; + // ADR-0030). deletionId String? - // FlowRoutes that ride this Edge. Two relation arms because a FlowRoute - // references an Edge twice — as the `outer` pipe at this scope and as the - // `inner` refinement one scope deeper (Slice 3). Slice 2 only ever writes - // `outer`; the `inner` arm is kept ahead of its writer so Slice 3 needs no - // schema change. Forces named-relation disambiguation, the same shape Node - // uses for its three Edge relations (see ADR-0005). - flowRoutesAsOuter FlowRoute[] @relation("FlowRoutesAsOuter") - flowRoutesAsInner FlowRoute[] @relation("FlowRoutesAsInner") - - @@index([projectId, canvasNodeId]) @@index([sourceId]) @@index([targetId]) @@index([deletionId]) } -// The imported contract on a Component (CONTEXT.md "FlowSpec"; ADR-0011) — an -// OpenAPI / AsyncAPI / TS-signature / GraphQL document, or hand-authored CUSTOM -// prose. 1:1 with a Component (`ownerNodeId @unique`): exactly one current -// FlowSpec per Component. Parsing is server-side, parse-on-write into Flow -// rows (never on read), with a bounded loader (size + depth caps) so a hostile -// spec cannot OOM. `source` is UNTRUSTED user-pasted content — stored -// verbatim, never interpolated (prompt-injection standing note). A malformed -// spec stores `parseError`, creates zero Flows, and never throws to the -// caller. Re-pasting is non-destructive: matching keys preserved, dropped keys -// soft-deleted with a fresh `deletionId` per re-parse batch (ADR-0008 pattern -// extended additively). -model FlowSpec { - id String @id @default(cuid()) +// The imported contract on a Component (CONTEXT.md "Spec"; ADR-0011, ADR-0025) +// — an OpenAPI / AsyncAPI / TS-signature / GraphQL / SQL-DDL document, or +// hand-authored CUSTOM prose. 1:1 with a Component (`ownerNodeId @unique`): +// exactly one current Spec per Component. Renamed from `FlowSpec` with the Flow +// model's retirement (#62); where the old FlowSpec projected Flows, a Spec now +// points at derived child Components (via their `Node.sourceSpecId` + +// `Node.specKey`). `source` is UNTRUSTED user-pasted content — stored verbatim, +// never interpolated (prompt-injection standing note). #62 lands the renamed +// row + the cascade (`deleteNode` sweeps the owned Spec — ADR-0030); the +// spec→Component generation (parse, diff, merge) is #64. +model Spec { + id String @id @default(cuid()) projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - ownerNodeId String @unique - ownerNode Node @relation("FlowSpecOnNode", fields: [ownerNodeId], references: [id], onDelete: Cascade) - kind FlowSpecKind + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + ownerNodeId String @unique + ownerNode Node @relation("SpecOnNode", fields: [ownerNodeId], references: [id], onDelete: Cascade) + kind SpecKind source String parsedAt DateTime? parseError String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - deletionId String? - flows Flow[] @relation("FlowDerivedFromSpec") - - @@index([projectId]) - @@index([deletionId]) -} - -// A named capability a Component exposes (CONTEXT.md "Flow"; ADR-0011) — an -// OpenAPI operation, a WS channel, an SSE stream, a function call, an event. -// Owned by a Component (`ownerNodeId`) and exists on the owner whether or not -// anything calls it. May be derived from a FlowSpec (`sourceSpecId != null`) -// or user-authored (`sourceSpecId = null`). `title` is UNTRUSTED display -// content; `signature` is UNTRUSTED structured content (prompt-injection -// standing note applies). The de-dupe rule `(ownerNodeId, key)` among active -// rows follows the ADR-0010 named pattern: service-primary `findFirst` fast -// path with a partial unique index `idx_flow_dedup` (in prisma/migrations) as -// a TOCTOU backstop — both translate to the same `ConflictError` shape with -// `details.conflictingFlowIds`. The partial index expresses -// `WHERE "deletedAt" IS NULL`, which Prisma schema cannot today; the raw SQL -// lives in the migration. `NULLS NOT DISTINCT` is intentionally omitted — -// both columns are NOT NULL, unlike `idx_edge_dedup` where `canvasNodeId` is -// nullable. -model Flow { - id String @id @default(cuid()) - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - ownerNodeId String - ownerNode Node @relation("FlowOnNode", fields: [ownerNodeId], references: [id], onDelete: Cascade) - sourceSpecId String? - sourceSpec FlowSpec? @relation("FlowDerivedFromSpec", fields: [sourceSpecId], references: [id], onDelete: SetNull) - kind FlowKind @default(GENERIC) - key String - title String - interaction FlowInteraction - signature Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - deletionId String? - - flowRoutes FlowRoute[] @relation("FlowRoutesForFlow") - - @@index([projectId, ownerNodeId]) - @@index([ownerNodeId]) - @@index([sourceSpecId]) - @@index([deletionId]) -} - -// The binding that says "this Connection at this scope carries this Flow" -// (see CONTEXT.md "FlowRoute"; master plan -// docs/plans/flow-routed-connections.md). A first-class row, individually -// addressable and individually soft-deletable, so wiring is removable without -// touching either the Flow or the Connection. -// -// Slice 2 covers the same-Canvas baseline: `outerEdgeId` names the Connection -// that carries the Flow at this scope; `innerEdgeId` is nullable but ALWAYS -// null in Slice 2 — Slice 3 introduces the gated cross-scope inner-Edge -// writer (`routeFlow` extended; ADR-0012). Keeping `innerEdgeId` in the -// schema ahead of its writer is forward-compat: Slice 3 adds nothing to the -// model. -// -// Owner-only via the owning Project (the same projectId-redundancy pattern -// Edge / Flow / FlowSpec carry — authz needs `project.ownerId` without an -// extra join, and the cascade sweeps filter by projectId for index -// friendliness). -// -// The de-dupe rule `(outerEdgeId, flowId)` among active rows follows the -// ADR-0010 named pattern (third adopter after `idx_edge_dedup` and -// `idx_flow_dedup`): service-primary `findFirst` fast path with a partial -// unique index `idx_flow_route_dedup` (in prisma/migrations) as a TOCTOU -// backstop — both translate to the same `ConflictError` shape with -// `details.conflictingFlowRouteIds`. The partial index expresses -// `WHERE "deletedAt" IS NULL`, which Prisma schema cannot today; the raw -// SQL lives in the migration. `NULLS NOT DISTINCT` is intentionally omitted -// — both columns are NOT NULL, unlike `idx_edge_dedup` where `canvasNodeId` -// is nullable. -model FlowRoute { - id String @id @default(cuid()) - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - flowId String - flow Flow @relation("FlowRoutesForFlow", fields: [flowId], references: [id], onDelete: Cascade) - outerEdgeId String - outerEdge Edge @relation("FlowRoutesAsOuter", fields: [outerEdgeId], references: [id], onDelete: Cascade) - innerEdgeId String? - innerEdge Edge? @relation("FlowRoutesAsInner", fields: [innerEdgeId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deletedAt DateTime? deletionId String? + // Child Components derived from this Spec (#64). The back-relation to + // `Node.sourceSpecId`. + derivedComponents Node[] @relation("SpecDerivedComponents") @@index([projectId]) - @@index([flowId]) - @@index([outerEdgeId]) - @@index([innerEdgeId]) @@index([deletionId]) } diff --git a/src/app/p/[slug]/_canvas/boundary-group-node.tsx b/src/app/p/[slug]/_canvas/boundary-group-node.tsx deleted file mode 100644 index 7b73ff1..0000000 --- a/src/app/p/[slug]/_canvas/boundary-group-node.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; - -import { type Node, type NodeProps } from "@xyflow/react"; -import { Boxes, ChevronDown, ChevronRight } from "lucide-react"; -import { useState } from "react"; - -import { KIND_ICON, KIND_LABEL } from "~/lib/node-kinds"; -import { type NodeKind } from "~/lib/schemas"; - -export type BoundaryGroupMember = { - nodeId: string; - title: string; - kind: NodeKind; -}; - -export type BoundaryGroupNodeData = { - members: BoundaryGroupMember[]; -}; - -export type BoundaryGroupNode = Node; - -/** - * The boundary-group node type for the Canvas (#14 follow-up): the single - * read-only stand-in a Canvas renders in place of its inherited boundary - * proxies, so a deep Canvas with many ancestors is not buried under N - * un-routable stand-ins (CONTEXT.md "Boundary group"). Like a boundary proxy it - * is derived (the `origin === "inherited"` rows of `deriveBoundaryProxies`), - * never a persisted Component — so not draggable, selectable, deletable, or - * descendable. Deliberately paletteless: refinement only binds an outer Edge - * incident to the current scope, so inherited proxies are context-only — route - * at the scope where the direct Connection lives (ADR-0012). Distinct from React - * Flow's own `"group"` node type, a parent-of-children layout primitive; this is - * a render-layer regrouping, not a positional parent. - * - * Client-only: domain types come from `~/lib` (never `~/server`), so the server - * graph stays out of the browser bundle (ADR-0004). Member `title` is untrusted - * user content rendered as plain text (prompt-injection standing note). - */ -export function BoundaryGroupNodeView({ data }: NodeProps) { - const count = data.members.length; - // React Flow reuses this node by id across getCanvas refetches (the id is - // derived from the scope, not the member set), so a one-shot `useState` - // initializer would latch the collapsed default even as members change. Derive - // the default; a user's explicit toggle wins thereafter. Same anti-latch shape - // the per-proxy node uses for its palette. Inherited externals are context, - // not a work surface, so the default is collapsed. - const [userToggled, setUserToggled] = useState(undefined); - const expanded = userToggled ?? false; - - return ( -
-
- - - {count} inherited {count === 1 ? "external" : "externals"} - - -
- - {expanded && ( -
    - {data.members.map((member) => { - const Icon = KIND_ICON[member.kind]; - return ( -
  • - - {member.title} - - {KIND_LABEL[member.kind]} - -
  • - ); - })} -
- )} -
- ); -} diff --git a/src/app/p/[slug]/_canvas/boundary-proxy-node.tsx b/src/app/p/[slug]/_canvas/boundary-proxy-node.tsx deleted file mode 100644 index 8f1e2a4..0000000 --- a/src/app/p/[slug]/_canvas/boundary-proxy-node.tsx +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; - -import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; -import { ChevronDown, ChevronRight } from "lucide-react"; -import { useContext, useState } from "react"; - -import { FLOW_INTERACTION_DISPLAY } from "~/lib/flow-interaction-display"; -import { type NodeKind } from "~/lib/schemas"; -import { type CanvasFlowPaletteItem } from "~/lib/types"; - -import { KIND_ICON } from "~/lib/node-kinds"; - -import { CanEditContext } from "./component-node"; - -/** - * The handle id prefix that marks a React Flow Handle as a Flow palette item on - * a boundary proxy. A drag to/from such a handle is a refinement route, not a - * plain Connection — the Canvas island branches on this prefix in `onConnect` - * and dispatches `routeFlow` with the encoded `flowId` (Slice 3 / ADR-0012). - */ -export const FLOW_HANDLE_PREFIX = "flow:"; - -export function flowHandleId(flowId: string): string { - return `${FLOW_HANDLE_PREFIX}${flowId}`; -} - -export function flowIdFromHandle(handleId: string | null | undefined): string | null { - return handleId?.startsWith(FLOW_HANDLE_PREFIX) - ? handleId.slice(FLOW_HANDLE_PREFIX.length) - : null; -} - -export type BoundaryProxyNodeData = { - title: string; - kind: NodeKind; - // "direct": an external the current scope connects to on its own parent - // Canvas — routable here. "inherited": projected down from an ancestor — - // context-only, collapsed by default to keep deep Canvases uncluttered (#14). - origin: "direct" | "inherited"; - // The single incident outer Connection a palette drag refines (ADR-0023). A - // Connection is undirected, so any Flow rides it regardless of interaction; - // null for inherited or unconnected proxies. - outerEdgeId: string | null; - flows: CanvasFlowPaletteItem[]; - hasMore: boolean; -}; - -export type BoundaryProxyNode = Node; - -/** - * The boundary-proxy node type for the Canvas (#14): a read-only, visually - * distinct stand-in for an external Component this scope connects to, projected - * inward so dependency context is not lost on Descent (CONTEXT.md "Boundary - * proxy"). It cannot be renamed, descended, or deleted — it is derived, never a - * persisted Component. - * - * For a DIRECT proxy an owner can refine its Flows: each palette item carries a - * React Flow Handle whose id encodes the Flow (`flowHandleId`), so dragging - * between a child Component's Port and a palette item synthesises a refinement - * route through the island's `onConnect` (Slice 3 / ADR-0012). The route is - * direction-agnostic — any Flow rides the single incident Connection regardless - * of its interaction, and the rendered arrowheads are derived from the routed - * Flows (ADR-0023). The handle's `type`/`position` are cosmetic-only (they stop - * mattering once the canvas runs in Loose mode). - * - * Client-only: domain types come from `~/lib` (never `~/server`), so the server - * graph stays out of the browser bundle (ADR-0004). `title`/`key` are untrusted - * user content rendered as plain text (prompt-injection standing note). - */ -export function BoundaryProxyNodeView({ data }: NodeProps) { - const Icon = KIND_ICON[data.kind]; - const canEdit = useContext(CanEditContext); - // A direct proxy with an incident outer Connection is routable. The - // Connection is undirected, so any Flow can ride it (ADR-0023). - const routable = data.origin === "direct" && data.outerEdgeId !== null; - // Inherited proxies start collapsed (context, not a work surface); direct - // proxies with a palette start open so the refinement gesture is discoverable. - // The default is DERIVED from `data` so a proxy that first renders with no - // flows (cold cache / pre-seed) opens once its palette arrives — React Flow - // reuses the node by id across getCanvas refetches, so a one-shot `useState` - // initializer would latch the empty-state default. A user's explicit toggle - // (`userToggled`) wins thereafter. - const [userToggled, setUserToggled] = useState(undefined); - const expanded = - userToggled ?? (data.origin === "direct" && data.flows.length > 0); - - return ( -
-
- - {data.title} - - {data.origin === "direct" ? "external" : "inherited"} - - {data.flows.length > 0 && ( - - )} -
- - {expanded && data.flows.length > 0 && ( -
    - {data.flows.map((flow) => { - // "Points at owner" (owner consumes) drags a child's output INTO - // the proxy (a target handle); PUSH (owner emits) drags onto a - // child's input (a source handle). DUPLEX points both ways — under - // the still-strict refinement handle it takes the consume side. - // (The handle type stops mattering once Slice 4 switches the canvas - // to Loose mode; ADR-0023.) - const ownerConsumes = flow.interaction !== "PUSH"; - const display = FLOW_INTERACTION_DISPLAY[flow.interaction]; - return ( -
  • - - {display.short} - -
    - {flow.title} - - {flow.key} - -
    - {/* Refinement Port. Rendered only when the owner can route here - (a direct proxy). An INBOUND Flow is consumed by the owner, - so the child's output drags INTO it (target); an OUTBOUND - Flow is emitted by the owner, so it drags onto a child's - input (source). The id encodes the Flow for `onConnect`. */} - {routable && canEdit && ( - - )} -
  • - ); - })} -
- )} - - {expanded && data.hasMore && ( -

- More flows available… -

- )} -
- ); -} diff --git a/src/app/p/[slug]/_canvas/canvas.tsx b/src/app/p/[slug]/_canvas/canvas.tsx index f78d971..2f98e05 100644 --- a/src/app/p/[slug]/_canvas/canvas.tsx +++ b/src/app/p/[slug]/_canvas/canvas.tsx @@ -8,8 +8,6 @@ import { type Connection, ConnectionMode, Controls, - type EdgeMarker, - MarkerType, Panel, ReactFlow, ReactFlowProvider, @@ -22,27 +20,12 @@ import { Suspense, useCallback, useMemo, useRef, useState } from "react"; import { Toaster, toast } from "sonner"; import { canConnect } from "~/lib/connection-rules"; -import { type FlowKind, type NodeKind } from "~/lib/schemas"; -import { - type CanvasBoundaryProxy, - type CanvasData, - type CanvasEdge, - type CanvasFlowPalette, - type CanvasNode, -} from "~/lib/types"; +import { type NodeKind } from "~/lib/schemas"; +import { type CanvasData, type CanvasEdge, type CanvasNode } from "~/lib/types"; import { api } from "~/trpc/react"; import { AddComponent } from "./add-component"; import { CopyMarkdownToolbar } from "./copy-markdown"; -import { - BoundaryGroupNodeView, - type BoundaryGroupNode, -} from "./boundary-group-node"; -import { - BoundaryProxyNodeView, - flowIdFromHandle, - type BoundaryProxyNode, -} from "./boundary-proxy-node"; import { ComponentDetailPanel, prefetchDocsEditor, @@ -58,10 +41,7 @@ import { import { ConnectionEdgeView, EditEdgeContext, - RouteFlowContext, type ConnectionEdge, - type ConnectionEdgeFlows, - type RouteFlowAction, } from "./connection-edge"; /** @@ -80,105 +60,18 @@ import { * same model — one mutation per gesture — writing the store and the cache mirror * together (via `patchCanvas`, which preserves sibling keys) and rolling both * back with a toast on failure. + * + * As of #62 every Connection renders as a plain line (its `interaction` defaults + * to `ASSOCIATION`); typed arrowheads and cross-scope rendering arrive in the + * next slices (#65 / #63). */ // Module-level: React Flow re-mounts every node/edge (and warns) if `nodeTypes` // / `edgeTypes` is a fresh object each render. Defining them once is the key // React Flow perf guard. -const nodeTypes = { - component: ComponentNodeView, - "boundary-proxy": BoundaryProxyNodeView, - "boundary-group": BoundaryGroupNodeView, -}; +const nodeTypes = { component: ComponentNodeView }; const edgeTypes = { connection: ConnectionEdgeView }; -// The Canvas holds three node kinds: interactive Components (draggable, -// persisted), read-only boundary proxies (derived, never persisted), and the -// boundary-group container that bundles inherited proxies (also derived). All -// live in the one React Flow `nodes` array. -type CanvasRFNode = ComponentNode | BoundaryProxyNode | BoundaryGroupNode; - -// "Passive node" is the taxonomy term (CONTEXT.md, ADR-0016) for a derived, -// read-only Canvas node — currently boundary-proxy and the boundary-group -// container — excluded from the detail panel, Descent, and hover-prefetch. -// Param is the discriminated union so a stray non-Canvas node cannot be passed -// and a new union member surfaces here when added. -function isPassiveNode(node: CanvasRFNode): boolean { - return node.type === "boundary-proxy" || node.type === "boundary-group"; -} - -// Boundary proxies have no stored position (they are derived, #13). Lay them -// out deterministically in a row above the interior Components so they read as -// "the outside, up top" and fitView frames them with the content. Direct -// (routable) proxies sort ahead of inherited ones (the query already orders -// them that way), so the routable Ports cluster on the left; the boundary-group -// container holding the inherited proxies sits in the column after them. -const BOUNDARY_ROW_Y = -220; -const BOUNDARY_COL_W = 260; - -function toBoundaryRFNode( - proxy: CanvasBoundaryProxy, - palette: CanvasFlowPalette | undefined, - index: number, -): BoundaryProxyNode { - return { - id: proxy.nodeId, - type: "boundary-proxy", - position: { x: index * BOUNDARY_COL_W, y: BOUNDARY_ROW_Y }, - draggable: false, - selectable: false, - deletable: false, - data: { - title: proxy.title, - kind: proxy.kind, - origin: proxy.origin, - outerEdgeId: proxy.outerEdgeId, - flows: palette?.flows ?? [], - hasMore: palette?.hasMore ?? false, - }, - }; -} - -// Bundle the inherited proxies into one read-only container node (#14). Its id -// is derived from the scope (not from any member), so it stays stable across -// getCanvas refetches — React Flow reuses the node and preserves the user's -// expand toggle even as the member set changes. Boundary proxies never exist at -// the root scope (deriveBoundaryProxies returns none), but the `?? "root"` -// keeps the id total. No palette/edge data: inherited proxies are not routable -// here (ADR-0012), so the container needs only enough to list its members. -function toBoundaryGroupRFNode( - inherited: CanvasBoundaryProxy[], - indexAfterDirect: number, - canvasNodeId: string | null, -): BoundaryGroupNode { - return { - id: `boundary-group:${canvasNodeId ?? "root"}`, - type: "boundary-group", - position: { x: indexAfterDirect * BOUNDARY_COL_W, y: BOUNDARY_ROW_Y }, - draggable: false, - selectable: false, - deletable: false, - data: { - members: inherited.map((p) => ({ - nodeId: p.nodeId, - title: p.title, - kind: p.kind, - })), - }, - }; -} - -// A Connection is undirected; its arrowheads are DERIVED from the Flows routed -// on it (ADR-0023). `withEdgeFlows` sets `markerStart` (arrow at the source -// endpoint) and/or `markerEnd` (arrow at the target endpoint) from each Edge's -// `arrowAtSource`/`arrowAtTarget` counts. These are MODULE CONSTANTS (never -// freshly built per render) so React Flow registers each marker once instead of -// re-registering every frame (the same identity-stability guard the structural -// marker carried). A freshly drawn Connection has no routed Flows, so it gets -// neither marker — a plain undirected line until a Flow gives it direction. -const MARKER_START: EdgeMarker = { type: MarkerType.ArrowClosed }; -const MARKER_END: EdgeMarker = { type: MarkerType.ArrowClosed }; - function toRFNode(n: CanvasNode): ComponentNode { return { id: n.id, @@ -191,7 +84,6 @@ function toRFNode(n: CanvasNode): ComponentNode { title: n.title, kind: n.kind, optimistic: n.id.startsWith("temp_"), - flowCount: n._count.flows, }, }; } @@ -202,8 +94,8 @@ function toRFEdge(e: CanvasEdge): ConnectionEdge { type: "connection", source: e.sourceId, target: e.targetId, - // No marker here — `withEdgeFlows` derives markerStart/markerEnd from the - // routed Flows (ADR-0023). A bare edge renders as an undirected line. + // No marker — every Connection renders as a plain line in this slice; the + // interaction-derived arrowheads are wired in #65 (ADR-0027). data: { label: e.label, optimistic: e.id.startsWith("temp_"), @@ -211,51 +103,6 @@ function toRFEdge(e: CanvasEdge): ConnectionEdge { }; } -/** - * Hydrate a base RF edge with its `edgeFlows` aggregation (Slice 2) and the - * endpoint metadata the "+ flow" popover needs. Kept as a free function (not - * folded into `toRFEdge`) because the per-edge merge needs canvas-wide - * `edgeFlows` + the source/target Component titles, and `toRFEdge` is also - * used during optimistic edge reconciliation where only the edge itself is - * known. - */ -function withEdgeFlows( - edge: ConnectionEdge, - flowsByEdge: Map, - titleByNode: Map, - slug: string, -): ConnectionEdge { - const flows = flowsByEdge.get(edge.id); - // `edge.source` / `edge.target` are React Flow's foreign-key fields — same - // values as the underlying `sourceId` / `targetId`. - const sourceTitle = titleByNode.get(edge.source); - const targetTitle = titleByNode.get(edge.target); - // Derived arrowheads: an arrow at the source endpoint is a `markerStart`, at - // the target endpoint a `markerEnd` (ADR-0023). Module-constant marker objects - // keep React Flow from re-registering markers every frame. Undefined (no - // routed Flows that way) renders no arrowhead on that end. - return { - ...edge, - markerStart: (flows?.arrowAtSource ?? 0) > 0 ? MARKER_START : undefined, - markerEnd: (flows?.arrowAtTarget ?? 0) > 0 ? MARKER_END : undefined, - data: { - ...edge.data, - label: edge.data?.label ?? null, - edgeFlows: flows, - endpoints: - sourceTitle !== undefined && targetTitle !== undefined - ? { - slug, - sourceId: edge.source, - sourceTitle, - targetId: edge.target, - targetTitle, - } - : undefined, - }, - }; -} - function optimisticCanvasNode( id: string, projectId: string, @@ -278,16 +125,15 @@ function optimisticCanvasNode( updatedAt: now, deletedAt: null, deletionId: null, - // A freshly-created Component owns no Flows yet (ADR-0011); the "N flows" - // pill renders only when the count is non-zero, so a 0 is correct here. - _count: { flows: 0 }, + // Generated-component provenance is null for a hand-added Component (#64). + sourceSpecId: null, + specKey: null, }; } function optimisticCanvasEdge( id: string, projectId: string, - canvasNodeId: string | null, sourceId: string, targetId: string, ): CanvasEdge { @@ -295,9 +141,12 @@ function optimisticCanvasEdge( return { id, projectId, - canvasNodeId, sourceId, targetId, + // A freshly-drawn Connection is an ASSOCIATION (a plain line) until the user + // types it (#65). The optimistic shape must match the getCanvas edge row so + // remount reconciliation never flickers (ADR-0027). + interaction: "ASSOCIATION", label: null, createdAt: now, updatedAt: now, @@ -325,26 +174,6 @@ function messageForDocsSaveFailure(error: unknown): string { return "Couldn't save documentation. Please try again."; } -/** - * Applies a +1/-1 delta to one Flow-kind bucket of an `edgeFlows` entry's - * `byKind` map, keeping the optimistic mirror in the same shape the server - * returns (zero-count kinds omitted, never negative). - */ -function bumpByKind( - byKind: Partial>, - kind: FlowKind, - delta: number, -): Partial> { - const next = { ...byKind }; - const value = (next[kind] ?? 0) + delta; - if (value <= 0) { - delete next[kind]; - } else { - next[kind] = value; - } - return next; -} - function CanvasInner({ scope, slug, @@ -360,16 +189,15 @@ function CanvasInner({ const router = useRouter(); // The root scope is the sentinel string "root" at the island boundary // (ADR-0004); every other scope IS a Node id — the parentId of this Canvas's - // Components and the canvasNodeId of its Connections. + // Components. (An Edge no longer stores a scope; ADR-0028.) const canvasNodeId = scope === "root" ? null : scope; // Stable across renders so it stays a single query key and a stable callback dep. const canvasInput = useMemo( () => ({ slug, canvasNodeId }), [slug, canvasNodeId], ); - const [ - { interiorNodes, interiorEdges, edgeFlows, boundaryProxies, flowPalettes, breadcrumbs }, - ] = api.architecture.getCanvas.useSuspenseQuery(canvasInput); + const [{ interiorNodes, interiorEdges, breadcrumbs }] = + api.architecture.getCanvas.useSuspenseQuery(canvasInput); // The kind palette ranks its suggestions by the scope's own Component kind — // the parent of any Component added here (CONTEXT.md "Kind affinity"). The @@ -381,61 +209,14 @@ function CanvasInner({ // owns interaction state. The island is keyed by scope (./index), so a Descent // (a scope change) remounts and re-seeds rather than inheriting these. // Persistence flows through one batched/single mutation per gesture (below), - // with the query cache kept in lockstep so a remount re-seeds it. Boundary - // proxies seed alongside the Components: they are read-only and never the - // subject of a Component mutation (their ids never match a rename/delete/ - // reconcile target), so they ride safely in the same store (#14). - // - // Direct proxies render individually (they are the routable work surface, #36); - // the inherited ones bundle into one boundary-group container so a deep Canvas - // is not buried under N un-routable stand-ins (#14). The container renders even - // for a single inherited proxy — one consistent affordance, and a refetch that - // flips the inherited count between 1 and 2 never reshuffles the surface. - const directProxies = boundaryProxies.filter((p) => p.origin === "direct"); - const inheritedProxies = boundaryProxies.filter( - (p) => p.origin === "inherited", + // with the query cache kept in lockstep so a remount re-seeds it. + const [nodes, setNodes, onNodesChange] = useNodesState( + interiorNodes.map(toRFNode), ); - const [nodes, setNodes, onNodesChange] = useNodesState([ - ...interiorNodes.map(toRFNode), - ...directProxies.map((p, i) => - toBoundaryRFNode(p, flowPalettes[p.nodeId], i), - ), - ...(inheritedProxies.length > 0 - ? [ - toBoundaryGroupRFNode( - inheritedProxies, - directProxies.length, - canvasNodeId, - ), - ] - : []), - ]); - // Initial edges seed: pure structural shape (no edgeFlows yet). The - // `edgesWithFlows` useMemo below hydrates the aggregation + endpoint - // metadata across rerenders. const [edges, setEdges, onEdgesChange] = useEdgesState( interiorEdges.map(toRFEdge), ); - // Slice 2: merge the canvas-wide edgeFlows aggregation and the endpoint - // Component titles onto each edge's `data`, in one place so the per-edge - // pill + "+ flow" trigger never have to fish for canvas-level state. Keyed - // on `[edges, edgeFlows, interiorNodes]` — the React Flow `edges` state is - // included so a freshly-drawn optimistic edge picks up its `endpoints` - // metadata on the next frame (its `edgeFlows` stays undefined until the - // server response, at which point the cache mirror + invalidate refresh - // both arms). - const enrichedEdges = useMemo(() => { - const flowsByEdge = new Map( - (edgeFlows ?? []).map((ef) => [ef.edgeId, ef]), - ); - const titleByNode = new Map( - interiorNodes.map((n) => [n.id, n.title] as const), - ); - return edges.map((e) => - withEdgeFlows(e, flowsByEdge, titleByNode, slug), - ); - }, [edges, edgeFlows, interiorNodes, slug]); const { screenToFlowPosition } = useReactFlow(); const createNode = api.architecture.createNode.useMutation(); // Destructured so the stable `mutateAsync` can be a dep of the context values @@ -453,10 +234,6 @@ function CanvasInner({ api.architecture.deleteNode.useMutation(); const { mutateAsync: restoreComponent } = api.architecture.restoreNode.useMutation(); - const { mutateAsync: routeFlow } = - api.architecture.routeFlow.useMutation(); - const { mutateAsync: unrouteFlow } = - api.architecture.unrouteFlow.useMutation(); // The query cache is the re-seed mirror. EVERY write goes through this merge // helper so a partial update can never drop a sibling key (e.g. node edits @@ -467,16 +244,11 @@ function CanvasInner({ (patch: (prev: CanvasData) => Partial) => { utils.architecture.getCanvas.setData(canvasInput, (old) => { // The zero-value defaults are load-bearing — a partial update on a cold - // cache (e.g. `commitRouteFlow` fires before the query has resolved - // once) would otherwise patch against a base that lacks a key and drop - // it. Keep EVERY getCanvas key here (boundaryProxies / flowPalettes - // joined the payload in Slice 3). + // cache would otherwise patch against a base that lacks a key and drop + // it. Keep EVERY getCanvas key here. const base: CanvasData = old ?? { interiorNodes: [], interiorEdges: [], - edgeFlows: [], - boundaryProxies: [], - flowPalettes: {}, breadcrumbs: [], }; return { ...base, ...patch(base) }; @@ -523,17 +295,13 @@ function CanvasInner({ posX: position.x, posY: position.y, }); - // Reconcile temp → real id in both stores, atomically by id. The - // `createNode` service returns the bare Node row; a fresh Node owns - // zero Flows, so we hydrate `_count` to keep the CanvasNode shape - // intact (ADR-0011). - const realWithCount: CanvasNode = { ...real, _count: { flows: 0 } }; + // Reconcile temp → real id in both stores, atomically by id. setNodes((ns) => - ns.map((n) => (n.id === tempId ? toRFNode(realWithCount) : n)), + ns.map((n) => (n.id === tempId ? toRFNode(real) : n)), ); patchCanvas((c) => ({ interiorNodes: c.interiorNodes.map((n) => - n.id === tempId ? realWithCount : n, + n.id === tempId ? real : n, ), })); } catch { @@ -613,7 +381,7 @@ function CanvasInner({ // one updateNodeKind mutation, both rolled back with a toast on failure. Same // conditional-rollback shape as `commitRename` (a newer change's optimistic // patch must not be clobbered by an older failing change's rollback). Kind is - // cosmetic, so no edge/flow state is touched (ADR-0018). + // cosmetic, so no edge state is touched (ADR-0018). const commitNodeKind = useCallback( (id: string, kind: NodeKind): void => { const prevKind = utils.architecture.getCanvas @@ -723,11 +491,7 @@ function CanvasInner({ // multi-select drag (onSelectionDragStop also routes here) commits together. // Rolls back store + cache and toasts on failure. const persistPositions = useCallback( - // Accepts the union React Flow hands `onNodeDragStop`; boundary proxies and - // the boundary-group container are `draggable: false` so they never appear - // here, and any that did would be skipped — they have no `interiorNodes` - // row to look up. - async (moved: CanvasRFNode[]) => { + async (moved: ComponentNode[]) => { const cached = utils.architecture.getCanvas.getData(canvasInput); const byId = new Map( cached?.interiorNodes.map((n) => [n.id, n] as const) ?? [], @@ -790,164 +554,18 @@ function CanvasInner({ [utils, canvasInput, projectId, updatePositions, setNodes, patchCanvas], ); - // Shared optimistic body for a refinement route from a boundary-proxy palette - // (Slice 3 / ADR-0012). Draws the inner Edge optimistically (store + cache - // mirror), runs `resolveRoute` (the mutation(s) that create the real route), - // reconciles the temp id to the real inner Edge (converging silently when the - // inner Edge already exists — a shared pipe), and rolls back + toasts on any - // failure. `resolveRoute` is the variation point: a direct route fires one - // `routeFlow`; the reverse-Connection path (Slice 4 / ADR-0013) creates the - // reverse outer Edge first, then routes against it — both roll back together - // here on failure. The routed-count pill refreshes when the user navigates - // back up (a fresh getCanvas), so no cross-scope cache write is needed. - const runOptimisticInnerRoute = useCallback( - async ( - source: string, - target: string, - resolveRoute: () => Promise<{ - innerEdgeId: string | null; - outerEdgeId: string; - }>, - ): Promise => { - const tempId = `temp_${crypto.randomUUID()}`; - setEdges((es) => - addEdge( - { - id: tempId, - type: "connection", - source, - target, - // No marker — undirected until a Flow is routed (ADR-0023). - data: { label: null, optimistic: true }, - }, - es, - ), - ); - patchCanvas((c) => ({ - interiorEdges: [ - ...c.interiorEdges, - optimisticCanvasEdge(tempId, projectId, canvasNodeId, source, target), - ], - })); - - try { - const route = await resolveRoute(); - const innerId = route.innerEdgeId; - // Reconcile the temp inner Edge to the real one. If the inner Edge - // already exists (a shared pipe other Flows converged on), just drop - // the temp — re-adding its id would duplicate a node in the store. - const real = - innerId === null - ? null - : optimisticCanvasEdge( - innerId, - projectId, - canvasNodeId, - source, - target, - ); - setEdges((es) => { - const withoutTemp = es.filter((e) => e.id !== tempId); - if (!real || withoutTemp.some((e) => e.id === real.id)) { - return withoutTemp; - } - return [...withoutTemp, toRFEdge(real)]; - }); - patchCanvas((c) => { - const without = c.interiorEdges.filter((e) => e.id !== tempId); - if (!real || without.some((e) => e.id === real.id)) { - return { interiorEdges: without }; - } - return { interiorEdges: [...without, real] }; - }); - // The Flow is now routed on `route.outerEdgeId` one scope up, so that - // Connection's "+ flow" popover (its unrouted filter) is stale. Refresh - // it; the routed-count pill itself refreshes on ascend. - void utils.architecture.getRoutedFlowIdsForEdge.invalidate({ - outerEdgeId: route.outerEdgeId, - slug, - }); - } catch { - setEdges((es) => es.filter((e) => e.id !== tempId)); - patchCanvas((c) => ({ - interiorEdges: c.interiorEdges.filter((e) => e.id !== tempId), - })); - toast.error("Couldn’t route the flow. Please try again."); - } - }, - [utils, projectId, canvasNodeId, setEdges, patchCanvas, slug], - ); - - // Route a Flow from a boundary proxy's palette onto the Connection between - // this scope's Component and the proxy owner. A Connection is undirected, so - // ANY incident outer Edge carries the Flow regardless of its interaction verb - // — the verb decides which way the derived arrow points, not whether the route - // is legal (ADR-0023, retiring ADR-0013's polarity-matching + reverse-Connection - // offer). The service's touches-endpoint check is the backstop. - const routeFromPalette = useCallback( - async (params: { - flowId: string; - proxyNodeId: string; - source: string; - target: string; - }): Promise => { - const { flowId, proxyNodeId, source, target } = params; - // Boundary proxies never exist at the root scope (deriveBoundaryProxies - // returns none), so a proxy drag implies a non-null current scope. - if (canvasNodeId === null) return; - // A still-optimistic child endpoint has no server id to bind yet. - if (source.startsWith("temp_") || target.startsWith("temp_")) { - toast.error("Finish adding that component before routing a flow to it."); - return; - } - const data = utils.architecture.getCanvas.getData(canvasInput); - const proxy = data?.boundaryProxies.find((p) => p.nodeId === proxyNodeId); - // The single incident outer Edge carries it — a Connection is undirected - // (ADR-0023). - const outerEdgeId = proxy?.outerEdgeId ?? null; - if (!outerEdgeId) { - toast.error("That flow can’t be routed here."); - return; - } - - await runOptimisticInnerRoute(source, target, () => - routeFlow({ - flowId, - outerEdgeId, - sourceNodeId: source, - targetNodeId: target, - }), - ); - }, - [canvasNodeId, utils, canvasInput, runOptimisticInnerRoute, routeFlow], - ); - // Draw a Connection. Refuses a still-optimistic (temp_) endpoint (no real id // to persist yet), then pre-flights the pure topology rules — no self-link, no // duplicate — via `canConnect`, so the user gets instant feedback rather than a // doomed round trip (the service stays authoritative). Optimistic edge in store // + cache mirror, one connectNodes mutation, reconcile temp → real id, roll - // back + toast on failure (a CONFLICT/BAD_REQUEST rejection rolls back the same - // way). + // back + toast on failure. A freshly drawn Connection is an ASSOCIATION (#65 + // adds the interaction picker). const handleConnect = useCallback( async (connection: Connection) => { const { source, target } = connection; if (!source || !target) return; - // Refinement route: one endpoint is a boundary-proxy palette item (its - // handle id encodes the Flow). Branch off to the cross-scope writer - // (Slice 3 / ADR-0012) — this is not a plain Component-to-Component draw. - const flowId = - flowIdFromHandle(connection.sourceHandle) ?? - flowIdFromHandle(connection.targetHandle); - if (flowId) { - const proxyNodeId = flowIdFromHandle(connection.sourceHandle) - ? source - : target; - await routeFromPalette({ flowId, proxyNodeId, source, target }); - return; - } - if (source.startsWith("temp_") || target.startsWith("temp_")) { toast.error("Finish adding that component before connecting it."); return; @@ -975,7 +593,6 @@ function CanvasInner({ type: "connection", source, target, - // No marker — undirected until a Flow is routed (ADR-0023). data: { label: null, optimistic: true }, }, es, @@ -984,14 +601,13 @@ function CanvasInner({ patchCanvas((c) => ({ interiorEdges: [ ...c.interiorEdges, - optimisticCanvasEdge(tempId, projectId, canvasNodeId, source, target), + optimisticCanvasEdge(tempId, projectId, source, target), ], })); try { const real = await connectNodes.mutateAsync({ projectId, - canvasNodeId, sourceId: source, targetId: target, }); @@ -1012,39 +628,26 @@ function CanvasInner({ [ utils, canvasInput, - canvasNodeId, setEdges, patchCanvas, projectId, connectNodes, - routeFromPalette, ], ); // Remove a Connection (React Flow's Delete/Backspace). `onEdgesChange` // already dropped it from the store; here we mirror the removal into the - // cache and soft-delete it server-side. A still-optimistic edge was never - // persisted, so it just disappears. Failure re-adds the edge to both - // stores and toasts. - // - // Slice 2 cascade: when `deleteEdge` sweeps incident FlowRoutes it returns - // a `deletionId` and the swept route ids; we drop the per-edge `edgeFlows` - // entry optimistically alongside the edge. The cascade case has no undo - // affordance here yet — the delete path uses React Flow's keyboard delete, - // which has no toast slot; wiring a `restoreEdge` undo is a Slice 5 - // affordance alongside the inspector. + // cache and soft-delete it server-side (a plain lone soft-delete — ADR-0030). + // A still-optimistic edge was never persisted, so it just disappears. Failure + // re-adds the edge to both stores and toasts. const handleEdgesDelete = useCallback( (deleted: ConnectionEdge[]) => { const cached = utils.architecture.getCanvas.getData(canvasInput); for (const edge of deleted) { if (edge.id.startsWith("temp_")) continue; const prev = cached?.interiorEdges.find((e) => e.id === edge.id); - const prevFlows = cached?.edgeFlows.find( - (ef) => ef.edgeId === edge.id, - ); patchCanvas((c) => ({ interiorEdges: c.interiorEdges.filter((e) => e.id !== edge.id), - edgeFlows: c.edgeFlows.filter((ef) => ef.edgeId !== edge.id), })); void removeEdge({ id: edge.id }).catch(() => { setEdges((es) => @@ -1053,7 +656,6 @@ function CanvasInner({ if (prev) { patchCanvas((c) => ({ interiorEdges: [...c.interiorEdges, prev], - edgeFlows: prevFlows ? [...c.edgeFlows, prevFlows] : c.edgeFlows, })); } toast.error("Couldn’t remove the connection. Please try again."); @@ -1120,9 +722,8 @@ function CanvasInner({ // Component-detail panel: opens when the owner single-selects a real (non- // temp_) Component. Sourced from React Flow's selection events rather than // from React Flow's internal selection state so a node added optimistically - // never auto-opens the panel before its server id arrives (ADR-0011 / Slice - // 1 detail panel scaffold). Cleared on pane click, scope change, or when - // the selected node is removed (`removeComponent` below). + // never auto-opens the panel before its server id arrives. Cleared on pane + // click, scope change, or when the selected node is removed. const [selectedNodeId, setSelectedNodeId] = useState(null); const closeDetailPanel = useCallback(() => setSelectedNodeId(null), []); @@ -1205,8 +806,8 @@ function CanvasInner({ // Edit a Connection's label: optimistic in store + cache mirror, one updateEdge // mutation, both rolled back with a toast on failure. Provided to the edges - // through context (below) so it stays one stable reference. (There is no - // direction to edit — the arrow is structural, output→input; ADR-0009.) + // through context (below) so it stays one stable reference. (A Connection's + // interaction is set at creation; the picker is #65.) const commitEdgeEdit = useCallback( (id: string, label: string | null): void => { const prev = utils.architecture.getCanvas @@ -1235,187 +836,6 @@ function CanvasInner({ [utils, canvasInput, setEdges, patchCanvas, editEdge], ); - // After the detail panel runs an attach + parse, the server's new flow - // count needs to land in BOTH the React Flow store (so the pill updates - // this frame on the same Component) and the cache mirror (so a remount - // re-seeds correctly). Cache invalidation alone doesn't reach the RF store - // — the seed is fire-and-forget by design (ADR-0004 island model). One - // stable callback the panel calls when the server responds. - const commitFlowCount = useCallback( - (id: string, flowCount: number) => { - setNodes((ns) => - ns.map((n) => - n.id === id && n.type === "component" - ? { ...n, data: { ...n.data, flowCount } } - : n, - ), - ); - patchCanvas((c) => ({ - interiorNodes: c.interiorNodes.map((n) => - n.id === id ? { ...n, _count: { flows: flowCount } } : n, - ), - })); - }, - [setNodes, patchCanvas], - ); - - // Route a Flow onto a Connection (Slice 2). Optimistic on the `edgeFlows` - // aggregation only — bump `routed`, decrement `unrouted`, and bump the - // arrow-direction counts by this Flow's deltas so the derived arrowhead - // appears THIS frame (the deltas come from the dispatcher, which computed them - // from the Flow's interaction via flowArrowEndpoints; ADR-0023). Fire - // `routeFlow`; on failure undo via an inverse delta (NOT a snapshot restore) - // so an overlapping in-flight route on the same edge isn't clobbered, then - // reconcile to server truth. - const commitRouteFlow = useCallback( - ( - flowId: string, - outerEdgeId: string, - flowKind: FlowKind, - arrowAtSourceDelta: number, - arrowAtTargetDelta: number, - ): void => { - patchCanvas((c) => ({ - edgeFlows: c.edgeFlows.map((ef) => - ef.edgeId === outerEdgeId - ? { - ...ef, - routed: ef.routed + 1, - unrouted: Math.max(0, ef.unrouted - 1), - byKind: bumpByKind(ef.byKind, flowKind, 1), - arrowAtSource: ef.arrowAtSource + arrowAtSourceDelta, - arrowAtTarget: ef.arrowAtTarget + arrowAtTargetDelta, - } - : ef, - ), - })); - - void routeFlow({ flowId, outerEdgeId }) - .then(() => { - // Refresh the popover's unrouted filter on next open. - void utils.architecture.getRoutedFlowIdsForEdge.invalidate({ - outerEdgeId, - slug, - }); - }) - .catch(() => { - // Inverse-delta rollback: undo only this op's own deltas against the - // current counts, so a concurrent route that succeeded mid-flight - // keeps its increment. Then reconcile to the authoritative counts — - // but only here, on the error path: a happy-path `getCanvas` - // refetch would cost a round-trip per route (Philosophy #1). - patchCanvas((c) => ({ - edgeFlows: c.edgeFlows.map((ef) => - ef.edgeId === outerEdgeId - ? { - ...ef, - routed: Math.max(0, ef.routed - 1), - unrouted: ef.unrouted + 1, - byKind: bumpByKind(ef.byKind, flowKind, -1), - arrowAtSource: Math.max( - 0, - ef.arrowAtSource - arrowAtSourceDelta, - ), - arrowAtTarget: Math.max( - 0, - ef.arrowAtTarget - arrowAtTargetDelta, - ), - } - : ef, - ), - })); - void utils.architecture.getCanvas.invalidate(canvasInput); - toast.error("Couldn’t route the flow. Please try again."); - }); - }, - [utils, canvasInput, patchCanvas, routeFlow, slug], - ); - - // Remove a FlowRoute (Slice 2). Mirror of `commitRouteFlow`: dec routed, - // inc unrouted, rollback on failure. Slice 2 has no UI surface that fires - // unroute yet (Slice 5's inspector owns the unroute affordance) — but the - // dispatch path is shipped now so the context contract is complete; a - // future inspector composes on top with zero service-layer changes. - const commitUnrouteFlow = useCallback( - ( - flowRouteId: string, - outerEdgeId: string, - flowKind: FlowKind, - arrowAtSourceDelta: number, - arrowAtTargetDelta: number, - ): void => { - patchCanvas((c) => ({ - edgeFlows: c.edgeFlows.map((ef) => - ef.edgeId === outerEdgeId - ? { - ...ef, - routed: Math.max(0, ef.routed - 1), - unrouted: ef.unrouted + 1, - byKind: bumpByKind(ef.byKind, flowKind, -1), - arrowAtSource: Math.max(0, ef.arrowAtSource - arrowAtSourceDelta), - arrowAtTarget: Math.max(0, ef.arrowAtTarget - arrowAtTargetDelta), - } - : ef, - ), - })); - - void unrouteFlow({ flowRouteId }) - .then(() => { - void utils.architecture.getRoutedFlowIdsForEdge.invalidate({ - outerEdgeId, - slug, - }); - }) - .catch(() => { - // Inverse-delta rollback + error-path reconcile — see - // `commitRouteFlow` for the rationale. - patchCanvas((c) => ({ - edgeFlows: c.edgeFlows.map((ef) => - ef.edgeId === outerEdgeId - ? { - ...ef, - routed: ef.routed + 1, - unrouted: Math.max(0, ef.unrouted - 1), - byKind: bumpByKind(ef.byKind, flowKind, 1), - arrowAtSource: ef.arrowAtSource + arrowAtSourceDelta, - arrowAtTarget: ef.arrowAtTarget + arrowAtTargetDelta, - } - : ef, - ), - })); - void utils.architecture.getCanvas.invalidate(canvasInput); - toast.error("Couldn’t remove the routing. Please try again."); - }); - }, - [utils, canvasInput, patchCanvas, unrouteFlow, slug], - ); - - // Single dispatch consumed by `RouteFlowContext`. Discriminated by `kind` - // — keeps the popover / inspector consumers honest about which op they - // mean and lets the canvas keep the rollback bookkeeping in one place. - const routeFlowDispatch = useCallback( - (action: RouteFlowAction) => { - if (action.kind === "route") { - commitRouteFlow( - action.flowId, - action.outerEdgeId, - action.flowKind, - action.arrowAtSourceDelta, - action.arrowAtTargetDelta, - ); - } else { - commitUnrouteFlow( - action.flowRouteId, - action.outerEdgeId, - action.flowKind, - action.arrowAtSourceDelta, - action.arrowAtTargetDelta, - ); - } - }, - [commitRouteFlow, commitUnrouteFlow], - ); - // Descent: open a Component's interior Canvas. One callback shared by the // node's "Open" button (via DescendComponentContext) and the flow's // double-click handler, so the route + prefetch logic lives in one place. @@ -1443,162 +863,148 @@ function CanvasInner({ return ( - - - - - - nodes={nodes} - edges={enrichedEdges} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} - onConnect={(c) => void handleConnect(c)} - // Loose connection mode: a Connection can be drawn between any - // two handles in either direction — Components are not - // directional, so a Port has no input/output role and the drag - // direction carries no meaning (direction is derived from the - // Flows routed on the Connection; ADR-0023, retiring ADR-0009's - // strict output→input rule). - connectionMode={ConnectionMode.Loose} - // Instant drag feedback: reject a self-link and a still-optimistic - // (temp_) endpoint by snapping back. Duplicates are deliberately - // allowed through (passing [] skips the duplicate rule) so - // onConnect can surface a toast — blocking them here would snap a - // duplicate back silently, with no explanation. - isValidConnection={(c) => { - const { source, target } = c; - if (!source || !target) return false; - if ( - source.startsWith("temp_") || - target.startsWith("temp_") - ) { - return false; - } - return canConnect({ source, target }, []).ok; - }} - onEdgesDelete={handleEdgesDelete} - onNodeClick={(_event, node) => { - // A `temp_…` Component has no server id yet; opening the - // detail panel would query for a node the server cannot - // find. Boundary proxies are read-only stand-ins — they - // have no editable detail panel. Single-click selection - // only for real Components — double-click still descends. - if (node.id.startsWith("temp_")) return; - if (isPassiveNode(node)) return; - setSelectedNodeId(node.id); - }} - onPaneClick={() => setSelectedNodeId(null)} - onNodeDoubleClick={(_event, node) => { - // Boundary proxies and the boundary-group container are - // read-only — they have no interior to descend into (descend - // into the real Component instead). - if (isPassiveNode(node)) return; - descend(node.id); - }} - onNodeMouseEnter={(_event, node) => { - // Make Descent feel instant: warm the interior Canvas payload (tRPC - // cache, the same key the descended island reads) and the route shell. - // Also warm the Plate docs-editor chunk so first selection of a - // Component doesn't pay a "Loading editor…" flash (ADR-0015 §6). - if (node.id.startsWith("temp_")) return; - if (isPassiveNode(node)) return; - void utils.architecture.getCanvas.prefetch({ - slug, - canvasNodeId: node.id, - }); - router.prefetch(`/p/${slug}/n/${node.id}`); - // Viewers open the read-only docs panel too, so warm the - // Plate chunk for everyone — no first-open flash (perf #1). - prefetchDocsEditor(); - }} - onNodeDragStop={(_event, _node, dragged) => - void persistPositions(dragged) - } - onSelectionDragStop={(_event, dragged) => - void persistPositions(dragged) + + + + + nodes={nodes} + edges={edges} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={(c) => void handleConnect(c)} + // Loose connection mode: a Connection can be drawn between any + // two handles in either direction — Components are not + // directional, so a Port has no input/output role and the drag + // direction carries no meaning here (a Connection's interaction + // is set separately; #65). The draw order is preserved on the + // Edge for the eventual arrowhead derivation (ADR-0027). + connectionMode={ConnectionMode.Loose} + // Instant drag feedback: reject a self-link and a still-optimistic + // (temp_) endpoint by snapping back. Duplicates are deliberately + // allowed through (passing [] skips the duplicate rule) so + // onConnect can surface a toast — blocking them here would snap a + // duplicate back silently, with no explanation. + isValidConnection={(c) => { + const { source, target } = c; + if (!source || !target) return false; + if ( + source.startsWith("temp_") || + target.startsWith("temp_") + ) { + return false; } - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - nodesDraggable={canEdit} - nodesConnectable={canEdit} - deleteKeyCode={canEdit ? undefined : null} - fitView - > - - - - {canEdit && ( - { + // A `temp_…` Component has no server id yet; opening the + // detail panel would query for a node the server cannot + // find. Single-click selection only for real Components — + // double-click still descends. + if (node.id.startsWith("temp_")) return; + setSelectedNodeId(node.id); + }} + onPaneClick={() => setSelectedNodeId(null)} + onNodeDoubleClick={(_event, node) => descend(node.id)} + onNodeMouseEnter={(_event, node) => { + // Make Descent feel instant: warm the interior Canvas payload (tRPC + // cache, the same key the descended island reads) and the route shell. + // Also warm the Plate docs-editor chunk so first selection of a + // Component doesn't pay a "Loading editor…" flash (ADR-0015 §6). + if (node.id.startsWith("temp_")) return; + void utils.architecture.getCanvas.prefetch({ + slug, + canvasNodeId: node.id, + }); + router.prefetch(`/p/${slug}/n/${node.id}`); + // Viewers open the read-only docs panel too, so warm the + // Plate chunk for everyone — no first-open flash (perf #1). + prefetchDocsEditor(); + }} + onNodeDragStop={(_event, _node, dragged) => + void persistPositions(dragged) + } + onSelectionDragStop={(_event, dragged) => + void persistPositions(dragged) + } + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + nodesDraggable={canEdit} + nodesConnectable={canEdit} + deleteKeyCode={canEdit ? undefined : null} + fitView + > + + + + {canEdit && ( + + )} + {/* Slug-readable: visible to any viewer, not gated on + edit. Always exports the whole project (ADR-0017 / + #15) — the scope-specific export lives on the + breadcrumb bar. */} + + + {selectedNodeId !== null && + (canEdit ? ( + + {/* Owner mode: full edit affordances wired to the + canvas's mutations. Discriminated `readOnly: false` + keeps write callbacks visible at compile time (#16). */} + - )} - {/* Slug-readable: visible to any viewer, not gated on - edit. Always exports the whole project (ADR-0017 / - #15) — the scope-specific export lives on the - breadcrumb bar. */} - - - {selectedNodeId !== null && - (canEdit ? ( - - {/* Owner mode: full edit affordances wired to the - canvas's mutations. Discriminated `readOnly: false` - keeps write callbacks visible at compile time (#16). */} - - - ) : ( - - {/* Viewer mode: read-only docs + Flow list, zero write - affordances. Discriminated `readOnly: true` omits - mutations at compile time (#16). */} - - - ))} - {nodes.length === 0 && ( - -

- Empty canvas. Add a Component to start modeling. -

- )} - -
-
-
-
+ ) : ( + + {/* Viewer mode: read-only docs, zero write affordances. + Discriminated `readOnly: true` omits mutations at + compile time (#16). */} + + + ))} + {nodes.length === 0 && ( + +

+ Empty canvas. Add a Component to start modeling. +

+
+ )} + + + +
); diff --git a/src/app/p/[slug]/_canvas/component-detail-panel.tsx b/src/app/p/[slug]/_canvas/component-detail-panel.tsx index 47527dc..49e0cb9 100644 --- a/src/app/p/[slug]/_canvas/component-detail-panel.tsx +++ b/src/app/p/[slug]/_canvas/component-detail-panel.tsx @@ -2,18 +2,10 @@ import { ChevronDown, X } from "lucide-react"; import dynamic from "next/dynamic"; -import { Suspense, useEffect, useState } from "react"; -import { toast } from "sonner"; +import { useEffect } from "react"; -import { FLOW_INTERACTION_DISPLAY } from "~/lib/flow-interaction-display"; import { KIND_ICON, KIND_LABEL } from "~/lib/node-kinds"; -import { type FlowSpecKind, type NodeKind } from "~/lib/schemas"; -import { - SPEC_KIND_LABEL, - SPEC_KIND_PLACEHOLDER, - specKindsFor, -} from "~/lib/spec-kinds"; -import { api } from "~/trpc/react"; +import { type NodeKind } from "~/lib/schemas"; import { KindPickerPopover } from "./kind-palette"; @@ -41,33 +33,28 @@ export function prefetchDocsEditor(): void { /** * Slide-in detail surface for a selected Component, opened when the owner - * single-selects a Component on the Canvas (Slice 1 of the flow-routed plan; - * ADR-0011). Two sections in this slice: + * single-selects a Component on the Canvas. Two sections in this slice: * - * 1. **Attach spec** — paste an OpenAPI document, server-side bounded parse, - * materialize Flow rows. - * 2. **Flow palette (read-only)** — the active Flows the Component owns. - * 3. **Documentation** — the Plate markdown editor (issues #11 / #12): a + * 1. **Kind** — the Component's kind row (owner: opens the kind palette). + * 2. **Documentation** — the Plate markdown editor (issues #11 / #12): a * rendered view that toggles to an editable surface with debounced * optimistic autosave. * - * A future slice will add the "+ route" affordance on a selected Connection - * (#35). The panel deliberately does NOT block the canvas (a sidebar, not a - * modal) so the user can keep zooming / panning while it is open — performance - * philosophy #1. + * The Spec paste field and the Flow palette were removed with the Flow model + * (#62); the spec → Component generation surface returns in #64. The panel + * deliberately does NOT block the canvas (a sidebar, not a modal) so the user + * can keep zooming / panning while it is open — performance philosophy #1. * * Dual-audience (#16): the owner sees the full edit surface; a capability - * **viewer** (`readOnly`) sees the same panel with docs + the Flow palette but - * NO write affordances — no Kind picker, no Attach-spec field, no docs Edit - * toggle. `readOnly` is a required discriminator: the write callbacks - * (`onChangeKind` / `onFlowCountChange` / `onCommitDocumentation`) are typed to - * exist only in owner mode, so handing the viewer panel a mutation is a compile - * error, never a leaked affordance. Read-only mode is presentation, not the - * authorization boundary — every mutation is still denied at the service layer - * (ADR-0002). Dismissed by deselect, Escape, or the close button. + * **viewer** (`readOnly`) sees the same panel with docs but NO write + * affordances — no Kind picker, no docs Edit toggle. `readOnly` is a required + * discriminator: the write callbacks (`onChangeKind` / `onCommitDocumentation`) + * are typed to exist only in owner mode, so handing the viewer panel a mutation + * is a compile error, never a leaked affordance. Read-only mode is presentation, + * not the authorization boundary — every mutation is still denied at the service + * layer (ADR-0002). Dismissed by deselect, Escape, or the close button. */ type ComponentDetailPanelProps = { - slug: string; ownerNodeId: string; /** The selected Component's current kind, shown in the Kind row. */ currentKind: NodeKind; @@ -86,31 +73,18 @@ type ComponentDetailPanelProps = { readOnly: false; /** Optimistic change-kind commit; the mutation lives on the canvas. */ onChangeKind: (ownerNodeId: string, kind: NodeKind) => void; - /** - * Called when the server returns a new flow count for the selected - * Component, so the canvas can update the React Flow store and the - * "N flows" pill on the same frame. The query-cache invalidation alone - * does NOT reach the RF store (the seed is fire-and-forget by design). - */ - onFlowCountChange: (ownerNodeId: string, flowCount: number) => void; /** Debounced optimistic docs autosave; the mutation lives on the canvas. */ onCommitDocumentation: (ownerNodeId: string, documentation: string) => void; } | { - /** Capability-viewer mode: read docs + Flow list, zero write affordances. */ + /** Capability-viewer mode: read docs, zero write affordances. */ readOnly: true; } ); export function ComponentDetailPanel(props: ComponentDetailPanelProps) { - const { - slug, - ownerNodeId, - currentKind, - parentKind, - initialDocumentation, - onClose, - } = props; + const { ownerNodeId, currentKind, parentKind, initialDocumentation, onClose } = + props; // Escape closes the panel from anywhere — the canvas keeps focus after the // single-select that opens the panel, so a handler on the panel root would // never fire from the user's most likely starting point. @@ -147,29 +121,6 @@ export function ComponentDetailPanel(props: ComponentDetailPanelProps) { /> )} - {!props.readOnly && ( - - )} - -
-

- Flow palette -

- Loading…

}> - -
-
- ); } - -function AttachSpecSection({ - ownerNodeId, - currentKind, - slug, - onFlowCountChange, -}: { - ownerNodeId: string; - /** The Component's kind — selects which spec formats the picker offers. */ - currentKind: NodeKind; - slug: string; - onFlowCountChange: (ownerNodeId: string, flowCount: number) => void; -}) { - const utils = api.useUtils(); - const specKinds = specKindsFor(currentKind); - const [kind, setKind] = useState(() => specKinds[0] ?? "CUSTOM"); - const [source, setSource] = useState(""); - const attach = api.architecture.attachFlowSpec.useMutation(); - - // Affinity is presentation-only and the component is remounted per - // `ownerNodeId`, but the owner can re-kind a Component while it stays - // selected — clamp to a valid option so the setKind(e.target.value as FlowSpecKind)} - disabled={attach.isPending} - > - {specKinds.map((k) => ( - - ))} - - -