Documentation

Graph Model

The graph is built from nodes and edges with structure driven by persisted relationships.

Species

Current taxonomy has exactly 6 species.

LevelSpeciesRoleReact Flow node type
1flowOrdered sequence containerflow
0viewReusable page/screenview
data-modelData entity/tabledataModel
api-endpointAPI endpointapiEndpoint
acceptanceA testable promise: What (title), How (metadata.gherkin, one Given/When/Then), Why (metadata.values, Bain elements), with per-platform status in metadata.platformStatuses. Id prefix AC-.acceptance
decisionan ADR-style decision record: Context (why), Decision (what), Consequences (how), with its own decision status (metadata.decision_status). Id prefix DEC-.decision

Config source: lib/config/species.ts

Canvas Visibility (Journey Map)

  • The canvas is the Journey map (vision.md § Core Product, spec/maps.md): it renders flow and view species as React Flow nodes over the full compose closure from the root — views chain the traversal onward, flows render as collapsed, expandable cards.
  • data-model and api-endpoint remain persisted graph species, editable from panels and import/export. They render as standalone cards on the System map (/project/[id]/maps/system); on the Journey map they surface inline on View cards via embedded API actions and in the detail panel's Connections section.
  • Cross-layer edges (calls, displays, queries) draw whenever both endpoints are visible.

Source: components/maps/JourneyMap.tsx, lib/utils/journey-graph.ts, components/graph/nodes/ViewNode.tsx

Library Views

Project library is available at /project/[id]/library with two browsing modes:

  • Gallery: card grid with title, prefixed ID, species/status badges, platforms, and flow playlist preview.
  • Directory: sortable table for id, title, species, status, and used in flow count.

Filtering:

  • Species selection is owned by the sidebar (?species= deep links: all when absent, or one of flow, view, data-model, api-endpoint, acceptance); the in-page bar carries search and the display-mode toggle only.
  • Search matches node title and description text.

Library source:

Acceptances View

  • Acceptances (/project/[id]/acceptances) — the parity matrix: acceptances grouped by the view/flow they cover, one status column per platform, filterable by platform/status/value/anchor/parity-gap. Editing (Gherkin, values, per-platform status) happens in the node detail panel.

Pyramid View

  • Pyramid (/project/[id]/pyramid) — the value-element gauge grid: four Bain tier sections (functional → emotional → life-changing → social impact), each rendering its value elements as cards showing the element's icon + label + per-platform delivery gauge + acceptance count. A platform chip row filters the gauges to a single platform (or all), and each element card links to the Acceptances matrix pre-filtered on that value (?value=<id>).

Composition Model

Root Node

  • project.root_node_id is the explicit canvas anchor when present.
  • If root_node_id is not set, the canvas infers roots from nodes with no incoming composes edge.

Source: components/maps/JourneyMap.tsx, lib/utils/journey-graph.ts, lib/data/types.ts

Playlist Expansion

  • Persisted parent/child links are composes edges.
  • Child ordering is read from node.metadata.playlist.entries.
  • When playlist entries do not reference all compose-edge children, missing children are appended after playlist-derived ordering.

Source: components/maps/JourneyMap.tsx, lib/utils/journey-graph.ts

The canvas walks the compose closure from the anchor (project.root_node_id, or inferred roots when absent): views are always rendered and chain the walk onward; flows are rendered as collapsed cards whose interiors (playlists) render only when expanded. Flows start collapsed, except that the first top-level flow (a flow reached without passing through another flow) auto-expands on initial load. Top-level expansion is accordion-style: opening one top-level flow collapses any other top-level flow already open.

Expanded flow children follow a strict alternating drill layout:

  • Root children are rendered horizontally below the root.
  • Level 2 children are rendered vertically below each level 1 node.
  • Level 3 children are rendered horizontally below each level 2 node.
  • The pattern continues alternating by depth.
  • Vertical drill segments always use top/bottom handles for both flow and view nodes.

