- Date: 2026-07-16
- Status: Accepted
- Builds on: ADR 0066 (
workflow_artboardpersistence), ADR 0063 (Studio packages split). Precedes thetavola/alberoUI, which is still being scoped.
Context
tavola (artboard authoring + prototype-link navigation) and albero
(auto-layout tree of artboards) are planned features of the new @arno/quadro
canvas/frame umbrella package (see apps/web/src/dev-tools/studio/_index.md).
Both need a persisted answer to "which component, on which artboard, navigates
to which other artboard?" — workflow_artboard (ADR 0066) has no such concept:
its tree jsonb has no link field, and there is no cross-artboard edge table
at all.
Decision
A dedicated table workflow_artboard_link, project-scoped, one row per source
node — a component has at most one outgoing prototype link, so
(project_id, source_artboard_id, source_node_id) is the natural primary key
and re-linking a node is an UPSERT (same shape as workflow_artboard's save).
Chosen over storing the link inline on the node (node.link: {targetArtboardId}
inside workflow_artboard.tree): albero must build the full edge graph for
every artboard in a project at once, and a flat table with one indexed query
beats parsing every artboard's tree jsonb to find links scattered inside it.
Schema (packages/db/src/schema.ts)
workflow_artboard_link
project_id text FK → project.id ON DELETE CASCADE
source_artboard_id text (workflow_artboard.artboard_id, not FK'd — see below)
source_node_id text (LayoutNode id inside the source artboard's tree)
target_artboard_id text (workflow_artboard.artboard_id, not FK'd — see below)
created_at / updated_at timestamptz default now()
PRIMARY KEY (project_id, source_artboard_id, source_node_id)
INDEX (project_id, target_artboard_id) -- albero's reverse-edge lookupNo composite FK into workflow_artboard's (project_id, artboard_id) PK —
no composite-FK precedent exists anywhere else in this schema (every FK in
schema.ts is a single column into a single-column PK/unique). Introducing the
first one here for two columns at once was judged not worth the asymmetry.
Both ends are instead validated against workflow_artboard at write time in
the API handler. Consequence: deleting an artboard does not cascade-delete
its links or links pointing to it — orphaned links just fail to resolve a
target at read time. Acceptable for now (tavola's UI hasn't shipped yet, so
there's no user-visible dangling-link scenario to guard); revisit if orphan
cleanup becomes an observed problem (candidate fix: a cascade cleanup pass in
the artboard-delete handler, not a schema FK).
Migration hand-written (no DATABASE_URL available to run drizzle-kit generate in this session) — timestamp-prefixed, appends journal idx 43.
Matches the exact CREATE TABLE / ADD CONSTRAINT shape drizzle-kit emits for
workflow_artboard (ADR 0066), diffed by hand against schema.ts.
API (apps/api/src/workflow-artboard-link.ts, mounted post-auth in index.ts)
Same requireOwnedProject(c, projectId) gate as every other project-scoped
route:
GET /api/v1/projects/:projectId/workflow-artboard-links→{ items: [...] }PUT /api/v1/workflow-artboard-links→ upsert on(project_id, source_artboard_id, source_node_id); validates both artboard ids exist in-project before writing (the FK the schema doesn't have).DELETE /api/v1/workflow-artboard-links?project=&source=&node=→ idempotent.
Frontend
Not built yet — this ADR covers persistence only. The tavola UI (the "+"
affordance on a component's top-right corner, the link-target picker dialog,
plain-click-to-navigate with a back-stack, real :hover on canvas) and
albero (the auto-layout tree view, opened from an icon on the main
artboard's outside top-right corner) are scoped in ongoing design conversation,
not yet in a state file.
Consequences
albero's edge graph is one query:SELECT * FROM workflow_artboard_link WHERE project_id = $1, joined in memory against the artboard list already fetched byuseWorkflowArtboards.- A node can have at most one outgoing link (the PK enforces it) — matches the described UX (one "+" per component, picking a new target replaces the old one, not adds a second).
- No incoming-link cardinality limit — multiple components (even across different source artboards) may point at the same target artboard, which is the normal case (a "back to home" button on several screens).
Alternatives considered
- Inline
node.linkfield in the tree jsonb — rejected, see Decision. - Composite FK into
workflow_artboard— rejected, see Decision (schema consistency over referential-integrity strictness at the DB layer). - Reusing the legacy ReactFlow
WorkflowEdgemodel (lib/workflow-store.ts, already has aninstanceId/eventId/action: "navigate"shape) — rejected: that model is attached to the dead-in-main-tab abstractScreenNodegraph (workflow-canvas.tsx, alive only for/share), not to the liveworkflow_artboardcomposition-artboard data modeltavolabuilds on.