-
Notifications
You must be signed in to change notification settings - Fork 0
Add Flows as first-class Component-owned entities (Slice 1) #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.