Layout is computed by elkjs (Eclipse Layout Kernel, layered algorithm). The page builds a flat list of nodes and compose edges with position: {x:0, y:0}, then an async useEffect calls computeElkLayout() which runs the ELK layered algorithm and returns positioned nodes.

Layout source: lib/utils/elk-layout.ts

Source: components/maps/JourneyMap.tsx, lib/utils/journey-graph.ts

Node Reuse

  • view and flow nodes are reusable and can appear multiple times across playlists.
  • Reuse is many-to-many: a single node can be referenced by many flow playlists, and one flow playlist can reference many nodes.
  • The source of truth for sequence semantics is the playlist (metadata.playlist.entries), while composes edges provide structural connectivity.
  • The detail panel's where-used UI derives reverse references by scanning all flow playlists.

Source: components/panels/NodeDetailPanel.tsx, lib/utils/where-used.ts, lib/data/types.ts

Playlist Entry Types

flow nodes store ordered playlist data in node.metadata.playlist.entries.

Entry TypeRequired FieldsNotes
viewview_idReference to an existing view node
flowflow_idReference to an existing flow node (cycle-checked before persist)
conditionlabel, if_true, if_falseTwo branch lists, each a recursive PlaylistEntry[]
junctionlabel, cases[]Each case has label + entries: PlaylistEntry[]

Editing source: components/panels/PlaylistEditor.tsx, components/panels/PlaylistEntryRow.tsx

Type source: lib/data/types.ts

Status Model

Statuses are configured in:

The lifecycle vocabulary is seven statuses:

StatusMeaning
ideaRaw capture — request, intuition, opportunity. The inbox.
discoveryActively being made ready to deliver, via design or specification.
backlogReady to be delivered; waiting to start.
developmentBeing implemented or executed.
releasingImplementation done; awaiting validation (QA) or effective release/distribution.
liveFully available.
archivedRetired from the working set.

The table reads top to bottom as the usual path, but transitions are documented, not enforced — status stays a free assignment, and a node can jump or move backwards without ceremony.

Being blocked is not a status. A node stalled by a dependency keeps its lifecycle status and sets metadata.blocked_by: non-empty means blocked at the current status. The value is a node id (rendered as a link in the detail panel) or free text naming the dependency; absence means unblocked.

Status is orthogonal to the stage axis (metadata.stage: beta, monitoring, deprecatedlib/config/stages.ts), which qualifies exposure of something already built and is unchanged by this vocabulary.

Legacy (since bundle schema_version 3): prioritized reads as backlog, and blocked reads as development with blocked_by set. The one-time migration also remapped old backlog to idea — the id survives with a different meaning (the old backlog was the unprioritized pile; the new one means ready to deliver), which is why that remap is keyed on the version bump rather than being a parse-time alias. Journal history keeps the old ids as written, and parsers accept them forever — see spec/bundle-format.md § Schema Versioning.

Rollup behavior:

  • acceptance is the primary carrier of stored per-platform status values (metadata.platformStatuses).
  • view also stores metadata.platformStatuses, but this is now a deprecated fallback: it is authoritative only while no acceptance covers the view (see covers under Edge Types).
  • flow status is computed for display by recursively walking playlist entries and aggregating descendant view platform statuses, including nested sub-flows and branch entries.
  • data-model and api-endpoint use single lifecycle status.

Sources:

Decisions

decision (§ Species) is an ADR-style record — Context/Why, Decision/What, Consequences/How — carrying its own status vocabulary in metadata.decision_status, distinct from the node lifecycle:

decision_statusMeaning
proposedDrafted, under consideration.
approvedAgreed — but not yet reality.
enactedIn effect: the implementing change shipped.
rejectedConsidered and declined. (terminal)
deprecatedWas enacted; no longer recommended, nothing replaces it. (terminal)
supersededReplaced by a later decision (see the supersedes edge). (terminal)

As with the node lifecycle above, the table reads top to bottom as the usual path, but transitions are documented, never enforced.

Lifecycle sync. The node's global status stays meaningful — every existing surface (library badges, filters, counts) keeps working without branching by species — via lifecycleStatusForDecision(decisionStatus):

