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
551 changes: 235 additions & 316 deletions CONTEXT.md

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion docs/adr/0005-edge-scope-and-service-enforced-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion docs/adr/0010-edge-dedup-partial-unique-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions docs/adr/0023-connection-direction-derived-from-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions docs/adr/0027-connection-carries-its-own-interaction.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 99 additions & 0 deletions docs/adr/0028-cross-scope-connections-lineal-ingress.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 83 additions & 0 deletions docs/adr/0030-cascade-undo-without-flowroutes.md
Original file line number Diff line number Diff line change
@@ -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.
Loading