Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 108 additions & 14 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +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`, **Polarity** / `FlowPolarity` —
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.

## Terms

### Component
Expand Down Expand Up @@ -227,25 +237,103 @@ exactly one place. *(See ADR-0001 and ADR-0002.)*
### Deletion id
The handle that ties together one cascading Component 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, and every incident or interior **Edge** — and
`restoreNode` clears `deletedAt` for *exactly* the rows bearing that id, so an undo restores the
operation's set and nothing outside it. 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** terms in code; users see only "delete" and "undo". *(Realized now via
`deleteNode`/`restoreNode`; see ADR-0008. The id is a bare stamped column today — a durable
`Deletion` entity and an MCP undo tool are deferred, additive future work.)*
deleted — the target **Node**, its subtree, every incident or interior **Edge**, and every owned
**Flow** + owned **FlowSpec** — and `restoreNode` clears `deletedAt` for *exactly* the rows
bearing that id, so an undo restores the operation's set and nothing outside it. 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**/**Flow** terms in code; users
see only "delete" and "undo". *(Realized now via `deleteNode`/`restoreNode`; see ADR-0008 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, and every
incident or interior **Edge** as one **Deletion id**, and `restoreNode` reverses exactly that
set (ADR-0008). 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.)*
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
**writes** — owner-only, never slug-granted (ADR-0002). The `Project` model also carries
`deletedAt` and all reads filter it; Project-level cascade remains future.)*
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### 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 a
**polarity** (see **Polarity**). 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. Binding a Flow to a **Connection** at a scope — the
`FlowRoute` — and palette rendering on **boundary proxies** land in subsequent slices. See
ADR-0011.)*

### FlowSpec
The imported contract — an OpenAPI document, an AsyncAPI document, a TypeScript signature, a
GraphQL schema, 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. *(Realized now for OpenAPI;
ASYNCAPI / TS_SIGNATURE / GRAPHQL / CUSTOM persist source and record `parseError` until their
parsers land additively. See ADR-0011.)*

### Polarity (`FlowPolarity`)
A **Flow**'s directional relationship to its **owner** **Component**: `INBOUND` (the owner
*consumes* — e.g. `GET /pets` on an API) or `OUTBOUND` (the owner *emits* — SSE, events,
server-pushed messages). Polarity is **owner-relative**: it answers "from the owner's
perspective, does data come in or go out?", which is the only frame in which a bidirectional
pipe resolves to **two Connections** under ADR-0009 without storing a `direction` field
anywhere. When a Flow is later routed onto a Connection (subsequent slices), polarity must
match the rendered arrow: an `OUTBOUND` Flow rides an **Edge** whose `sourceId` is the owner;
an `INBOUND` Flow rides an Edge whose `targetId` is the owner. The word in prose and UI is
**polarity**; the type name in code is `FlowPolarity` — the same prose/type-name pattern
**Component kind** / `NodeKind` uses. Never "direction" (that's structural, taken, and
ADR-0009 forbids re-introducing it). *(Realized now as a per-Flow field; the polarity-vs-arrow
consistency check at route time lands with a subsequent slice. ADR-0011 records the decision.)*

### 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**. Each item shows the Flow's `title`, `kind`,
and `polarity`. 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 you own. The boundary-proxy palette — the same surface, projected
inward through a **boundary proxy** so a child Component can route external Flows onto its
interior pipes — lands with a subsequent slice.)*

### Flow kind (`FlowKind`)
A **Flow**'s category, stored on it as `kind: FlowKind`. One of seven values: `GENERIC` (the
default — a hand-authored Flow with no formal contract), `OPENAPI_OPERATION`,
`ASYNCAPI_CHANNEL`, `SSE_STREAM`, `WEBSOCKET`, `FUNCTION_CALL`, `EVENT`. 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.

### Flow spec kind (`FlowSpecKind`)
A **FlowSpec**'s source format, stored on it as `kind: FlowSpecKind`. One of five values:
`OPENAPI`, `ASYNCAPI`, `TS_SIGNATURE`, `GRAPHQL`, `CUSTOM`. The value selects which parser
materializes Flows from `source`; `CUSTOM` is for a hand-authored contract the canonical
parsers do not cover. The word in prose and the enum name in code are **spec kind** /
`FlowSpecKind`. *(`OPENAPI` is the parser realized now; `ASYNCAPI` / `TS_SIGNATURE` /
`GRAPHQL` / `CUSTOM` persist source and record `parseError` until their parsers land
additively.)*

## Standing notes

Expand All @@ -256,3 +344,9 @@ When this content is later fed to an LLM (markdown export, MCP resources), it mu
the system must not. Every code path that hands graph content to a model carries this obligation.
Defenses live at the output/serialization boundary (added in a later milestone); today we only
adopt the mindset — store text verbatim and never interpolate user content into queries.

**Parse-time trust too.** Untrusted content that is later *parsed* — a pasted **FlowSpec**'s
`source`, future contract imports — must go through a **bounded loader** with size and depth
caps so a hostile input cannot OOM the server before it ever reaches the output boundary. The
caps belong to the parser itself (testable in isolation), not just the API surface; a future
caller bypassing input validation must still hit the cap.
136 changes: 136 additions & 0 deletions docs/adr/0011-flows-as-first-class-component-owned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 11. Flows are first-class rows owned by Components; FlowSpec is the 1:1 contract source-of-truth

## Status

Accepted.

## Context

Today's `Edge` is a pipe with a label, and the label is one untrusted string. The PRD's vision
(issue #2, plan `docs/plans/flow-routed-connections.md`) needs three things the current model
cannot express:

1. **Per-capability addressability.** "Did anything wire `POST /pets`?" must be answerable; today
the answer lives in the prose of an Edge label, where nothing can index it, nothing can
soft-delete it independently, and nothing reachable from an MCP agent can name it.
2. **Owner-anchored existence.** An API exposes `GET /pets` whether or not anyone is calling it.
Tying the capability to an Edge means it does not exist until someone draws a Connection —
backwards from how documented systems actually work.
3. **A spec as the source of truth.** Pasting OpenAPI YAML must produce addressable rows whose
lifecycle is the spec's lifecycle. A spec lives on the Component, not on a Connection — the
Connection is downstream of it.

The pressure to decide now (rather than at the routing slice) is that Slice 1 of the flow-routed
plan ships value alone: an agent can model contracts the moment Flow + FlowSpec exist, even
before routing lands. Per the repo's "docs travel with code slices" convention, the ADR
justifying the new entities travels with the slice that introduces them.

## Decision

1. **`Flow` is a first-class row owned by a `Node` (`ownerNodeId`).** A Flow carries `kind`
(see CONTEXT.md "Flow kind"), `key` (stable identity within the owner — e.g. `"GET /pets"`),
`title` (UNTRUSTED display label), `polarity` (see CONTEXT.md "Polarity"), `signature` (the
parsed contract fragment as `Json?`), `sourceSpecId?` (null = user-authored; non-null =
derived from a FlowSpec), and the same soft-delete columns Node/Edge carry (`deletedAt`,
`deletionId`). Flows are individually addressable, individually soft-deletable, and
individually MCP-resolvable.

2. **`FlowSpec` is the 1:1 contract on a Component** (`ownerNodeId @unique`). The spec is the
source of truth; Flow rows are its parsed projection, regenerated by re-parse.
`FlowSpec.source` is the pasted raw text, UNTRUSTED (prompt-injection standing note);
`kind: FlowSpecKind` selects the parser (`OPENAPI` / `ASYNCAPI` / `TS_SIGNATURE` / `GRAPHQL`
/ `CUSTOM`); `parsedAt` and `parseError` record parse status. A malformed spec stores
`parseError`, creates zero Flows, and never throws to the caller. Re-paste is
**non-destructive**: matching keys preserved, dropped keys soft-deleted with a fresh
`deletionId` per re-parse batch — so downstream wiring orphans visibly rather than vanishing
silently.

3. **`FlowPolarity` encodes per-Flow direction relative to the owner.** A Flow's polarity is
owner-relative: `INBOUND` means the owner consumes (`GET /pets` on an API), `OUTBOUND` means
the owner emits (SSE, events). The Edge keeps no stored direction (ADR-0009 untouched); the
rendered arrow stays structural, and a bidirectional pipe still resolves to two Edges — each
carrying the polarity-matched Flows that point its way. The polarity-vs-arrow consistency
check is **service-enforced when routing lands**, not in Slice 1; Slice 1 only persists the
field.

4. **De-dupe is `(ownerNodeId, key)` among active Flow rows, named pattern** per ADR-0010:
service-primary `findFirst` throws `ConflictError`; a partial unique index
`idx_flow_dedup ON "Flow" ("ownerNodeId", "key") WHERE "deletedAt" IS NULL` is the TOCTOU
backstop; an `isFlowDedupCollision` helper in `prisma-errors.ts` narrows `P2002` to that
index; the `ConflictError.details` channel widens additively with `conflictingFlowIds`.
`NULLS NOT DISTINCT` is intentionally omitted — both columns are NOT NULL, unlike Edge's
nullable `canvasNodeId` (where the clause IS load-bearing). Carrying it here would mislead
the next reader.

5. **`deleteNode` cascade gains additive arms.** The cascade now stamps owned `Flow` rows and
the owned `FlowSpec` with the same `deletionId` the Node and Edge sweep stamps (ADR-0008).
`restoreNode` revives exactly that set with `(ownerNodeId, key)` pre-checks parallel to the
existing Edge pre-check. **The `deletionId` contract widens additively:** it is the grouping
handle for soft-deleted rows; `restoreNode` is one specific operation that consumes it.
Other operations may mint `deletionId`s without making them `restoreNode`-restorable — Slice
1's non-destructive re-parse does exactly this (each re-parse batch mints a fresh id to
stamp dropped Flows; `restoreNode` of that id finds no Nodes and reports not-found, which is
harmless).

6. **`Flow.signature` is `Json?` and UNTRUSTED.** The parser writes it verbatim from the
pasted spec. It travels with no interpretation through the service layer; the prompt-injection
standing note (parse-time clause) covers it.

## Alternatives considered

- **`ComponentPort` as persisted rows.** Modeling each operation as a Port on the Component
would force parent-arrow rendering ("14 routed of 23") to be a derived aggregation over Ports,
which rots the moment a Port is added without a Connection — and conflicts with ADR-0009 (two
Ports per Component, structural). Rejected.
- **Spec in `Node.documentation` + parse-on-read.** Couples documentation edits to read
performance; a hostile spec can OOM `getCanvas`; no MCP `list-flows` resource is possible
because there are no rows to list. Rejected.
- **`Edge.content` as opaque JSON.** Loses indexability, soft-delete granularity, and MCP
addressability — the label-as-prose problem this ADR exists to fix, dressed in different
syntax. Rejected.
- **Hard-replace on re-parse (drop all derived Flows, re-insert).** Rejected for the
non-destructive variant: orphaning downstream wiring silently is the failure mode the spec
workflow exists to avoid.

## Consequences

### Reviewable invariants this slice adds

- "Spec is parsed on write into rows, never on read. The Canvas read never carries
`FlowSpec.source`."
- "A Flow's `key` is unique among active rows of the same owner. The `idx_flow_dedup` partial
unique index backstops the service `findFirst`; both translate to the same `ConflictError`
shape with `details.conflictingFlowIds`. Widening the key, removing the partial `WHERE`, or
adopting a naive `@@unique` is a reviewable regression."
- "Re-parsing a spec preserves matching keys and soft-deletes dropped keys with a fresh
`deletionId` per re-parse batch — never a hard-replace. Hand-authored Flows
(`sourceSpecId IS NULL`) are NEVER swept by a re-parse — they belong to the user, not the
spec."
- "`deleteNode`'s cascade stamps owned Flows and the owned FlowSpec with the same `deletionId`.
Reducing the sweep to Nodes + Edges is a regression."
- "`updateFlow` rejects edits on a spec-derived Flow (`sourceSpecId != null`). The spec is the
source of truth; edit the spec and re-paste to change derived Flows."

### Notes

- **Future routing builds on this foundation.** Binding a Flow to a Connection at a scope (the
`FlowRoute` entity), aggregating routed/unrouted counts on parent Edges, the gated cross-scope
inner-Edge writer (`routeFlow`), and the polarity-vs-arrow check ride subsequent slices and
earn their own ADRs there.
- **`FlowSpec.source` is untrusted.** The prompt-injection standing note (CONTEXT.md, parse-time
clause) covers it; the bounded loader (size + depth caps; `MAX_FLOW_SPEC_SOURCE_BYTES = 1 MB`,
`MAX_DEPTH = 32`, `MAX_OPERATIONS = 500`) is its parse-time defense.
Storing the raw text verbatim, never interpolating, is the same posture `label`, `title`, and
`documentation` already follow.
- **`ConflictErrorDetails` widens additively.** Adding `conflictingFlowIds?` alongside the
existing `conflictingEdgeIds?` does not change existing callers.
- **OpenAPI is the only realized parser in Slice 1.** `ASYNCAPI` / `TS_SIGNATURE` / `GRAPHQL` /
`CUSTOM` persist source verbatim with `parseError = "Parser for <kind> is not implemented
yet."` and zero derived Flows. The enum stays complete so the API contract is stable for the
MCP follow-up; additional parsers land additively. Webhooks, callbacks, and external `$ref`
are intentionally NOT extracted from OpenAPI specs — the walker iterates the closed set
`paths.*.{get,put,post,delete,patch,options,head,trace}` and never resolves a `$ref` (the
security boundary).
- **MCP tools are scoped to a follow-up issue.** Slice 1 ships the schema, service, tRPC
adapters, and UI. MCP tools (`attach-flow-spec`, `add-flow`, `list-flows`) compose on top
without service-layer change and are gated on issue #18 (Authenticated MCP route).
Loading