decision_statusSynced status
proposeddiscovery
approvedbacklog
enactedlive
rejected / deprecated / supersededarchived

The mapping is applied at write time by the decision editor (and documented for agents/CLI); the validator cross-checks it as the decision-lifecycle-mismatch warning, never an error — a hand-edited bundle must not brick on a stale sync.

metadata.decided_at (optional, ISO 8601) exists so a historical backfill can date a decision correctly: a bulk-imported node.created journal event carries the backfill run's timestamp, not the day the decision was actually made — decided_at is the only place the real date lives.

Decisions carry no platforms (an empty array, exempt from the platforms-non-empty validator error — see spec/bundle-format.md) and are excluded from delivery/coverage rollups and from the Journey/System maps by construction (see the Taxonomy Update Checklist note below).

Model source: packages/schema/src/decision.ts.

Decision surfaces:

  • Decision Log (/project/[id]/decisions) — decisions newest-first (by decided_at, falling back to journal node.created), decision-status badge, filter chips by decision status, supersession chains rendered inline.
  • Node detail panel (components/panels/NodeDetailPanel.tsx) — Context and Consequences textareas, the decision-status dropdown, decided_at, and the supersedes/generates/impacts connections.
  • Library (/project/[id]/library) — gallery card and directory row like any other species; the decision-status badge stands in for platform chips.
  • Not present: the canvas (Journey/System maps) and the Delivery board — same posture as acceptance today (no custom Canvas registration; excluded from SPECIES_OPTIONS / map-kind species defaults).

Products

A project describes a family of apps sharing one graph — an end-user app, a web-only back office, a public API — not one product with three platforms. A product names one app in that family and the platforms it may ship on.

Definitions live at project.metadata.products: ProductDefinition[] (id, title, description?, platforms[], root_node_id?), following the stored-maps precedent exactly: an additive optional field in an already-catchall object, so no schema_version bump.

Membership is stored by some species and derived by others:

SpeciesMembership
flow, viewStored in node.metadata.product — one product id
acceptanceAnchors first — the products of the views/flows its covers edges reach. Stored metadata.product answers only for an acceptance that covers nothing
data-model, api-endpointDerived from consumers — a walk out of the membership-bearing flows and views along calls / displays / queries, each edge in its stored direction, hopping only into system-layer targets. Producers MUST NOT store metadata.product

Shared substrate is the norm, so the system layer never claims a product of its own, and a restriction is not a partition — a data model two products both reach appears in both.

An absent answer is a triage state, not "applies everywhere". An unassigned flow, view, or anchorless acceptance appears under "All products" only. An orphan data model or endpoint — reached by no consumer — stays visible under every scope instead, because burying the node that most needs attention is the failure this model exists to end (spec/maps.md § Orphans).

Each product may carry its own journey anchor, and the map resolves it in strict order — the map definition's root_node_id, then the product's, then project.root_node_id (spec/maps.md § Product Scope). That middle level is what makes a product an app rather than a tag: Admin opens on Admin's own front door.

A project declaring no products behaves exactly as it did before products existed: one implicit product spanning every platform, every node in it, no warnings, no migration.

Normative text — the definition, membership, and the eight warning-severity validator rules: spec/bundle-format.md § Products.

Projections: packages/schema/src/products.ts (resolveProducts, productOf, productPlatforms, effectiveNodePlatforms, buildProductUsageIndex / productsUsingNode).

App-side scope resolution (the one membership answer every surface asks): lib/utils/product-scope.ts, lib/hooks/useProductScope.ts.

Platforms

Platforms are configured in:

Nodes target one or more platforms; per-platform notes/statuses/screenshots are stored in node metadata, keyed by platform.

node.platforms stays authoritative. A product supplies a menu — the platforms that product may ship on — and readers intersect rather than trust: effectiveNodePlatforms(node, product) returns node.platforms ∩ product.platforms in PLATFORM_IDS order, so a platform outside the menu drops out of the display instead of corrupting it. That is why containment is a validator warning and never an error (§ Products).

The arity rule

A surface never picks a per-platform shape for itself; it counts. The scope's platform menu — productPlatforms(project, productId) — is the sole input:

Effective platformsShape
≥ 2The aggregate plus one ring / column / tab per platform
1A single bar (or a single column headed Status), carrying no platform name
0The same single bar — availability is simply not a tracked dimension here (a CLI, a public API)

1 and 0 render identically, deliberately. At arity 1 the aggregate and the lone platform ring carry the same numbers, and a lone ring standing beside three-ring cards from another scope reads as data missing rather than absent; at arity 0 there is nothing that could be missing, so the same bar says so without inventing a third shape. A project declaring no products resolves to every platform, so the ≥ 2 row is today's rendering unchanged.

The threshold is written once, in platformAvailabilityShape (lib/utils/product-scope.ts); the switch over it is components/graph/nodes/PlatformAvailability.tsx, which every platform-bearing surface composes rather than choosing a shape itself. Platform filters and detail-panel tabs read the same arity and hide themselves at ≤ 1.

Shape versus fact

Two different questions read two different sources. Swapping them breaks the degenerate case, so the distinction is load-bearing:

QuestionRead
How many columns / rings / tabs does this surface show?scope.platforms — the scope's menu
What does this node actually ship on?scopedPlatforms(node, scope) — the node's own platforms, intersected with the menu of the node's own product

scopedPlatforms intersects against the node's product, not the scope's list, and that is the whole delivery fix: under "All products" the scope's list is the union of every product, so intersecting a node against it would leave a web-only admin view contributing to the Android column exactly as it did before products existed.

Source:

Edge Types

Edge TypeUse
composesComposition hierarchy and ordered flow sequences
callsView/flow to API relationship (either direction — calls names the initiator), or API endpoint to API endpoint (first-party endpoint fanning out to internal/external APIs)
displaysView to data-model relationship
queriesAPI to data-model relationship
coversAcceptance to view/flow relationship — anchors a testable promise to the surface(s) it covers
supersedesDecision to decision — the source replaces the target; recording it also moves the target to decision_status: superseded
generatesDecision to acceptance — the decision produced this testable promise
impactsDecision to flow | view | data-model | api-endpoint — the decision affects this existing node (or spawned it)

generates and impacts are deliberately disjoint by design: an acceptance is generated by a decision, never merely impacted, so impacts does not admit an acceptance target — consumers must never infer an edge's meaning from its endpoints.

Config source: lib/config/edge-types.ts

Rendering mapping source: lib/utils/journey-graph.ts, lib/utils/system-graph.ts

calls edges between a view and API endpoint are projected into View card UI. The edge's direction names the initiator, so both directions are legal and mean different things:

  • API -> View: inbound/read affordance (cloud-download icon) — the server opens the channel: a webhook, a server-sent events stream, a push.
  • View -> API: outbound/write affordance (cloud-upload icon) — the view initiates the request.

Because calls runs upward as well as downward, anything that walks it must say which direction it means. buildProductUsageIndex (packages/schema/src/products.ts) restricts hops to system-layer targets for exactly this reason — a direction-blind walk would climb an inbound edge back up into another product's views.

calls edges between two API endpoints (a first-party endpoint fanning out to internal/external APIs) have no view endpoint to project onto, so they surface only on the System map (/project/[id]/maps/system), drawn between the two endpoint cards.

Source: components/maps/JourneyMap.tsx, lib/utils/journey-graph.ts, components/graph/nodes/ViewNode.tsx

covers — acceptance → view | flow. Zero covers edges = product-level acceptance (legal, not an orphan). Stored per-platform status lives on acceptances; a covered view's per-platform status is computed from its covering acceptances, falling back to the view's stored platformStatuses when uncovered (spec §3.4).

Source: packages/schema/src/acceptance.ts (projections; not yet consumed by the app's status rollups — see the Surfaces plan).

Node And Edge Components

Node registration is in:

Current custom registrations:

  • flow -> FlowNode
  • view -> ViewNode
  • dataModel -> DataModelNode
  • apiEndpoint -> ApiEndpointNode

dataModel and apiEndpoint remain registered node types for compatibility, but the current project page renderer does not add those species into visibleNodes.

acceptance nodes and covers edges have no custom Canvas registration yet — they render with generic node/edge components, and the map-kind defaults exclude them (see the dated note under the Taxonomy Update Checklist).

decision nodes and the supersedes/generates/impacts edges have no custom Canvas registration either, same posture — the map-kind defaults exclude them too (see the 2026-08-03 note under the Taxonomy Update Checklist).

Edge registration is also in components/graph/Canvas.tsx.

Taxonomy Update Checklist

  1. Update config array in lib/config/*.
  2. Update graph builders and rendering filters in lib/utils/journey-graph.ts and lib/utils/system-graph.ts.
  3. Update Canvas registrations in components/graph/Canvas.tsx.
  4. Update forms/panels that branch by species.
  5. Update seed data in seed/pebbles.json.
  6. Update this document.

2026-07-19 — acceptance/covers: steps 3–4 deferred to the Surfaces plan (docs/superpowers/specs/2026-07-19-acceptance-value-model-design.md §9); maps exclude acceptances by default. Step 1 is done (lib/config/species.ts, lib/config/edge-types.ts); step 2 needed no change — journey and system map kinds' species defaults in packages/schema/src/maps.ts exclude acceptance, so lib/utils/journey-graph.ts and lib/utils/system-graph.ts have no acceptance-specific branches. lib/utils/graph-build.ts already maps acceptance to React Flow node type "acceptance" as a forward-fix placeholder for when Canvas registration lands. Step 5 is done (seed).

2026-08-02 — products: a product is project metadata, not a species, so this change touches the checklist sideways. Step 1 is done, but not in lib/config/* — the definitions are data (spec/bundle-format.md § Products), and the config files stayed untouched precisely because a product is authored per project rather than compiled in. Step 2 needed no change: membership never joins the graph builders, it restricts the node list handed to them (mapScopedNodes, lib/utils/product-scope.ts), so lib/utils/journey-graph.ts and lib/utils/system-graph.ts have no product-specific branches. Step 3 needed none either — no new node or edge type. Steps 5 and 6 are done (seed/pebbles.json gains a web-only admin product beside the three-platform app, so the example project exercises both arities of the rule below; this document, above). Step 4 is deferred: no form or panel writes metadata.product today, so products are authorable only by an agent or by hand-editing a bundle — the P3 milestone (rfcs/products.md § Phased plan) owns the product manager, the picker in the node forms, and bulk reassignment. Also deferred: the per-surface product override. The global scope in the sidebar is the whole of it for now; every projection already takes the product as an argument and every surface already resolves through useEffectiveProduct, so that milestone changes one function to override ?? global and adds a control.

2026-08-03 — decisions: steps 1, 2, and 4 are done; step 3 (Canvas registration) is deferred, same posture as acceptance — no custom node/edge components for decision/supersedes/generates/impacts yet. Step 2 needed no change: the journey and system map kinds' species defaults in packages/schema/src/maps.ts (KIND_DEFAULTS) list species explicitly and do not include decision, so lib/utils/journey-graph.ts and lib/utils/system-graph.ts needed no decision-specific branches. lib/utils/graph-build.ts already maps decision to React Flow node type "decision" and supersedes/generates/impacts to their own edge types, as forward-fix placeholders for when Canvas registration lands (same pattern as acceptance/covers). Step 1 is done (lib/config/species.ts, lib/config/edge-types.ts). Step 4 is done: the node detail panel branches on node.species === "decision" for the Context/Consequences/decision-status/decided_at editor and the supersedes/generates/impacts connections, and the library gallery card swaps in the decision-status badge. Step 5 is done: seed/pebbles.json carries a worked example exercising the full edge grammar (DEC-adopt-glyph-wobble supersedes DEC-linear-glyph-fade, generates an acceptance, impacts a view), and seed/arkaik-self-map.json carries cycle 1's own standing decisions (§7 of 2026-08-03-decisions-species-design.md). Step 6 is this note.